super-backlog 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/backlog-browser.js +98 -0
- package/dist/dashboard/data.js +91 -4
- package/dist/dashboard/hub.js +7 -4
- package/dist/dashboard/render.js +7 -7
- package/dist/dashboard/server.js +0 -76
- package/dist/lib/version-check.js +3 -2
- package/dist/models/dashboard-api.js +35 -6
- package/dist/models/install.js +9 -2
- package/dist/templates/dashboard.html +422 -66
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/dashboard/backlog-browser.ts
|
|
2
|
+
import { request } from 'node:http';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import crossSpawn from 'cross-spawn';
|
|
5
|
+
import { resolveBacklogBin } from '../lib/run.js';
|
|
6
|
+
function defaultSpawn(bin, args, cwd) {
|
|
7
|
+
const child = crossSpawn(bin, args, { cwd, stdio: 'ignore' });
|
|
8
|
+
return child;
|
|
9
|
+
}
|
|
10
|
+
function defaultGetFreePort() {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const srv = createServer();
|
|
13
|
+
srv.once('error', reject);
|
|
14
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
15
|
+
const addr = srv.address();
|
|
16
|
+
const port = addr !== null && typeof addr === 'object' ? addr.port : 0;
|
|
17
|
+
srv.close(() => resolve(port));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function defaultProbe(url) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const r = request(url, { method: 'GET' }, (res) => {
|
|
24
|
+
res.resume();
|
|
25
|
+
resolve((res.statusCode ?? 500) < 500);
|
|
26
|
+
});
|
|
27
|
+
r.on('error', () => resolve(false));
|
|
28
|
+
r.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
}
|
|
34
|
+
/** Manages one `backlog browser` process for one project cwd. */
|
|
35
|
+
export function createBrowserManager(cwd, deps = {}) {
|
|
36
|
+
const resolveBin = deps.resolveBin ?? resolveBacklogBin;
|
|
37
|
+
const spawnFn = deps.spawnFn ?? defaultSpawn;
|
|
38
|
+
const getFreePort = deps.getFreePort ?? defaultGetFreePort;
|
|
39
|
+
const probe = deps.probe ?? defaultProbe;
|
|
40
|
+
const timeoutMs = deps.timeoutMs ?? 10_000;
|
|
41
|
+
const intervalMs = deps.intervalMs ?? 200;
|
|
42
|
+
let child = null;
|
|
43
|
+
let url = '';
|
|
44
|
+
let starting = null;
|
|
45
|
+
function alive() {
|
|
46
|
+
return child !== null && child.exitCode === null;
|
|
47
|
+
}
|
|
48
|
+
async function start() {
|
|
49
|
+
const bin = resolveBin(cwd);
|
|
50
|
+
if (!bin)
|
|
51
|
+
return { ok: false, code: 503, message: 'backlog cli not found' };
|
|
52
|
+
let port;
|
|
53
|
+
try {
|
|
54
|
+
port = await getFreePort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, code: 500, message: 'no free port for the backlog browser' };
|
|
58
|
+
}
|
|
59
|
+
url = `http://127.0.0.1:${port}/`;
|
|
60
|
+
const spawned = spawnFn(bin, ['browser', '--port', String(port), '--no-open'], cwd);
|
|
61
|
+
let spawnFailed = false;
|
|
62
|
+
spawned.on('error', () => {
|
|
63
|
+
spawnFailed = true;
|
|
64
|
+
if (child === spawned)
|
|
65
|
+
child = null;
|
|
66
|
+
});
|
|
67
|
+
child = spawned;
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
if (spawnFailed || spawned.exitCode !== null)
|
|
71
|
+
break;
|
|
72
|
+
if (await probe(url))
|
|
73
|
+
return { ok: true, url };
|
|
74
|
+
await sleep(intervalMs);
|
|
75
|
+
}
|
|
76
|
+
spawned.kill();
|
|
77
|
+
if (child === spawned)
|
|
78
|
+
child = null;
|
|
79
|
+
return { ok: false, code: 500, message: 'backlog browser did not start' };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ensure() {
|
|
83
|
+
if (alive() && url !== '')
|
|
84
|
+
return Promise.resolve({ ok: true, url });
|
|
85
|
+
if (starting === null) {
|
|
86
|
+
starting = start().finally(() => {
|
|
87
|
+
starting = null;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return starting;
|
|
91
|
+
},
|
|
92
|
+
close() {
|
|
93
|
+
if (alive())
|
|
94
|
+
child?.kill();
|
|
95
|
+
child = null;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/dashboard/data.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// src/dashboard/data.ts
|
|
2
2
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { basename, join } from 'node:path';
|
|
4
5
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
6
|
+
import { isNewerVersion } from '../lib/version-check.js';
|
|
5
7
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
6
8
|
function isRecord(v) {
|
|
7
9
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -50,17 +52,28 @@ function normalizeAcs(value) {
|
|
|
50
52
|
}
|
|
51
53
|
return out;
|
|
52
54
|
}
|
|
55
|
+
function firstAssignee(t) {
|
|
56
|
+
const list = t['assignees'];
|
|
57
|
+
if (Array.isArray(list)) {
|
|
58
|
+
for (const entry of list) {
|
|
59
|
+
const name = asString(entry);
|
|
60
|
+
if (name !== undefined)
|
|
61
|
+
return name;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return asString(t['assignee']);
|
|
65
|
+
}
|
|
53
66
|
export function normalizeTasks(rawTasks) {
|
|
54
67
|
return rawTasks.map((t) => ({
|
|
55
68
|
id: asString(t['id']) ?? '',
|
|
56
69
|
title: asString(t['title']) ?? '(untitled)',
|
|
57
70
|
status: asString(t['status']) ?? 'Unknown',
|
|
58
71
|
priority: asString(t['priority']),
|
|
59
|
-
assignee:
|
|
60
|
-
updated: asString(t['updated_at']) ?? asString(t['updated']),
|
|
72
|
+
assignee: firstAssignee(t),
|
|
73
|
+
updated: asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated']),
|
|
61
74
|
milestone: asString(t['milestone']),
|
|
62
75
|
description: asString(t['description']),
|
|
63
|
-
acs: normalizeAcs(t['acceptance_criteria']),
|
|
76
|
+
acs: normalizeAcs(t['acceptanceCriteria'] ?? t['acceptance_criteria']),
|
|
64
77
|
}));
|
|
65
78
|
}
|
|
66
79
|
export function computeStatuses(tasks) {
|
|
@@ -263,6 +276,79 @@ function readProjectIdentity(cwd) {
|
|
|
263
276
|
const description = asString(cfg['description']) ?? asString(pkg?.['description']) ?? '';
|
|
264
277
|
return { name, description };
|
|
265
278
|
}
|
|
279
|
+
function sectionBetween(content, begin, end) {
|
|
280
|
+
const from = content.indexOf(begin);
|
|
281
|
+
if (from === -1)
|
|
282
|
+
return undefined;
|
|
283
|
+
const to = content.indexOf(end, from + begin.length);
|
|
284
|
+
if (to === -1)
|
|
285
|
+
return undefined;
|
|
286
|
+
const text = content.slice(from + begin.length, to).trim();
|
|
287
|
+
return text === '' ? undefined : text;
|
|
288
|
+
}
|
|
289
|
+
/** Parse one backlog task markdown file via its explicit section markers. */
|
|
290
|
+
export function parseTaskFile(content) {
|
|
291
|
+
const idMatch = /^id:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(content);
|
|
292
|
+
const description = sectionBetween(content, '<!-- SECTION:DESCRIPTION:BEGIN -->', '<!-- SECTION:DESCRIPTION:END -->');
|
|
293
|
+
const acs = [];
|
|
294
|
+
const acBlock = sectionBetween(content, '<!-- AC:BEGIN -->', '<!-- AC:END -->');
|
|
295
|
+
if (acBlock !== undefined) {
|
|
296
|
+
for (const line of acBlock.split(/\r?\n/)) {
|
|
297
|
+
const m = /^-\s*\[( |x|X)\]\s*(?:#\d+\s*)?(.+)$/.exec(line.trim());
|
|
298
|
+
if (m)
|
|
299
|
+
acs.push({ text: m[2].trim(), checked: m[1].toLowerCase() === 'x' });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { id: idMatch?.[1]?.trim(), description, acs };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Fill description/ACs from backlog/tasks/*.md where `task list --json`
|
|
306
|
+
* (schemaVersion 1) does not carry them. The CLI stays the source of truth
|
|
307
|
+
* for the list and statuses; files only supply missing detail fields.
|
|
308
|
+
*/
|
|
309
|
+
export function enrichTasksFromFiles(cwd, tasks) {
|
|
310
|
+
const dir = join(cwd, 'backlog', 'tasks');
|
|
311
|
+
let files;
|
|
312
|
+
try {
|
|
313
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return tasks;
|
|
317
|
+
}
|
|
318
|
+
const byId = new Map();
|
|
319
|
+
for (const file of files) {
|
|
320
|
+
try {
|
|
321
|
+
const detail = parseTaskFile(readFileSync(join(dir, file), 'utf8'));
|
|
322
|
+
if (detail.id !== undefined)
|
|
323
|
+
byId.set(detail.id.toUpperCase(), detail);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// unreadable file -> no enrichment for that task
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return tasks.map((t) => {
|
|
330
|
+
const detail = byId.get(t.id.toUpperCase());
|
|
331
|
+
if (!detail)
|
|
332
|
+
return t;
|
|
333
|
+
return {
|
|
334
|
+
...t,
|
|
335
|
+
description: t.description ?? detail.description,
|
|
336
|
+
acs: t.acs.length > 0 ? t.acs : detail.acs,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function readLatestVersion(home, kitVersion) {
|
|
341
|
+
try {
|
|
342
|
+
const raw = readFileSync(join(home, '.super-backlog', 'version-check.json'), 'utf8');
|
|
343
|
+
const parsed = JSON.parse(raw);
|
|
344
|
+
if (!isRecord(parsed) || typeof parsed.latest !== 'string')
|
|
345
|
+
return null;
|
|
346
|
+
return isNewerVersion(parsed.latest, kitVersion) ? parsed.latest : null;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
266
352
|
export function collectDashboardData(cwd, opts) {
|
|
267
353
|
const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
|
|
268
354
|
? opts.today.trim()
|
|
@@ -271,6 +357,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
271
357
|
project: readProjectIdentity(cwd),
|
|
272
358
|
generatedAt: new Date().toISOString(),
|
|
273
359
|
kitVersion: opts.kitVersion,
|
|
360
|
+
latestVersion: readLatestVersion(opts.home ?? homedir(), opts.kitVersion),
|
|
274
361
|
statuses: [],
|
|
275
362
|
milestones: [],
|
|
276
363
|
tasks: [],
|
|
@@ -288,7 +375,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
288
375
|
if (res.status !== 0)
|
|
289
376
|
return base;
|
|
290
377
|
const rawTasks = parseTasksJson(res.stdout);
|
|
291
|
-
const tasks = normalizeTasks(rawTasks);
|
|
378
|
+
const tasks = enrichTasksFromFiles(cwd, normalizeTasks(rawTasks));
|
|
292
379
|
return {
|
|
293
380
|
...base,
|
|
294
381
|
tasks,
|
package/dist/dashboard/hub.js
CHANGED
|
@@ -4,9 +4,10 @@ import { createServer } from 'node:http';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import process from 'node:process';
|
|
7
|
+
import { createBrowserManager } from './backlog-browser.js';
|
|
7
8
|
import { collectDashboardData } from './data.js';
|
|
8
9
|
import { renderDashboard } from './render.js';
|
|
9
|
-
import { createDebouncedReloader, createReloadBroker,
|
|
10
|
+
import { createDebouncedReloader, createReloadBroker, DASHBOARD_PORT, recursiveWatchSupported, } from './server.js';
|
|
10
11
|
import { atomicWrite } from '../lib/atomic.js';
|
|
11
12
|
import { projectSlug, realpathKey } from '../lib/slug.js';
|
|
12
13
|
import { KIT_VERSION } from '../lib/version.js';
|
|
@@ -86,6 +87,7 @@ export async function startHubServer(opts) {
|
|
|
86
87
|
entry.reloader.cancel();
|
|
87
88
|
entry.broker.close();
|
|
88
89
|
entry.watcher?.close();
|
|
90
|
+
entry.browser.close();
|
|
89
91
|
}
|
|
90
92
|
function register(project) {
|
|
91
93
|
let computed;
|
|
@@ -133,7 +135,7 @@ export async function startHubServer(opts) {
|
|
|
133
135
|
broker,
|
|
134
136
|
reloader,
|
|
135
137
|
watcher: watchBacklog(project.cwd, reloader),
|
|
136
|
-
|
|
138
|
+
browser: createBrowserManager(project.cwd, opts.browserDeps),
|
|
137
139
|
modelApi: createModelApiHandler(project.cwd),
|
|
138
140
|
};
|
|
139
141
|
projects.set(slug, entry);
|
|
@@ -238,8 +240,9 @@ export async function startHubServer(opts) {
|
|
|
238
240
|
}
|
|
239
241
|
if (rest.startsWith('/api/')) {
|
|
240
242
|
req.url = rest;
|
|
241
|
-
if (rest === '/api/
|
|
242
|
-
await entry.
|
|
243
|
+
if (rest === '/api/backlog-browser' && method === 'POST') {
|
|
244
|
+
const result = await entry.browser.ensure();
|
|
245
|
+
sendJson(res, result.ok ? 200 : result.code, result);
|
|
243
246
|
return;
|
|
244
247
|
}
|
|
245
248
|
if (entry.broker.handler(req, res)) {
|
package/dist/dashboard/render.js
CHANGED
|
@@ -5,14 +5,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
/** The nine pipeline phases; must stay consistent with src/templates/workflow-block.md. */
|
|
6
6
|
export const PIPELINE_PHASES = [
|
|
7
7
|
{ n: 1, name: 'Idea', gate: 'User states a need; capture it before doing anything else' },
|
|
8
|
-
{ n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work' },
|
|
8
|
+
{ n: 2, name: 'Brainstorming', gate: 'Explore intent, requirements and design before any creative work', command: '/superpowers:brainstorming' },
|
|
9
9
|
{ n: 3, name: 'Design gate', gate: 'Human approves the design document' },
|
|
10
|
-
{ n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria' },
|
|
11
|
-
{ n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists' },
|
|
12
|
-
{ n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human' },
|
|
13
|
-
{ n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR' },
|
|
14
|
-
{ n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims' },
|
|
15
|
-
{ n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI' },
|
|
10
|
+
{ n: 4, name: 'Spec-to-backlog', gate: 'Decompose the approved design into reviewed tasks with acceptance criteria', command: '/spec-to-backlog' },
|
|
11
|
+
{ n: 5, name: 'Review gate', gate: 'Human reviews specs and acceptance criteria before any code exists', command: '/task-review-gate' },
|
|
12
|
+
{ n: 6, name: 'Plan-before-code', gate: 'A written implementation plan is approved by the human', command: '/superpowers:writing-plans' },
|
|
13
|
+
{ n: 7, name: 'TDD implementation', gate: 'Failing test first, then code; one task per session/PR', command: '/superpowers:subagent-driven-development' },
|
|
14
|
+
{ n: 8, name: 'Verification & final summary', gate: 'Run tests/lint/typecheck; verification evidence before success claims', command: '/superpowers:verification-before-completion' },
|
|
15
|
+
{ n: 9, name: 'Merge & archive', gate: 'Merge the branch, then close/archive the task via the backlog CLI', command: 'backlog task archive <id>' },
|
|
16
16
|
];
|
|
17
17
|
function readTemplate() {
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url)); // src/dashboard at dev time, dist/dashboard at runtime
|
package/dist/dashboard/server.js
CHANGED
|
@@ -2,83 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
-
import crossSpawn from 'cross-spawn';
|
|
6
|
-
import { resolveBacklogBin } from '../lib/run.js';
|
|
7
5
|
export const DASHBOARD_PORT = 6428;
|
|
8
|
-
const WHITELIST = new Map([
|
|
9
|
-
['browser', ['browser']],
|
|
10
|
-
['board', ['board']],
|
|
11
|
-
]);
|
|
12
|
-
function isRecord(v) {
|
|
13
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
-
}
|
|
15
|
-
async function readBody(req) {
|
|
16
|
-
const chunks = [];
|
|
17
|
-
for await (const chunk of req) {
|
|
18
|
-
chunks.push(Buffer.from(chunk));
|
|
19
|
-
}
|
|
20
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
21
|
-
}
|
|
22
|
-
/** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
|
|
23
|
-
export function createRunApiHandler(cwd) {
|
|
24
|
-
return async (req, res) => {
|
|
25
|
-
if (req.url !== '/api/run') {
|
|
26
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
27
|
-
res.end('not found');
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (req.method !== 'POST') {
|
|
31
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
32
|
-
res.end('not found');
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
let body;
|
|
36
|
-
try {
|
|
37
|
-
body = await readBody(req);
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
41
|
-
res.end(JSON.stringify({ error: 'failed to read body' }));
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
let payload;
|
|
45
|
-
try {
|
|
46
|
-
payload = JSON.parse(body);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
50
|
-
res.end(JSON.stringify({ error: 'invalid json' }));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
|
|
54
|
-
res.writeHead(400, { 'content-type': 'application/json' });
|
|
55
|
-
res.end(JSON.stringify({ error: 'unknown command' }));
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const bin = resolveBacklogBin(cwd);
|
|
59
|
-
if (!bin) {
|
|
60
|
-
res.writeHead(503, { 'content-type': 'application/json' });
|
|
61
|
-
res.end(JSON.stringify({ error: 'backlog cli not found' }));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const args = WHITELIST.get(payload.command);
|
|
65
|
-
try {
|
|
66
|
-
const child = crossSpawn(bin, args, {
|
|
67
|
-
cwd,
|
|
68
|
-
detached: true,
|
|
69
|
-
stdio: 'ignore',
|
|
70
|
-
});
|
|
71
|
-
child.on('error', () => { });
|
|
72
|
-
child.unref();
|
|
73
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
74
|
-
res.end(JSON.stringify({ ok: true }));
|
|
75
|
-
}
|
|
76
|
-
catch (err) {
|
|
77
|
-
res.writeHead(500, { 'content-type': 'application/json' });
|
|
78
|
-
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
6
|
/** SSE broker: keeps a set of response objects and broadcasts named events. */
|
|
83
7
|
export function createReloadBroker() {
|
|
84
8
|
const clients = new Set();
|
|
@@ -8,7 +8,8 @@ const FETCH_TIMEOUT_MS = 2000;
|
|
|
8
8
|
function cachePath(home) {
|
|
9
9
|
return join(home, '.super-backlog', 'version-check.json');
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** Triple-numeric semver compare; non-numeric parts are treated as not newer. */
|
|
12
|
+
export function isNewerVersion(latest, installed) {
|
|
12
13
|
const a = latest.split('.').slice(0, 3).map(Number);
|
|
13
14
|
const b = installed.split('.').slice(0, 3).map(Number);
|
|
14
15
|
if (a.length < 3 || b.length < 3)
|
|
@@ -106,7 +107,7 @@ export async function applyVersionHint(installed, deps) {
|
|
|
106
107
|
if (deps.env.SBL_SKIP_UPDATE_CHECK)
|
|
107
108
|
return;
|
|
108
109
|
const cache = readCache(deps.home);
|
|
109
|
-
if (cache &&
|
|
110
|
+
if (cache && isNewerVersion(cache.latest, installed)) {
|
|
110
111
|
deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
|
|
111
112
|
}
|
|
112
113
|
if (!cache || isStale(cache.checkedAt, deps.now())) {
|
|
@@ -1,18 +1,47 @@
|
|
|
1
|
+
// src/models/dashboard-api.ts
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { loadConfig } from './config.js';
|
|
2
5
|
import { discoverModels } from './discovery.js';
|
|
3
|
-
|
|
6
|
+
import { writeResolvedTiers, writeRouterConfig } from './install.js';
|
|
7
|
+
function sendJson(res, status, body) {
|
|
8
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
9
|
+
res.end(JSON.stringify(body));
|
|
10
|
+
}
|
|
11
|
+
function routerInstalled(cwd) {
|
|
12
|
+
return existsSync(join(cwd, '.super-backlog', 'models.json'));
|
|
13
|
+
}
|
|
14
|
+
export function createModelApiHandler(cwd, deps = {}) {
|
|
15
|
+
const discover = deps.discover ?? discoverModels;
|
|
4
16
|
return async (req, res) => {
|
|
5
17
|
const url = req.url ?? '/';
|
|
6
18
|
const method = req.method ?? 'GET';
|
|
7
19
|
if (method === 'GET' && url === '/api/models') {
|
|
8
|
-
res
|
|
9
|
-
|
|
20
|
+
sendJson(res, 200, { config: loadConfig(cwd), installed: routerInstalled(cwd), status: 'ok' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (method === 'POST' && (url === '/api/models/enable' || url === '/api/models/disable')) {
|
|
24
|
+
const enabled = url === '/api/models/enable';
|
|
25
|
+
try {
|
|
26
|
+
writeRouterConfig(cwd, enabled);
|
|
27
|
+
sendJson(res, 200, { ok: true, config: loadConfig(cwd), installed: routerInstalled(cwd) });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
sendJson(res, 500, { ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
31
|
+
}
|
|
10
32
|
return;
|
|
11
33
|
}
|
|
12
34
|
if (method === 'POST' && url === '/api/models/discover') {
|
|
13
|
-
const result = await
|
|
14
|
-
|
|
15
|
-
|
|
35
|
+
const result = await discover(cwd);
|
|
36
|
+
if (result) {
|
|
37
|
+
try {
|
|
38
|
+
writeResolvedTiers(cwd, result);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// discovery result still returned; the modal just won't remember it
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
sendJson(res, 200, result ?? { error: 'discovery failed' });
|
|
16
45
|
return;
|
|
17
46
|
}
|
|
18
47
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
package/dist/models/install.js
CHANGED
|
@@ -6,13 +6,20 @@ import { loadConfig } from './config.js';
|
|
|
6
6
|
const CONFIG_DIR = '.super-backlog';
|
|
7
7
|
const CONFIG_FILE = 'models.json';
|
|
8
8
|
export function writeRouterConfig(cwd, enabled) {
|
|
9
|
+
writeConfigPatch(cwd, { enabled });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
/** Persist discovered tiers so the dashboard shows them across sessions. */
|
|
13
|
+
export function writeResolvedTiers(cwd, resolved) {
|
|
14
|
+
writeConfigPatch(cwd, { resolved });
|
|
15
|
+
}
|
|
16
|
+
function writeConfigPatch(cwd, patch) {
|
|
9
17
|
const dir = join(cwd, CONFIG_DIR);
|
|
10
18
|
const path = join(dir, CONFIG_FILE);
|
|
11
19
|
if (!existsSync(dir)) {
|
|
12
20
|
mkdirSync(dir, { recursive: true });
|
|
13
21
|
}
|
|
14
22
|
const current = loadConfig(cwd);
|
|
15
|
-
const next = { ...current,
|
|
23
|
+
const next = { ...current, ...patch };
|
|
16
24
|
atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
17
|
-
return true;
|
|
18
25
|
}
|
|
@@ -107,8 +107,9 @@
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/* ---------- Detail panel ---------- */
|
|
110
|
+
dialog { margin: auto; } /* the global reset removes the UA margin that centers dialogs */
|
|
110
111
|
#task-dialog {
|
|
111
|
-
width: min(
|
|
112
|
+
width: min(720px, 92vw); max-height: 85vh; overflow-y: auto; overflow-x: hidden; padding: 0;
|
|
112
113
|
border: 1px solid var(--line-strong); border-radius: 14px;
|
|
113
114
|
background: var(--surface); color: var(--text);
|
|
114
115
|
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
|
@@ -122,8 +123,45 @@
|
|
|
122
123
|
#task-dialog[open] { animation: none; }
|
|
123
124
|
}
|
|
124
125
|
@keyframes sbl-dialog-in { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
|
|
127
|
+
/* ---------- Backlog browser overlay ---------- */
|
|
128
|
+
#backlog-dialog {
|
|
129
|
+
width: 96vw; height: 94vh; max-width: none; max-height: none; padding: 0;
|
|
130
|
+
display: none; flex-direction: column;
|
|
131
|
+
border: 1px solid var(--line-strong); border-radius: 14px;
|
|
132
|
+
background: var(--surface); color: var(--text);
|
|
133
|
+
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
|
134
|
+
}
|
|
135
|
+
#backlog-dialog[open] { display: flex; animation: sbl-dialog-in .18s ease-out; }
|
|
136
|
+
#backlog-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
|
|
137
|
+
@media (prefers-reduced-motion: reduce) {
|
|
138
|
+
#backlog-dialog[open] { animation: none; }
|
|
139
|
+
}
|
|
140
|
+
.backlog-dialog-bar {
|
|
141
|
+
display: flex; align-items: center; gap: 14px; padding: 8px 14px;
|
|
142
|
+
border-bottom: 1px solid var(--line-strong); background: var(--surface-2);
|
|
143
|
+
}
|
|
144
|
+
.backlog-dialog-title { font-weight: 700; font-size: .9rem; }
|
|
145
|
+
.backlog-dialog-bar a {
|
|
146
|
+
margin-left: auto; font-family: var(--mono); font-size: .72rem;
|
|
147
|
+
color: var(--muted); text-decoration: none;
|
|
148
|
+
}
|
|
149
|
+
.backlog-dialog-bar a:hover { color: var(--accent); }
|
|
150
|
+
#backlog-close {
|
|
151
|
+
font: inherit; font-size: 1.1rem; line-height: 1; cursor: pointer;
|
|
152
|
+
color: var(--muted); background: none; border: 1px solid transparent;
|
|
153
|
+
border-radius: 8px; padding: 4px 10px;
|
|
154
|
+
}
|
|
155
|
+
#backlog-close:hover { color: var(--text); border-color: var(--line); }
|
|
156
|
+
#backlog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
157
|
+
#backlog-frame { flex: 1; width: 100%; border: 0; background: var(--bg); }
|
|
158
|
+
.dialog-content { padding: 0 24px 26px; }
|
|
159
|
+
.detail-head {
|
|
160
|
+
position: sticky; top: 0; z-index: 1;
|
|
161
|
+
display: flex; align-items: center; gap: 10px;
|
|
162
|
+
margin: 0 -24px 12px; padding: 16px 24px 12px;
|
|
163
|
+
background: var(--surface); border-bottom: 1px solid var(--line);
|
|
164
|
+
}
|
|
127
165
|
.detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
|
|
128
166
|
.status-chip { font-family: var(--mono); font-size: .68rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
|
|
129
167
|
.status-chip[data-tone="ok"] { color: var(--ok); border-color: #2b5642; background: var(--ok-bg); }
|
|
@@ -132,13 +170,31 @@
|
|
|
132
170
|
.status-chip[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
|
|
133
171
|
.detail-close { margin-left: auto; background: none; border: none; color: var(--dim); font-size: 1.3rem; cursor: pointer; line-height: 1; }
|
|
134
172
|
.detail-close:hover { color: var(--danger); }
|
|
135
|
-
.detail-title { font-size: 1.
|
|
136
|
-
#task-dialog .detail-desc { color: var(--muted); margin-bottom:
|
|
137
|
-
#task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin:
|
|
138
|
-
.meta
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
.detail-title { font-size: 1.3rem; font-weight: 700; line-height: 1.3; margin-bottom: 10px; overflow-wrap: anywhere; }
|
|
174
|
+
#task-dialog .detail-desc { color: var(--muted); line-height: 1.6; margin-bottom: 10px; white-space: normal; }
|
|
175
|
+
#task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 18px 0 8px; }
|
|
176
|
+
.detail-meta {
|
|
177
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
178
|
+
gap: 12px 18px; margin: 16px 0 4px; padding: 14px 16px;
|
|
179
|
+
border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2);
|
|
180
|
+
}
|
|
181
|
+
.meta-cell .meta-label {
|
|
182
|
+
display: block; color: var(--dim); font-size: .66rem;
|
|
183
|
+
letter-spacing: 1.2px; text-transform: uppercase; margin-bottom: 4px;
|
|
184
|
+
}
|
|
185
|
+
.meta-value { font-size: .88rem; color: var(--text); overflow-wrap: anywhere; }
|
|
186
|
+
.meta-value.empty { color: var(--dim); }
|
|
187
|
+
.meta-value[data-tone="danger"] { color: var(--danger); }
|
|
188
|
+
.meta-value[data-tone="warn"] { color: var(--warn); }
|
|
189
|
+
.meta-value[data-tone="dim"] { color: var(--muted); }
|
|
190
|
+
.ac-progress { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }
|
|
191
|
+
.ac-count { font-family: var(--mono); font-size: .78rem; color: var(--muted); white-space: nowrap; }
|
|
192
|
+
.ac-bar { flex: 1; height: 4px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
|
|
193
|
+
.ac-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); }
|
|
141
194
|
.dep-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
195
|
+
.dep-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 7px; background: var(--dim); }
|
|
196
|
+
.dep-dot.done { background: var(--ok); }
|
|
197
|
+
.detail-cmd { margin-top: 18px; }
|
|
142
198
|
.dep-link {
|
|
143
199
|
font-family: var(--mono); font-size: .74rem; padding: 2px 10px; border-radius: 999px;
|
|
144
200
|
border: 1px solid #274a63; background: #10202e; color: var(--accent); cursor: pointer;
|
|
@@ -245,23 +301,98 @@
|
|
|
245
301
|
.spark-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
|
|
246
302
|
|
|
247
303
|
/* ---------- Pipeline stepper ---------- */
|
|
248
|
-
.stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); margin: 4px 0
|
|
249
|
-
.step {
|
|
304
|
+
.stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 2px; margin: 4px 0 6px; }
|
|
305
|
+
.step {
|
|
306
|
+
position: relative; min-width: 0; padding: 10px 4px 12px; text-align: center;
|
|
307
|
+
background: none; border: 1px solid transparent; border-radius: 10px; cursor: pointer;
|
|
308
|
+
font: inherit; color: inherit; transition: background .15s ease, border-color .15s ease;
|
|
309
|
+
}
|
|
250
310
|
.step::before {
|
|
251
|
-
content: ""; position: absolute; top:
|
|
311
|
+
content: ""; position: absolute; top: 27px; left: -50%; width: 100%; height: 2px;
|
|
252
312
|
background: var(--line-strong); z-index: 0;
|
|
253
313
|
}
|
|
254
314
|
.step:first-child::before { display: none; }
|
|
315
|
+
.step:hover { background: var(--surface-2); border-color: var(--line); }
|
|
316
|
+
.step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
317
|
+
.step[aria-expanded="true"] { background: var(--accent-dim); border-color: var(--accent); }
|
|
318
|
+
.step[aria-expanded="true"].gate { border-color: var(--warn); background: var(--warn-bg); }
|
|
255
319
|
.step-num {
|
|
256
|
-
position: relative; z-index: 1; width:
|
|
320
|
+
position: relative; z-index: 1; width: 34px; height: 34px; margin: 0 auto 10px;
|
|
257
321
|
display: flex; align-items: center; justify-content: center;
|
|
258
|
-
border-radius: 50%; font-family: var(--mono); font-size: .
|
|
322
|
+
border-radius: 50%; font-family: var(--mono); font-size: .9rem; font-weight: 700;
|
|
259
323
|
background: var(--surface-2); border: 2px solid var(--line-strong); color: var(--muted);
|
|
260
324
|
}
|
|
261
325
|
.step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px rgba(255,180,84,.25); }
|
|
262
|
-
.step-label {
|
|
263
|
-
|
|
264
|
-
|
|
326
|
+
.step-label {
|
|
327
|
+
display: block; font-size: .8rem; font-weight: 600; color: var(--text); line-height: 1.3;
|
|
328
|
+
overflow-wrap: break-word;
|
|
329
|
+
}
|
|
330
|
+
.step.gate .step-label { color: var(--warn); }
|
|
331
|
+
|
|
332
|
+
/* ---------- Phase detail panel ---------- */
|
|
333
|
+
#phase-detail {
|
|
334
|
+
margin: 2px 0 12px; padding: 14px 18px; border: 1px solid var(--line-strong);
|
|
335
|
+
border-left: 3px solid var(--accent); border-radius: 10px; background: var(--surface);
|
|
336
|
+
}
|
|
337
|
+
#phase-detail.open { animation: phasein .18s ease; }
|
|
338
|
+
#phase-detail.gate { border-left-color: var(--warn); }
|
|
339
|
+
@keyframes phasein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
340
|
+
.phase-detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
|
|
341
|
+
.phase-detail-num { font-family: var(--mono); font-size: .85rem; font-weight: 700; color: var(--accent); }
|
|
342
|
+
#phase-detail.gate .phase-detail-num { color: var(--warn); }
|
|
343
|
+
.phase-detail-name { font-size: 1rem; font-weight: 700; }
|
|
344
|
+
.phase-detail-gate { margin: 0 0 10px; font-size: .88rem; line-height: 1.55; color: var(--muted); }
|
|
345
|
+
.phase-cmd {
|
|
346
|
+
display: inline-flex; align-items: center; gap: 10px; padding: 7px 12px;
|
|
347
|
+
font: inherit; cursor: pointer; border-radius: 8px;
|
|
348
|
+
background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--text);
|
|
349
|
+
transition: border-color .15s ease;
|
|
350
|
+
}
|
|
351
|
+
.phase-cmd:hover { border-color: var(--accent); }
|
|
352
|
+
.phase-cmd:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
353
|
+
.phase-cmd .cmd-line { font-family: var(--mono); font-size: .8rem; color: var(--accent); }
|
|
354
|
+
.phase-cmd .cmd-title { font-size: .72rem; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
|
|
355
|
+
@media (prefers-reduced-motion: reduce) {
|
|
356
|
+
.step, .phase-cmd { transition: none; }
|
|
357
|
+
#phase-detail.open { animation: none; }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* ---------- Sidebar version + update badge ---------- */
|
|
361
|
+
.side-version {
|
|
362
|
+
display: flex; align-items: center; gap: 8px; margin: 6px 0 2px;
|
|
363
|
+
font-family: var(--mono); font-size: .78rem; color: var(--dim);
|
|
364
|
+
}
|
|
365
|
+
.update-badge {
|
|
366
|
+
font: inherit; font-family: var(--mono); font-size: .68rem; cursor: pointer;
|
|
367
|
+
color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn);
|
|
368
|
+
border-radius: 999px; padding: 2px 9px; transition: box-shadow .15s ease;
|
|
369
|
+
}
|
|
370
|
+
.update-badge:hover { box-shadow: 0 0 10px rgba(255,180,84,.3); }
|
|
371
|
+
.update-badge:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
|
|
372
|
+
.update-badge .cmd-title { font-size: inherit; font-weight: 600; color: inherit; }
|
|
373
|
+
.side-models {
|
|
374
|
+
display: block; margin: 4px 0 0; padding: 2px 0; cursor: pointer; text-align: left;
|
|
375
|
+
font: inherit; font-family: var(--mono); font-size: .78rem; color: var(--muted);
|
|
376
|
+
background: none; border: none;
|
|
377
|
+
}
|
|
378
|
+
.side-models:hover { color: var(--accent); }
|
|
379
|
+
.side-models:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
380
|
+
|
|
381
|
+
/* ---------- Models modal ---------- */
|
|
382
|
+
#models-dialog {
|
|
383
|
+
width: min(560px, 92vw); max-height: 80vh; overflow-y: auto; overflow-x: hidden; padding: 0;
|
|
384
|
+
border: 1px solid var(--line-strong); border-radius: 14px;
|
|
385
|
+
background: var(--surface); color: var(--text);
|
|
386
|
+
box-shadow: 0 24px 80px rgba(0,0,0,.55);
|
|
387
|
+
}
|
|
388
|
+
#models-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
|
|
389
|
+
#models-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
|
|
390
|
+
@media (prefers-reduced-motion: reduce) {
|
|
391
|
+
#models-dialog[open] { animation: none; }
|
|
392
|
+
}
|
|
393
|
+
.phase-cmd:disabled { opacity: .6; cursor: default; }
|
|
394
|
+
.models-head-title { font-weight: 700; }
|
|
395
|
+
.models-row { display: flex; align-items: center; gap: 10px; margin: 14px 0; flex-wrap: wrap; }
|
|
265
396
|
|
|
266
397
|
/* ---------- Dependency flow ---------- */
|
|
267
398
|
.sub-head {
|
|
@@ -302,6 +433,8 @@
|
|
|
302
433
|
<aside class="sbl-side">
|
|
303
434
|
<div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b></div>
|
|
304
435
|
<div class="kicker">SUPERPOWERS × BACKLOG.MD</div>
|
|
436
|
+
<div class="side-version" id="side-version"><span>v__KIT_VERSION__</span></div>
|
|
437
|
+
<button type="button" id="models-btn" class="side-models">model router</button>
|
|
305
438
|
<nav aria-label="Dashboard sections">
|
|
306
439
|
<a href="#sec-01"><span class="n">01</span>Board & Quick Actions</a>
|
|
307
440
|
<a href="#sec-02"><span class="n">02</span>Status</a>
|
|
@@ -322,14 +455,12 @@
|
|
|
322
455
|
<main>
|
|
323
456
|
|
|
324
457
|
<section id="sec-01">
|
|
325
|
-
<div class="sec-head"><span class="sec-num">01</span><h2>Board & Quick Actions</h2><span class="tagline">
|
|
458
|
+
<div class="sec-head"><span class="sec-num">01</span><h2>Board & Quick Actions</h2><span class="tagline">the full Backlog.md UI · one click away</span></div>
|
|
326
459
|
<div id="quickactions" class="mount">
|
|
327
460
|
<div class="cmd-row" id="cmd-buttons">
|
|
328
|
-
<button type="button" class="cmd-btn"
|
|
329
|
-
<button type="button" class="cmd-btn" data-cmd="board"><span class="cmd-title">Backlog Board</span><span class="cmd-line">backlog board</span></button>
|
|
330
|
-
<button type="button" class="cmd-btn" data-copy="sbl dashboard"><span class="cmd-title">Live Dashboard</span><span class="cmd-line">sbl dashboard</span></button>
|
|
461
|
+
<button type="button" class="cmd-btn" id="backlog-btn"><span class="cmd-title">Backlog</span><span class="cmd-line">board · tasks · docs · decisions</span></button>
|
|
331
462
|
</div>
|
|
332
|
-
<p class="hint">
|
|
463
|
+
<p class="hint">Opens the Backlog.md browser in an overlay — served locally per project, started on demand.</p>
|
|
333
464
|
</div>
|
|
334
465
|
<div id="drafts" class="mount">
|
|
335
466
|
<h3 class="sub-head">Drafts</h3>
|
|
@@ -373,8 +504,9 @@
|
|
|
373
504
|
</section>
|
|
374
505
|
|
|
375
506
|
<section id="sec-05">
|
|
376
|
-
<div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea → merge · every <span class="term" data-term="Review Gate">Review Gate</span> included</span></div>
|
|
507
|
+
<div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea → merge · every <span class="term" data-term="Review Gate">Review Gate</span> included · click a step for details</span></div>
|
|
377
508
|
<div id="stepper" class="mount"></div>
|
|
509
|
+
<div id="phase-detail" hidden></div>
|
|
378
510
|
<h3 class="sub-head">Flow</h3>
|
|
379
511
|
<div id="depgraph" class="mount"></div>
|
|
380
512
|
</section>
|
|
@@ -398,7 +530,27 @@
|
|
|
398
530
|
</main>
|
|
399
531
|
</div>
|
|
400
532
|
|
|
401
|
-
<dialog id="task-dialog"
|
|
533
|
+
<dialog id="task-dialog"></dialog>
|
|
534
|
+
|
|
535
|
+
<dialog id="backlog-dialog" aria-label="Backlog browser">
|
|
536
|
+
<div class="backlog-dialog-bar">
|
|
537
|
+
<span class="backlog-dialog-title">Backlog</span>
|
|
538
|
+
<a id="backlog-open-tab" href="#" target="_blank" rel="noopener">open in new tab</a>
|
|
539
|
+
<button type="button" id="backlog-close" aria-label="Close">×</button>
|
|
540
|
+
</div>
|
|
541
|
+
<iframe id="backlog-frame" title="Backlog.md browser"></iframe>
|
|
542
|
+
</dialog>
|
|
543
|
+
|
|
544
|
+
<dialog id="models-dialog" aria-label="Model router">
|
|
545
|
+
<div class="dialog-content">
|
|
546
|
+
<div class="detail-head">
|
|
547
|
+
<span class="detail-id">MODELS</span>
|
|
548
|
+
<span class="models-head-title">Model router</span>
|
|
549
|
+
<button type="button" class="detail-close" id="models-close" aria-label="Close">×</button>
|
|
550
|
+
</div>
|
|
551
|
+
<div id="models-body"></div>
|
|
552
|
+
</div>
|
|
553
|
+
</dialog>
|
|
402
554
|
<div id="sbl-tip" role="tooltip" hidden></div>
|
|
403
555
|
|
|
404
556
|
<script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
|
|
@@ -421,6 +573,16 @@
|
|
|
421
573
|
badge.textContent = data.source === 'fallback-empty' ? 'no live data' : 'live backlog data';
|
|
422
574
|
}
|
|
423
575
|
|
|
576
|
+
var sideVersion = $('#side-version');
|
|
577
|
+
if (sideVersion && data.latestVersion) {
|
|
578
|
+
var upd = el('button', 'update-badge');
|
|
579
|
+
upd.type = 'button';
|
|
580
|
+
upd.appendChild(el('span', 'cmd-title', 'v' + data.latestVersion + ' available'));
|
|
581
|
+
upd.setAttribute('data-tip', 'Update: npm i -g super-backlog (click to copy)');
|
|
582
|
+
upd.addEventListener('click', function () { copyCommand(upd, 'npm i -g super-backlog'); });
|
|
583
|
+
sideVersion.appendChild(upd);
|
|
584
|
+
}
|
|
585
|
+
|
|
424
586
|
var KEYS = ['id', 'title', 'status', 'milestone', 'priority', 'assignee', 'updated'];
|
|
425
587
|
var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null };
|
|
426
588
|
function field(task, key) {
|
|
@@ -511,26 +673,134 @@
|
|
|
511
673
|
/* ---------- Quick action buttons ---------- */
|
|
512
674
|
function cmdFeedback(btn, text) {
|
|
513
675
|
var title = btn.querySelector('.cmd-title');
|
|
514
|
-
if (!title
|
|
515
|
-
btn.
|
|
516
|
-
|
|
676
|
+
if (!title) return;
|
|
677
|
+
if (btn.__sblFbTimer) {
|
|
678
|
+
clearTimeout(btn.__sblFbTimer); /* newer feedback overrides the pending one */
|
|
679
|
+
} else {
|
|
680
|
+
btn.__sblFbOrig = title.textContent;
|
|
681
|
+
}
|
|
517
682
|
title.textContent = text;
|
|
518
|
-
setTimeout(function () {
|
|
683
|
+
btn.__sblFbTimer = setTimeout(function () {
|
|
684
|
+
title.textContent = btn.__sblFbOrig;
|
|
685
|
+
btn.__sblFbTimer = null;
|
|
686
|
+
}, 1200);
|
|
519
687
|
}
|
|
520
688
|
function copyCommand(btn, command) {
|
|
521
689
|
if (navigator.clipboard) navigator.clipboard.writeText(command).catch(function () {});
|
|
522
690
|
cmdFeedback(btn, 'copied \u2713');
|
|
523
691
|
}
|
|
524
|
-
document.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
692
|
+
var backlogBtn = document.getElementById('backlog-btn');
|
|
693
|
+
var backlogDialog = document.getElementById('backlog-dialog');
|
|
694
|
+
var backlogFrame = document.getElementById('backlog-frame');
|
|
695
|
+
var backlogTab = document.getElementById('backlog-open-tab');
|
|
696
|
+
function openBacklog(url) {
|
|
697
|
+
if (!backlogDialog || !backlogFrame) return;
|
|
698
|
+
if (backlogFrame.getAttribute('src') !== url) backlogFrame.setAttribute('src', url);
|
|
699
|
+
if (backlogTab) backlogTab.setAttribute('href', url);
|
|
700
|
+
backlogDialog.showModal();
|
|
701
|
+
}
|
|
702
|
+
if (backlogBtn) {
|
|
703
|
+
backlogBtn.addEventListener('click', function () {
|
|
704
|
+
cmdFeedback(backlogBtn, 'starting\u2026');
|
|
705
|
+
fetch('api/backlog-browser', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
706
|
+
.then(function (res) { return res.json().then(function (data) { return { ok: res.ok, data: data }; }); })
|
|
707
|
+
.then(function (r) {
|
|
708
|
+
if (!r.ok || !r.data || !r.data.url) throw new Error('start failed');
|
|
709
|
+
openBacklog(r.data.url);
|
|
710
|
+
})
|
|
711
|
+
.catch(function () { cmdFeedback(backlogBtn, 'failed \u2717'); });
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
var backlogClose = document.getElementById('backlog-close');
|
|
715
|
+
if (backlogClose && backlogDialog) {
|
|
716
|
+
backlogClose.addEventListener('click', function () { backlogDialog.close(); });
|
|
717
|
+
backlogDialog.addEventListener('click', function (e) {
|
|
718
|
+
if (e.target === backlogDialog) backlogDialog.close();
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/* ---------- Models modal ---------- */
|
|
723
|
+
var modelsBtn = document.getElementById('models-btn');
|
|
724
|
+
var modelsDialog = document.getElementById('models-dialog');
|
|
725
|
+
var modelsBody = document.getElementById('models-body');
|
|
726
|
+
function tierCell(label, value) {
|
|
727
|
+
var c = el('div', 'meta-cell');
|
|
728
|
+
c.appendChild(el('span', 'meta-label', label));
|
|
729
|
+
c.appendChild(el('span', 'meta-value' + (value ? '' : ' empty'), value || '—'));
|
|
730
|
+
return c;
|
|
731
|
+
}
|
|
732
|
+
function actionButton(cmdLine, label, onClick) {
|
|
733
|
+
var b = el('button', 'phase-cmd');
|
|
734
|
+
b.type = 'button';
|
|
735
|
+
b.appendChild(el('span', 'cmd-line', cmdLine));
|
|
736
|
+
b.appendChild(el('span', 'cmd-title', label));
|
|
737
|
+
b.addEventListener('click', onClick);
|
|
738
|
+
return b;
|
|
739
|
+
}
|
|
740
|
+
function renderTiers(mount, tiers) {
|
|
741
|
+
mount.textContent = '';
|
|
742
|
+
if (!tiers || tiers.error || (!tiers.workhorse && !tiers.budget)) {
|
|
743
|
+
mount.appendChild(el('p', 'hint', 'Discovery failed — is the OpenCode CLI available?'));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
mount.appendChild(tierCell('Workhorse', tiers.workhorse));
|
|
747
|
+
mount.appendChild(tierCell('Budget', tiers.budget));
|
|
748
|
+
}
|
|
749
|
+
function renderModelsDialog(info) {
|
|
750
|
+
if (!modelsBody) return;
|
|
751
|
+
modelsBody.textContent = '';
|
|
752
|
+
var enabled = !!(info.config && info.config.enabled);
|
|
753
|
+
var row = el('div', 'models-row');
|
|
754
|
+
var chip = el('span', 'status-chip', info.installed ? (enabled ? 'enabled' : 'disabled') : 'not installed');
|
|
755
|
+
if (info.installed) chip.setAttribute('data-tone', enabled ? 'ok' : 'warn');
|
|
756
|
+
row.appendChild(chip);
|
|
757
|
+
var toggle = actionButton(
|
|
758
|
+
enabled ? 'sbl models disable' : 'sbl models enable',
|
|
759
|
+
enabled ? 'disable' : 'enable',
|
|
760
|
+
function () {
|
|
761
|
+
fetch('api/models/' + (enabled ? 'disable' : 'enable'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
762
|
+
.then(function (res) { if (!res.ok) throw new Error('toggle failed'); return res.json(); })
|
|
763
|
+
.then(function (next) { renderModelsDialog(next); })
|
|
764
|
+
.catch(function () { cmdFeedback(toggle, 'failed ✗'); });
|
|
765
|
+
},
|
|
766
|
+
);
|
|
767
|
+
row.appendChild(toggle);
|
|
768
|
+
var discover = actionButton('sbl models discover', 'run', function () {
|
|
769
|
+
discover.disabled = true;
|
|
770
|
+
cmdFeedback(discover, 'running…');
|
|
771
|
+
fetch('api/models/discover', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
772
|
+
.then(function (res) { return res.json(); })
|
|
773
|
+
.then(function (r) { renderTiers(document.getElementById('models-tiers'), r); })
|
|
774
|
+
.catch(function () { cmdFeedback(discover, 'failed ✗'); })
|
|
775
|
+
.then(function () { discover.disabled = false; });
|
|
776
|
+
});
|
|
777
|
+
row.appendChild(discover);
|
|
778
|
+
modelsBody.appendChild(row);
|
|
779
|
+
var tiers = el('div', 'detail-meta');
|
|
780
|
+
tiers.id = 'models-tiers';
|
|
781
|
+
var resolved = (info.config && info.config.resolved) || {};
|
|
782
|
+
tiers.appendChild(tierCell('Workhorse', resolved.workhorse));
|
|
783
|
+
tiers.appendChild(tierCell('Budget', resolved.budget));
|
|
784
|
+
modelsBody.appendChild(tiers);
|
|
785
|
+
if (!info.installed) {
|
|
786
|
+
modelsBody.appendChild(el('p', 'hint', 'Install the router first, then enable it:'));
|
|
787
|
+
var hint = actionButton('sbl init --models', 'copy', function () { copyCommand(hint, 'sbl init --models'); });
|
|
788
|
+
modelsBody.appendChild(hint);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (modelsBtn && modelsDialog) {
|
|
792
|
+
modelsBtn.addEventListener('click', function () {
|
|
793
|
+
fetch('api/models')
|
|
794
|
+
.then(function (res) { if (!res.ok) throw new Error('load failed'); return res.json(); })
|
|
795
|
+
.then(function (info) { renderModelsDialog(info); modelsDialog.showModal(); })
|
|
531
796
|
.catch(function () {});
|
|
532
797
|
});
|
|
533
|
-
|
|
798
|
+
var modelsClose = document.getElementById('models-close');
|
|
799
|
+
if (modelsClose) modelsClose.addEventListener('click', function () { modelsDialog.close(); });
|
|
800
|
+
modelsDialog.addEventListener('click', function (e) {
|
|
801
|
+
if (e.target === modelsDialog) modelsDialog.close();
|
|
802
|
+
});
|
|
803
|
+
}
|
|
534
804
|
|
|
535
805
|
/* ---------- Drafts ---------- */
|
|
536
806
|
function renderDrafts(drafts) {
|
|
@@ -702,23 +972,60 @@
|
|
|
702
972
|
mount.appendChild(svg);
|
|
703
973
|
}
|
|
704
974
|
|
|
975
|
+
function renderPhaseDetail(panel, p, isGate) {
|
|
976
|
+
panel.textContent = '';
|
|
977
|
+
panel.classList.toggle('gate', isGate);
|
|
978
|
+
var head = el('div', 'phase-detail-head');
|
|
979
|
+
head.appendChild(el('span', 'phase-detail-num', String(p.n).padStart(2, '0')));
|
|
980
|
+
head.appendChild(el('span', 'phase-detail-name', p.name));
|
|
981
|
+
panel.appendChild(head);
|
|
982
|
+
panel.appendChild(el('p', 'phase-detail-gate', p.gate));
|
|
983
|
+
if (p.command) {
|
|
984
|
+
var cmd = el('button', 'phase-cmd');
|
|
985
|
+
cmd.type = 'button';
|
|
986
|
+
cmd.appendChild(el('span', 'cmd-line', p.command));
|
|
987
|
+
cmd.appendChild(el('span', 'cmd-title', 'copy'));
|
|
988
|
+
cmd.addEventListener('click', function () { copyCommand(cmd, p.command); });
|
|
989
|
+
panel.appendChild(cmd);
|
|
990
|
+
}
|
|
991
|
+
panel.hidden = false;
|
|
992
|
+
panel.classList.remove('open');
|
|
993
|
+
void panel.offsetWidth; /* restart the open animation on re-render */
|
|
994
|
+
panel.classList.add('open');
|
|
995
|
+
}
|
|
996
|
+
|
|
705
997
|
function renderStepper(mount, phases) {
|
|
706
998
|
mount.textContent = '';
|
|
707
999
|
if (!phases || phases.length === 0) {
|
|
708
1000
|
mount.appendChild(el('p', 'hint', 'Pipeline unavailable.'));
|
|
709
1001
|
return;
|
|
710
1002
|
}
|
|
1003
|
+
var panel = document.getElementById('phase-detail');
|
|
711
1004
|
var wrap = el('div', 'stepper');
|
|
1005
|
+
var current = null;
|
|
712
1006
|
phases.forEach(function (p) {
|
|
713
|
-
var
|
|
1007
|
+
var isGate = /gate/i.test(String(p.name));
|
|
1008
|
+
var step = el('button', 'step' + (isGate ? ' gate' : ''));
|
|
1009
|
+
step.type = 'button';
|
|
714
1010
|
step.setAttribute('data-phase', p.n);
|
|
1011
|
+
step.setAttribute('aria-expanded', 'false');
|
|
1012
|
+
step.setAttribute('aria-controls', 'phase-detail');
|
|
715
1013
|
step.appendChild(el('div', 'step-num', String(p.n)));
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1014
|
+
step.appendChild(el('span', 'step-label', p.name));
|
|
1015
|
+
step.addEventListener('click', function () {
|
|
1016
|
+
if (!panel) return;
|
|
1017
|
+
var wasOpen = current === step;
|
|
1018
|
+
wrap.querySelectorAll('.step').forEach(function (s) { s.setAttribute('aria-expanded', 'false'); });
|
|
1019
|
+
if (wasOpen) {
|
|
1020
|
+
panel.hidden = true;
|
|
1021
|
+
panel.classList.remove('open');
|
|
1022
|
+
current = null;
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
step.setAttribute('aria-expanded', 'true');
|
|
1026
|
+
current = step;
|
|
1027
|
+
renderPhaseDetail(panel, p, isGate);
|
|
1028
|
+
});
|
|
722
1029
|
wrap.appendChild(step);
|
|
723
1030
|
});
|
|
724
1031
|
mount.appendChild(wrap);
|
|
@@ -830,32 +1137,67 @@
|
|
|
830
1137
|
return list;
|
|
831
1138
|
}
|
|
832
1139
|
function depSection(titleText, ids) {
|
|
1140
|
+
var valid = ids.filter(function (id) { return findTask(id); });
|
|
1141
|
+
if (valid.length === 0) return null;
|
|
833
1142
|
var block = el('div');
|
|
834
1143
|
block.appendChild(el('h4', '', titleText));
|
|
835
1144
|
var row = el('div', 'dep-row');
|
|
836
|
-
|
|
837
|
-
var
|
|
1145
|
+
valid.forEach(function (id) {
|
|
1146
|
+
var dep = findTask(id);
|
|
1147
|
+
var btn = el('button', 'dep-link');
|
|
838
1148
|
btn.type = 'button';
|
|
1149
|
+
var dot = el('span', 'dep-dot' + (dep && isDoneStatus(dep.status) ? ' done' : ''));
|
|
1150
|
+
dot.setAttribute('aria-hidden', 'true');
|
|
1151
|
+
btn.appendChild(dot);
|
|
1152
|
+
btn.appendChild(document.createTextNode(id));
|
|
839
1153
|
btn.addEventListener('click', function () { openDetail(id); });
|
|
840
1154
|
row.appendChild(btn);
|
|
841
1155
|
});
|
|
842
|
-
if (row.children.length === 0) row.appendChild(el('p', 'detail-empty', 'None'));
|
|
843
1156
|
block.appendChild(row);
|
|
844
1157
|
return block;
|
|
845
1158
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1159
|
+
var PRIORITY_TONE = { high: 'danger', medium: 'warn', low: 'dim' };
|
|
1160
|
+
function priorityTone(priority) {
|
|
1161
|
+
return PRIORITY_TONE[String(priority || '').toLowerCase()] || '';
|
|
1162
|
+
}
|
|
1163
|
+
function metaGrid(task) {
|
|
1164
|
+
var grid = el('div', 'detail-meta');
|
|
1165
|
+
function cell(label, value, tone) {
|
|
1166
|
+
var c = el('div', 'meta-cell');
|
|
1167
|
+
c.appendChild(el('span', 'meta-label', label));
|
|
1168
|
+
var v = el('span', 'meta-value' + (value ? '' : ' empty'), value || '—');
|
|
1169
|
+
if (value && tone) v.setAttribute('data-tone', tone);
|
|
1170
|
+
c.appendChild(v);
|
|
1171
|
+
return c;
|
|
853
1172
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
return
|
|
1173
|
+
grid.appendChild(cell('Milestone', task.milestone));
|
|
1174
|
+
grid.appendChild(cell('Priority', task.priority, priorityTone(task.priority)));
|
|
1175
|
+
grid.appendChild(cell('Assignee', task.assignee));
|
|
1176
|
+
grid.appendChild(cell('Updated', task.updated));
|
|
1177
|
+
return grid;
|
|
1178
|
+
}
|
|
1179
|
+
function descParagraphs(text) {
|
|
1180
|
+
var frag = document.createDocumentFragment();
|
|
1181
|
+
String(text).split(/\n\s*\n/).forEach(function (para) {
|
|
1182
|
+
var trimmed = para.trim();
|
|
1183
|
+
if (trimmed !== '') frag.appendChild(el('p', 'detail-desc', trimmed));
|
|
1184
|
+
});
|
|
1185
|
+
return frag;
|
|
1186
|
+
}
|
|
1187
|
+
function acSection(task) {
|
|
1188
|
+
var frag = document.createDocumentFragment();
|
|
1189
|
+
var done = task.acs.filter(function (ac) { return ac.checked; }).length;
|
|
1190
|
+
frag.appendChild(el('h4', '', 'Acceptance criteria'));
|
|
1191
|
+
var progress = el('div', 'ac-progress');
|
|
1192
|
+
progress.appendChild(el('span', 'ac-count', done + ' / ' + task.acs.length + ' done'));
|
|
1193
|
+
var bar = el('div', 'ac-bar');
|
|
1194
|
+
var fill = el('div', 'ac-bar-fill');
|
|
1195
|
+
fill.style.width = task.acs.length > 0 ? Math.round((done / task.acs.length) * 100) + '%' : '0%';
|
|
1196
|
+
bar.appendChild(fill);
|
|
1197
|
+
progress.appendChild(bar);
|
|
1198
|
+
frag.appendChild(progress);
|
|
1199
|
+
frag.appendChild(acList(task));
|
|
1200
|
+
return frag;
|
|
859
1201
|
}
|
|
860
1202
|
function openDetail(id) {
|
|
861
1203
|
var t = findTask(id);
|
|
@@ -872,15 +1214,29 @@
|
|
|
872
1214
|
closeBtn.addEventListener('click', closeDetail);
|
|
873
1215
|
head.appendChild(closeBtn);
|
|
874
1216
|
content.appendChild(head);
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
content.appendChild(
|
|
1217
|
+
var title = el('h3', 'detail-title', t.title);
|
|
1218
|
+
title.id = 'detail-title-h';
|
|
1219
|
+
dialog.setAttribute('aria-labelledby', 'detail-title-h');
|
|
1220
|
+
content.appendChild(title);
|
|
1221
|
+
if (t.description) {
|
|
1222
|
+
content.appendChild(descParagraphs(t.description));
|
|
1223
|
+
} else {
|
|
1224
|
+
content.appendChild(el('p', 'detail-desc', 'No description.'));
|
|
881
1225
|
}
|
|
882
|
-
content.appendChild(
|
|
883
|
-
content.appendChild(
|
|
1226
|
+
content.appendChild(metaGrid(t));
|
|
1227
|
+
if (t.acs.length > 0) content.appendChild(acSection(t));
|
|
1228
|
+
var out = depSection('Depends on', depsOut[id] || []);
|
|
1229
|
+
if (out) content.appendChild(out);
|
|
1230
|
+
var inn = depSection('Needed by', depsIn[id] || []);
|
|
1231
|
+
if (inn) content.appendChild(inn);
|
|
1232
|
+
var cmd = el('button', 'phase-cmd detail-cmd');
|
|
1233
|
+
cmd.type = 'button';
|
|
1234
|
+
/* backlog task ids are TASK-<n>; other prefixes fall back to the raw id */
|
|
1235
|
+
var cmdLine = 'backlog task edit ' + t.id.replace(/^task-/i, '');
|
|
1236
|
+
cmd.appendChild(el('span', 'cmd-line', cmdLine));
|
|
1237
|
+
cmd.appendChild(el('span', 'cmd-title', 'copy'));
|
|
1238
|
+
cmd.addEventListener('click', function () { copyCommand(cmd, cmdLine); });
|
|
1239
|
+
content.appendChild(cmd);
|
|
884
1240
|
dialog.textContent = '';
|
|
885
1241
|
dialog.appendChild(content);
|
|
886
1242
|
dialog.showModal();
|