super-backlog 1.3.1 → 1.3.3

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 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]`. See `sbl help` for every flag.
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
 
@@ -95,6 +97,10 @@ When enabled:
95
97
 
96
98
  The router is fully owned by super-backlog and removed by `sbl uninstall`. See the [model router design](docs/superpowers/specs/2026-08-26-sbl-model-router-design.md) for details.
97
99
 
100
+ ## Pipeline phases
101
+
102
+ Tasks carry their pipeline phase as a label (`phase/spec` → `phase/plan` → `phase/impl` → `phase/verify`), managed by `sbl phase <task-id> [phase|done]` and checked by `sbl doctor`. The dashboard stepper, task table, and task modal render the live phase. Details: [docs/guide/pipeline-phases.md](docs/guide/pipeline-phases.md).
103
+
98
104
  ## Project Dashboard
99
105
 
100
106
  `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.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { runDashboard } from './commands/dashboard.js';
8
8
  import { runDoctor } from './commands/doctor.js';
9
9
  import { runInit } from './commands/init.js';
10
10
  import { runModels } from './commands/models.js';
11
+ import { runPhase } from './commands/phase.js';
11
12
  import { runUninstall } from './commands/uninstall.js';
12
13
  import { runUpdate } from './commands/update.js';
13
14
  import { KIT_VERSION } from './lib/version.js';
@@ -20,8 +21,9 @@ Commands:
20
21
  init Install the kit into the current project
21
22
  uninstall Remove kit-managed files (project data kept unless --with-backlog)
22
23
  update Refresh kit-managed files and report upstream versions
23
- dashboard Start the project dashboard server (live-reload)
24
+ dashboard Start the project dashboard server (live-reload) (alias: db)
24
25
  models Manage the model router (show, enable, disable, discover)
26
+ phase Show or advance a task's pipeline phase (spec|plan|impl|verify|done)
25
27
  doctor Check the environment (node, PowerShell policy, backlog CLI)
26
28
 
27
29
  init options:
@@ -38,14 +40,17 @@ uninstall options:
38
40
  --fix-all Also remove the global npm package (no prompts)
39
41
 
40
42
  update options:
41
- (none) Refreshes injected files, skills, hook; prints upstream versions
43
+ --no-self Skip self-updating the CLI before refreshing
42
44
 
43
45
  dashboard options:
44
46
  --port <n> Port for the dashboard server (default: 6428)
45
47
  --no-open Do not open the dashboard browser automatically
46
48
 
47
49
  doctor options:
48
- (none) Prints one [ok]/[warn]/[skip] line per check; exit 4 on any warn
50
+ (none) Prints one [ok]/[warn]/[skip]/[fail] line per check; exit 4 on any warn, 1 on any fail
51
+
52
+ phase options:
53
+ --json Print the query result as JSON (phase + labels)
49
54
 
50
55
  Global options:
51
56
  --version Print the super-backlog version and exit
@@ -102,12 +107,17 @@ export async function runCli(argv) {
102
107
  });
103
108
  }
104
109
  case 'update': {
105
- const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
110
+ const parsed = parseArgs({
111
+ args: rest,
112
+ allowPositionals: true,
113
+ options: { 'no-self': { type: 'boolean' } },
114
+ });
106
115
  return await runUpdate(process.cwd(), {
107
116
  values: parsed.values,
108
117
  positionals: parsed.positionals,
109
118
  });
110
119
  }
