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,94 @@
|
|
|
1
|
+
// src/dashboard/layering.ts
|
|
2
|
+
/**
|
|
3
|
+
* Layer tasks for the dependency graph: depth = 1 + max(depth of prerequisites).
|
|
4
|
+
*
|
|
5
|
+
* Deterministic by construction:
|
|
6
|
+
* - input node order drives every traversal and is preserved as Map key order;
|
|
7
|
+
* - edges referencing unknown nodes are ignored;
|
|
8
|
+
* - when no node is ready, the remaining cycle members are detected and appended,
|
|
9
|
+
* as one group, to the deepest safe layer; their downstream dependents then
|
|
10
|
+
* continue normally.
|
|
11
|
+
*/
|
|
12
|
+
export function assignLayers(nodes, deps) {
|
|
13
|
+
const nodeSet = new Set(nodes);
|
|
14
|
+
const prereqs = new Map();
|
|
15
|
+
for (const n of nodes)
|
|
16
|
+
prereqs.set(n, []);
|
|
17
|
+
for (const d of deps) {
|
|
18
|
+
if (!nodeSet.has(d.from) || !nodeSet.has(d.to))
|
|
19
|
+
continue; // dangling ref
|
|
20
|
+
const list = prereqs.get(d.from);
|
|
21
|
+
if (list && !list.includes(d.to))
|
|
22
|
+
list.push(d.to);
|
|
23
|
+
}
|
|
24
|
+
const layers = new Map();
|
|
25
|
+
const remaining = new Set(nodes);
|
|
26
|
+
let current = 0;
|
|
27
|
+
while (remaining.size > 0) {
|
|
28
|
+
const ready = nodes.filter((n) => remaining.has(n) && prereqs.get(n)?.every((p) => !remaining.has(p)));
|
|
29
|
+
if (ready.length === 0) {
|
|
30
|
+
// Stalled: every remaining node sits behind a cycle. Append exactly the
|
|
31
|
+
// self-reachable members to the next layer; the rest resume peeling.
|
|
32
|
+
const cyclic = cycleMembers(nodes, prereqs, remaining);
|
|
33
|
+
current += 1;
|
|
34
|
+
for (const n of nodes) {
|
|
35
|
+
if (cyclic.has(n)) {
|
|
36
|
+
layers.set(n, current);
|
|
37
|
+
remaining.delete(n);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (cyclic.size === 0)
|
|
41
|
+
break; // unreachable safety net
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
current += 1;
|
|
45
|
+
for (const n of ready) {
|
|
46
|
+
layers.set(n, current);
|
|
47
|
+
remaining.delete(n);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (remaining.size > 0) {
|
|
51
|
+
// Only reachable via the safety net; park leftovers on the deepest layer.
|
|
52
|
+
for (const n of nodes) {
|
|
53
|
+
if (remaining.has(n))
|
|
54
|
+
layers.set(n, Math.max(current, 1));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const out = new Map();
|
|
58
|
+
for (const n of nodes)
|
|
59
|
+
out.set(n, layers.get(n) ?? 1);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Nodes that can reach themselves using only edges inside `remaining`. */
|
|
63
|
+
function cycleMembers(nodes, prereqs, remaining) {
|
|
64
|
+
const adj = new Map();
|
|
65
|
+
for (const n of nodes) {
|
|
66
|
+
if (!remaining.has(n))
|
|
67
|
+
continue;
|
|
68
|
+
adj.set(n, (prereqs.get(n) ?? []).filter((p) => remaining.has(p)));
|
|
69
|
+
}
|
|
70
|
+
const cyclic = new Set();
|
|
71
|
+
for (const start of nodes) {
|
|
72
|
+
if (!remaining.has(start) || cyclic.has(start))
|
|
73
|
+
continue;
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const stack = [...(adj.get(start) ?? [])];
|
|
76
|
+
let found = false;
|
|
77
|
+
while (stack.length > 0 && !found) {
|
|
78
|
+
const cur = stack.pop();
|
|
79
|
+
if (cur === undefined)
|
|
80
|
+
break;
|
|
81
|
+
if (cur === start) {
|
|
82
|
+
found = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(cur))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(cur);
|
|
88
|
+
stack.push(...(adj.get(cur) ?? []));
|
|
89
|
+
}
|
|
90
|
+
if (found)
|
|
91
|
+
cyclic.add(start);
|
|
92
|
+
}
|
|
93
|
+
return cyclic;
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/dashboard/regen.ts
|
|
2
|
+
// Standalone regeneration entry invoked by the post-commit freshness hook:
|
|
3
|
+
// node "<repo>/node_modules/super-backlog/dist/dashboard/regen.js"
|
|
4
|
+
// Contract: never throw, never exit non-zero - a broken dashboard generation
|
|
5
|
+
// must never block or fail a user's commit. All errors go to console.error.
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { collectDashboardData } from './data.js';
|
|
10
|
+
import { renderDashboard } from './render.js';
|
|
11
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
12
|
+
import { KIT_VERSION } from '../lib/version.js';
|
|
13
|
+
/** Regenerate <cwd>/dashboard.html. Throws on failure; the direct-run wrapper swallows. */
|
|
14
|
+
export function regenerateDashboard(cwd) {
|
|
15
|
+
const html = renderDashboard(collectDashboardData(cwd, { kitVersion: KIT_VERSION }));
|
|
16
|
+
atomicWrite(join(cwd, 'dashboard.html'), html);
|
|
17
|
+
}
|
|
18
|
+
const invokedDirectly = process.argv[1] !== undefined &&
|
|
19
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
20
|
+
if (invokedDirectly) {
|
|
21
|
+
try {
|
|
22
|
+
regenerateDashboard(process.cwd());
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : err);
|
|
26
|
+
}
|
|
27
|
+
process.exit(0); // always - success or failure
|
|
28
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/dashboard/render.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/** The nine pipeline phases; must stay consistent with src/templates/workflow-block.md. */
|
|
6
|
+
export const PIPELINE_PHASES = [
|
|
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' },
|
|
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' },
|
|
16
|
+
];
|
|
17
|
+
function readTemplate() {
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url)); // src/dashboard at dev time, dist/dashboard at runtime
|
|
19
|
+
const candidates = [
|
|
20
|
+
join(here, '..', 'templates', 'dashboard.html'),
|
|
21
|
+
join(here, 'templates', 'dashboard.html'),
|
|
22
|
+
];
|
|
23
|
+
for (const c of candidates)
|
|
24
|
+
if (existsSync(c))
|
|
25
|
+
return readFileSync(c, 'utf8');
|
|
26
|
+
throw new Error('template not found: dashboard.html');
|
|
27
|
+
}
|
|
28
|
+
function esc(s) {
|
|
29
|
+
return s.replace(/[&<>"']/g, (c) => {
|
|
30
|
+
switch (c) {
|
|
31
|
+
case '&': return '&';
|
|
32
|
+
case '<': return '<';
|
|
33
|
+
case '>': return '>';
|
|
34
|
+
case '"': return '"';
|
|
35
|
+
default: return ''';
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function toneFor(status) {
|
|
40
|
+
const s = status.toLowerCase();
|
|
41
|
+
if (s === 'done' || s === 'complete' || s === 'completed')
|
|
42
|
+
return 'ok';
|
|
43
|
+
if (s.includes('progress') || s === 'review')
|
|
44
|
+
return 'accent';
|
|
45
|
+
if (s.includes('block') || s === 'rejected')
|
|
46
|
+
return 'danger';
|
|
47
|
+
if (s.includes('hold') || s.includes('wait') || s === 'draft')
|
|
48
|
+
return 'warn';
|
|
49
|
+
return 'dim';
|
|
50
|
+
}
|
|
51
|
+
/** Sidebar pills with live counts; click-to-filter wiring lands in a later task. */
|
|
52
|
+
function statusPillsHtml(statuses) {
|
|
53
|
+
if (statuses.length === 0)
|
|
54
|
+
return '<span class="pill-empty">no data</span>';
|
|
55
|
+
return statuses
|
|
56
|
+
.map((s) => `<button type="button" class="pill" data-status="${esc(s.status)}"` +
|
|
57
|
+
` data-count="${s.count}" data-tone="${toneFor(s.status)}">` +
|
|
58
|
+
`<span class="n">${s.count}</span> ${esc(s.status)}</button>`)
|
|
59
|
+
.join('\n ');
|
|
60
|
+
}
|
|
61
|
+
function jsonIsland(value) {
|
|
62
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
63
|
+
}
|
|
64
|
+
/** Fill the static template shell with data; all dynamic task/metric content stays in the JSON islands. */
|
|
65
|
+
export function renderDashboard(data) {
|
|
66
|
+
return readTemplate()
|
|
67
|
+
.replaceAll('__PROJECT_NAME__', () => esc(data.project.name))
|
|
68
|
+
.replaceAll('__PROJECT_DESC__', () => esc(data.project.description))
|
|
69
|
+
.replaceAll('__GENERATED_AT__', () => esc(data.generatedAt))
|
|
70
|
+
.replaceAll('__KIT_VERSION__', () => esc(data.kitVersion))
|
|
71
|
+
.replaceAll('__STATUS_PILLS__', () => statusPillsHtml(data.statuses))
|
|
72
|
+
.replaceAll('__SBL_DATA_JSON__', () => jsonIsland(data))
|
|
73
|
+
.replaceAll('__SBL_GLOSSARY_JSON__', () => jsonIsland(data.glossary))
|
|
74
|
+
.replaceAll('__SBL_PHASES_JSON__', () => jsonIsland(PIPELINE_PHASES));
|
|
75
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/dashboard/server.ts
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { watch } from 'node:fs';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
export const DASHBOARD_PORT = 6428;
|
|
9
|
+
function openInBrowser(url) {
|
|
10
|
+
try {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' })
|
|
13
|
+
.on('error', () => { }) // async ENOENT must not become an uncaught throw
|
|
14
|
+
.unref();
|
|
15
|
+
}
|
|
16
|
+
else if (process.platform === 'darwin') {
|
|
17
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' })
|
|
18
|
+
.on('error', () => { })
|
|
19
|
+
.unref();
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' })
|
|
23
|
+
.on('error', () => { })
|
|
24
|
+
.unref();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// best-effort only; a missing opener must never crash the server
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Serve the latest dashboard bytes; changes inside <cwd>/backlog trigger
|
|
33
|
+
* `regenerate()` debounced by 300ms. Pass port 0 for an ephemeral port (tests).
|
|
34
|
+
*/
|
|
35
|
+
export async function startServeServer(cwd, opts = {}) {
|
|
36
|
+
const file = opts.file ?? 'dashboard.html';
|
|
37
|
+
const filePath = isAbsolute(file) ? file : join(cwd, file);
|
|
38
|
+
const regenerate = opts.regenerate;
|
|
39
|
+
let timer = null;
|
|
40
|
+
const debouncedRegenerate = () => {
|
|
41
|
+
if (!regenerate)
|
|
42
|
+
return;
|
|
43
|
+
if (timer !== null)
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
timer = null;
|
|
47
|
+
void Promise.resolve()
|
|
48
|
+
.then(regenerate)
|
|
49
|
+
.catch(() => { }); // regeneration failures never kill the server
|
|
50
|
+
}, 300);
|
|
51
|
+
};
|
|
52
|
+
let watcher = null;
|
|
53
|
+
try {
|
|
54
|
+
// recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
|
|
55
|
+
watcher = watch(join(cwd, 'backlog'), { persistent: true, recursive: true }, debouncedRegenerate);
|
|
56
|
+
watcher.on('error', () => { }); // e.g. watched dir removed mid-session
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
watcher = null; // no backlog dir -> no live reload; serving still works
|
|
60
|
+
}
|
|
61
|
+
const server = createServer((req, res) => {
|
|
62
|
+
const url = req.url ?? '/';
|
|
63
|
+
const method = req.method ?? 'GET';
|
|
64
|
+
if (method !== 'GET' || !(url === '/' || url === '/index.html')) {
|
|
65
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
66
|
+
res.end('not found');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
readFile(filePath)
|
|
70
|
+
.then((bytes) => {
|
|
71
|
+
res.writeHead(200, {
|
|
72
|
+
'content-type': 'text/html; charset=utf-8',
|
|
73
|
+
'cache-control': 'no-store',
|
|
74
|
+
});
|
|
75
|
+
res.end(bytes);
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {
|
|
78
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
79
|
+
res.end('dashboard not generated yet');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
const requestedPort = opts.port ?? DASHBOARD_PORT;
|
|
83
|
+
const port = await new Promise((resolvePort, rejectPort) => {
|
|
84
|
+
server.once('error', rejectPort);
|
|
85
|
+
server.listen(requestedPort, '127.0.0.1', () => {
|
|
86
|
+
const addr = server.address();
|
|
87
|
+
if (addr !== null && typeof addr === 'object')
|
|
88
|
+
resolvePort(addr.port);
|
|
89
|
+
else
|
|
90
|
+
resolvePort(requestedPort);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
if (opts.openBrowser)
|
|
94
|
+
openInBrowser(`http://127.0.0.1:${port}/`);
|
|
95
|
+
return {
|
|
96
|
+
server,
|
|
97
|
+
port,
|
|
98
|
+
close() {
|
|
99
|
+
if (timer !== null)
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
timer = null;
|
|
102
|
+
watcher?.close();
|
|
103
|
+
watcher = null;
|
|
104
|
+
return new Promise((resolveClose) => {
|
|
105
|
+
server.close(() => resolveClose());
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// src/init/execute.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { atomicWrite } from '../lib/atomic.js';
|
|
7
|
+
import { installGuardHook, installRefreshHook } from '../lib/hooks.js';
|
|
8
|
+
import { injectBlock } from '../lib/markers.js';
|
|
9
|
+
import { applyPluginEntry } from '../lib/opencode.js';
|
|
10
|
+
import { OwnershipError, renderSkill } from '../lib/ownership.js';
|
|
11
|
+
import { addDevDependencies, mergeScripts, WANTED_DEVS, WANTED_SCRIPTS, } from '../lib/pkgjson.js';
|
|
12
|
+
import { installCmdsFor } from '../lib/pm.js';
|
|
13
|
+
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
14
|
+
import { agentsBlockContents } from './planner.js';
|
|
15
|
+
export class UpstreamError extends Error {
|
|
16
|
+
}
|
|
17
|
+
export class RefusalError extends Error {
|
|
18
|
+
}
|
|
19
|
+
const UPSTREAM_PKGS = ['backlog.md@latest', 'super-backlog@latest'];
|
|
20
|
+
export const POINTER_HEADING_RE = /^##\s+Workflow system \(managed by super-backlog\)\s*$/m;
|
|
21
|
+
export const CLAUDE_PLUGIN_INSTRUCTION = 'Claude Code: run /plugin install superpowers@claude-plugins-official inside Claude Code to enable the Superpowers plugin.';
|
|
22
|
+
export const CLAUDE_PLUGIN_WARNING = 'claude plugin install must be run manually';
|
|
23
|
+
export function findGitDir(startDir) {
|
|
24
|
+
let dir = resolve(startDir);
|
|
25
|
+
for (;;) {
|
|
26
|
+
const candidate = join(dir, '.git');
|
|
27
|
+
if (existsSync(candidate))
|
|
28
|
+
return candidate;
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir)
|
|
31
|
+
return null;
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function readTemplate(name) {
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url)); // src/init at dev time, dist/init at runtime
|
|
37
|
+
const candidates = [join(here, '..', 'templates', name), join(here, 'templates', name)];
|
|
38
|
+
for (const c of candidates)
|
|
39
|
+
if (existsSync(c))
|
|
40
|
+
return readFileSync(c, 'utf8');
|
|
41
|
+
throw new Error(`template not found: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
function readTextIfExists(path) {
|
|
44
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
45
|
+
}
|
|
46
|
+
function prettyJson(value) {
|
|
47
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
48
|
+
}
|
|
49
|
+
function skipInstallEnv() {
|
|
50
|
+
const v = process.env.SBL_SKIP_INSTALL;
|
|
51
|
+
return v !== undefined && v !== '';
|
|
52
|
+
}
|
|
53
|
+
function fabricateBacklogConfig(cwd, projectName) {
|
|
54
|
+
atomicWrite(join(cwd, 'backlog', 'config.yml'), `project_name: ${projectName}\n`);
|
|
55
|
+
}
|
|
56
|
+
function runUpstreamInstall(cwd, op, ctx) {
|
|
57
|
+
if (op.pm === 'none')
|
|
58
|
+
throw new UpstreamError('cannot install dependencies without a package manager');
|
|
59
|
+
if (skipInstallEnv()) {
|
|
60
|
+
fabricateBacklogConfig(cwd, ctx.projectName);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const { cmd, args } = installCmdsFor(op.pm, UPSTREAM_PKGS);
|
|
64
|
+
const inst = runCapture(cmd, args, cwd);
|
|
65
|
+
if (inst.status !== 0) {
|
|
66
|
+
throw new UpstreamError(`\`${cmd} ${args.join(' ')}\` failed with exit code ${inst.status}${inst.stderr.trim() ? `:\n${inst.stderr.trim()}` : ''}`);
|
|
67
|
+
}
|
|
68
|
+
if (ctx.hasBacklogConfig)
|
|
69
|
+
return;
|
|
70
|
+
const bin = resolveBacklogBin(cwd);
|
|
71
|
+
if (!bin)
|
|
72
|
+
throw new UpstreamError('backlog binary not found after dependency installation');
|
|
73
|
+
const init = runCapture(bin, ['init', ctx.projectName, '--defaults', '--agent-instructions', 'none'], cwd);
|
|
74
|
+
if (init.status !== 0) {
|
|
75
|
+
throw new UpstreamError(`\`${bin} init\` failed with exit code ${init.status}${init.stderr.trim() ? `:\n${init.stderr.trim()}` : ''}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class InvalidJsonError extends Error {
|
|
79
|
+
}
|
|
80
|
+
/** Reads path and throws InvalidJsonError when present-but-unparseable. Missing/empty file passes. */
|
|
81
|
+
export function validateJsonFile(path, label) {
|
|
82
|
+
const raw = readTextIfExists(path);
|
|
83
|
+
if (raw === null || raw.trim() === '')
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new InvalidJsonError(`${label} is not valid JSON - fix it manually, then re-run`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function parseJsonFile(path, label) {
|
|
93
|
+
validateJsonFile(path, label);
|
|
94
|
+
return JSON.parse(readTextIfExists(path));
|
|
95
|
+
}
|
|
96
|
+
function applyMergeJson(cwd, op) {
|
|
97
|
+
if (op.path === 'package.json') {
|
|
98
|
+
const path = join(cwd, 'package.json');
|
|
99
|
+
const pkg = parseJsonFile(path, 'package.json');
|
|
100
|
+
const merged = addDevDependencies(mergeScripts(pkg, WANTED_SCRIPTS).pkg, WANTED_DEVS).pkg;
|
|
101
|
+
const next = prettyJson(merged);
|
|
102
|
+
if (readTextIfExists(path) === next)
|
|
103
|
+
return false;
|
|
104
|
+
atomicWrite(path, next);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const path = join(cwd, 'opencode.json');
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = applyPluginEntry(parseJsonFile(path, 'opencode.json'));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof OwnershipError)
|
|
114
|
+
throw new RefusalError(err.message);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
if (!result.changed)
|
|
118
|
+
return false;
|
|
119
|
+
atomicWrite(path, prettyJson(result.config));
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
function applyInjectAgentsBlock(cwd, ctx) {
|
|
123
|
+
const path = join(cwd, 'AGENTS.md');
|
|
124
|
+
const current = readTextIfExists(path) ?? '';
|
|
125
|
+
const injected = injectBlock(current, ctx.version, agentsBlockContents(ctx.version));
|
|
126
|
+
if (injected.action === 'unchanged')
|
|
127
|
+
return false;
|
|
128
|
+
atomicWrite(path, injected.content);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
function applyClaudePointer(cwd) {
|
|
132
|
+
const path = join(cwd, 'CLAUDE.md');
|
|
133
|
+
const current = readTextIfExists(path) ?? '';
|
|
134
|
+
if (POINTER_HEADING_RE.test(current))
|
|
135
|
+
return false;
|
|
136
|
+
let template = readTemplate('claude-pointer.md');
|
|
137
|
+
if (!template.endsWith('\n'))
|
|
138
|
+
template += '\n';
|
|
139
|
+
const sep = current.length === 0 ? '' : current.endsWith('\n\n') ? '' : current.endsWith('\n') ? '\n' : '\n\n';
|
|
140
|
+
atomicWrite(path, current + sep + template);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const GLUE_SKILLS = ['spec-to-backlog', 'backlog-status-report', 'task-review-gate'];
|
|
144
|
+
function applyCopySkills(cwd, version) {
|
|
145
|
+
for (const skill of GLUE_SKILLS) {
|
|
146
|
+
const rendered = renderSkill(readTemplate(`skill-${skill}.md`), version);
|
|
147
|
+
atomicWrite(join(cwd, '.opencode', 'skill', skill, 'SKILL.md'), rendered);
|
|
148
|
+
atomicWrite(join(cwd, '.claude', 'skills', skill, 'SKILL.md'), rendered);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
async function applyGenerateDashboard(cwd, warnings) {
|
|
153
|
+
try {
|
|
154
|
+
// Non-literal specifier: the dashboard module lands in a later batch; a missing
|
|
155
|
+
// module must degrade to a warning here, never crash init.
|
|
156
|
+
const specifier = '../commands/dashboard.js';
|
|
157
|
+
const mod = (await import(specifier));
|
|
158
|
+
if (typeof mod.generateDashboard !== 'function') {
|
|
159
|
+
throw new Error('dashboard module does not export generateDashboard');
|
|
160
|
+
}
|
|
161
|
+
await mod.generateDashboard(cwd, { serve: false });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
warnings.push(`dashboard generation skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
export async function executeActions(cwd, actions, ctx) {
|
|
169
|
+
const warnings = [];
|
|
170
|
+
let applied = 0;
|
|
171
|
+
let skipped = 0;
|
|
172
|
+
let claudeHarnessPlanned = false; // detected via the existing write-claude-pointer action path
|
|
173
|
+
for (const action of actions) {
|
|
174
|
+
switch (action.kind) {
|
|
175
|
+
case 'upstream-install':
|
|
176
|
+
runUpstreamInstall(cwd, action, ctx);
|
|
177
|
+
applied++;
|
|
178
|
+
break;
|
|
179
|
+
case 'merge-json':
|
|
180
|
+
applyMergeJson(cwd, action) ? applied++ : skipped++;
|
|
181
|
+
break;
|
|
182
|
+
case 'inject-agents-block':
|
|
183
|
+
applyInjectAgentsBlock(cwd, ctx) ? applied++ : skipped++;
|
|
184
|
+
break;
|
|
185
|
+
case 'write-claude-pointer':
|
|
186
|
+
applyClaudePointer(cwd) ? applied++ : skipped++;
|
|
187
|
+
claudeHarnessPlanned = true;
|
|
188
|
+
break;
|
|
189
|
+
case 'copy-skills':
|
|
190
|
+
applyCopySkills(cwd, ctx.version) ? applied++ : skipped++;
|
|
191
|
+
break;
|
|
192
|
+
case 'install-guard-hook': {
|
|
193
|
+
const gitDir = findGitDir(cwd);
|
|
194
|
+
if (!gitDir) {
|
|
195
|
+
warnings.push('no .git directory found - guard hook not installed');
|
|
196
|
+
skipped++;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
installGuardHook(gitDir, ctx.version);
|
|
200
|
+
applied++;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'install-refresh-hook': {
|
|
205
|
+
const gitDir = findGitDir(cwd);
|
|
206
|
+
if (!gitDir) {
|
|
207
|
+
warnings.push('no .git directory found - dashboard refresh hook not installed');
|
|
208
|
+
skipped++;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
installRefreshHook(gitDir, ctx.version);
|
|
212
|
+
applied++;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'generate-dashboard':
|
|
217
|
+
(await applyGenerateDashboard(cwd, warnings)) ? applied++ : skipped++;
|
|
218
|
+
break;
|
|
219
|
+
case 'write':
|
|
220
|
+
atomicWrite(join(cwd, action.path), action.contents);
|
|
221
|
+
applied++;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Spec §5.1: the marketplace plugin cannot be installed from a script - after
|
|
226
|
+
// writing skills, print the exact command and surface it as a warning (exit 4).
|
|
227
|
+
if (claudeHarnessPlanned) {
|
|
228
|
+
console.log(CLAUDE_PLUGIN_INSTRUCTION);
|
|
229
|
+
warnings.push(CLAUDE_PLUGIN_WARNING);
|
|
230
|
+
}
|
|
231
|
+
return { applied, skipped, warnings };
|
|
232
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/init/planner.ts
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { applyPluginEntry } from '../lib/opencode.js';
|
|
6
|
+
function readTemplate(name) {
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url)); // src/init at dev time, dist/init at runtime
|
|
8
|
+
const candidates = [join(here, '..', 'templates', name), join(here, 'templates', name)];
|
|
9
|
+
for (const c of candidates)
|
|
10
|
+
if (existsSync(c))
|
|
11
|
+
return readFileSync(c, 'utf8');
|
|
12
|
+
throw new Error(`template not found: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
export function agentsBlockContents(version) {
|
|
15
|
+
return readTemplate('workflow-block.md').replace(/\{\{VERSION\}\}/g, version);
|
|
16
|
+
}
|
|
17
|
+
export function planInit(state, opts, _version) {
|
|
18
|
+
const actions = [];
|
|
19
|
+
const warnings = [];
|
|
20
|
+
const degradedAuto = opts.pm === 'auto' && state.detectedPm === null;
|
|
21
|
+
if (!opts.skipInstall && !degradedAuto && opts.pm !== 'skip') {
|
|
22
|
+
if (opts.pm === 'auto' && state.detectedPm !== null) {
|
|
23
|
+
actions.push({ kind: 'upstream-install', pm: state.detectedPm });
|
|
24
|
+
}
|
|
25
|
+
else if (opts.pm !== 'auto') {
|
|
26
|
+
actions.push({ kind: 'upstream-install', pm: opts.pm });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (degradedAuto && !opts.skipInstall) {
|
|
30
|
+
warnings.push('no package manager detected - dependency installation and package.json merge were skipped; ' +
|
|
31
|
+
'install backlog.md and super-backlog manually, or re-run with --pm <npm|pnpm|bun>');
|
|
32
|
+
}
|
|
33
|
+
// opencode.json merge depends only on harness selection and applyPluginEntry
|
|
34
|
+
// not throwing a near-miss - never on package.json presence or PM detection
|
|
35
|
+
if (opts.harnesses.includes('opencode')) {
|
|
36
|
+
try {
|
|
37
|
+
applyPluginEntry(state.opencodeConfig);
|
|
38
|
+
actions.push({ kind: 'merge-json', path: 'opencode.json', transform: 'plugin-entry' });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
warnings.push(err instanceof Error ? err.message : String(err));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!degradedAuto && state.pkgExists) {
|
|
45
|
+
actions.push({ kind: 'merge-json', path: 'package.json', transform: 'scripts-and-devdeps' });
|
|
46
|
+
}
|
|
47
|
+
if (opts.harnesses.length > 0) {
|
|
48
|
+
actions.push({ kind: 'inject-agents-block' });
|
|
49
|
+
actions.push({ kind: 'copy-skills' });
|
|
50
|
+
}
|
|
51
|
+
if (opts.harnesses.includes('claude'))
|
|
52
|
+
actions.push({ kind: 'write-claude-pointer' });
|
|
53
|
+
if (opts.guard)
|
|
54
|
+
actions.push({ kind: 'install-guard-hook' });
|
|
55
|
+
if (opts.refreshHook ?? true)
|
|
56
|
+
actions.push({ kind: 'install-refresh-hook' });
|
|
57
|
+
if (opts.dashboard)
|
|
58
|
+
actions.push({ kind: 'generate-dashboard' });
|
|
59
|
+
return { actions, warnings };
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/lib/atomic.ts
|
|
2
|
+
import { mkdirSync, renameSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export function atomicWrite(filePath, contents) {
|
|
5
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
6
|
+
const tmp = filePath + '.tmp';
|
|
7
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
8
|
+
try {
|
|
9
|
+
renameSync(tmp, filePath);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
// Windows rename over an existing file can fail on some filesystems.
|
|
13
|
+
rmSync(filePath, { force: true });
|
|
14
|
+
renameSync(tmp, filePath);
|
|
15
|
+
}
|
|
16
|
+
}
|