nubos-pilot 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,7 +63,7 @@ const COMMANDS = [
63
63
  { name: 'worktree-remove', category: 'Execution', description: 'Remove a slice worktree + delete its branch (--force / --keep-branch)' },
64
64
  { name: 'worktree-list', category: 'Execution', description: 'List all nubos-pilot-managed slice worktrees (np/<mid>-<sid> only) as JSON' },
65
65
  { name: 'worktree-ff-merge', category: 'Execution', description: 'Fast-forward merge a slice branch back to its base (fails hard on non-FF)' },
66
- { name: 'dashboard', category: 'Utility', description: 'One-shot console dashboard of milestones/slices/handoffs/worktrees (--json, --no-color, --watch [seconds])' },
66
+ { name: 'dashboard', category: 'Utility', description: 'One-shot console dashboard of milestones, slices, and tasks. Read-only; flags: --json, --no-color' },
67
67
  { name: 'thread-resume', category: 'Utility', description: 'Bump a thread markdown on resume (status OPEN→IN_PROGRESS, refresh last_resumed) via atomic write' },
68
68
  { name: 'state-incr', category: 'Capture', description: 'Increment a whitelisted STATE.md counter (e.g. pending_todos) under withFileLock' },
69
69
 
@@ -1,81 +1,29 @@
1
1
  'use strict';
2
2
 
3
- const { NubosPilotError } = require('../../lib/core.cjs');
4
- const { collectSnapshot, renderSnapshot, ANSI } = require('../../lib/dashboard.cjs');
5
-
6
- const MIN_WATCH_SECONDS = 1;
7
- const MAX_WATCH_SECONDS = 3600;
3
+ const { collectSnapshot, renderSnapshot } = require('../../lib/dashboard.cjs');
8
4
 
9
5
  function _parseArgs(args) {
10
- const out = { json: false, noColor: false, watch: null };
11
- for (let i = 0; i < args.length; i++) {
12
- const a = args[i];
13
- if (a === '--json') { out.json = true; continue; }
14
- if (a === '--no-color') { out.noColor = true; continue; }
15
- if (a === '--watch') {
16
- const raw = args[i + 1];
17
- if (raw && !raw.startsWith('-')) { out.watch = Number(raw); i += 1; }
18
- else out.watch = 3;
19
- continue;
20
- }
6
+ const out = { json: false, noColor: false };
7
+ for (const a of args) {
8
+ if (a === '--json') out.json = true;
9
+ else if (a === '--no-color') out.noColor = true;
21
10
  }
22
11
  return out;
23
12
  }
24
13
 
25
- function _renderOnce(cwd, stdout, parsed) {
26
- const snap = collectSnapshot(cwd);
27
- if (parsed.json) {
28
- stdout.write(JSON.stringify(snap, null, 2) + '\n');
29
- return;
30
- }
31
- const useColor = !parsed.noColor && Boolean(stdout.isTTY);
32
- stdout.write(renderSnapshot(snap, { color: useColor }) + '\n');
33
- }
34
-
35
14
  function run(args, opts) {
36
15
  const o = opts || {};
37
16
  const cwd = o.cwd || process.cwd();
38
17
  const stdout = o.stdout || process.stdout;
39
18
  const parsed = _parseArgs(Array.isArray(args) ? args : []);
40
19
 
41
- if (parsed.watch != null) {
42
- if (!Number.isFinite(parsed.watch) || parsed.watch < MIN_WATCH_SECONDS || parsed.watch > MAX_WATCH_SECONDS) {
43
- throw new NubosPilotError(
44
- 'dashboard-watch-out-of-range',
45
- '--watch seconds must be between ' + MIN_WATCH_SECONDS + ' and ' + MAX_WATCH_SECONDS,
46
- { got: parsed.watch },
47
- );
48
- }
49
- if (parsed.json) {
50
- throw new NubosPilotError(
51
- 'dashboard-watch-incompatible-json',
52
- '--watch cannot be combined with --json (use a shell loop if you need JSON polling)',
53
- {},
54
- );
55
- }
56
- const tty = Boolean(stdout.isTTY);
57
- const clear = tty ? ANSI.clearScreen : '';
58
- const render = () => {
59
- try {
60
- stdout.write(clear);
61
- _renderOnce(cwd, stdout, parsed);
62
- } catch (err) {
63
- process.stderr.write('[nubos-pilot dashboard] render failed: ' + ((err && err.message) || err) + '\n');
64
- }
65
- };
66
- render();
67
- const handle = setInterval(render, parsed.watch * 1000);
68
- const stop = () => {
69
- clearInterval(handle);
70
- try { if (tty) stdout.write('\x1b[?25h'); } catch {}
71
- process.exit(0);
72
- };
73
- process.on('SIGINT', stop);
74
- process.on('SIGTERM', stop);
75
- return new Promise(() => { });
20
+ const snap = collectSnapshot(cwd);
21
+ if (parsed.json) {
22
+ stdout.write(JSON.stringify(snap, null, 2) + '\n');
23
+ return 0;
76
24
  }
77
-
78
- _renderOnce(cwd, stdout, parsed);
25
+ const useColor = !parsed.noColor && Boolean(stdout.isTTY);
26
+ stdout.write(renderSnapshot(snap, { color: useColor }) + '\n');
79
27
  return 0;
80
28
  }
81
29
 
package/lib/dashboard.cjs CHANGED
@@ -4,11 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const { extractFrontmatter } = require('./frontmatter.cjs');
7
- const { projectStateDir } = require('./core.cjs');
8
- const { listMilestones, listSlices, listTasks, mId, sId } = require('./layout.cjs');
9
- const { listHandoffs } = require('./handoff.cjs');
10
- const { listSliceWorktrees, worktreeIsolationEnabled } = require('./worktree.cjs');
11
- const { workspaceGitInfo } = require('./git.cjs');
7
+ const { listMilestones, listSlices, listTasks, mId } = require('./layout.cjs');
12
8
 
13
9
  const ANSI = Object.freeze({
14
10
  reset: '\x1b[0m',
@@ -20,7 +16,6 @@ const ANSI = Object.freeze({
20
16
  blue: '\x1b[34m',
21
17
  cyan: '\x1b[36m',
22
18
  gray: '\x1b[90m',
23
- clearScreen: '\x1b[2J\x1b[H',
24
19
  });
25
20
 
26
21
  const STATUS_GLYPHS = Object.freeze({
@@ -41,6 +36,15 @@ function _safeReadFile(p) {
41
36
  catch { return null; }
42
37
  }
43
38
 
39
+ function _taskStatus(planPath) {
40
+ const raw = _safeReadFile(planPath);
41
+ if (!raw) return 'pending';
42
+ try {
43
+ const { frontmatter } = extractFrontmatter(raw);
44
+ return typeof frontmatter.status === 'string' ? frontmatter.status : 'pending';
45
+ } catch { return 'pending'; }
46
+ }
47
+
44
48
  function _collectMilestones(projectRoot) {
45
49
  const out = [];
46
50
  for (const m of listMilestones(projectRoot)) {
@@ -49,93 +53,34 @@ function _collectMilestones(projectRoot) {
49
53
  for (const s of listSlices(m.number, projectRoot)) {
50
54
  const tasks = listTasks(m.number, s.number, projectRoot);
51
55
  const counts = { total: 0, pending: 0, 'in-progress': 0, done: 0, skipped: 0, parked: 0 };
56
+ const statuses = [];
52
57
  for (const t of tasks) {
53
- const raw = _safeReadFile(t.plan_path);
54
- if (!raw) continue;
55
- let fm;
56
- try { ({ frontmatter: fm } = extractFrontmatter(raw)); } catch { fm = {}; }
57
- const status = typeof fm.status === 'string' ? fm.status : 'pending';
58
+ const status = _taskStatus(t.plan_path);
59
+ statuses.push(status);
58
60
  counts.total += 1;
59
61
  if (Object.prototype.hasOwnProperty.call(counts, status)) counts[status] += 1;
60
62
  }
61
63
  slices.push({
62
64
  id: s.id,
63
65
  full_id: s.full_id,
64
- path: s.path,
65
66
  counts,
66
- tasks_statuses: tasks.map((t) => {
67
- const raw = _safeReadFile(t.plan_path);
68
- if (!raw) return 'pending';
69
- try { return extractFrontmatter(raw).frontmatter.status || 'pending'; }
70
- catch { return 'pending'; }
71
- }),
67
+ task_statuses: statuses,
72
68
  });
73
69
  }
74
70
  out.push({
75
71
  id: m.id,
76
72
  number: m.number,
77
- name: (meta && typeof meta.name === 'string') ? meta.name : null,
78
- status: (meta && typeof meta.status === 'string') ? meta.status : null,
73
+ name: typeof meta.name === 'string' ? meta.name : null,
74
+ status: typeof meta.status === 'string' ? meta.status : null,
79
75
  slices,
80
76
  });
81
77
  }
82
78
  return out;
83
79
  }
84
80
 
85
- function _readState(projectRoot) {
86
- const p = path.join(projectStateDir(projectRoot), 'STATE.md');
87
- const raw = _safeReadFile(p);
88
- if (!raw) return { current_milestone: null, current_task: null };
89
- try {
90
- const { frontmatter } = extractFrontmatter(raw);
91
- return {
92
- current_milestone: frontmatter.current_milestone || null,
93
- current_task: frontmatter.current_task || null,
94
- };
95
- } catch {
96
- return { current_milestone: null, current_task: null };
97
- }
98
- }
99
-
100
81
  function collectSnapshot(projectRoot) {
101
82
  const cwd = projectRoot || process.cwd();
102
- const git = (() => { try { return workspaceGitInfo(cwd); } catch { return { is_repo: false }; } })();
103
- const state = _readState(cwd);
104
- const milestones = _collectMilestones(cwd);
105
- const worktrees = (() => { try { return listSliceWorktrees(cwd); } catch { return []; } })();
106
- const handoffs = (() => {
107
- try { return listHandoffs({}, cwd); } catch { return []; }
108
- })();
109
- const openHandoffs = handoffs.filter((h) => h.status === 'open');
110
- return {
111
- generated_at: new Date().toISOString(),
112
- project_root: cwd,
113
- git,
114
- state,
115
- milestones,
116
- worktrees,
117
- handoffs: {
118
- total: handoffs.length,
119
- open: openHandoffs.length,
120
- recent: handoffs.slice(-5).reverse(),
121
- },
122
- worktree_isolation: worktreeIsolationEnabled(cwd),
123
- };
124
- }
125
-
126
- function _pad(s, n) {
127
- const str = String(s);
128
- if (str.length >= n) return str;
129
- return str + ' '.repeat(n - str.length);
130
- }
131
-
132
- function _sliceBar(statuses, useColor) {
133
- const parts = [];
134
- for (const s of statuses) {
135
- const g = STATUS_GLYPHS[s] || STATUS_GLYPHS.pending;
136
- parts.push(useColor ? g.color + g.glyph + ANSI.reset : g.glyph);
137
- }
138
- return parts.join(' ');
83
+ return { milestones: _collectMilestones(cwd) };
139
84
  }
140
85
 
141
86
  function _summarizeCounts(c, useColor) {
@@ -149,73 +94,46 @@ function _summarizeCounts(c, useColor) {
149
94
  return bits.join(' · ') || paint(ANSI.dim, 'no tasks');
150
95
  }
151
96
 
97
+ function _checkboxRow(statuses, useColor) {
98
+ const parts = [];
99
+ for (const s of statuses) {
100
+ const g = STATUS_GLYPHS[s] || STATUS_GLYPHS.pending;
101
+ parts.push(useColor ? g.color + g.glyph + ANSI.reset : g.glyph);
102
+ }
103
+ return parts.join(' ');
104
+ }
105
+
152
106
  function renderSnapshot(snap, opts) {
153
107
  const o = opts || {};
154
108
  const useColor = o.color !== false;
155
- const lines = [];
156
109
  const c = (code, text) => useColor ? code + text + ANSI.reset : text;
110
+ const lines = [];
157
111
 
158
- const title = 'nubos-pilot';
159
- const branch = (snap.git && snap.git.current_branch) ? snap.git.current_branch : '(no git)';
160
- lines.push(c(ANSI.bold + ANSI.blue, title) + ' ' + c(ANSI.dim, snap.project_root));
161
- lines.push(c(ANSI.dim, 'Branch: ' + branch + ' · Generated: ' + snap.generated_at));
162
- if (snap.worktree_isolation) {
163
- lines.push(c(ANSI.cyan, 'Worktree isolation: on') + c(ANSI.dim, ' (' + snap.worktrees.length + ' active)'));
164
- } else {
165
- lines.push(c(ANSI.dim, 'Worktree isolation: off'));
166
- }
112
+ lines.push(c(ANSI.bold + ANSI.blue, 'nubos-pilot'));
167
113
  lines.push('');
168
114
 
169
- if (snap.milestones.length === 0) {
115
+ if (!snap.milestones || snap.milestones.length === 0) {
170
116
  lines.push(c(ANSI.dim, 'No milestones yet. Run /np:new-project or /np:new-milestone.'));
171
- } else {
172
- lines.push(c(ANSI.bold, 'Milestones'));
173
- for (const m of snap.milestones) {
174
- const name = m.name ? ' — ' + m.name : '';
175
- const status = m.status ? ' ' + c(ANSI.dim, '[' + m.status + ']') : '';
176
- const marker = (snap.state.current_milestone === m.id) ? c(ANSI.cyan, '▶ ') : ' ';
177
- lines.push(marker + c(ANSI.bold, m.id) + name + status);
178
- if (m.slices.length === 0) {
179
- lines.push(' ' + c(ANSI.dim, 'no slices planned'));
180
- }
181
- for (const s of m.slices) {
182
- lines.push(' ' + c(ANSI.bold, s.full_id) + ' ' + _summarizeCounts(s.counts, useColor));
183
- if (s.tasks_statuses.length > 0) {
184
- lines.push(' ' + _sliceBar(s.tasks_statuses, useColor));
185
- }
186
- }
187
- lines.push('');
188
- }
189
- }
190
-
191
- if (snap.worktrees.length > 0) {
192
- lines.push(c(ANSI.bold, 'Active worktrees'));
193
- for (const w of snap.worktrees) {
194
- lines.push(' ' + c(ANSI.cyan, w.slice_full_id) + ' ' + c(ANSI.dim, w.path));
195
- }
196
117
  lines.push('');
118
+ return lines.join('\n');
197
119
  }
198
120
 
199
- const totalH = snap.handoffs.total;
200
- const openH = snap.handoffs.open;
201
- lines.push(c(ANSI.bold, 'Handoffs') + c(ANSI.dim, ' (' + openH + ' open / ' + totalH + ' total)'));
202
- if (snap.handoffs.recent.length === 0) {
203
- lines.push(' ' + c(ANSI.dim, 'none'));
204
- } else {
205
- for (const h of snap.handoffs.recent) {
206
- const statusColor = h.status === 'open' ? ANSI.yellow
207
- : h.status === 'acted' ? ANSI.green
208
- : h.status === 'archived' ? ANSI.dim
209
- : ANSI.reset;
210
- const status = c(statusColor, _pad(h.status, 9));
211
- const route = c(ANSI.cyan, _pad(h.from_agent + ' → ' + h.to_agent, 32));
212
- const ms = h.milestone ? c(ANSI.dim, ' [' + h.milestone + ']') : '';
213
- lines.push(' ' + status + ' ' + route + ' ' + h.topic + ms);
121
+ for (const m of snap.milestones) {
122
+ const name = m.name ? ' — ' + m.name : '';
123
+ const status = m.status ? ' ' + c(ANSI.dim, '[' + m.status + ']') : '';
124
+ lines.push(c(ANSI.bold, m.id) + name + status);
125
+ if (m.slices.length === 0) {
126
+ lines.push(' ' + c(ANSI.dim, 'no slices planned'));
127
+ }
128
+ for (const s of m.slices) {
129
+ lines.push(' ' + c(ANSI.bold, s.full_id) + ' ' + _summarizeCounts(s.counts, useColor));
130
+ if (s.task_statuses.length > 0) {
131
+ lines.push(' ' + _checkboxRow(s.task_statuses, useColor));
132
+ }
214
133
  }
134
+ lines.push('');
215
135
  }
216
- lines.push('');
217
136
 
218
- lines.push(c(ANSI.dim, 'Refresh: re-run `np-tools.cjs dashboard` (or --watch <seconds>).'));
219
137
  return lines.join('\n');
220
138
  }
221
139
 
@@ -50,14 +50,11 @@ function _writeMeta(root, mNum, meta) {
50
50
  fs.writeFileSync(path.join(dir, mIdStr + '-META.json'), JSON.stringify(meta), 'utf-8');
51
51
  }
52
52
 
53
- test('DB-1: collectSnapshot returns a shape with all top-level keys', () => {
53
+ test('DB-1: collectSnapshot returns only { milestones } shape', () => {
54
54
  const root = _sandbox();
55
55
  try {
56
56
  const snap = dashboard.collectSnapshot(root);
57
- for (const key of ['generated_at', 'project_root', 'git', 'state', 'milestones', 'worktrees', 'handoffs', 'worktree_isolation']) {
58
- assert.ok(key in snap, 'missing key: ' + key);
59
- }
60
- assert.equal(snap.project_root, root);
57
+ assert.deepEqual(Object.keys(snap), ['milestones']);
61
58
  assert.equal(Array.isArray(snap.milestones), true);
62
59
  } finally {
63
60
  fs.rmSync(root, { recursive: true, force: true });
@@ -82,12 +79,13 @@ test('DB-2: collectSnapshot counts task statuses per slice', () => {
82
79
  assert.deepEqual(m.slices[0].counts, {
83
80
  total: 5, pending: 1, 'in-progress': 1, done: 2, skipped: 1, parked: 0,
84
81
  });
82
+ assert.deepEqual(m.slices[0].task_statuses, ['done', 'done', 'in-progress', 'pending', 'skipped']);
85
83
  } finally {
86
84
  fs.rmSync(root, { recursive: true, force: true });
87
85
  }
88
86
  });
89
87
 
90
- test('DB-3: renderSnapshot produces a non-empty string with key headings', () => {
88
+ test('DB-3: renderSnapshot prints milestone, slice, checkbox row', () => {
91
89
  const root = _sandbox();
92
90
  try {
93
91
  _writeMeta(root, 1, { name: 'Auth', status: 'active' });
@@ -95,12 +93,13 @@ test('DB-3: renderSnapshot produces a non-empty string with key headings', () =>
95
93
  _writeTask(root, 1, 1, 2, 'pending', 'Logout');
96
94
  const snap = dashboard.collectSnapshot(root);
97
95
  const out = dashboard.renderSnapshot(snap, { color: false });
98
- assert.match(out, /nubos-pilot/);
99
- assert.match(out, /Milestones/);
100
- assert.match(out, /M001/);
101
- assert.match(out, /Auth/);
102
- assert.match(out, /Handoffs/);
103
- assert.match(out, /Refresh/);
96
+ assert.match(out, /^nubos-pilot/);
97
+ assert.match(out, /M001 — Auth/);
98
+ assert.match(out, /\[active\]/);
99
+ assert.match(out, /M001-S001/);
100
+ assert.match(out, /1 done/);
101
+ assert.match(out, /1 pending/);
102
+ assert.match(out, /\[x\] \[ \]/);
104
103
  } finally {
105
104
  fs.rmSync(root, { recursive: true, force: true });
106
105
  }
@@ -117,15 +116,14 @@ test('DB-4: renderSnapshot shows "No milestones yet" when none exist', () => {
117
116
  }
118
117
  });
119
118
 
120
- test('DB-5: renderSnapshot with color=false emits no ANSI escape sequences', () => {
119
+ test('DB-5: renderSnapshot with color=false emits no ANSI codes', () => {
121
120
  const root = _sandbox();
122
121
  try {
123
122
  _writeMeta(root, 1, { name: 'Auth' });
124
123
  _writeTask(root, 1, 1, 1, 'done', 'X');
125
124
  const snap = dashboard.collectSnapshot(root);
126
125
  const out = dashboard.renderSnapshot(snap, { color: false });
127
- const seen = /\x1b\[/.test(out);
128
- assert.equal(seen, false, 'renderSnapshot with color=false must not emit ANSI codes');
126
+ assert.equal(/\x1b\[/.test(out), false, 'render with color=false must not emit ANSI');
129
127
  } finally {
130
128
  fs.rmSync(root, { recursive: true, force: true });
131
129
  }
@@ -150,32 +148,31 @@ test('DB-7: STATUS_GLYPHS covers all task-status enum values', () => {
150
148
  }
151
149
  });
152
150
 
153
- test('DB-8: collectSnapshot falls back to empty handoffs list on unreadable directory', () => {
151
+ test('DB-8: empty slice (no tasks) renders "no tasks" indicator', () => {
154
152
  const root = _sandbox();
155
153
  try {
154
+ _writeMeta(root, 1, { name: 'Empty' });
155
+ fs.mkdirSync(path.join(root, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'tasks'), { recursive: true });
156
156
  const snap = dashboard.collectSnapshot(root);
157
- assert.equal(snap.handoffs.total, 0);
158
- assert.equal(snap.handoffs.open, 0);
159
- assert.deepEqual(snap.handoffs.recent, []);
157
+ const out = dashboard.renderSnapshot(snap, { color: false });
158
+ assert.match(out, /M001-S001/);
159
+ assert.match(out, /no tasks/);
160
160
  } finally {
161
161
  fs.rmSync(root, { recursive: true, force: true });
162
162
  }
163
163
  });
164
164
 
165
- test('DB-9: renderSnapshot shows worktree-isolation flag correctly', () => {
165
+ test('DB-9: multiple milestones render in numeric order', () => {
166
166
  const root = _sandbox();
167
167
  try {
168
- const snap1 = dashboard.collectSnapshot(root);
169
- const out1 = dashboard.renderSnapshot(snap1, { color: false });
170
- assert.match(out1, /Worktree isolation: off/);
171
- fs.writeFileSync(
172
- path.join(root, '.nubos-pilot', 'config.json'),
173
- JSON.stringify({ workflow: { worktree_isolation: true } }),
174
- 'utf-8',
175
- );
176
- const snap2 = dashboard.collectSnapshot(root);
177
- const out2 = dashboard.renderSnapshot(snap2, { color: false });
178
- assert.match(out2, /Worktree isolation: on/);
168
+ _writeMeta(root, 2, { name: 'Second' });
169
+ _writeMeta(root, 1, { name: 'First' });
170
+ _writeTask(root, 1, 1, 1, 'done', 'X');
171
+ _writeTask(root, 2, 1, 1, 'pending', 'Y');
172
+ const snap = dashboard.collectSnapshot(root);
173
+ assert.equal(snap.milestones.length, 2);
174
+ assert.equal(snap.milestones[0].id, 'M001');
175
+ assert.equal(snap.milestones[1].id, 'M002');
179
176
  } finally {
180
177
  fs.rmSync(root, { recursive: true, force: true });
181
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "AI-driven planning and execution tool for code projects",
5
5
  "homepage": "https://github.com/Nubos-AI/nubos-pilot",
6
6
  "repository": {
@@ -0,0 +1,49 @@
1
+ ---
2
+ command: np:dashboard
3
+ description: One-shot console dashboard of milestones, slices, and tasks. Read-only — no files written, no state mutated, no git commit.
4
+ ---
5
+
6
+ # np:dashboard
7
+
8
+ Read-only snapshot of milestones, slices, and task statuses for the project.
9
+
10
+ ```bash
11
+ node .nubos-pilot/bin/np-tools.cjs dashboard
12
+ ```
13
+
14
+ That is the entire workflow. The CLI prints the snapshot to stdout — milestone-by-milestone, with one row per slice showing per-status counts plus a checkbox row of all tasks in the slice (`[ ]` pending, `[~]` in-progress, `[x]` done, `[-]` skipped, `[!]` parked).
15
+
16
+ ## No Commit
17
+
18
+ Read-only. No files are written, no state is mutated, no git commit is made.
19
+
20
+ ## Scope Guardrail
21
+
22
+ <scope_guardrail>
23
+ **Do:**
24
+ - Run the CLI with no arguments for the formatted view, `--json` for the raw snapshot, or `--no-color` for plain text.
25
+ - Treat the output as a render — re-run the workflow when you want a fresh view.
26
+
27
+ **Don't:**
28
+ - Add a long-running watch loop here — single-shot only, by design (ADR-0001).
29
+ - Mutate any state from this workflow — strictly read-only.
30
+ - Add additional sections beyond milestones / slices / tasks. Drill-down into handoffs / checkpoints / worktrees uses their dedicated commands.
31
+ </scope_guardrail>
32
+
33
+ ## Output
34
+
35
+ Stdout only — formatted milestone overview with checkbox rows per slice. No files created. No state mutated. No git commit.
36
+
37
+ ## Success Criteria
38
+
39
+ - [ ] Empty project renders the "No milestones yet" placeholder line.
40
+ - [ ] Every milestone in `roadmap.yaml` appears with its name and status.
41
+ - [ ] Every slice's checkbox row reflects current task frontmatter `status` values.
42
+ - [ ] `--json` emits the snapshot shape `{ milestones: [{ id, number, name, status, slices: [{ id, full_id, counts, task_statuses }] }] }`.
43
+ - [ ] `--no-color` emits no ANSI escape sequences.
44
+ - [ ] Zero file writes, zero state mutations, zero commits.
45
+
46
+ ## Related Workflows
47
+
48
+ - **`/np:stats`** — phases-table + metrics aggregation (commit-history view).
49
+ - **`/np:state`** — current STATE.md frontmatter snapshot.