nubos-pilot 0.7.0 → 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.
Files changed (37) hide show
  1. package/agents/np-executor.md +32 -0
  2. package/agents/np-planner.md +28 -0
  3. package/agents/np-researcher.md +28 -0
  4. package/agents/np-verifier.md +15 -0
  5. package/bin/np-tools/_commands.cjs +10 -0
  6. package/bin/np-tools/dashboard.cjs +30 -0
  7. package/bin/np-tools/doctor.cjs +38 -6
  8. package/bin/np-tools/doctor.test.cjs +29 -0
  9. package/bin/np-tools/handoff-list.cjs +27 -0
  10. package/bin/np-tools/handoff-read.cjs +20 -0
  11. package/bin/np-tools/handoff-status.cjs +26 -0
  12. package/bin/np-tools/handoff-write.cjs +59 -0
  13. package/bin/np-tools/plan-milestone.cjs +14 -0
  14. package/bin/np-tools/render-todo.cjs +24 -0
  15. package/bin/np-tools/reset-slice.cjs +31 -2
  16. package/bin/np-tools/resume-work.cjs +42 -0
  17. package/bin/np-tools/worktree-create.cjs +24 -0
  18. package/bin/np-tools/worktree-ff-merge.cjs +33 -0
  19. package/bin/np-tools/worktree-list.cjs +14 -0
  20. package/bin/np-tools/worktree-remove.cjs +38 -0
  21. package/docs/adr/0008-worktree-isolation-per-slice.md +140 -0
  22. package/docs/adr/0009-tui-framework-for-dashboard.md +95 -0
  23. package/lib/config-defaults.cjs +1 -0
  24. package/lib/dashboard.cjs +145 -0
  25. package/lib/dashboard.test.cjs +179 -0
  26. package/lib/git.cjs +21 -0
  27. package/lib/handoff.cjs +277 -0
  28. package/lib/handoff.test.cjs +227 -0
  29. package/lib/tasks.cjs +13 -2
  30. package/lib/todo.cjs +128 -0
  31. package/lib/todo.test.cjs +179 -0
  32. package/lib/worktree.cjs +304 -0
  33. package/lib/worktree.test.cjs +228 -0
  34. package/np-tools.cjs +10 -0
  35. package/package.json +1 -1
  36. package/workflows/dashboard.md +49 -0
  37. package/workflows/execute-phase.md +33 -0