120
+ case 'db':
111
121
  case 'dashboard': {
112
122
  const parsed = parseArgs({
113
123
  args: rest,
@@ -136,6 +146,19 @@ export async function runCli(argv) {
136
146
  }
137
147
  case 'doctor':
138
148
  return runDoctor(process.cwd());
149
+ case 'phase': {
150
+ const parsed = parseArgs({
151
+ args: rest,
152
+ allowPositionals: true,
153
+ options: {
154
+ json: { type: 'boolean' },
155
+ },
156
+ });
157
+ return runPhase(process.cwd(), {
158
+ values: parsed.values,
159
+ positionals: parsed.positionals,
160
+ });
161
+ }
139
162
  default:
140
163
  console.error(`Unknown command "${command}".\n`);
141
164
  console.error(HELP);
@@ -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 && isPidAlive(state.pid)) {
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
- if (values['port'] !== undefined && port !== state.port) {
173
- console.error(`error: a hub is already running on ${state.port}`);
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();
@@ -1,21 +1,63 @@
1
1
  // src/commands/doctor.ts
2
2
  import process from 'node:process';
3
+ import { extractPhaseLabels, PHASES } from '../lib/phase.js';
3
4
  import { getEffectiveExecutionPolicy, isBlockingExecutionPolicy, } from '../lib/powershell.js';
4
- import { resolveBacklogBin } from '../lib/run.js';
5
- const MARK = { ok: '[ok] ', warn: '[warn]', skip: '[skip]' };
5
+ import { resolveBacklogBin, runCapture } from '../lib/run.js';
6
+ const MARK = {
7
+ ok: '[ok] ',
8
+ warn: '[warn]',
9
+ skip: '[skip]',
10
+ fail: '[fail]',
11
+ };
12
+ function defaultReadTaskLabels(cwd, resolveBacklog) {
13
+ const bin = resolveBacklog(cwd);
14
+ if (!bin)
15
+ return null;
16
+ const res = runCapture(bin, ['task', 'list', '--json'], cwd);
17
+ if (res.status !== 0)
18
+ return null;
19
+ try {
20
+ const parsed = JSON.parse(res.stdout);
21
+ if (!Array.isArray(parsed.tasks))
22
+ return null;
23
+ const rows = [];
24
+ for (const t of parsed.tasks) {
25
+ if (typeof t !== 'object' || t === null)
26
+ continue;
27
+ const id = t.id;
28
+ const status = t.status;
29
+ const labels = t.labels;
30
+ if (typeof id !== 'string' || typeof status !== 'string')
31
+ continue;
32
+ rows.push({
33
+ id,
34
+ status,
35
+ labels: Array.isArray(labels) ? labels.filter((l) => typeof l === 'string') : [],
36
+ });
37
+ }
38
+ return rows;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
6
44
  export function runDoctor(cwd, deps = {}) {
7
45
  const platform = deps.platform ?? process.platform;
8
46
  const nodeVersion = deps.nodeVersion ?? process.versions.node;
9
47
  const resolveBacklog = deps.resolveBacklog ?? resolveBacklogBin;
48
+ const readTaskLabels = deps.readTaskLabels ?? ((c) => defaultReadTaskLabels(c, resolveBacklog));
10
49
  const log = deps.log ?? ((line) => console.log(line));
11
50
  let okCount = 0;
12
51
  let warnCount = 0;
13
52
  let skipCount = 0;
53
+ let failCount = 0;
14
54
  const emit = (status, line, extra = []) => {
15
55
  if (status === 'ok')
16
56
  okCount += 1;
17
57
  else if (status === 'warn')
18
58
  warnCount += 1;
59
+ else if (status === 'fail')
60
+ failCount += 1;
19
61
  else
20
62
  skipCount += 1;
21
63
  log(`${MARK[status]} ${line}`);
@@ -60,6 +102,43 @@ export function runDoctor(cwd, deps = {}) {
60
102
  'fix: npx.cmd super-backlog init',
61
103
  ]);
62
104
  }
63
- log(`doctor summary: ${okCount} ok, ${warnCount} warn, ${skipCount} skip`);
105
+ // check 4: phase label hygiene
106
+ const taskRows = readTaskLabels(cwd);
107
+ if (taskRows === null) {
108
+ emit('skip', 'phase label hygiene (task labels unreadable - backlog CLI unavailable)');
109
+ }
110
+ else {
111
+ const known = new Set(PHASES.map((p) => `phase/${p}`));
112
+ let problems = 0;
113
+ let legacy = 0;
114
+ for (const row of taskRows) {
115
+ const phaseLabels = extractPhaseLabels(row.labels);
116
+ const unknown = phaseLabels.filter((l) => !known.has(l));
117
+ if (phaseLabels.length > 1) {
118
+ problems += 1;
119
+ emit('fail', `${row.id}: multiple phase labels (${phaseLabels.join(', ')})`, [
120
+ `fix: sbl phase ${row.id} <phase> after removing the stale label`,
121
+ ]);
122
+ }
123
+ for (const l of unknown) {
124
+ problems += 1;
125
+ emit('fail', `${row.id}: unknown phase label ${l}`, [
126
+ `fix: backlog task edit ${row.id} --remove-label ${l}`,
127
+ ]);
128
+ }
129
+ if (unknown.length === 0 && phaseLabels.length === 0 && row.status === 'In Progress') {
130
+ legacy += 1;
131
+ emit('warn', `${row.id}: In Progress without phase label (legacy inventory)`, [
132
+ `fix: sbl phase ${row.id} spec`,
133
+ ]);
134
+ }
135
+ }
136
+ if (problems === 0 && legacy === 0) {
137
+ emit('ok', `phase label hygiene clean (${taskRows.length} tasks)`);
138
+ }
139
+ }
140
+ log(`doctor summary: ${okCount} ok, ${warnCount} warn, ${skipCount} skip, ${failCount} fail`);
141
+ if (failCount > 0)
142
+ return 1;
64
143
  return warnCount > 0 ? 4 : 0;
65
144
  }
@@ -0,0 +1,86 @@
1
+ // src/commands/phase.ts
2
+ import { derivePhase, extractPhaseLabels, isPhaseTarget, planTransition } from '../lib/phase.js';
3
+ import { resolveBacklogBin, runCapture } from '../lib/run.js';
4
+ function readLabels(raw) {
5
+ try {
6
+ const parsed = JSON.parse(raw);
7
+ const labels = parsed?.task?.labels;
8
+ if (!Array.isArray(labels))
9
+ return null;
10
+ return labels.filter((l) => typeof l === 'string');
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export function runPhase(cwd, args, deps = {}) {
17
+ const resolveBacklog = deps.resolveBacklog ?? resolveBacklogBin;
18
+ const run = deps.run ?? runCapture;
19
+ const log = deps.log ?? ((line) => console.log(line));
20
+ const id = args.positionals[0];
21
+ const target = args.positionals[1];
22
+ const json = args.values['json'] === true;
23
+ if (!id || (target !== undefined && !isPhaseTarget(target))) {
24
+ log(`usage: sbl phase <task-id> [${['spec', 'plan', 'impl', 'verify', 'done'].join('|')}] [--json]`);
25
+ return 1;
26
+ }
27
+ const bin = resolveBacklog(cwd);
28
+ if (!bin) {
29
+ log('error: backlog CLI not found - run sbl init or npm install first');
30
+ return 1;
31
+ }
32
+ const view = run(bin, ['task', 'view', id, '--json'], cwd);
33
+ if (view.status !== 0) {
34
+ log(`error: backlog task view ${id} failed (exit ${view.status})`);
35
+ if (view.stderr.trim())
36
+ log(view.stderr.trim());
37
+ return 1;
38
+ }
39
+ const labels = readLabels(view.stdout);
40
+ if (labels === null) {
41
+ log(`error: unreadable task view JSON for ${id}`);
42
+ return 1;
43
+ }
44
+ const phase = derivePhase(labels);
45
+ if (target === undefined) {
46
+ if (json) {
47
+ log(JSON.stringify({ id, phase, labels: extractPhaseLabels(labels) }));
48
+ }
49
+ else {
50
+ log(phase ? `${id}: phase/${phase}` : `${id}: none`);
51
+ }
52
+ return 0;
53
+ }
54
+ const result = planTransition(labels, target);
55
+ if (!result.ok) {
56
+ if (result.reason === 'multiple-phases') {
57
+ log(`error: ${id} carries multiple phase labels (${extractPhaseLabels(labels).join(', ')}) - run sbl doctor`);
58
+ }
59
+ else if (result.reason === 'no-phase') {
60
+ log(`error: ${id} has no phase label - start with: sbl phase ${id} spec`);
61
+ }
62
+ else {
63
+ log(`error: unknown phase "${String(target)}"`);
64
+ }
65
+ return 1;
66
+ }
67
+ const { remove, add } = result.plan;
68
+ if (remove === null && add === null) {
69
+ log(`${id}: no phase label present, nothing to do`);
70
+ return 0;
71
+ }
72
+ const editArgs = ['task', 'edit', id];
73
+ if (remove)
74
+ editArgs.push('--remove-label', remove);
75
+ if (add)
76
+ editArgs.push('--add-label', add);
77
+ const edit = run(bin, editArgs, cwd);
78
+ if (edit.status !== 0) {
79
+ log(`error: backlog task edit failed (exit ${edit.status})`);
80
+ if (edit.stderr.trim())
81
+ log(edit.stderr.trim());
82
+ return 3;
83
+ }
84
+ log(add ? `${id}: ${add}` : `${id}: phase label removed (done)`);
85
+ return 0;
86
+ }
@@ -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, _args) {
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
- // test seam: SBL_FORCE_OFFLINE makes e2e runs take the offline path deterministically
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;
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, join } from 'node:path';
5
5
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
6
+ import { derivePhase } from '../lib/phase.js';
6
7
  import { isNewerVersion } from '../lib/version-check.js';
7
8
  import { readSimpleKeys } from '../lib/yamlmini.js';
8
9
  import { computeKpis } from './metrics.js';
@@ -16,6 +17,9 @@ function asString(v) {
16
17
  return String(v);
17
18
  return undefined;
18
19
  }
20
+ function asStrings(v) {
21
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
22
+ }
19
23
  /**
20
24
  * Parse the stdout of `backlog task list --json` defensively.
21
25
  * Accepts `{tasks:[...]}` and bare-array shapes; anything else throws
@@ -70,6 +74,8 @@ export function normalizeTasks(rawTasks) {
70
74
  title: asString(t['title']) ?? '(untitled)',
71
75
  status: asString(t['status']) ?? 'Unknown',
72
76
  priority: asString(t['priority']),
77
+ labels: asStrings(t['labels']),
78
+ phase: derivePhase(asStrings(t['labels'])),
73
79
  assignee: firstAssignee(t),
74
80
  created: asString(t['createdAt']) ?? asString(t['created_at']) ?? asString(t['created']),
75
81
  updated: asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated']),
@@ -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') {
@@ -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,30 @@
1
+ // src/lib/phase.ts
2
+ export const PHASES = ['spec', 'plan', 'impl', 'verify'];
3
+ export const PHASE_LABEL_PREFIX = 'phase/';
4
+ const PHASE_LABELS = PHASES.map((p) => PHASE_LABEL_PREFIX + p);
5
+ export function phaseLabel(p) {
6
+ return PHASE_LABEL_PREFIX + p;
7
+ }
8
+ export function isPhaseTarget(v) {
9
+ return v === 'done' || PHASES.includes(v);
10
+ }
11
+ export function extractPhaseLabels(labels) {
12
+ return labels.filter((l) => l.startsWith(PHASE_LABEL_PREFIX));
13
+ }
14
+ export function derivePhase(labels) {
15
+ const found = labels.find((l) => PHASE_LABELS.includes(l));
16
+ return found ? found.slice(PHASE_LABEL_PREFIX.length) : null;
17
+ }
18
+ export function planTransition(labels, target) {
19
+ if (!isPhaseTarget(target))
20
+ return { ok: false, reason: 'unknown-phase' };
21
+ const phaseLabels = extractPhaseLabels(labels);
22
+ if (phaseLabels.length > 1)
23
+ return { ok: false, reason: 'multiple-phases' };
24
+ const current = phaseLabels[0] ?? null;
25
+ if (current === null && target !== 'spec')
26
+ return { ok: false, reason: 'no-phase' };
27
+ const remove = current;
28
+ const add = target === 'done' ? null : phaseLabel(target);
29
+ return { ok: true, plan: { remove, add } };
30
+ }
@@ -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
- export async function defaultFetchLatest() {
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), FETCH_TIMEOUT_MS);
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
@@ -395,6 +395,12 @@
395
395
  overflow-wrap: break-word;
396
396
  }
397
397
  .step.gate .step-label { color: var(--warn); }
398
+ .step .step-count { position: relative; z-index: 1; display: inline-block; margin-top: 6px;
399
+ padding: 0 .45rem; border-radius: 999px; font: 600 .68rem/1.5 var(--mono);
400
+ background: color-mix(in oklab, var(--accent) 18%, transparent); color: var(--accent); }
401
+
402
+ /* ---------- Phase chip in task rows ---------- */
403
+ .cell-id .status-chip { margin-left: .45rem; }
398
404
 
399
405
  /* ---------- Phase detail panel ---------- */
400
406
  #phase-detail {
@@ -659,7 +665,7 @@
659
665
  var upd = el('button', 'update-badge');
660
666
  upd.type = 'button';
661
667
  upd.appendChild(el('span', 'cmd-title', 'v' + data.latestVersion + ' available'));
662
- upd.setAttribute('data-tip', 'Update: npm i -g super-backlog (click to copy)');
668
+ upd.setAttribute('data-tip', 'Update: run sbl update (click copies npm i -g super-backlog)');
663
669
  upd.addEventListener('click', function () { copyCommand(upd, 'npm i -g super-backlog'); });
664
670
  sideVersion.appendChild(upd);
665
671
  }
@@ -766,6 +772,13 @@
766
772
  var chip = el('span', 'status-chip', field(task, k));
767
773
  chip.setAttribute('data-tone', toneOf(task.status));
768
774
  td.appendChild(chip);
775
+ } else if (k === 'id') {
776
+ td.textContent = field(task, k);
777
+ if (task.phase) {
778
+ var pc = el('span', 'status-chip', 'phase/' + task.phase);
779
+ pc.setAttribute('data-tone', 'accent');
780
+ td.appendChild(pc);
781
+ }
769
782
  } else if (k === 'updated') {
770
783
  td.textContent = formatRelative(field(task, k));
771
784
  var exact = formatExact(field(task, k));
@@ -1342,6 +1355,10 @@
1342
1355
  step.setAttribute('aria-controls', 'phase-detail');
1343
1356
  step.appendChild(el('div', 'step-num', String(p.n)));
1344
1357
  step.appendChild(el('span', 'step-label', p.name));
1358
+ var phaseKey = PHASE_STEP[p.n];
1359
+ if (phaseKey && phaseCounts[phaseKey] > 0) {
1360
+ step.appendChild(el('span', 'step-count', String(phaseCounts[phaseKey])));
1361
+ }
1345
1362
  step.addEventListener('click', function () {
1346
1363
  if (!panel) return;
1347
1364
  var wasOpen = current === step;
@@ -1368,6 +1385,15 @@
1368
1385
  } catch (e) {
1369
1386
  phases = [];
1370
1387
  }
1388
+
1389
+ /* ---------- Pipeline phase state (labels on tasks) ---------- */
1390
+ var PHASE_STEP = { 5: 'spec', 6: 'plan', 7: 'impl', 8: 'verify' };
1391
+ var NEXT_PHASE = { spec: 'plan', plan: 'impl', impl: 'verify', verify: 'done' };
1392
+ var phaseCounts = { spec: 0, plan: 0, impl: 0, verify: 0 };
1393
+ data.tasks.forEach(function (t) {
1394
+ if (t.phase && phaseCounts[t.phase] !== undefined) phaseCounts[t.phase] += 1;
1395
+ });
1396
+
1371
1397
  if ($('#donut')) renderDonut($('#donut'), data.statuses);
1372
1398
  if ($('#kpis')) renderKpis($('#kpis'), data.kpis);
1373
1399
  if ($('#aging')) renderAging($('#aging'), data.tasks);
@@ -1505,6 +1531,7 @@
1505
1531
  }
1506
1532
  grid.appendChild(cell('Milestone', task.milestone));
1507
1533
  grid.appendChild(cell('Priority', task.priority, priorityTone(task.priority)));
1534
+ grid.appendChild(cell('Phase', task.phase ? 'phase/' + task.phase : ''));
1508
1535
  grid.appendChild(cell('Assignee', task.assignee));
1509
1536
  grid.appendChild(cell('Updated', task.updated));
1510
1537
  return grid;
@@ -1570,6 +1597,15 @@
1570
1597
  cmd.appendChild(el('span', 'cmd-title', 'copy'));
1571
1598
  cmd.addEventListener('click', function () { copyCommand(cmd, cmdLine); });
1572
1599
  content.appendChild(cmd);
1600
+ if (t.phase && NEXT_PHASE[t.phase]) {
1601
+ var advanceCmd = 'sbl phase ' + t.id.replace(/^task-/i, '') + ' ' + NEXT_PHASE[t.phase];
1602
+ var adv = el('button', 'phase-cmd detail-cmd');
1603
+ adv.type = 'button';
1604
+ adv.appendChild(el('span', 'cmd-line', advanceCmd));
1605
+ adv.appendChild(el('span', 'cmd-title', 'copy: advance'));
1606
+ adv.addEventListener('click', function () { copyCommand(adv, advanceCmd); });
1607
+ content.appendChild(adv);
1608
+ }
1573
1609
  dialog.textContent = '';
1574
1610
  dialog.appendChild(content);
1575
1611
  dialog.showModal();
@@ -18,15 +18,17 @@ Bridge between Superpowers (brainstorming, writing-plans) and Backlog.md.
18
18
  1. Read `backlog instructions overview` and `backlog instructions task-creation` first.
19
19
  2. Decompose: every plan unit becomes ONE task, small enough for one session/PR.
20
20
  3. Create per task:
21
- backlog task create "Title" -d "<goal/context>" --ac "<criterion 1>" --ac "<criterion 2>" --type feature --label feature --ref "<path/to/plan-doc>"
21
+ backlog task create "Title" -d "<goal/context>" --ac "<criterion 1>" --ac "<criterion 2>" --type feature --labels feature,phase/spec --ref "<path/to/plan-doc>"
22
22
  - Dependencies: --dep TASK-y (order follows the plan).
23
23
  - Larger efforts: backlog milestone add `"<Name>"`, attach via -m.
24
24
  - Reference the plan doc via --ref; NEVER copy it into the task.
25
25
  4. Never set --plan or --notes at create time — those belong to the "task started" checkpoint after codebase research.
26
- 5. STOP at the review gate: the human reviews specs and acceptance criteria (backlog board / backlog browser / dashboard.html) before any code exists.
26
+ 5. Every created task starts at `phase/spec`. Later phase changes happen only via `sbl phase <id> <phase>` at gate passages never by editing labels manually.
27
+ 6. STOP at the review gate: the human reviews specs and acceptance criteria (backlog board / backlog browser / project dashboard) before any code exists.
27
28
 
28
29
  ## Boundaries
29
30
 
30
31
  - Never hand-edit task markdown; use the backlog CLI exclusively.
31
32
  - No code, no worktrees, no status changes inside this skill.
33
+ - Phase labels are set with creation and advanced only via `sbl phase`.
32
34
  - Project-specific human-gate topics get their own tasks with an explicit review gate.
@@ -1,30 +1,44 @@
1
1
  ---
2
2
  name: task-review-gate
3
- description: Enforce the human review checkpoint before implementation starts. Use after tasks were created from a plan, or when the user asks to implement a specific task, to present the task and its acceptance criteria and wait for explicit approval before any code.
3
+ description: Enforce the human review checkpoint before implementation starts. Use at session start on an existing task, right after spec-to-backlog created tasks, or when the user asks to implement a specific task: present the task, its pipeline phase and acceptance criteria, and wait for explicit approval before any code.
4
4
  ---
5
5
 
6
- # Task Review Gate: no code before an explicit yes
6
+ # Task Review Gate: session entry, review gate, resume
7
7
 
8
- Human checkpoint between reviewed specs and the first line of code.
8
+ Human checkpoint between reviewed specs and the first line of code — and the
9
+ re-entry point for every session that continues a running task.
9
10
 
10
11
  ## When this skill runs
11
12
 
12
- 1. Right after spec-to-backlog created tasks (review specs + acceptance criteria).
13
- 2. When the user asks to implement a specific task (restate scope before starting).
13
+ 1. At the start of a session that works on an existing task (resume).
14
+ 2. Right after spec-to-backlog created tasks (review specs + acceptance criteria).
15
+ 3. When the user asks to implement a specific task (restate scope before starting).
14
16
 
15
17
  ## Procedure
16
18
 
17
19
  1. Load the task: `backlog task view <ID> --plain`.
18
- 2. Present compactly: goal, every acceptance criterion, dependencies, and the
19
- recorded plan if one exists.
20
- 3. STOP and wait for the user's explicit approval. Silence or a topic change
20
+ 2. Load the phase: `sbl phase <ID>` (prints `phase/spec|plan|impl|verify` or `none`).
21
+ 3. Present compactly: goal, every acceptance criterion, dependencies, the
22
+ recorded plan if one exists, and the current phase.
23
+ 4. STOP and wait for the user's explicit approval. Silence or a topic change
21
24
  is NOT approval.
22
- 4. Only after approval: set the task In Progress via the backlog CLI and start
23
- with a plan-before-code pass if no plan is recorded yet.
25
+ 5. After approval, resume per phase:
26
+ - `phase/spec` review gate passed: advance with `sbl phase <ID> plan`,
27
+ then start the plan-before-code pass.
28
+ - `phase/plan` — plan recorded: advance with `sbl phase <ID> impl` once the
29
+ human approves the plan, then TDD.
30
+ - `phase/impl` — refresh context from the recorded plan and implementation
31
+ notes, then continue TDD where it stopped.
32
+ - `phase/verify` — collect verification evidence (tests/lint/typecheck),
33
+ write the final summary, then `sbl phase <ID> done` at archival.
34
+ - `none` — a task without a phase label is either legacy (offer
35
+ `sbl phase <ID> spec`) or not yet started (walk the review gate first).
36
+ 6. Set the task In Progress via the backlog CLI when work starts.
24
37
 
25
38
  ## Boundaries
26
39
 
27
40
  - Never approve the gate yourself; vague consent is not approval.
41
+ - Phase changes only via `sbl phase` — never edit labels by hand.
28
42
  - Trivial edits stay exempt only on explicit user instruction.
29
43
  - If acceptance criteria look wrong or incomplete, send the user back to task
30
44
  editing instead of starting.
@@ -8,17 +8,17 @@ methodology skills that decide how the work is done.
8
8
 
9
9
  ### Pipeline (follow in order)
10
10
 
11
- | # | Phase | Gate to pass |
12
- |---|-------|--------------|
13
- | 1 | Idea | User states a need; capture it before doing anything else |
14
- | 2 | Brainstorming | Explore intent, requirements and design before any creative work |
15
- | 3 | Design gate | Human approves the design document |
16
- | 4 | Spec-to-backlog | Decompose the approved design into reviewed tasks with acceptance criteria |
17
- | 5 | Review gate | Human reviews specs and acceptance criteria before any code exists |
18
- | 6 | Plan-before-code | A written implementation plan is approved by the human |
19
- | 7 | TDD implementation | Failing test first, then code; one task per session/PR |
20
- | 8 | Verification & final summary | Run tests/lint/typecheck; verification evidence before success claims |
21
- | 9 | Merge & archive | Merge the branch, then close/archive the task via the backlog CLI |
11
+ | # | Phase | Phase label | Gate to pass |
12
+ |---|-------|-------------|--------------|
13
+ | 1 | Idea | — | User states a need; capture it before doing anything else |
14
+ | 2 | Brainstorming | — | Explore intent, requirements and design before any creative work |
15
+ | 3 | Design gate | — | Human approves the design document |
16
+ | 4 | Spec-to-backlog | `phase/spec` set at creation | Decompose the approved design into reviewed tasks with acceptance criteria |
17
+ | 5 | Review gate | `phase/spec` | Human reviews specs and acceptance criteria before any code exists |
18
+ | 6 | Plan-before-code | `phase/plan` | A written implementation plan is approved by the human |
19
+ | 7 | TDD implementation | `phase/impl` | Failing test first, then code; one task per session/PR |
20
+ | 8 | Verification & final summary | `phase/verify` | Run tests/lint/typecheck; verification evidence before success claims |
21
+ | 9 | Merge & archive | label removed (`done`) | Merge the branch, then close/archive the task via the backlog CLI |
22
22
 
23
23
  ### Binding rules
24
24
 
@@ -26,6 +26,7 @@ methodology skills that decide how the work is done.
26
26
  2. Plan before code — implementation starts only after an approved written plan.
27
27
  3. Task status changes always go through the CLI backed by verification evidence, never from memory.
28
28
  4. Skills take precedence over habit whenever a matching skill exists.
29
+ 5. Phase transitions only via `sbl phase <id> <phase>`, always at a gate passage — never edit phase labels by hand.
29
30
 
30
31
  Project-specific human gates are intentionally out of scope for this block.
31
32
  Add project-specific human gates below the block.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {