super-backlog 1.3.0 → 1.3.2
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/README.md +4 -2
- package/dist/cli.js +8 -3
- package/dist/commands/dashboard.js +40 -12
- package/dist/commands/update.js +87 -8
- package/dist/dashboard/data.js +44 -13
- package/dist/dashboard/hub.js +1 -1
- package/dist/dashboard/metrics.js +97 -0
- package/dist/lib/hub-state.js +2 -2
- package/dist/lib/self-update.js +57 -0
- package/dist/lib/version-check.js +7 -3
- package/dist/templates/dashboard.html +536 -133
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,9 @@ sbl dashboard # live Project Dashboard on http://localhost:642
|
|
|
73
73
|
| `dashboard.html` | generated Project Dashboard | not installed in user projects; generated on demand by `sbl dashboard` |
|
|
74
74
|
| `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
|
|
75
75
|
|
|
76
|
-
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]
|
|
76
|
+
Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]` (alias: `sbl db`). See `sbl help` for every flag.
|
|
77
|
+
|
|
78
|
+
`sbl update` first self-updates a globally installed CLI to the latest npm version and re-runs itself (opt out with `--no-self` or `SBL_SKIP_UPDATE_CHECK`), then refreshes injected files.
|
|
77
79
|
|
|
78
80
|
## Model router (opt-in)
|
|
79
81
|
|
|
@@ -97,7 +99,7 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
|
|
|
97
99
|
|
|
98
100
|
## Project Dashboard
|
|
99
101
|
|
|
100
|
-
`sbl dashboard` starts a local hub that serves
|
|
102
|
+
`sbl dashboard` starts a local hub that serves an HTS-style cockpit (light/dark theme toggle) rendered from your Backlog data in eight sections — Board & Quick Actions, Status (KPI tiles, donut, and an aging strip for open tasks), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Milestones, Drafts (click a card for details), Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Activity (a 26-week calendar heatmap; click a day for its tasks), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). Typefaces load from Google Fonts with full system fallbacks — the one external resource; everything else is inline, and the dashboard still renders offline. Bookmark `http://127.0.0.1:6428/p/<project_name>/`. The hub watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. A second repo's `sbl dashboard` attaches to the same hub. `Ctrl+C` in the hub terminal stops all projects.
|
|
101
103
|
|
|
102
104
|
### Keeping it fresh
|
|
103
105
|
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ Commands:
|
|
|
20
20
|
init Install the kit into the current project
|
|
21
21
|
uninstall Remove kit-managed files (project data kept unless --with-backlog)
|
|
22
22
|
update Refresh kit-managed files and report upstream versions
|
|
23
|
-
dashboard Start the project dashboard server (live-reload)
|
|
23
|
+
dashboard Start the project dashboard server (live-reload) (alias: db)
|
|
24
24
|
models Manage the model router (show, enable, disable, discover)
|
|
25
25
|
doctor Check the environment (node, PowerShell policy, backlog CLI)
|
|
26
26
|
|
|
@@ -38,7 +38,7 @@ uninstall options:
|
|
|
38
38
|
--fix-all Also remove the global npm package (no prompts)
|
|
39
39
|
|
|
40
40
|
update options:
|
|
41
|
-
|
|
41
|
+
--no-self Skip self-updating the CLI before refreshing
|
|
42
42
|
|
|
43
43
|
dashboard options:
|
|
44
44
|
--port <n> Port for the dashboard server (default: 6428)
|
|
@@ -102,12 +102,17 @@ export async function runCli(argv) {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
case 'update': {
|
|
105
|
-
const parsed = parseArgs({
|
|
105
|
+
const parsed = parseArgs({
|
|
106
|
+
args: rest,
|
|
107
|
+
allowPositionals: true,
|
|
108
|
+
options: { 'no-self': { type: 'boolean' } },
|
|
109
|
+
});
|
|
106
110
|
return await runUpdate(process.cwd(), {
|
|
107
111
|
values: parsed.values,
|
|
108
112
|
positionals: parsed.positionals,
|
|
109
113
|
});
|
|
110
114
|
}
|
|
115
|
+
case 'db':
|
|
111
116
|
case 'dashboard': {
|
|
112
117
|
const parsed = parseArgs({
|
|
113
118
|
args: rest,
|
|
@@ -11,6 +11,9 @@ import { atomicWrite } from '../lib/atomic.js';
|
|
|
11
11
|
import { clearHubState, isPidAlive, newHubToken, readHubState, writeHubState } from '../lib/hub-state.js';
|
|
12
12
|
import { projectSlug } from '../lib/slug.js';
|
|
13
13
|
import { KIT_VERSION } from '../lib/version.js';
|
|
14
|
+
/** Max time to wait for an outdated hub to exit after killPid, in 100ms polls. */
|
|
15
|
+
const STOP_POLL_MAX_ATTEMPTS = 50;
|
|
16
|
+
const STOP_POLL_INTERVAL_MS = 100;
|
|
14
17
|
async function regenerateInto(outPath, cwd) {
|
|
15
18
|
const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
|
|
16
19
|
atomicWrite(outPath, renderDashboard(data));
|
|
@@ -151,6 +154,9 @@ export async function runDashboard(cwd, args, deps = {}) {
|
|
|
151
154
|
const attach = deps.attach ?? defaultAttach;
|
|
152
155
|
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
|
153
156
|
const pid = (deps.nowPid ?? (() => process.pid))();
|
|
157
|
+
const isAlive = deps.isAlive ?? isPidAlive;
|
|
158
|
+
const killPid = deps.killPid ?? ((p) => { process.kill(p); });
|
|
159
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
154
160
|
let slugResult;
|
|
155
161
|
try {
|
|
156
162
|
slugResult = projectSlug(cwd);
|
|
@@ -165,22 +171,44 @@ export async function runDashboard(cwd, args, deps = {}) {
|
|
|
165
171
|
}
|
|
166
172
|
const slug = slugResult.slug;
|
|
167
173
|
const state = readHubState(home);
|
|
168
|
-
if (state !== null &&
|
|
174
|
+
if (state !== null && isAlive(state.pid)) {
|
|
169
175
|
try {
|
|
170
176
|
const status = await attach(`http://127.0.0.1:${state.port}/api/hub/status?token=${encodeURIComponent(state.token)}`, undefined);
|
|
171
177
|
if (status.status === 200) {
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
const statusJson = typeof status.json === 'object' && status.json !== null
|
|
179
|
+
? status.json
|
|
180
|
+
: undefined;
|
|
181
|
+
const liveVersion = typeof statusJson?.version === 'string' ? statusJson.version : undefined;
|
|
182
|
+
const effectiveVersion = liveVersion ?? state.version;
|
|
183
|
+
if (effectiveVersion === KIT_VERSION) {
|
|
184
|
+
if (values['port'] !== undefined && port !== state.port) {
|
|
185
|
+
console.error(`error: a hub is already running on ${state.port}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
return await attachToHub({
|
|
189
|
+
cwd,
|
|
190
|
+
port: state.port,
|
|
191
|
+
token: state.token,
|
|
192
|
+
attach,
|
|
193
|
+
openBrowser,
|
|
194
|
+
noOpen,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
console.error(`hub v${effectiveVersion ?? 'unknown'} does not match this CLI (v${KIT_VERSION}) — restarting it`);
|
|
198
|
+
killPid(state.pid);
|
|
199
|
+
let dead = !isAlive(state.pid);
|
|
200
|
+
for (let attempt = 0; !dead && attempt < STOP_POLL_MAX_ATTEMPTS; attempt++) {
|
|
201
|
+
await sleep(STOP_POLL_INTERVAL_MS);
|
|
202
|
+
dead = !isAlive(state.pid);
|
|
203
|
+
}
|
|
204
|
+
if (dead) {
|
|
205
|
+
clearHubState(home, state.pid);
|
|
206
|
+
// fall through to the fresh-start path below
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
console.error(`error: could not stop the outdated hub (pid ${state.pid}) — stop it manually and re-run`);
|
|
174
210
|
return 1;
|
|
175
211
|
}
|
|
176
|
-
return await attachToHub({
|
|
177
|
-
cwd,
|
|
178
|
-
port: state.port,
|
|
179
|
-
token: state.token,
|
|
180
|
-
attach,
|
|
181
|
-
openBrowser,
|
|
182
|
-
noOpen,
|
|
183
|
-
});
|
|
184
212
|
}
|
|
185
213
|
}
|
|
186
214
|
catch {
|
|
@@ -206,7 +234,7 @@ export async function runDashboard(cwd, args, deps = {}) {
|
|
|
206
234
|
console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
|
|
207
235
|
return 1;
|
|
208
236
|
}
|
|
209
|
-
writeHubState(home, { pid, port: hub.port, token });
|
|
237
|
+
writeHubState(home, { pid, port: hub.port, token, version: KIT_VERSION });
|
|
210
238
|
const result = hub.register({ cwd, file: outPath, regenerate });
|
|
211
239
|
if (!result.ok) {
|
|
212
240
|
await hub.close();
|
package/dist/commands/update.js
CHANGED
|
@@ -1,13 +1,86 @@
|
|
|
1
1
|
// src/commands/update.ts
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, join, resolve } from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
|
+
import spawn from 'cross-spawn';
|
|
5
6
|
import { executeActions, findGitDir, InvalidJsonError, validateJsonFile, RefusalError, UpstreamError } from '../init/execute.js';
|
|
6
7
|
import { planInit } from '../init/planner.js';
|
|
7
8
|
import { GUARD_RE } from '../lib/hooks.js';
|
|
8
9
|
import { detectPackageManager } from '../lib/pm.js';
|
|
9
10
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
11
|
+
import { detectInstallKind, runSelfUpdate } from '../lib/self-update.js';
|
|
10
12
|
import { KIT_VERSION } from '../lib/version.js';
|
|
13
|
+
import { fetchLatestVersion } from '../lib/version-check.js';
|
|
14
|
+
/** Longer than the startup version-hint fetch: this one blocks `sbl update` directly. */
|
|
15
|
+
const SELF_UPDATE_FETCH_TIMEOUT_MS = 10000;
|
|
16
|
+
function resolveGlobalRoot(cwd) {
|
|
17
|
+
const r = runCapture('npm', ['root', '-g'], cwd);
|
|
18
|
+
if (r.status !== 0)
|
|
19
|
+
return null;
|
|
20
|
+
const line = firstLine(r.stdout);
|
|
21
|
+
return line === '' ? null : line;
|
|
22
|
+
}
|
|
23
|
+
function resolveBinRealPath() {
|
|
24
|
+
const argvBin = process.argv[1];
|
|
25
|
+
if (!argvBin)
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
return realpathSync(argvBin);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Runs the self-update check/install and, when a new version was installed,
|
|
36
|
+
* re-execs the new binary once (env-guarded via SBL_SELF_UPDATED) and
|
|
37
|
+
* returns its exit code. Returns null when the caller should fall through
|
|
38
|
+
* to the normal refresh (no update available, offline, install failed, or
|
|
39
|
+
* the update was skipped entirely).
|
|
40
|
+
*/
|
|
41
|
+
async function maybeSelfUpdate(cwd, args, override) {
|
|
42
|
+
const skip = args.values['no-self'] === true ||
|
|
43
|
+
Boolean(process.env.SBL_SELF_UPDATED) ||
|
|
44
|
+
Boolean(process.env.SBL_SKIP_UPDATE_CHECK) ||
|
|
45
|
+
Boolean(process.env.SBL_FORCE_OFFLINE);
|
|
46
|
+
if (skip)
|
|
47
|
+
return null;
|
|
48
|
+
const binRealPath = override.binRealPath !== undefined ? override.binRealPath : resolveBinRealPath();
|
|
49
|
+
const globalRoot = override.globalRoot !== undefined ? override.globalRoot : resolveGlobalRoot(cwd);
|
|
50
|
+
const installKind = binRealPath === null ? 'unknown' : detectInstallKind(binRealPath, cwd, globalRoot);
|
|
51
|
+
const fetchLatest = override.fetchLatest ?? (() => fetchLatestVersion(SELF_UPDATE_FETCH_TIMEOUT_MS));
|
|
52
|
+
const npmInstallGlobal = override.npmInstallGlobal ?? ((spec) => runCapture('npm', ['i', '-g', spec], cwd));
|
|
53
|
+
const result = await runSelfUpdate({
|
|
54
|
+
installed: KIT_VERSION,
|
|
55
|
+
fetchLatest,
|
|
56
|
+
installKind,
|
|
57
|
+
npmInstallGlobal,
|
|
58
|
+
log: (line) => console.log(line),
|
|
59
|
+
warn: (line) => console.warn(`warning: ${line}`),
|
|
60
|
+
});
|
|
61
|
+
if (result.kind !== 'updated')
|
|
62
|
+
return null;
|
|
63
|
+
// npm rewrites the global package's files in place, so process.argv[1]
|
|
64
|
+
// still points at a valid path after the install -- re-resolve anyway in
|
|
65
|
+
// case the realpath target moved (e.g. a version-pinned symlink).
|
|
66
|
+
const updatedBinPath = override.binRealPath !== undefined ? override.binRealPath : resolveBinRealPath();
|
|
67
|
+
if (updatedBinPath === null)
|
|
68
|
+
return null; // can't re-exec without a bin path; fall through on the old version
|
|
69
|
+
const spawnSelf = override.spawnSelf ??
|
|
70
|
+
((binPath, rArgs, execCwd, env) => {
|
|
71
|
+
const r = spawn.sync(process.execPath, [binPath, 'update', ...rArgs], {
|
|
72
|
+
cwd: execCwd,
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
env,
|
|
75
|
+
});
|
|
76
|
+
return { status: r.status ?? 1 };
|
|
77
|
+
});
|
|
78
|
+
const res = spawnSelf(updatedBinPath, args.positionals, cwd, {
|
|
79
|
+
...process.env,
|
|
80
|
+
SBL_SELF_UPDATED: '1',
|
|
81
|
+
});
|
|
82
|
+
return res.status ?? 1;
|
|
83
|
+
}
|
|
11
84
|
const REFRESH_KINDS = new Set([
|
|
12
85
|
'inject-agents-block',
|
|
13
86
|
'write-claude-pointer',
|
|
@@ -29,7 +102,10 @@ function guardHookInstalled(cwd) {
|
|
|
29
102
|
return false;
|
|
30
103
|
return GUARD_RE.test(readFileSync(hookPath, 'utf8'));
|
|
31
104
|
}
|
|
32
|
-
export async function runUpdate(cwd,
|
|
105
|
+
export async function runUpdate(cwd, args, selfUpdateOverride = {}) {
|
|
106
|
+
const selfUpdateExitCode = await maybeSelfUpdate(cwd, args, selfUpdateOverride);
|
|
107
|
+
if (selfUpdateExitCode !== null)
|
|
108
|
+
return selfUpdateExitCode;
|
|
33
109
|
// Up-front detection-failure check (mirrors uninstall): refuse before mutating anything.
|
|
34
110
|
for (const f of ['package.json', 'opencode.json']) {
|
|
35
111
|
const p = join(cwd, f);
|
|
@@ -100,14 +176,17 @@ export async function runUpdate(cwd, _args) {
|
|
|
100
176
|
warnings.push(`\`${bin} --version\` failed with exit code ${local.status}`);
|
|
101
177
|
}
|
|
102
178
|
}
|
|
179
|
+
const probePublished = selfUpdateOverride.probePublished ??
|
|
180
|
+
(() => {
|
|
181
|
+
// test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
|
|
182
|
+
if (process.env.SBL_FORCE_OFFLINE)
|
|
183
|
+
throw new Error('forced offline');
|
|
184
|
+
const view = runCapture('npm', ['view', 'backlog.md', 'version'], cwd);
|
|
185
|
+
return view.status === 0 ? firstLine(view.stdout) : null;
|
|
186
|
+
});
|
|
103
187
|
let published = null;
|
|
104
188
|
try {
|
|
105
|
-
|
|
106
|
-
if (process.env.SBL_FORCE_OFFLINE)
|
|
107
|
-
throw new Error('forced offline');
|
|
108
|
-
const view = runCapture('npm', ['view', 'backlog.md', 'version'], cwd);
|
|
109
|
-
if (view.status === 0)
|
|
110
|
-
published = firstLine(view.stdout);
|
|
189
|
+
published = probePublished();
|
|
111
190
|
}
|
|
112
191
|
catch {
|
|
113
192
|
published = null;
|
package/dist/dashboard/data.js
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, join } from 'node:path';
|
|
|
5
5
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
6
6
|
import { isNewerVersion } from '../lib/version-check.js';
|
|
7
7
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
8
|
+
import { computeKpis } from './metrics.js';
|
|
8
9
|
function isRecord(v) {
|
|
9
10
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
10
11
|
}
|
|
@@ -70,6 +71,7 @@ export function normalizeTasks(rawTasks) {
|
|
|
70
71
|
status: asString(t['status']) ?? 'Unknown',
|
|
71
72
|
priority: asString(t['priority']),
|
|
72
73
|
assignee: firstAssignee(t),
|
|
74
|
+
created: asString(t['createdAt']) ?? asString(t['created_at']) ?? asString(t['created']),
|
|
73
75
|
updated: asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated']),
|
|
74
76
|
milestone: asString(t['milestone']),
|
|
75
77
|
description: asString(t['description']),
|
|
@@ -140,20 +142,24 @@ function shiftDay(day, deltaDays) {
|
|
|
140
142
|
const [y, mo, d] = day.split('-').map(Number);
|
|
141
143
|
return new Date(Date.UTC(y, mo - 1, d) + deltaDays * 86400000).toISOString().slice(0, 10);
|
|
142
144
|
}
|
|
143
|
-
|
|
145
|
+
export const ACTIVITY_DAYS = 182;
|
|
146
|
+
/** Bucket tasks into exactly ACTIVITY_DAYS UTC daily buckets ending at `today`, oldest first. */
|
|
144
147
|
export function computeActivity(rawTasks, today) {
|
|
145
|
-
const
|
|
148
|
+
const byDay = new Map();
|
|
146
149
|
for (const t of rawTasks) {
|
|
147
|
-
const day = isoDay(asString(t['updated_at']) ?? asString(t['updated'])) ??
|
|
148
|
-
isoDay(asString(t['created_at'])) ??
|
|
150
|
+
const day = isoDay(asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated'])) ??
|
|
151
|
+
isoDay(asString(t['createdAt']) ?? asString(t['created_at'])) ??
|
|
149
152
|
today;
|
|
150
|
-
|
|
153
|
+
const ids = byDay.get(day) ?? [];
|
|
154
|
+
ids.push(asString(t['id']) ?? '');
|
|
155
|
+
byDay.set(day, ids);
|
|
151
156
|
}
|
|
152
|
-
const start = shiftDay(today, -
|
|
157
|
+
const start = shiftDay(today, -(ACTIVITY_DAYS - 1));
|
|
153
158
|
const out = [];
|
|
154
|
-
for (let i = 0; i <
|
|
159
|
+
for (let i = 0; i < ACTIVITY_DAYS; i++) {
|
|
155
160
|
const date = shiftDay(start, i);
|
|
156
|
-
|
|
161
|
+
const ids = byDay.get(date) ?? [];
|
|
162
|
+
out.push({ date, count: ids.length, ids });
|
|
157
163
|
}
|
|
158
164
|
return out;
|
|
159
165
|
}
|
|
@@ -230,13 +236,33 @@ function readProjectGlossary(cwd) {
|
|
|
230
236
|
}
|
|
231
237
|
}
|
|
232
238
|
function readDraftFile(path) {
|
|
233
|
-
const keys = readSimpleKeys(path, [
|
|
239
|
+
const keys = readSimpleKeys(path, [
|
|
240
|
+
'id', 'title', 'status', 'priority', 'assignee',
|
|
241
|
+
'created_date', 'updated_date', 'created', 'updated',
|
|
242
|
+
]);
|
|
234
243
|
const id = asString(keys.id);
|
|
235
244
|
const title = asString(keys.title);
|
|
236
245
|
const status = asString(keys.status);
|
|
237
246
|
if (!id || !title || !status)
|
|
238
247
|
return null;
|
|
239
|
-
|
|
248
|
+
let detail = { acs: [] };
|
|
249
|
+
try {
|
|
250
|
+
detail = parseTaskFile(readFileSync(path, 'utf8'));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// keys-only draft when the file cannot be re-read
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
id,
|
|
257
|
+
title,
|
|
258
|
+
status,
|
|
259
|
+
description: detail.description,
|
|
260
|
+
priority: asString(keys.priority),
|
|
261
|
+
assignee: asString(keys.assignee),
|
|
262
|
+
created: asString(keys['created_date']) ?? asString(keys['created']),
|
|
263
|
+
updated: asString(keys['updated_date']) ?? asString(keys['updated']),
|
|
264
|
+
acs: detail.acs,
|
|
265
|
+
};
|
|
240
266
|
}
|
|
241
267
|
export function readDrafts(cwd) {
|
|
242
268
|
const draftsDir = join(cwd, 'backlog', 'drafts');
|
|
@@ -353,6 +379,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
353
379
|
const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
|
|
354
380
|
? opts.today.trim()
|
|
355
381
|
: new Date().toISOString().slice(0, 10);
|
|
382
|
+
const activity = computeActivity([], today);
|
|
356
383
|
const base = {
|
|
357
384
|
project: readProjectIdentity(cwd),
|
|
358
385
|
generatedAt: new Date().toISOString(),
|
|
@@ -363,8 +390,9 @@ export function collectDashboardData(cwd, opts) {
|
|
|
363
390
|
tasks: [],
|
|
364
391
|
deps: [],
|
|
365
392
|
drafts: readDrafts(cwd),
|
|
366
|
-
activity
|
|
393
|
+
activity,
|
|
367
394
|
glossary: mergeGlossary(readProjectGlossary(cwd)),
|
|
395
|
+
kpis: computeKpis([], [], activity, today),
|
|
368
396
|
source: 'fallback-empty',
|
|
369
397
|
};
|
|
370
398
|
try {
|
|
@@ -376,13 +404,16 @@ export function collectDashboardData(cwd, opts) {
|
|
|
376
404
|
return base;
|
|
377
405
|
const rawTasks = parseTasksJson(res.stdout);
|
|
378
406
|
const tasks = enrichTasksFromFiles(cwd, normalizeTasks(rawTasks));
|
|
407
|
+
const deps = computeDeps(rawTasks);
|
|
408
|
+
const taskActivity = computeActivity(rawTasks, today);
|
|
379
409
|
return {
|
|
380
410
|
...base,
|
|
381
411
|
tasks,
|
|
382
412
|
statuses: computeStatuses(tasks),
|
|
383
413
|
milestones: computeMilestones(tasks),
|
|
384
|
-
deps
|
|
385
|
-
activity:
|
|
414
|
+
deps,
|
|
415
|
+
activity: taskActivity,
|
|
416
|
+
kpis: computeKpis(tasks, deps, taskActivity, today),
|
|
386
417
|
source: 'backlog-json',
|
|
387
418
|
};
|
|
388
419
|
}
|
package/dist/dashboard/hub.js
CHANGED
|
@@ -166,7 +166,7 @@ export async function startHubServer(opts) {
|
|
|
166
166
|
sendText(res, 401, 'unauthorized');
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
|
-
sendJson(res, 200, { pid: process.pid, port });
|
|
169
|
+
sendJson(res, 200, { pid: process.pid, port, version: KIT_VERSION });
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
172
|
if (pathname === '/api/hub/register' && method === 'POST') {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
function isDone(status) {
|
|
2
|
+
const s = status.toLowerCase();
|
|
3
|
+
return s === 'done' || s === 'complete' || s === 'completed';
|
|
4
|
+
}
|
|
5
|
+
function isWip(status) {
|
|
6
|
+
const s = status.toLowerCase();
|
|
7
|
+
return s.includes('progress') || s.includes('review');
|
|
8
|
+
}
|
|
9
|
+
function utcDay(value) {
|
|
10
|
+
if (!value)
|
|
11
|
+
return null;
|
|
12
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
|
13
|
+
if (m)
|
|
14
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
15
|
+
const t = Date.parse(value);
|
|
16
|
+
if (Number.isNaN(t))
|
|
17
|
+
return null;
|
|
18
|
+
const d = new Date(t);
|
|
19
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
20
|
+
}
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
export function computeKpis(tasks, deps, activity, today) {
|
|
23
|
+
const todayMs = utcDay(today) ?? Date.UTC(1970, 0, 1);
|
|
24
|
+
const total = tasks.length;
|
|
25
|
+
const doneTasks = tasks.filter((t) => isDone(t.status));
|
|
26
|
+
const done = doneTasks.length;
|
|
27
|
+
const open = total - done;
|
|
28
|
+
const progressPct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
29
|
+
const doneInWindow = (fromDaysAgo, toDaysAgo) => doneTasks.filter((t) => {
|
|
30
|
+
const d = utcDay(t.updated);
|
|
31
|
+
if (d === null)
|
|
32
|
+
return false;
|
|
33
|
+
const age = (todayMs - d) / DAY_MS;
|
|
34
|
+
return age >= toDaysAgo && age < fromDaysAgo;
|
|
35
|
+
}).length;
|
|
36
|
+
const velocity7 = doneInWindow(7, 0);
|
|
37
|
+
const velocityPrev7 = doneInWindow(14, 7);
|
|
38
|
+
let forecastDate = null;
|
|
39
|
+
if (velocity7 > 0 && open > 0) {
|
|
40
|
+
const daysNeeded = Math.ceil((open / velocity7) * 7);
|
|
41
|
+
forecastDate = new Date(todayMs + daysNeeded * DAY_MS).toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
44
|
+
const unresolved = new Set();
|
|
45
|
+
for (const dep of deps) {
|
|
46
|
+
const from = byId.get(dep.from);
|
|
47
|
+
const to = byId.get(dep.to);
|
|
48
|
+
if (from && !isDone(from.status) && to && !isDone(to.status))
|
|
49
|
+
unresolved.add(dep.from);
|
|
50
|
+
}
|
|
51
|
+
const openTasks = tasks.filter((t) => !isDone(t.status));
|
|
52
|
+
const wip = openTasks.filter((t) => isWip(t.status)).length;
|
|
53
|
+
const blocked = openTasks.filter((t) => t.status.toLowerCase().includes('block') || unresolved.has(t.id)).length;
|
|
54
|
+
const ages = [];
|
|
55
|
+
for (const t of openTasks) {
|
|
56
|
+
const d = utcDay(t.created) ?? utcDay(t.updated);
|
|
57
|
+
if (d === null)
|
|
58
|
+
continue;
|
|
59
|
+
ages.push({ id: t.id, days: Math.max(0, Math.round((todayMs - d) / DAY_MS)) });
|
|
60
|
+
}
|
|
61
|
+
ages.sort((a, b) => b.days - a.days);
|
|
62
|
+
const oldest = ages[0] ?? null;
|
|
63
|
+
let medianOpenAgeDays = null;
|
|
64
|
+
if (ages.length > 0) {
|
|
65
|
+
const mid = Math.floor(ages.length / 2);
|
|
66
|
+
medianOpenAgeDays =
|
|
67
|
+
ages.length % 2 === 1 ? ages[mid].days : Math.round((ages[mid - 1].days + ages[mid].days) / 2);
|
|
68
|
+
}
|
|
69
|
+
const activityTotal30 = activity.slice(-30).reduce((sum, b) => sum + b.count, 0);
|
|
70
|
+
const windowTotal = activity.reduce((sum, b) => sum + b.count, 0);
|
|
71
|
+
const activityAvgPerWeek = activity.length > 0 ? Math.round((windowTotal / (activity.length / 7)) * 10) / 10 : 0;
|
|
72
|
+
const perWeekday = [0, 0, 0, 0, 0, 0, 0];
|
|
73
|
+
for (const b of activity) {
|
|
74
|
+
const d = utcDay(b.date);
|
|
75
|
+
if (d !== null)
|
|
76
|
+
perWeekday[new Date(d).getUTCDay()] += b.count;
|
|
77
|
+
}
|
|
78
|
+
const maxWeekday = Math.max(...perWeekday);
|
|
79
|
+
const busiestWeekday = maxWeekday > 0 ? perWeekday.indexOf(maxWeekday) : null;
|
|
80
|
+
let streakDays = 0;
|
|
81
|
+
let i = activity.length - 1;
|
|
82
|
+
if (i >= 0 && activity[i].count === 0)
|
|
83
|
+
i--; // today may still be empty
|
|
84
|
+
while (i >= 0 && activity[i].count > 0) {
|
|
85
|
+
streakDays++;
|
|
86
|
+
i--;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
total, done, open, progressPct,
|
|
90
|
+
velocity7, velocityPrev7, forecastDate,
|
|
91
|
+
wip, blocked,
|
|
92
|
+
oldestOpenId: oldest ? oldest.id : null,
|
|
93
|
+
oldestOpenDays: oldest ? oldest.days : null,
|
|
94
|
+
medianOpenAgeDays,
|
|
95
|
+
activityTotal30, activityAvgPerWeek, busiestWeekday, streakDays,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/lib/hub-state.js
CHANGED
|
@@ -16,8 +16,8 @@ export function readHubState(home) {
|
|
|
16
16
|
typeof parsed.token !== 'string') {
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
|
-
const { pid, port, token } = parsed;
|
|
20
|
-
return { pid, port, token };
|
|
19
|
+
const { pid, port, token, version } = parsed;
|
|
20
|
+
return typeof version === 'string' ? { pid, port, token, version } : { pid, port, token };
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
23
23
|
return null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/lib/self-update.ts
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { isNewerVersion } from './version-check.js';
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes separators for prefix comparison and, on win32, case as well:
|
|
7
|
+
* `npm root -g` and `realpathSync` can disagree on drive-letter/path casing
|
|
8
|
+
* (`C:\` vs `c:\`) even for the same install, and Windows paths are
|
|
9
|
+
* case-insensitive anyway, so comparing case-sensitively there would
|
|
10
|
+
* misclassify a real global install as `unknown`. Takes an optional
|
|
11
|
+
* `caseInsensitive` override (default: `process.platform === 'win32'`) so
|
|
12
|
+
* the function stays pure and testable for both platforms' behavior.
|
|
13
|
+
*/
|
|
14
|
+
function normalizeForCompare(p, caseInsensitive) {
|
|
15
|
+
const withForwardSlashes = p.replace(/\\/g, '/');
|
|
16
|
+
return caseInsensitive ? withForwardSlashes.toLowerCase() : withForwardSlashes;
|
|
17
|
+
}
|
|
18
|
+
function isUnder(path, root) {
|
|
19
|
+
return path === root || path.startsWith(`${root}/`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Classifies the running binary's real path as a local (project
|
|
23
|
+
* node_modules) install, a global npm install, or unknown (treated the
|
|
24
|
+
* same as local: never mutated, hint only). Pure function -- callers
|
|
25
|
+
* gather binRealPath (from `process.argv[1]` + `realpathSync`) and
|
|
26
|
+
* globalRoot (from `npm root -g`, captured once, null on failure).
|
|
27
|
+
*/
|
|
28
|
+
export function detectInstallKind(binRealPath, cwd, globalRoot, caseInsensitive = process.platform === 'win32') {
|
|
29
|
+
const bin = normalizeForCompare(binRealPath, caseInsensitive);
|
|
30
|
+
const localRoot = normalizeForCompare(join(cwd, 'node_modules'), caseInsensitive);
|
|
31
|
+
if (isUnder(bin, localRoot))
|
|
32
|
+
return 'local';
|
|
33
|
+
if (globalRoot !== null && isUnder(bin, normalizeForCompare(globalRoot, caseInsensitive)))
|
|
34
|
+
return 'global';
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
export async function runSelfUpdate(deps) {
|
|
38
|
+
const latest = await deps.fetchLatest();
|
|
39
|
+
if (latest === null) {
|
|
40
|
+
deps.warn('could not check for a newer super-backlog (offline?)');
|
|
41
|
+
return { kind: 'unavailable' };
|
|
42
|
+
}
|
|
43
|
+
if (!isNewerVersion(latest, deps.installed)) {
|
|
44
|
+
return { kind: 'current' };
|
|
45
|
+
}
|
|
46
|
+
if (deps.installKind !== 'global') {
|
|
47
|
+
deps.log(`a newer super-backlog (${latest}) is available; update the dependency yourself, e.g. npm i -D super-backlog@${latest}`);
|
|
48
|
+
return { kind: 'skipped-local', latest };
|
|
49
|
+
}
|
|
50
|
+
const result = deps.npmInstallGlobal(`super-backlog@${latest}`);
|
|
51
|
+
if (result.status === 0) {
|
|
52
|
+
deps.log(`self-updated to ${latest}, re-running update...`);
|
|
53
|
+
return { kind: 'updated', latest };
|
|
54
|
+
}
|
|
55
|
+
deps.warn(`self-update to ${latest} failed (npm install exited ${String(result.status)}); continuing with ${deps.installed}`);
|
|
56
|
+
return { kind: 'failed', latest };
|
|
57
|
+
}
|
|
@@ -57,7 +57,8 @@ function isStale(checkedAt, now) {
|
|
|
57
57
|
function unrefStream(stream) {
|
|
58
58
|
stream?.unref?.();
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
/** Queries the npm registry for the latest published version, racing a timeout. */
|
|
61
|
+
export async function fetchLatestVersion(timeoutMs = FETCH_TIMEOUT_MS) {
|
|
61
62
|
const work = new Promise((resolvePromise) => {
|
|
62
63
|
let child;
|
|
63
64
|
try {
|
|
@@ -89,7 +90,7 @@ export async function defaultFetchLatest() {
|
|
|
89
90
|
});
|
|
90
91
|
let timer;
|
|
91
92
|
const timeout = new Promise((resolveTimeout) => {
|
|
92
|
-
timer = setTimeout(() => resolveTimeout(null),
|
|
93
|
+
timer = setTimeout(() => resolveTimeout(null), timeoutMs);
|
|
93
94
|
timer.unref();
|
|
94
95
|
});
|
|
95
96
|
try {
|
|
@@ -103,12 +104,15 @@ export async function defaultFetchLatest() {
|
|
|
103
104
|
clearTimeout(timer);
|
|
104
105
|
}
|
|
105
106
|
}
|
|
107
|
+
export async function defaultFetchLatest() {
|
|
108
|
+
return fetchLatestVersion(FETCH_TIMEOUT_MS);
|
|
109
|
+
}
|
|
106
110
|
export async function applyVersionHint(installed, deps) {
|
|
107
111
|
if (deps.env.SBL_SKIP_UPDATE_CHECK)
|
|
108
112
|
return;
|
|
109
113
|
const cache = readCache(deps.home);
|
|
110
114
|
if (cache && isNewerVersion(cache.latest, installed)) {
|
|
111
|
-
deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: npm i -g super-backlog`);
|
|
115
|
+
deps.log(`super-backlog ${cache.latest} is available (installed ${installed}). Update: sbl update (or npm i -g super-backlog)`);
|
|
112
116
|
}
|
|
113
117
|
if (!cache || isStale(cache.checkedAt, deps.now())) {
|
|
114
118
|
void deps
|