super-backlog 0.3.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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/cli.js +130 -0
- package/dist/commands/dashboard.js +69 -0
- package/dist/commands/doctor.js +65 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/uninstall.js +279 -0
- package/dist/commands/update.js +138 -0
- package/dist/dashboard/data.js +281 -0
- package/dist/dashboard/layering.js +94 -0
- package/dist/dashboard/regen.js +28 -0
- package/dist/dashboard/render.js +75 -0
- package/dist/dashboard/server.js +109 -0
- package/dist/init/execute.js +232 -0
- package/dist/init/planner.js +60 -0
- package/dist/lib/atomic.js +16 -0
- package/dist/lib/hooks.js +78 -0
- package/dist/lib/markers.js +41 -0
- package/dist/lib/opencode.js +17 -0
- package/dist/lib/ownership.js +15 -0
- package/dist/lib/pkgjson.js +41 -0
- package/dist/lib/pm.js +20 -0
- package/dist/lib/powershell.js +44 -0
- package/dist/lib/run.js +32 -0
- package/dist/lib/validate-task.js +22 -0
- package/dist/lib/version.js +12 -0
- package/dist/lib/yamlmini.js +18 -0
- package/dist/templates/claude-pointer.md +5 -0
- package/dist/templates/dashboard-refresh-hook.sh +18 -0
- package/dist/templates/dashboard.html +1001 -0
- package/dist/templates/guard-hook.sh +25 -0
- package/dist/templates/skill-backlog-status-report.md +31 -0
- package/dist/templates/skill-spec-to-backlog.md +32 -0
- package/dist/templates/skill-task-review-gate.md +30 -0
- package/dist/templates/workflow-block.md +31 -0
- package/package.json +43 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// src/commands/uninstall.ts
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { findGitDir, POINTER_HEADING_RE } from '../init/execute.js';
|
|
5
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
6
|
+
import { removeGuardHook, removeRefreshHook } from '../lib/hooks.js';
|
|
7
|
+
import { stripOwned } from '../lib/markers.js';
|
|
8
|
+
import { PLUGIN_SPEC } from '../lib/opencode.js';
|
|
9
|
+
import { isOwnedSkillFile } from '../lib/ownership.js';
|
|
10
|
+
import { WANTED_SCRIPTS } from '../lib/pkgjson.js';
|
|
11
|
+
const OWNED_SKILL_DIRS = [
|
|
12
|
+
'.opencode/skill/spec-to-backlog',
|
|
13
|
+
'.opencode/skill/backlog-status-report',
|
|
14
|
+
'.opencode/skill/task-review-gate',
|
|
15
|
+
'.claude/skills/spec-to-backlog',
|
|
16
|
+
'.claude/skills/backlog-status-report',
|
|
17
|
+
'.claude/skills/task-review-gate',
|
|
18
|
+
];
|
|
19
|
+
// ownership probe: the kit's generated dashboard carries both markers
|
|
20
|
+
function isKitDashboard(content) {
|
|
21
|
+
return content.includes('id="sbl-data"') && content.includes('super-backlog');
|
|
22
|
+
}
|
|
23
|
+
function prettyJson(value) {
|
|
24
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
25
|
+
}
|
|
26
|
+
function removePointerSection(content) {
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
const idx = lines.findIndex((line) => POINTER_HEADING_RE.test(line));
|
|
29
|
+
if (idx === -1)
|
|
30
|
+
return { content, removed: false };
|
|
31
|
+
let end = lines.length;
|
|
32
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
33
|
+
if (/^#{1,6}\s/.test(lines[i])) {
|
|
34
|
+
end = i;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const kept = [...lines.slice(0, idx), ...lines.slice(end)];
|
|
39
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === '')
|
|
40
|
+
kept.pop();
|
|
41
|
+
return { content: kept.length > 0 ? `${kept.join('\n')}\n` : '', removed: true };
|
|
42
|
+
}
|
|
43
|
+
function uninstallPackageJson(cwd, pkg, withBacklog, report) {
|
|
44
|
+
const path = join(cwd, 'package.json');
|
|
45
|
+
if (pkg === null) {
|
|
46
|
+
for (const name of Object.keys(WANTED_SCRIPTS)) {
|
|
47
|
+
report.push({ verdict: 'skipped', label: `npm script "${name}" (package.json not found)` });
|
|
48
|
+
}
|
|
49
|
+
for (const name of ['backlog.md', 'super-backlog']) {
|
|
50
|
+
report.push({
|
|
51
|
+
verdict: 'skipped',
|
|
52
|
+
label: `devDependency "${name}" (package.json not found)`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let changed = false;
|
|
58
|
+
const hadScripts = pkg.scripts !== undefined;
|
|
59
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
60
|
+
for (const [name, wanted] of Object.entries(WANTED_SCRIPTS)) {
|
|
61
|
+
if (!(name in scripts)) {
|
|
62
|
+
report.push({ verdict: 'skipped', label: `npm script "${name}" (not defined)` });
|
|
63
|
+
}
|
|
64
|
+
else if (scripts[name] === wanted) {
|
|
65
|
+
delete scripts[name];
|
|
66
|
+
changed = true;
|
|
67
|
+
report.push({ verdict: 'removed', label: `npm script "${name}"` });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
report.push({
|
|
71
|
+
verdict: 'kept',
|
|
72
|
+
label: `npm script "${name}" (differs from kit default)`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const hadDeps = pkg.devDependencies !== undefined;
|
|
77
|
+
const devDependencies = { ...(pkg.devDependencies ?? {}) };
|
|
78
|
+
for (const name of ['backlog.md', 'super-backlog']) {
|
|
79
|
+
const spec = devDependencies[name];
|
|
80
|
+
if (spec === undefined) {
|
|
81
|
+
report.push({ verdict: 'skipped', label: `devDependency "${name}" (not present)` });
|
|
82
|
+
}
|
|
83
|
+
else if (withBacklog || spec === 'latest') {
|
|
84
|
+
delete devDependencies[name];
|
|
85
|
+
changed = true;
|
|
86
|
+
report.push({ verdict: 'removed', label: `devDependency "${name}"` });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
report.push({
|
|
90
|
+
verdict: 'kept',
|
|
91
|
+
label: `devDependency "${name}" pinned to ${spec} (use --with-backlog to force removal)`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!changed)
|
|
96
|
+
return;
|
|
97
|
+
const next = { ...pkg };
|
|
98
|
+
if (hadScripts || Object.keys(scripts).length > 0)
|
|
99
|
+
next.scripts = scripts;
|
|
100
|
+
if (hadDeps || Object.keys(devDependencies).length > 0)
|
|
101
|
+
next.devDependencies = devDependencies;
|
|
102
|
+
atomicWrite(path, prettyJson(next));
|
|
103
|
+
}
|
|
104
|
+
function uninstallPluginEntry(cwd, config, report) {
|
|
105
|
+
const path = join(cwd, 'opencode.json');
|
|
106
|
+
if (config === null) {
|
|
107
|
+
report.push({ verdict: 'skipped', label: 'opencode.json plugin entry (file not found)' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const raw = config.plugin;
|
|
111
|
+
if (raw === undefined) {
|
|
112
|
+
report.push({ verdict: 'skipped', label: 'opencode.json plugin entry (none)' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const list = Array.isArray(raw) ? [...raw] : [raw];
|
|
116
|
+
if (list.some((entry) => typeof entry === 'string' && entry === PLUGIN_SPEC)) {
|
|
117
|
+
const rest = list.filter((entry) => !(typeof entry === 'string' && entry === PLUGIN_SPEC));
|
|
118
|
+
if (rest.length === 0)
|
|
119
|
+
delete config.plugin;
|
|
120
|
+
else
|
|
121
|
+
config.plugin = rest;
|
|
122
|
+
atomicWrite(path, prettyJson(config));
|
|
123
|
+
report.push({ verdict: 'removed', label: 'opencode.json plugin entry' });
|
|
124
|
+
}
|
|
125
|
+
else if (list.some((entry) => typeof entry === 'string' && entry.startsWith('superpowers@'))) {
|
|
126
|
+
report.push({
|
|
127
|
+
verdict: 'kept',
|
|
128
|
+
label: 'opencode.json plugin entry (differs from kit default)',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
report.push({ verdict: 'skipped', label: 'opencode.json plugin entry (no kit entry)' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function runUninstall(cwd, args) {
|
|
136
|
+
const withBacklog = args.values['with-backlog'] === true;
|
|
137
|
+
const report = [];
|
|
138
|
+
// validate both JSON files up front - no mutation happens unless parsing succeeds
|
|
139
|
+
let pkg = null;
|
|
140
|
+
const pkgPath = join(cwd, 'package.json');
|
|
141
|
+
if (existsSync(pkgPath)) {
|
|
142
|
+
try {
|
|
143
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
console.error('error: package.json is not valid JSON - fix it manually, then re-run');
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let opencodeConfig = null;
|
|
151
|
+
const ocPath = join(cwd, 'opencode.json');
|
|
152
|
+
if (existsSync(ocPath)) {
|
|
153
|
+
try {
|
|
154
|
+
opencodeConfig = JSON.parse(readFileSync(ocPath, 'utf8'));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
console.error('error: opencode.json is not valid JSON - fix it manually, then re-run');
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const agentsPath = join(cwd, 'AGENTS.md');
|
|
162
|
+
if (!existsSync(agentsPath)) {
|
|
163
|
+
report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (file not found)' });
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const stripped = stripOwned(readFileSync(agentsPath, 'utf8'));
|
|
167
|
+
if (stripped.removed) {
|
|
168
|
+
atomicWrite(agentsPath, stripped.content);
|
|
169
|
+
report.push({ verdict: 'removed', label: 'AGENTS.md managed block' });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
report.push({ verdict: 'skipped', label: 'AGENTS.md managed block (none found)' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const claudePath = join(cwd, 'CLAUDE.md');
|
|
176
|
+
if (!existsSync(claudePath)) {
|
|
177
|
+
report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (file not found)' });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const res = removePointerSection(readFileSync(claudePath, 'utf8'));
|
|
181
|
+
if (res.removed) {
|
|
182
|
+
atomicWrite(claudePath, res.content);
|
|
183
|
+
report.push({ verdict: 'removed', label: 'CLAUDE.md pointer section' });
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
report.push({ verdict: 'skipped', label: 'CLAUDE.md pointer section (none found)' });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const rel of OWNED_SKILL_DIRS) {
|
|
190
|
+
const abs = join(cwd, ...rel.split('/'));
|
|
191
|
+
const skillMd = join(abs, 'SKILL.md');
|
|
192
|
+
if (!existsSync(skillMd)) {
|
|
193
|
+
report.push(existsSync(abs)
|
|
194
|
+
? { verdict: 'kept', label: `${rel}/ (no SKILL.md - left untouched)` }
|
|
195
|
+
: { verdict: 'skipped', label: `${rel}/ (not found)` });
|
|
196
|
+
}
|
|
197
|
+
else if (isOwnedSkillFile(readFileSync(skillMd, 'utf8'))) {
|
|
198
|
+
rmSync(abs, { recursive: true, force: true });
|
|
199
|
+
report.push({ verdict: 'removed', label: `${rel}/` });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
report.push({
|
|
203
|
+
verdict: 'kept',
|
|
204
|
+
label: `${rel}/ (SKILL.md not managed by super-backlog)`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
uninstallPackageJson(cwd, pkg, withBacklog, report);
|
|
209
|
+
uninstallPluginEntry(cwd, opencodeConfig, report);
|
|
210
|
+
const gitDir = findGitDir(cwd);
|
|
211
|
+
if (!gitDir) {
|
|
212
|
+
report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (no .git directory)' });
|
|
213
|
+
}
|
|
214
|
+
else if (!existsSync(join(gitDir, 'hooks', 'pre-commit'))) {
|
|
215
|
+
report.push({ verdict: 'skipped', label: 'git pre-commit guard hook (not installed)' });
|
|
216
|
+
}
|
|
217
|
+
else if (removeGuardHook(gitDir)) {
|
|
218
|
+
report.push({ verdict: 'removed', label: 'git pre-commit guard hook' });
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
report.push({
|
|
222
|
+
verdict: 'kept',
|
|
223
|
+
label: 'git pre-commit hook (no super-backlog guard block)',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (!gitDir) {
|
|
227
|
+
report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (no .git directory)' });
|
|
228
|
+
}
|
|
229
|
+
else if (!existsSync(join(gitDir, 'hooks', 'post-commit'))) {
|
|
230
|
+
report.push({ verdict: 'skipped', label: 'git post-commit dashboard-refresh hook (not installed)' });
|
|
231
|
+
}
|
|
232
|
+
else if (removeRefreshHook(gitDir)) {
|
|
233
|
+
report.push({ verdict: 'removed', label: 'git post-commit dashboard-refresh hook' });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
report.push({
|
|
237
|
+
verdict: 'kept',
|
|
238
|
+
label: 'git post-commit hook (no super-backlog dashboard-refresh block)',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const dashboardPath = join(cwd, 'dashboard.html');
|
|
242
|
+
if (!existsSync(dashboardPath)) {
|
|
243
|
+
report.push({ verdict: 'skipped', label: 'dashboard.html (not found)' });
|
|
244
|
+
}
|
|
245
|
+
else if (isKitDashboard(readFileSync(dashboardPath, 'utf8'))) {
|
|
246
|
+
rmSync(dashboardPath);
|
|
247
|
+
report.push({ verdict: 'removed', label: 'dashboard.html' });
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
report.push({ verdict: 'kept', label: 'dashboard.html (not generated by super-backlog)' });
|
|
251
|
+
}
|
|
252
|
+
let dataDeleted = false;
|
|
253
|
+
const backlogDir = join(cwd, 'backlog');
|
|
254
|
+
if (!existsSync(backlogDir)) {
|
|
255
|
+
report.push({ verdict: 'skipped', label: 'backlog/ (not found)' });
|
|
256
|
+
}
|
|
257
|
+
else if (withBacklog) {
|
|
258
|
+
rmSync(backlogDir, { recursive: true, force: true });
|
|
259
|
+
dataDeleted = true;
|
|
260
|
+
report.push({ verdict: 'removed', label: 'backlog/ (project task data)' });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
report.push({
|
|
264
|
+
verdict: 'kept',
|
|
265
|
+
label: 'backlog/ (project task data preserved - pass --with-backlog to delete)',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
console.log('super-backlog uninstall');
|
|
269
|
+
for (const line of report)
|
|
270
|
+
console.log(`${line.verdict}: ${line.label}`);
|
|
271
|
+
if (dataDeleted) {
|
|
272
|
+
console.log('');
|
|
273
|
+
console.log('============================================================');
|
|
274
|
+
console.log('DATA DELETED: the backlog/ directory (project task data)');
|
|
275
|
+
console.log('was permanently removed. This cannot be undone.');
|
|
276
|
+
console.log('============================================================');
|
|
277
|
+
}
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/commands/update.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { executeActions, findGitDir, InvalidJsonError, validateJsonFile, RefusalError, UpstreamError } from '../init/execute.js';
|
|
6
|
+
import { planInit } from '../init/planner.js';
|
|
7
|
+
import { GUARD_RE, REFRESH_RE } from '../lib/hooks.js';
|
|
8
|
+
import { detectPackageManager } from '../lib/pm.js';
|
|
9
|
+
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
10
|
+
import { KIT_VERSION } from '../lib/version.js';
|
|
11
|
+
const REFRESH_KINDS = new Set([
|
|
12
|
+
'inject-agents-block',
|
|
13
|
+
'write-claude-pointer',
|
|
14
|
+
'copy-skills',
|
|
15
|
+
'install-guard-hook',
|
|
16
|
+
'install-refresh-hook',
|
|
17
|
+
'generate-dashboard',
|
|
18
|
+
]);
|
|
19
|
+
export function refreshActions(all) {
|
|
20
|
+
return all.filter((action) => REFRESH_KINDS.has(action.kind));
|
|
21
|
+
}
|
|
22
|
+
function firstLine(text) {
|
|
23
|
+
return text.trim().split(/\r?\n/)[0] ?? '';
|
|
24
|
+
}
|
|
25
|
+
function guardHookInstalled(cwd) {
|
|
26
|
+
const gitDir = findGitDir(cwd);
|
|
27
|
+
if (!gitDir)
|
|
28
|
+
return false;
|
|
29
|
+
const hookPath = join(gitDir, 'hooks', 'pre-commit');
|
|
30
|
+
if (!existsSync(hookPath))
|
|
31
|
+
return false;
|
|
32
|
+
return GUARD_RE.test(readFileSync(hookPath, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
function refreshHookInstalled(cwd) {
|
|
35
|
+
const gitDir = findGitDir(cwd);
|
|
36
|
+
if (!gitDir)
|
|
37
|
+
return false;
|
|
38
|
+
const hookPath = join(gitDir, 'hooks', 'post-commit');
|
|
39
|
+
if (!existsSync(hookPath))
|
|
40
|
+
return false;
|
|
41
|
+
return REFRESH_RE.test(readFileSync(hookPath, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
export async function runUpdate(cwd, _args) {
|
|
44
|
+
// Up-front detection-failure check (mirrors uninstall): refuse before mutating anything.
|
|
45
|
+
for (const f of ['package.json', 'opencode.json']) {
|
|
46
|
+
const p = join(cwd, f);
|
|
47
|
+
if (!existsSync(p))
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
validateJsonFile(p, f);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof InvalidJsonError) {
|
|
54
|
+
console.error(`error: ${err.message}`);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const state = {
|
|
61
|
+
cwd,
|
|
62
|
+
detectedPm: detectPackageManager(cwd),
|
|
63
|
+
hasBacklogConfig: existsSync(join(cwd, 'backlog', 'config.yml')),
|
|
64
|
+
agentsExists: existsSync(join(cwd, 'AGENTS.md')),
|
|
65
|
+
claudeMdExists: existsSync(join(cwd, 'CLAUDE.md')),
|
|
66
|
+
opencodeConfig: undefined,
|
|
67
|
+
pkgExists: existsSync(join(cwd, 'package.json')),
|
|
68
|
+
};
|
|
69
|
+
const projectName = basename(resolve(cwd));
|
|
70
|
+
const opts = {
|
|
71
|
+
projectName,
|
|
72
|
+
harnesses: ['opencode', 'claude'],
|
|
73
|
+
pm: 'auto',
|
|
74
|
+
guard: guardHookInstalled(cwd),
|
|
75
|
+
refreshHook: refreshHookInstalled(cwd),
|
|
76
|
+
dashboard: existsSync(join(cwd, 'dashboard.html')),
|
|
77
|
+
skipInstall: false,
|
|
78
|
+
};
|
|
79
|
+
const plan = planInit(state, opts, KIT_VERSION);
|
|
80
|
+
const actions = refreshActions(plan.actions);
|
|
81
|
+
const warnings = [...plan.warnings];
|
|
82
|
+
try {
|
|
83
|
+
const result = await executeActions(cwd, actions, {
|
|
84
|
+
version: KIT_VERSION,
|
|
85
|
+
projectName,
|
|
86
|
+
hasBacklogConfig: state.hasBacklogConfig,
|
|
87
|
+
});
|
|
88
|
+
warnings.push(...result.warnings);
|
|
89
|
+
console.log(`super-backlog update complete - refreshed ${actions.length} action(s), applied ${result.applied}, skipped ${result.skipped}`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (err instanceof RefusalError) {
|
|
93
|
+
console.error(`error: ${err.message}`);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
if (err instanceof UpstreamError) {
|
|
97
|
+
console.error(`error: upstream command failed: ${err.message}`);
|
|
98
|
+
return 3;
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
console.log('upstream versions:');
|
|
103
|
+
const bin = resolveBacklogBin(cwd);
|
|
104
|
+
if (bin === null) {
|
|
105
|
+
warnings.push('backlog binary not found - local backlog.md version unavailable');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const local = runCapture(bin, ['--version'], cwd);
|
|
109
|
+
if (local.status === 0) {
|
|
110
|
+
console.log(` backlog.md (local): ${firstLine(local.stdout)}`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
warnings.push(`\`${bin} --version\` failed with exit code ${local.status}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
117
|
+
let published = null;
|
|
118
|
+
try {
|
|
119
|
+
// test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
|
|
120
|
+
if (process.env.SBL_FORCE_OFFLINE)
|
|
121
|
+
throw new Error('forced offline');
|
|
122
|
+
const view = runCapture(npm, ['view', 'backlog.md', 'version'], cwd);
|
|
123
|
+
if (view.status === 0)
|
|
124
|
+
published = firstLine(view.stdout);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
published = null;
|
|
128
|
+
}
|
|
129
|
+
if (published === null) {
|
|
130
|
+
warnings.push('could not query the npm registry (offline?) - published version unavailable');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.log(` backlog.md (latest): ${published}`);
|
|
134
|
+
}
|
|
135
|
+
for (const warning of warnings)
|
|
136
|
+
console.log(`warning: ${warning}`);
|
|
137
|
+
return warnings.length > 0 ? 4 : 0;
|
|
138
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// src/dashboard/data.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
5
|
+
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
6
|
+
function isRecord(v) {
|
|
7
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
8
|
+
}
|
|
9
|
+
function asString(v) {
|
|
10
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
11
|
+
return v.trim();
|
|
12
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
13
|
+
return String(v);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse the stdout of `backlog task list --json` defensively.
|
|
18
|
+
* Accepts `{tasks:[...]}` and bare-array shapes; anything else throws
|
|
19
|
+
* so the caller can degrade to fallback-empty.
|
|
20
|
+
*/
|
|
21
|
+
export function parseTasksJson(raw) {
|
|
22
|
+
const trimmed = raw.trim();
|
|
23
|
+
if (trimmed.length === 0)
|
|
24
|
+
throw new Error('empty task list output');
|
|
25
|
+
const parsed = JSON.parse(trimmed);
|
|
26
|
+
if (Array.isArray(parsed))
|
|
27
|
+
return parsed.filter(isRecord);
|
|
28
|
+
if (isRecord(parsed) && Array.isArray(parsed['tasks'])) {
|
|
29
|
+
return parsed['tasks'].filter(isRecord);
|
|
30
|
+
}
|
|
31
|
+
throw new Error('unrecognized task list shape');
|
|
32
|
+
}
|
|
33
|
+
function normalizeAcs(value) {
|
|
34
|
+
if (!Array.isArray(value))
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const item of value) {
|
|
38
|
+
if (typeof item === 'string') {
|
|
39
|
+
if (item.trim().length > 0)
|
|
40
|
+
out.push({ text: item.trim(), checked: false });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (isRecord(item)) {
|
|
44
|
+
const text = asString(item['text']) ?? asString(item['title']) ?? asString(item['description']);
|
|
45
|
+
if (text === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
const checked = item['checked'] === true || item['done'] === true;
|
|
48
|
+
out.push({ text, checked });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
export function normalizeTasks(rawTasks) {
|
|
54
|
+
return rawTasks.map((t) => ({
|
|
55
|
+
id: asString(t['id']) ?? '',
|
|
56
|
+
title: asString(t['title']) ?? '(untitled)',
|
|
57
|
+
status: asString(t['status']) ?? 'Unknown',
|
|
58
|
+
priority: asString(t['priority']),
|
|
59
|
+
assignee: asString(t['assignee']),
|
|
60
|
+
updated: asString(t['updated_at']) ?? asString(t['updated']),
|
|
61
|
+
milestone: asString(t['milestone']),
|
|
62
|
+
description: asString(t['description']),
|
|
63
|
+
acs: normalizeAcs(t['acceptance_criteria']),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
export function computeStatuses(tasks) {
|
|
67
|
+
const counts = new Map();
|
|
68
|
+
for (const t of tasks)
|
|
69
|
+
counts.set(t.status, (counts.get(t.status) ?? 0) + 1);
|
|
70
|
+
return [...counts.entries()]
|
|
71
|
+
.map(([status, count]) => ({ status, count }))
|
|
72
|
+
.sort((a, b) => a.status.localeCompare(b.status));
|
|
73
|
+
}
|
|
74
|
+
export function computeMilestones(tasks) {
|
|
75
|
+
const acc = new Map();
|
|
76
|
+
for (const t of tasks) {
|
|
77
|
+
if (!t.milestone)
|
|
78
|
+
continue;
|
|
79
|
+
const m = acc.get(t.milestone) ?? { done: 0, total: 0 };
|
|
80
|
+
m.total++;
|
|
81
|
+
if (t.status.toLowerCase() === 'done')
|
|
82
|
+
m.done++;
|
|
83
|
+
acc.set(t.milestone, m);
|
|
84
|
+
}
|
|
85
|
+
return [...acc.entries()]
|
|
86
|
+
.map(([name, m]) => ({ name, done: m.done, total: m.total }))
|
|
87
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Extract dependency edges from raw task JSON. Accepts `dependsOn` or `deps`
|
|
91
|
+
* as an array of string ids; anything else contributes nothing. Dangling `to`
|
|
92
|
+
* ids are kept (the graph filters later), malformed entries are dropped.
|
|
93
|
+
*/
|
|
94
|
+
export function computeDeps(rawTasks) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const t of rawTasks) {
|
|
97
|
+
const from = asString(t['id']);
|
|
98
|
+
if (!from)
|
|
99
|
+
continue;
|
|
100
|
+
for (const field of ['dependsOn', 'deps']) {
|
|
101
|
+
const value = t[field];
|
|
102
|
+
if (!Array.isArray(value))
|
|
103
|
+
continue;
|
|
104
|
+
for (const entry of value) {
|
|
105
|
+
if (typeof entry !== 'string')
|
|
106
|
+
continue;
|
|
107
|
+
const to = entry.trim();
|
|
108
|
+
if (to.length === 0)
|
|
109
|
+
continue;
|
|
110
|
+
out.push({ from, to });
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function isoDay(value) {
|
|
118
|
+
if (!value)
|
|
119
|
+
return undefined;
|
|
120
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim());
|
|
121
|
+
if (m)
|
|
122
|
+
return m[1];
|
|
123
|
+
const d = new Date(value);
|
|
124
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10);
|
|
125
|
+
}
|
|
126
|
+
function shiftDay(day, deltaDays) {
|
|
127
|
+
const [y, mo, d] = day.split('-').map(Number);
|
|
128
|
+
return new Date(Date.UTC(y, mo - 1, d) + deltaDays * 86400000).toISOString().slice(0, 10);
|
|
129
|
+
}
|
|
130
|
+
/** Bucket tasks into exactly 30 UTC daily buckets ending at `today`, oldest first. */
|
|
131
|
+
export function computeActivity(rawTasks, today) {
|
|
132
|
+
const counts = new Map();
|
|
133
|
+
for (const t of rawTasks) {
|
|
134
|
+
const day = isoDay(asString(t['updated_at']) ?? asString(t['updated'])) ??
|
|
135
|
+
isoDay(asString(t['created_at'])) ??
|
|
136
|
+
today;
|
|
137
|
+
counts.set(day, (counts.get(day) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
const start = shiftDay(today, -29);
|
|
140
|
+
const out = [];
|
|
141
|
+
for (let i = 0; i < 30; i++) {
|
|
142
|
+
const date = shiftDay(start, i);
|
|
143
|
+
out.push({ date, count: counts.get(date) ?? 0 });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
export const BUILT_IN_GLOSSARY = [
|
|
148
|
+
{ term: 'AC', definition: 'Acceptance criterion — one checkable condition a task must satisfy before it can be done.' },
|
|
149
|
+
{ term: 'DoD', definition: 'Definition of Done — the shared bar every task must clear before moving to Done.' },
|
|
150
|
+
{ term: 'Milestone', definition: 'A named delivery waypoint that groups tasks and tracks done/total progress.' },
|
|
151
|
+
{ term: 'Review Gate', definition: 'A human checkpoint where specs, plans or code are reviewed before work proceeds.' },
|
|
152
|
+
{ term: 'TDD', definition: 'Test-Driven Development — write the failing test first (RED), then minimal code to pass (GREEN).' },
|
|
153
|
+
{ term: 'Brainstorming', definition: 'Structured exploration of intent, requirements and design before any creative work.' },
|
|
154
|
+
{ term: 'Design Gate', definition: 'The point where a human approves the design document before decomposition.' },
|
|
155
|
+
{ term: 'Spec-to-Backlog', definition: 'Decomposing an approved design into reviewed backlog tasks with acceptance criteria.' },
|
|
156
|
+
{ term: 'Plan-before-Code', definition: 'Implementation starts only after a written implementation plan is approved.' },
|
|
157
|
+
{ term: 'Draft', definition: 'An unapproved Backlog.md task proposal awaiting promotion to the board.' },
|
|
158
|
+
{ term: 'Worktree', definition: 'An isolated git checkout (e.g. .worktrees/<branch>) used for feature work.' },
|
|
159
|
+
{ term: 'Backlog.md', definition: 'File-based task management CLI owning specs, statuses and history under backlog/.' },
|
|
160
|
+
{ term: 'Superpowers', definition: 'The methodology skill set that decides HOW the work is done.' },
|
|
161
|
+
{ term: 'Pipeline', definition: 'The nine workflow phases from Idea to Merge & archive.' },
|
|
162
|
+
{ term: 'Freshness Hook', definition: 'A post-commit git hook that regenerates dashboard.html when commits touch backlog/.' },
|
|
163
|
+
];
|
|
164
|
+
/** Split `## Term` headings plus their following non-heading block into entries; empty sections are skipped. */
|
|
165
|
+
export function parseGlossaryMarkdown(content) {
|
|
166
|
+
const out = [];
|
|
167
|
+
let term;
|
|
168
|
+
let buffer = [];
|
|
169
|
+
const flush = () => {
|
|
170
|
+
if (term !== undefined) {
|
|
171
|
+
const definition = buffer.join('\n').trim();
|
|
172
|
+
if (definition.length > 0)
|
|
173
|
+
out.push({ term, definition });
|
|
174
|
+
}
|
|
175
|
+
term = undefined;
|
|
176
|
+
buffer = [];
|
|
177
|
+
};
|
|
178
|
+
for (const line of content.split(/\r?\n/)) {
|
|
179
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
180
|
+
if (heading) {
|
|
181
|
+
flush();
|
|
182
|
+
term = heading[1].trim();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (term !== undefined)
|
|
186
|
+
buffer.push(line);
|
|
187
|
+
}
|
|
188
|
+
flush();
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** Built-in terms first; project terms override case-insensitively in place, new terms append. */
|
|
192
|
+
export function mergeGlossary(projectEntries) {
|
|
193
|
+
const out = BUILT_IN_GLOSSARY.map((e) => ({ ...e }));
|
|
194
|
+
const indexByTerm = new Map(out.map((e, i) => [e.term.toLowerCase(), i]));
|
|
195
|
+
for (const entry of projectEntries) {
|
|
196
|
+
const key = entry.term.toLowerCase();
|
|
197
|
+
const existing = indexByTerm.get(key);
|
|
198
|
+
if (existing !== undefined) {
|
|
199
|
+
out[existing] = { term: out[existing].term, definition: entry.definition };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
indexByTerm.set(key, out.length);
|
|
203
|
+
out.push({ term: entry.term, definition: entry.definition });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function readProjectGlossary(cwd) {
|
|
209
|
+
try {
|
|
210
|
+
const path = join(cwd, 'backlog', 'docs', 'glossary.md');
|
|
211
|
+
if (!existsSync(path))
|
|
212
|
+
return [];
|
|
213
|
+
return parseGlossaryMarkdown(readFileSync(path, 'utf8'));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function readProjectIdentity(cwd) {
|
|
220
|
+
const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), [
|
|
221
|
+
'project_name',
|
|
222
|
+
'name',
|
|
223
|
+
'description',
|
|
224
|
+
]);
|
|
225
|
+
let pkg;
|
|
226
|
+
const pkgPath = join(cwd, 'package.json');
|
|
227
|
+
if (existsSync(pkgPath)) {
|
|
228
|
+
try {
|
|
229
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
pkg = undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const name = asString(cfg['project_name']) ??
|
|
236
|
+
asString(cfg['name']) ??
|
|
237
|
+
asString(pkg?.['name']) ??
|
|
238
|
+
'Untitled project';
|
|
239
|
+
const description = asString(cfg['description']) ?? asString(pkg?.['description']) ?? '';
|
|
240
|
+
return { name, description };
|
|
241
|
+
}
|
|
242
|
+
export function collectDashboardData(cwd, opts) {
|
|
243
|
+
const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
|
|
244
|
+
? opts.today.trim()
|
|
245
|
+
: new Date().toISOString().slice(0, 10);
|
|
246
|
+
const base = {
|
|
247
|
+
project: readProjectIdentity(cwd),
|
|
248
|
+
generatedAt: new Date().toISOString(),
|
|
249
|
+
kitVersion: opts.kitVersion,
|
|
250
|
+
statuses: [],
|
|
251
|
+
milestones: [],
|
|
252
|
+
tasks: [],
|
|
253
|
+
deps: [],
|
|
254
|
+
activity: computeActivity([], today),
|
|
255
|
+
glossary: mergeGlossary(readProjectGlossary(cwd)),
|
|
256
|
+
source: 'fallback-empty',
|
|
257
|
+
};
|
|
258
|
+
try {
|
|
259
|
+
const bin = resolveBacklogBin(cwd);
|
|
260
|
+
if (!bin)
|
|
261
|
+
return base;
|
|
262
|
+
const res = runCapture(bin, ['task', 'list', '--json'], cwd);
|
|
263
|
+
if (res.status !== 0)
|
|
264
|
+
return base;
|
|
265
|
+
const rawTasks = parseTasksJson(res.stdout);
|
|
266
|
+
const tasks = normalizeTasks(rawTasks);
|
|
267
|
+
return {
|
|
268
|
+
...base,
|
|
269
|
+
tasks,
|
|
270
|
+
statuses: computeStatuses(tasks),
|
|
271
|
+
milestones: computeMilestones(tasks),
|
|
272
|
+
deps: computeDeps(rawTasks),
|
|
273
|
+
activity: computeActivity(rawTasks, today),
|
|
274
|
+
source: 'backlog-json',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Any parse/exec failure degrades to an empty dashboard; never crash.
|
|
279
|
+
return base;
|
|
280
|
+
}
|
|
281
|
+
}
|