@@ -0,0 +1,277 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { extractFrontmatter } = require('./frontmatter.cjs');
7
+ const { atomicWriteFileSync, NubosPilotError, projectStateDir } = require('./core.cjs');
8
+ const { milestoneDir, parseMId } = require('./layout.cjs');
9
+
10
+ const STATUS_ENUM = new Set(['open', 'read', 'acted', 'archived']);
11
+ const AGENT_RE = /^[a-zA-Z0-9_\-\*]+$/;
12
+
13
+ function _slugify(s) {
14
+ return String(s || '')
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/^-+|-+$/g, '')
18
+ .slice(0, 48) || 'note';
19
+ }
20
+
21
+ let _lastTs = 0;
22
+ function _isoZ() {
23
+ const now = Date.now();
24
+ const adjusted = now > _lastTs ? now : _lastTs + 1;
25
+ _lastTs = adjusted;
26
+ return new Date(adjusted).toISOString();
27
+ }
28
+
29
+ function _genId() {
30
+ return crypto.randomBytes(4).toString('hex');
31
+ }
32
+
33
+ function _handoffsRoot({ milestone, cwd }) {
34
+ const base = projectStateDir(cwd || process.cwd());
35
+ if (milestone) {
36
+ const mNum = typeof milestone === 'string' ? parseMId(milestone) : milestone;
37
+ return path.join(milestoneDir(mNum, cwd), 'handoffs');
38
+ }
39
+ return path.join(base, 'handoffs');
40
+ }
41
+
42
+ function _validateAgentName(field, value) {
43
+ if (typeof value !== 'string' || value.length === 0 || !AGENT_RE.test(value)) {
44
+ throw new NubosPilotError(
45
+ 'handoff-invalid-agent',
46
+ `${field} must be a non-empty slug (alphanumerics, _, -, or *); got: ${JSON.stringify(value)}`,
47
+ { field, value },
48
+ );
49
+ }
50
+ }
51
+
52
+ function _buildFilename({ createdAt, from, to, topic, id }) {
53
+ const stamp = createdAt.replace(/[:.]/g, '-');
54
+ const slug = _slugify(topic);
55
+ return stamp + '__' + from + '-to-' + to + '__' + slug + '__' + id + '.md';
56
+ }
57
+
58
+ function _serialize({ id, from, to, topic, createdAt, milestone, slice, task, status, body }) {
59
+ const fmLines = [
60
+ '---',
61
+ 'schema_version: 1',
62
+ 'id: ' + JSON.stringify(id),
63
+ 'from_agent: ' + JSON.stringify(from),
64
+ 'to_agent: ' + JSON.stringify(to),
65
+ 'topic: ' + JSON.stringify(topic),
66
+ 'created_at: ' + JSON.stringify(createdAt),
67
+ 'milestone: ' + (milestone ? JSON.stringify(milestone) : 'null'),
68
+ 'slice: ' + (slice ? JSON.stringify(slice) : 'null'),
69
+ 'task: ' + (task ? JSON.stringify(task) : 'null'),
70
+ 'status: ' + JSON.stringify(status),
71
+ '---',
72
+ ];
73
+ const trimmed = (body || '').replace(/\s+$/, '');
74
+ return fmLines.join('\n') + '\n\n' + (trimmed.length ? trimmed + '\n' : '');
75
+ }
76
+
77
+ function writeHandoff(input, cwd) {
78
+ const o = input || {};
79
+ _validateAgentName('from', o.from);
80
+ _validateAgentName('to', o.to);
81
+ if (typeof o.topic !== 'string' || o.topic.length === 0) {
82
+ throw new NubosPilotError('handoff-missing-topic', 'topic required (non-empty string)', {});
83
+ }
84
+ const status = o.status || 'open';
85
+ if (!STATUS_ENUM.has(status)) {
86
+ throw new NubosPilotError(
87
+ 'handoff-invalid-status',
88
+ `status '${status}' not in [${[...STATUS_ENUM].join(', ')}]`,
89
+ { status },
90
+ );
91
+ }
92
+
93
+ const id = _genId();
94
+ const createdAt = _isoZ();
95
+ const workingDir = cwd || process.cwd();
96
+ const dir = _handoffsRoot({ milestone: o.milestone || null, cwd: workingDir });
97
+ fs.mkdirSync(dir, { recursive: true });
98
+
99
+ const filename = _buildFilename({
100
+ createdAt,
101
+ from: o.from,
102
+ to: o.to,
103
+ topic: o.topic,
104
+ id,
105
+ });
106
+ const target = path.join(dir, filename);
107
+
108
+ const content = _serialize({
109
+ id,
110
+ from: o.from,
111
+ to: o.to,
112
+ topic: o.topic,
113
+ createdAt,
114
+ milestone: o.milestone || null,
115
+ slice: o.slice || null,
116
+ task: o.task || null,
117
+ status,
118
+ body: o.body || '',
119
+ });
120
+ atomicWriteFileSync(target, content);
121
+ return { id, path: target, filename, created_at: createdAt };
122
+ }
123
+
124
+ function _readHandoffFile(filePath) {
125
+ let raw;
126
+ try { raw = fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
127
+ let fm;
128
+ try { ({ frontmatter: fm } = extractFrontmatter(raw)); }
129
+ catch { return null; }
130
+ if (!fm || typeof fm.id !== 'string') return null;
131
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
132
+ return { frontmatter: fm, body, path: filePath };
133
+ }
134
+
135
+ function _listDirs({ milestone, cwd, global }) {
136
+ const dirs = [];
137
+ const working = cwd || process.cwd();
138
+ if (!global) {
139
+ if (milestone) {
140
+ dirs.push(_handoffsRoot({ milestone, cwd: working }));
141
+ } else {
142
+ const msRoot = path.join(projectStateDir(working), 'milestones');
143
+ try {
144
+ const entries = fs.readdirSync(msRoot, { withFileTypes: true });
145
+ for (const e of entries) {
146
+ if (!e.isDirectory()) continue;
147
+ const hDir = path.join(msRoot, e.name, 'handoffs');
148
+ dirs.push(hDir);
149
+ }
150
+ } catch {}
151
+ }
152
+ }
153
+ dirs.push(_handoffsRoot({ milestone: null, cwd: working }));
154
+ return dirs;
155
+ }
156
+
157
+ function listHandoffs(opts, cwd) {
158
+ const o = opts || {};
159
+ const forAgent = o.for || null;
160
+ const status = o.status || null;
161
+ const milestone = o.milestone || null;
162
+ const onlyGlobal = Boolean(o.global);
163
+
164
+ if (forAgent) _validateAgentName('for', forAgent);
165
+ if (status && !STATUS_ENUM.has(status)) {
166
+ throw new NubosPilotError(
167
+ 'handoff-invalid-status',
168
+ `status '${status}' not in [${[...STATUS_ENUM].join(', ')}]`,
169
+ { status },
170
+ );
171
+ }
172
+
173
+ const out = [];
174
+ const seen = new Set();
175
+ const dirs = _listDirs({ milestone, cwd, global: onlyGlobal });
176
+ for (const dir of dirs) {
177
+ let entries;
178
+ try { entries = fs.readdirSync(dir); } catch { continue; }
179
+ for (const name of entries) {
180
+ if (!name.endsWith('.md')) continue;
181
+ const filePath = path.join(dir, name);
182
+ if (seen.has(filePath)) continue;
183
+ seen.add(filePath);
184
+ const rec = _readHandoffFile(filePath);
185
+ if (!rec) continue;
186
+ const fm = rec.frontmatter;
187
+ if (forAgent && fm.to_agent !== forAgent && fm.to_agent !== '*') continue;
188
+ if (status && fm.status !== status) continue;
189
+ out.push({
190
+ id: String(fm.id),
191
+ from_agent: fm.from_agent,
192
+ to_agent: fm.to_agent,
193
+ topic: fm.topic,
194
+ created_at: fm.created_at,
195
+ milestone: fm.milestone === 'null' ? null : fm.milestone,
196
+ slice: fm.slice === 'null' ? null : fm.slice,
197
+ task: fm.task === 'null' ? null : fm.task,
198
+ status: fm.status,
199
+ path: filePath,
200
+ });
201
+ }
202
+ }
203
+ out.sort((a, b) => {
204
+ const ta = String(a.created_at);
205
+ const tb = String(b.created_at);
206
+ if (ta !== tb) return ta < tb ? -1 : 1;
207
+ const ia = String(a.id);
208
+ const ib = String(b.id);
209
+ if (ia !== ib) return ia < ib ? -1 : 1;
210
+ const pa = String(a.path);
211
+ const pb = String(b.path);
212
+ if (pa !== pb) return pa < pb ? -1 : 1;
213
+ return 0;
214
+ });
215
+ return out;
216
+ }
217
+
218
+ function readHandoff(id, cwd) {
219
+ if (typeof id !== 'string' || id.length === 0) {
220
+ throw new NubosPilotError('handoff-missing-id', 'id required', {});
221
+ }
222
+ const all = listHandoffs({}, cwd);
223
+ const match = all.find((h) => h.id === id);
224
+ if (!match) {
225
+ throw new NubosPilotError('handoff-not-found', `no handoff with id ${id}`, { id });
226
+ }
227
+ const rec = _readHandoffFile(match.path);
228
+ return {
229
+ id: match.id,
230
+ from_agent: match.from_agent,
231
+ to_agent: match.to_agent,
232
+ topic: match.topic,
233
+ created_at: match.created_at,
234
+ milestone: match.milestone,
235
+ slice: match.slice,
236
+ task: match.task,
237
+ status: match.status,
238
+ body: rec ? rec.body.replace(/^\s+/, '').replace(/\s+$/, '\n') : '',
239
+ path: match.path,
240
+ };
241
+ }
242
+
243
+ function setHandoffStatus(id, newStatus, cwd) {
244
+ if (!STATUS_ENUM.has(newStatus)) {
245
+ throw new NubosPilotError(
246
+ 'handoff-invalid-status',
247
+ `status '${newStatus}' not in [${[...STATUS_ENUM].join(', ')}]`,
248
+ { newStatus },
249
+ );
250
+ }
251
+ const all = listHandoffs({}, cwd);
252
+ const match = all.find((h) => h.id === id);
253
+ if (!match) {
254
+ throw new NubosPilotError('handoff-not-found', `no handoff with id ${id}`, { id });
255
+ }
256
+ const raw = fs.readFileSync(match.path, 'utf-8');
257
+ const fmMatch = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/);
258
+ if (!fmMatch) {
259
+ throw new NubosPilotError('handoff-frontmatter-missing', 'handoff file has no YAML frontmatter', { id });
260
+ }
261
+ const [, openFence, fmBody, closeFence] = fmMatch;
262
+ if (!/^status:\s*.*$/m.test(fmBody)) {
263
+ throw new NubosPilotError('handoff-status-line-missing', 'frontmatter has no status field', { id });
264
+ }
265
+ const newFmBody = fmBody.replace(/^status:\s*.*$/m, 'status: ' + newStatus);
266
+ const rest = raw.slice(fmMatch[0].length);
267
+ atomicWriteFileSync(match.path, openFence + newFmBody + closeFence + rest);
268
+ return newStatus;
269
+ }
270
+
271
+ module.exports = {
272
+ writeHandoff,
273
+ listHandoffs,
274
+ readHandoff,
275
+ setHandoffStatus,
276
+ STATUS_ENUM,
277
+ };
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const handoff = require('./handoff.cjs');
10
+
11
+ function _sandbox() {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-handoff-'));
13
+ fs.mkdirSync(path.join(root, '.nubos-pilot', 'milestones', 'M001'), { recursive: true });
14
+ return root;
15
+ }
16
+
17
+ test('HO-1: writeHandoff creates a file with correct frontmatter and returns id', () => {
18
+ const root = _sandbox();
19
+ try {
20
+ const res = handoff.writeHandoff({
21
+ from: 'np-executor',
22
+ to: 'np-verifier',
23
+ topic: 'Feature Flag X',
24
+ milestone: 'M001',
25
+ slice: 'M001-S002',
26
+ body: 'I hardcoded the flag for now.',
27
+ }, root);
28
+ assert.match(res.id, /^[a-f0-9]{8}$/);
29
+ assert.ok(fs.existsSync(res.path));
30
+ assert.match(res.path, /M001\/handoffs\//);
31
+ const raw = fs.readFileSync(res.path, 'utf-8');
32
+ assert.match(raw, /from_agent: "?np-executor"?/);
33
+ assert.match(raw, /to_agent: "?np-verifier"?/);
34
+ assert.match(raw, /status: "?open"?/);
35
+ assert.match(raw, /I hardcoded the flag/);
36
+ } finally {
37
+ fs.rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('HO-2: writeHandoff without milestone writes under global handoffs/', () => {
42
+ const root = _sandbox();
43
+ try {
44
+ const res = handoff.writeHandoff({
45
+ from: 'np-researcher',
46
+ to: '*',
47
+ topic: 'General finding',
48
+ body: 'x',
49
+ }, root);
50
+ assert.match(res.path, /\.nubos-pilot\/handoffs\//);
51
+ assert.doesNotMatch(res.path, /milestones\//);
52
+ } finally {
53
+ fs.rmSync(root, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test('HO-3: listHandoffs with for-filter returns addressed + broadcast', () => {
58
+ const root = _sandbox();
59
+ try {
60
+ handoff.writeHandoff({ from: 'a', to: 'verifier', topic: 'to-verifier', milestone: 'M001' }, root);
61
+ handoff.writeHandoff({ from: 'a', to: 'planner', topic: 'to-planner', milestone: 'M001' }, root);
62
+ handoff.writeHandoff({ from: 'a', to: '*', topic: 'broadcast', milestone: 'M001' }, root);
63
+ const list = handoff.listHandoffs({ for: 'verifier' }, root);
64
+ const topics = list.map((h) => h.topic);
65
+ assert.deepEqual(topics.sort(), ['broadcast', 'to-verifier'].sort());
66
+ } finally {
67
+ fs.rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test('HO-4: listHandoffs with status filter', () => {
72
+ const root = _sandbox();
73
+ try {
74
+ const a = handoff.writeHandoff({ from: 'x', to: 'y', topic: 'one', milestone: 'M001' }, root);
75
+ handoff.writeHandoff({ from: 'x', to: 'y', topic: 'two', milestone: 'M001' }, root);
76
+ handoff.setHandoffStatus(a.id, 'acted', root);
77
+ const open = handoff.listHandoffs({ status: 'open' }, root);
78
+ const acted = handoff.listHandoffs({ status: 'acted' }, root);
79
+ assert.equal(open.length, 1);
80
+ assert.equal(acted.length, 1);
81
+ assert.equal(open[0].topic, 'two');
82
+ assert.equal(acted[0].topic, 'one');
83
+ } finally {
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test('HO-5: setHandoffStatus rewrites status field in place', () => {
89
+ const root = _sandbox();
90
+ try {
91
+ const { id, path: p } = handoff.writeHandoff({ from: 'a', to: 'b', topic: 't', milestone: 'M001' }, root);
92
+ handoff.setHandoffStatus(id, 'read', root);
93
+ const raw = fs.readFileSync(p, 'utf-8');
94
+ assert.match(raw, /status: read/);
95
+ assert.doesNotMatch(raw, /status: open/);
96
+ } finally {
97
+ fs.rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ test('HO-6: setHandoffStatus rejects invalid status', () => {
102
+ const root = _sandbox();
103
+ try {
104
+ const { id } = handoff.writeHandoff({ from: 'a', to: 'b', topic: 't', milestone: 'M001' }, root);
105
+ assert.throws(
106
+ () => handoff.setHandoffStatus(id, 'bogus', root),
107
+ (err) => err.name === 'NubosPilotError' && err.code === 'handoff-invalid-status',
108
+ );
109
+ } finally {
110
+ fs.rmSync(root, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test('HO-7: setHandoffStatus on missing id throws handoff-not-found', () => {
115
+ const root = _sandbox();
116
+ try {
117
+ assert.throws(
118
+ () => handoff.setHandoffStatus('deadbeef', 'acted', root),
119
+ (err) => err.name === 'NubosPilotError' && err.code === 'handoff-not-found',
120
+ );
121
+ } finally {
122
+ fs.rmSync(root, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test('HO-8: readHandoff returns body and metadata for an existing id', () => {
127
+ const root = _sandbox();
128
+ try {
129
+ const body = 'Line one.\nLine two.';
130
+ const { id } = handoff.writeHandoff({
131
+ from: 'a',
132
+ to: 'b',
133
+ topic: 'x',
134
+ milestone: 'M001',
135
+ body,
136
+ }, root);
137
+ const rec = handoff.readHandoff(id, root);
138
+ assert.equal(rec.id, id);
139
+ assert.equal(rec.topic, 'x');
140
+ assert.match(rec.body, /Line one\./);
141
+ assert.match(rec.body, /Line two\./);
142
+ assert.equal(rec.milestone, 'M001');
143
+ } finally {
144
+ fs.rmSync(root, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test('HO-9: writeHandoff validates agent-name format', () => {
149
+ const root = _sandbox();
150
+ try {
151
+ assert.throws(
152
+ () => handoff.writeHandoff({ from: '', to: 'b', topic: 't' }, root),
153
+ (err) => err.name === 'NubosPilotError' && err.code === 'handoff-invalid-agent',
154
+ );
155
+ assert.throws(
156
+ () => handoff.writeHandoff({ from: 'a', to: 'has space', topic: 't' }, root),
157
+ (err) => err.name === 'NubosPilotError' && err.code === 'handoff-invalid-agent',
158
+ );
159
+ } finally {
160
+ fs.rmSync(root, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test('HO-10: writeHandoff requires topic', () => {
165
+ const root = _sandbox();
166
+ try {
167
+ assert.throws(
168
+ () => handoff.writeHandoff({ from: 'a', to: 'b', topic: '' }, root),
169
+ (err) => err.name === 'NubosPilotError' && err.code === 'handoff-missing-topic',
170
+ );
171
+ } finally {
172
+ fs.rmSync(root, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test('HO-11: listHandoffs without milestone scans all milestones + global', () => {
177
+ const root = _sandbox();
178
+ try {
179
+ fs.mkdirSync(path.join(root, '.nubos-pilot', 'milestones', 'M002'), { recursive: true });
180
+ handoff.writeHandoff({ from: 'a', to: 'b', topic: 'in-m1', milestone: 'M001' }, root);
181
+ handoff.writeHandoff({ from: 'a', to: 'b', topic: 'in-m2', milestone: 'M002' }, root);
182
+ handoff.writeHandoff({ from: 'a', to: 'b', topic: 'global' }, root);
183
+ const all = handoff.listHandoffs({}, root);
184
+ const topics = all.map((h) => h.topic).sort();
185
+ assert.deepEqual(topics, ['global', 'in-m1', 'in-m2']);
186
+ } finally {
187
+ fs.rmSync(root, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test('HO-12: listHandoffs with global=true skips milestone scopes', () => {
192
+ const root = _sandbox();
193
+ try {
194
+ handoff.writeHandoff({ from: 'a', to: 'b', topic: 'in-m1', milestone: 'M001' }, root);
195
+ handoff.writeHandoff({ from: 'a', to: 'b', topic: 'global' }, root);
196
+ const globals = handoff.listHandoffs({ global: true }, root);
197
+ assert.equal(globals.length, 1);
198
+ assert.equal(globals[0].topic, 'global');
199
+ } finally {
200
+ fs.rmSync(root, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test('HO-13: listHandoffs sort order is chronological', () => {
205
+ const root = _sandbox();
206
+ try {
207
+ const a = handoff.writeHandoff({ from: 'a', to: 'b', topic: 'first', milestone: 'M001' }, root);
208
+ const b = handoff.writeHandoff({ from: 'a', to: 'b', topic: 'second', milestone: 'M001' }, root);
209
+ const list = handoff.listHandoffs({}, root);
210
+ const order = list.map((h) => h.topic);
211
+ const firstIdx = order.indexOf('first');
212
+ const secondIdx = order.indexOf('second');
213
+ assert.ok(firstIdx !== -1 && secondIdx !== -1);
214
+ assert.ok(firstIdx < secondIdx, 'first must come before second');
215
+ assert.ok(a.created_at <= b.created_at);
216
+ } finally {
217
+ fs.rmSync(root, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test('HO-14: STATUS_ENUM covers open, read, acted, archived', () => {
222
+ assert.ok(handoff.STATUS_ENUM.has('open'));
223
+ assert.ok(handoff.STATUS_ENUM.has('read'));
224
+ assert.ok(handoff.STATUS_ENUM.has('acted'));
225
+ assert.ok(handoff.STATUS_ENUM.has('archived'));
226
+ assert.equal(handoff.STATUS_ENUM.size, 4);
227
+ });
package/lib/tasks.cjs CHANGED
@@ -305,8 +305,6 @@ function setTaskStatus(taskId, newStatus, cwd = process.cwd()) {
305
305
  return withFileLock(filePath, () => {
306
306
  const raw = fs.readFileSync(filePath, 'utf-8');
307
307
 
308
-
309
-
310
308
  const { frontmatter } = extractFrontmatter(raw);
311
309
  if (!('status' in frontmatter)) {
312
310
  throw new NubosPilotError(
@@ -317,6 +315,19 @@ function setTaskStatus(taskId, newStatus, cwd = process.cwd()) {
317
315
  }
318
316
  const next = _rewriteStatusLine(raw, newStatus);
319
317
  atomicWriteFileSync(filePath, next);
318
+
319
+ const sliceFullId = typeof frontmatter.slice === 'string' ? frontmatter.slice : null;
320
+ if (sliceFullId) {
321
+ try {
322
+ const { renderTodoMd } = require('./todo.cjs');
323
+ renderTodoMd(sliceFullId, cwd);
324
+ } catch (err) {
325
+ process.stderr.write(
326
+ '[nubos-pilot warn] TODO.md render failed for ' + sliceFullId + ': ' + ((err && err.message) || err) + '\n',
327
+ );
328
+ }
329
+ }
330
+
320
331
  return newStatus;
321
332
  });
322
333
  }
package/lib/todo.cjs ADDED
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { extractFrontmatter } = require('./frontmatter.cjs');
6
+ const { atomicWriteFileSync, NubosPilotError } = require('./core.cjs');
7
+ const { parseSliceFullId, sliceDir, listTasks, mId } = require('./layout.cjs');
8
+
9
+ const STATUS_CHECKBOX = Object.freeze({
10
+ 'pending': '[ ]',
11
+ 'in-progress': '[~]',
12
+ 'done': '[x]',
13
+ 'skipped': '[-]',
14
+ 'parked': '[!]',
15
+ });
16
+
17
+ function _checkbox(status) {
18
+ return STATUS_CHECKBOX[status] || '[?]';
19
+ }
20
+
21
+ function _taskNameFromPlan(planPath) {
22
+ let raw;
23
+ try { raw = fs.readFileSync(planPath, 'utf-8'); } catch { return null; }
24
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
25
+ const lines = body.split(/\r?\n/);
26
+ for (const line of lines) {
27
+ const m = line.match(/^#\s+(.+?)\s*$/);
28
+ if (m) {
29
+ const header = m[1].trim();
30
+ const split = header.match(/^\S+\s+—\s+(.+)$/);
31
+ return split ? split[1].trim() : header;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function _nowIsoZ() {
38
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
39
+ }
40
+
41
+ function _collectTasks(sliceFullId, cwd) {
42
+ const { milestone, slice } = parseSliceFullId(sliceFullId);
43
+ const entries = listTasks(milestone, slice, cwd);
44
+ const result = [];
45
+ for (const e of entries) {
46
+ let raw;
47
+ try { raw = fs.readFileSync(e.plan_path, 'utf-8'); } catch { continue; }
48
+ let fm;
49
+ try { ({ frontmatter: fm } = extractFrontmatter(raw)); }
50
+ catch { fm = {}; }
51
+ const status = typeof fm.status === 'string' ? fm.status : 'pending';
52
+ result.push({
53
+ id: e.full_id,
54
+ short_id: e.id,
55
+ status,
56
+ name: _taskNameFromPlan(e.plan_path),
57
+ plan_path: e.plan_path,
58
+ });
59
+ }
60
+ return result;
61
+ }
62
+
63
+ function _buildContent(sliceFullId, tasks) {
64
+ const counts = { total: 0, pending: 0, 'in-progress': 0, done: 0, skipped: 0, parked: 0 };
65
+ for (const t of tasks) {
66
+ counts.total += 1;
67
+ if (Object.prototype.hasOwnProperty.call(counts, t.status)) {
68
+ counts[t.status] += 1;
69
+ }
70
+ }
71
+ const { milestone } = parseSliceFullId(sliceFullId);
72
+ const fm = [
73
+ '---',
74
+ 'schema_version: 1',
75
+ 'milestone_id: ' + mId(milestone),
76
+ 'slice_id: ' + sliceFullId,
77
+ 'total: ' + counts.total,
78
+ 'pending: ' + counts.pending,
79
+ 'in_progress: ' + counts['in-progress'],
80
+ 'done: ' + counts.done,
81
+ 'skipped: ' + counts.skipped,
82
+ 'parked: ' + counts.parked,
83
+ 'updated_at: ' + _nowIsoZ(),
84
+ '---',
85
+ ].join('\n');
86
+
87
+ const heading = '# Slice ' + sliceFullId;
88
+ let body;
89
+ if (tasks.length === 0) {
90
+ body = '_No tasks yet._';
91
+ } else {
92
+ const rows = tasks.map((t) => {
93
+ const name = t.name || '(unnamed)';
94
+ return '- ' + _checkbox(t.status) + ' **' + t.id + '** — ' + name;
95
+ });
96
+ body = rows.join('\n');
97
+ }
98
+ return fm + '\n\n' + heading + '\n\n' + body + '\n';
99
+ }
100
+
101
+ function todoPath(sliceFullId, cwd) {
102
+ const { milestone, slice } = parseSliceFullId(sliceFullId);
103
+ return path.join(sliceDir(milestone, slice, cwd || process.cwd()), 'TODO.md');
104
+ }
105
+
106
+ function renderTodoMd(sliceFullId, cwd) {
107
+ if (!sliceFullId || typeof sliceFullId !== 'string') {
108
+ throw new NubosPilotError(
109
+ 'todo-missing-slice-id',
110
+ 'renderTodoMd requires sliceFullId (e.g. M001-S001)',
111
+ { got: sliceFullId },
112
+ );
113
+ }
114
+ parseSliceFullId(sliceFullId);
115
+ const workingDir = cwd || process.cwd();
116
+ const tasks = _collectTasks(sliceFullId, workingDir);
117
+ const content = _buildContent(sliceFullId, tasks);
118
+ const target = todoPath(sliceFullId, workingDir);
119
+ fs.mkdirSync(path.dirname(target), { recursive: true });
120
+ atomicWriteFileSync(target, content);
121
+ return target;
122
+ }
123
+
124
+ module.exports = {
125
+ renderTodoMd,
126
+ todoPath,
127
+ STATUS_CHECKBOX,
128
+ };