nubos-pilot 0.5.5 → 0.5.7

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.
@@ -223,13 +223,15 @@ Inside each `S<NNN>-PLAN.md`, every `<task>` tag MUST have these four attributes
223
223
 
224
224
  The scaffolder (`_extractTasksFromSlicePlan` in `bin/np-tools/plan-milestone.cjs`) reads ONLY these opening-tag attributes. Without them, zero task files are scaffolded and execute-phase has nothing to dispatch.
225
225
 
226
+ Inside the body, every `<task>` MUST also contain a `<files_modified>` block listing the files the executor will touch (one per line or comma-separated). An empty or missing `<files_modified>` block produces `files_modified: []` in task frontmatter, which causes `commit-task` to fail (`commit-task-no-files`) unless the executor reported touched files via `checkpoint touch` as a runtime fallback. Plans MUST declare intent up-front; relying on the checkpoint fallback is a last-resort safety net.
227
+
226
228
  Correct example for `slices/S001/S001-PLAN.md`:
227
229
 
228
230
  ```
229
231
  <tasks>
230
232
  <task id="M001-S001-T0001" depends_on="" wave="1" tier="sonnet">
231
233
  <name>Seed login form</name>
232
- <files>src/auth/LoginForm.tsx</files>
234
+ <files_modified>src/auth/LoginForm.tsx</files_modified>
233
235
  <read_first>
234
236
  - src/auth/AuthProvider.tsx
235
237
  </read_first>
@@ -7,7 +7,7 @@ const { TASK_ID_RE, setTaskStatus } = require('../../lib/tasks.cjs');
7
7
  const layout = require('../../lib/layout.cjs');
8
8
  const git = require('../../lib/git.cjs');
9
9
  const { commitTask, findCommitByTaskId } = git;
10
- const { deleteCheckpoint } = require('../../lib/checkpoint.cjs');
10
+ const { deleteCheckpoint, readCheckpoint } = require('../../lib/checkpoint.cjs');
11
11
 
12
12
  function _resolveTaskFile(taskId, cwd) {
13
13
  const parsed = layout.parseTaskFullId(taskId);
@@ -70,11 +70,21 @@ function run(args, ctx) {
70
70
  const { filePath } = _resolveTaskFile(taskId, cwd);
71
71
  const raw = fs.readFileSync(filePath, 'utf-8');
72
72
  const { frontmatter, body } = extractFrontmatter(raw);
73
- const files = Array.isArray(frontmatter.files_modified) ? frontmatter.files_modified : [];
73
+ const declared = Array.isArray(frontmatter.files_modified) ? frontmatter.files_modified : [];
74
+ let files = declared.slice();
75
+ let filesSource = 'frontmatter';
76
+ if (files.length === 0) {
77
+ const cp = readCheckpoint(taskId, cwd);
78
+ const touched = cp && Array.isArray(cp.files_touched) ? cp.files_touched : [];
79
+ if (touched.length > 0) {
80
+ files = touched.slice();
81
+ filesSource = 'checkpoint';
82
+ }
83
+ }
74
84
  if (files.length === 0) {
75
85
  throw new NubosPilotError(
76
86
  'commit-task-no-files',
77
- 'Task ' + taskId + ' has empty files_modified',
87
+ 'Task ' + taskId + ' has empty files_modified and no files_touched in checkpoint',
78
88
  { taskId },
79
89
  );
80
90
  }
@@ -93,7 +103,7 @@ function run(args, ctx) {
93
103
  process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
94
104
  }
95
105
 
96
- const payload = { ok: true, task_id: taskId, sha, files: safeFiles };
106
+ const payload = { ok: true, task_id: taskId, sha, files: safeFiles, files_source: filesSource };
97
107
  stdout.write(JSON.stringify(payload));
98
108
  return payload;
99
109
  }
@@ -158,3 +158,40 @@ test('CT-5: commit-task unknown task id → task-not-found', () => {
158
158
  (err) => err && err.code === 'commit-task-not-found',
159
159
  );
160
160
  });
161
+
162
+ test('CT-6: empty files_modified falls back to checkpoint.files_touched', () => {
163
+ const root = makeRepo();
164
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0010', []);
165
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
166
+ fs.writeFileSync(path.join(root, 'src', 'b.ts'), 'export const b = 2;\n', 'utf-8');
167
+ const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0010.json');
168
+ fs.mkdirSync(path.dirname(cpPath), { recursive: true });
169
+ fs.writeFileSync(cpPath, JSON.stringify({
170
+ schema_version: 1,
171
+ task_id: 'M006-S001-T0010',
172
+ status: 'pre-commit',
173
+ files_touched: ['src/b.ts'],
174
+ }), 'utf-8');
175
+ const prev = process.cwd();
176
+ process.chdir(root);
177
+ const cap = _capture();
178
+ try {
179
+ subcmd.run(['M006-S001-T0010'], { cwd: root, stdout: cap.stub });
180
+ } finally {
181
+ process.chdir(prev);
182
+ }
183
+ const payload = JSON.parse(cap.get());
184
+ assert.equal(payload.ok, true);
185
+ assert.equal(payload.files_source, 'checkpoint');
186
+ assert.deepEqual(payload.files, ['src/b.ts']);
187
+ });
188
+
189
+ test('CT-7: empty files_modified AND no checkpoint → commit-task-no-files', () => {
190
+ const root = makeRepo();
191
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0011', []);
192
+ const cap = _capture();
193
+ assert.throws(
194
+ () => subcmd.run(['M006-S001-T0011'], { cwd: root, stdout: cap.stub }),
195
+ (err) => err && err.code === 'commit-task-no-files',
196
+ );
197
+ });
@@ -8,6 +8,7 @@ const crypto = require('node:crypto');
8
8
  const {
9
9
  NubosPilotError,
10
10
  projectStateDir,
11
+ atomicWriteFileSync,
11
12
  } = require('../../lib/core.cjs');
12
13
  const layout = require('../../lib/layout.cjs');
13
14
  const { getPhase } = require('../../lib/roadmap.cjs');
@@ -141,6 +142,84 @@ function _initPayload(mNum, cwd) {
141
142
  };
142
143
  }
143
144
 
145
+ function _readTaskSummaryBody(summaryPath) {
146
+ if (!fs.existsSync(summaryPath)) return null;
147
+ const raw = fs.readFileSync(summaryPath, 'utf-8');
148
+ const { body } = extractFrontmatter(raw);
149
+ return String(body || '').trim();
150
+ }
151
+
152
+ function _finalizeSlice(mNum, sNum, cwd) {
153
+ const slicePath = layout.findSliceDir(mNum, sNum, cwd);
154
+ if (!slicePath) {
155
+ throw new NubosPilotError(
156
+ 'finalize-slice-not-found',
157
+ 'Slice ' + layout.sliceFullId(mNum, sNum) + ' does not exist',
158
+ { milestone: mNum, slice: sNum },
159
+ );
160
+ }
161
+ const summaryPath = layout.sliceSummaryPath(mNum, sNum, cwd);
162
+ const tasks = _sliceTasksSorted(mNum, sNum, cwd);
163
+ const doneTasks = tasks.filter((t) => t.status === 'done');
164
+ const pendingTasks = tasks.filter((t) => t.status !== 'done');
165
+
166
+ const lines = [
167
+ '---',
168
+ 'slice: ' + JSON.stringify(layout.sliceFullId(mNum, sNum)),
169
+ 'milestone: ' + JSON.stringify(layout.mId(mNum)),
170
+ 'type: slice-summary',
171
+ 'task_count: ' + tasks.length,
172
+ 'tasks_done: ' + doneTasks.length,
173
+ 'tasks_pending: ' + pendingTasks.length,
174
+ 'generated_at: ' + JSON.stringify(new Date().toISOString()),
175
+ '---',
176
+ '',
177
+ '# ' + layout.sliceFullId(mNum, sNum) + ' — SUMMARY',
178
+ '',
179
+ '_Auto-aggregated from task summaries by `execute-milestone finalize-slice`._',
180
+ '',
181
+ '## Task Roll-Up',
182
+ '',
183
+ '| Task | Status | Name |',
184
+ '|------|--------|------|',
185
+ ];
186
+ for (const t of tasks) {
187
+ lines.push('| ' + t.id + ' | ' + t.status + ' | ' + (t.name || '').replace(/\|/g, '\\|') + ' |');
188
+ }
189
+ lines.push('', '## Task Summaries', '');
190
+ for (const t of tasks) {
191
+ lines.push('### ' + t.id + ' — ' + (t.name || ''));
192
+ lines.push('');
193
+ const body = _readTaskSummaryBody(t.summary_path);
194
+ if (body) {
195
+ lines.push(body);
196
+ } else {
197
+ lines.push('_No T<NNNN>-SUMMARY.md file present._');
198
+ }
199
+ lines.push('');
200
+ }
201
+ atomicWriteFileSync(summaryPath, lines.join('\n'));
202
+ return {
203
+ slice: layout.sliceFullId(mNum, sNum),
204
+ summary_path: summaryPath,
205
+ task_count: tasks.length,
206
+ tasks_done: doneTasks.length,
207
+ tasks_pending: pendingTasks.length,
208
+ };
209
+ }
210
+
211
+ function _finalizeMilestone(mNum, cwd) {
212
+ const slices = layout.listSlices(mNum, cwd);
213
+ if (slices.length === 0) {
214
+ return { milestone: layout.mId(mNum), finalized: [], reason: 'no-slices' };
215
+ }
216
+ const finalized = [];
217
+ for (const s of slices) {
218
+ finalized.push(_finalizeSlice(mNum, s.number, cwd));
219
+ }
220
+ return { milestone: layout.mId(mNum), finalized, reason: 'ok' };
221
+ }
222
+
144
223
  function _findTaskByFullId(mNum, taskFullId, cwd) {
145
224
  let parsed;
146
225
  try {
@@ -218,6 +297,19 @@ function run(args, ctx) {
218
297
  _emit(payload, stdout, cwd);
219
298
  return payload;
220
299
  }
300
+ case 'finalize-slice': {
301
+ const mNum = _validateMilestoneArg(list[1]);
302
+ const sNum = _validateMilestoneArg(list[2]);
303
+ const payload = _finalizeSlice(mNum, sNum, cwd);
304
+ _emit(payload, stdout, cwd);
305
+ return payload;
306
+ }
307
+ case 'finalize-milestone': {
308
+ const mNum = _validateMilestoneArg(list[1]);
309
+ const payload = _finalizeMilestone(mNum, cwd);
310
+ _emit(payload, stdout, cwd);
311
+ return payload;
312
+ }
221
313
  default:
222
314
  throw new NubosPilotError(
223
315
  'execute-milestone-unknown-verb',
@@ -152,3 +152,98 @@ test('EM-6: unknown verb throws', () => {
152
152
  (err) => err && err.code === 'execute-milestone-unknown-verb',
153
153
  );
154
154
  });
155
+
156
+ function _writeTaskSummary(sandbox, mNum, sNum, tNum, body) {
157
+ const mId = 'M' + String(mNum).padStart(3, '0');
158
+ const sId = 'S' + String(sNum).padStart(3, '0');
159
+ const tId = 'T' + String(tNum).padStart(4, '0');
160
+ const fullId = mId + '-' + sId + '-' + tId;
161
+ const dir = path.join(sandbox, '.nubos-pilot', 'milestones', mId, 'slices', sId, 'tasks', tId);
162
+ const content = [
163
+ '---',
164
+ 'id: ' + JSON.stringify(fullId),
165
+ 'status: done',
166
+ '---',
167
+ '',
168
+ body,
169
+ '',
170
+ ].join('\n');
171
+ fs.writeFileSync(path.join(dir, tId + '-SUMMARY.md'), content);
172
+ }
173
+
174
+ test('EM-7: finalize-slice writes S<NNN>-SUMMARY.md aggregating task summaries', () => {
175
+ const sandbox = makeSandbox();
176
+ seedRoadmapYaml(sandbox, _roadmap());
177
+ seedMilestoneDir(sandbox, 1, {});
178
+ seedSliceDir(sandbox, 1, 1, {});
179
+ _seedTask(sandbox, 1, 1, 1, ['src/a.ts']);
180
+ _seedTask(sandbox, 1, 1, 2, ['src/b.ts']);
181
+ _writeTaskSummary(sandbox, 1, 1, 1, '## Changes\n- Added src/a.ts');
182
+ _writeTaskSummary(sandbox, 1, 1, 2, '## Changes\n- Added src/b.ts');
183
+
184
+ const cap = _capture();
185
+ subcmd.run(['finalize-slice', '1', '1'], { cwd: sandbox, stdout: cap.stub });
186
+ const out = JSON.parse(cap.get());
187
+ assert.equal(out.slice, 'M001-S001');
188
+ assert.equal(out.task_count, 2);
189
+
190
+ const summaryPath = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'S001-SUMMARY.md');
191
+ assert.ok(fs.existsSync(summaryPath));
192
+ const body = fs.readFileSync(summaryPath, 'utf-8');
193
+ assert.match(body, /slice: "M001-S001"/);
194
+ assert.match(body, /type: slice-summary/);
195
+ assert.match(body, /### M001-S001-T0001/);
196
+ assert.match(body, /### M001-S001-T0002/);
197
+ assert.match(body, /Added src\/a.ts/);
198
+ assert.match(body, /Added src\/b.ts/);
199
+ });
200
+
201
+ test('EM-8: finalize-slice fails when slice directory does not exist', () => {
202
+ const sandbox = makeSandbox();
203
+ seedRoadmapYaml(sandbox, _roadmap());
204
+ seedMilestoneDir(sandbox, 1, {});
205
+ const cap = _capture();
206
+ assert.throws(
207
+ () => subcmd.run(['finalize-slice', '1', '9'], { cwd: sandbox, stdout: cap.stub }),
208
+ (err) => err && err.code === 'finalize-slice-not-found',
209
+ );
210
+ });
211
+
212
+ test('EM-9: finalize-milestone iterates every slice and produces one summary per slice', () => {
213
+ const sandbox = makeSandbox();
214
+ seedRoadmapYaml(sandbox, _roadmap());
215
+ seedMilestoneDir(sandbox, 1, {});
216
+ seedSliceDir(sandbox, 1, 1, {});
217
+ seedSliceDir(sandbox, 1, 2, {});
218
+ _seedTask(sandbox, 1, 1, 1, ['src/a.ts']);
219
+ _seedTask(sandbox, 1, 2, 1, ['src/c.ts']);
220
+
221
+ const cap = _capture();
222
+ subcmd.run(['finalize-milestone', '1'], { cwd: sandbox, stdout: cap.stub });
223
+ const out = JSON.parse(cap.get());
224
+ assert.equal(out.milestone, 'M001');
225
+ assert.equal(out.finalized.length, 2);
226
+ assert.equal(out.reason, 'ok');
227
+
228
+ const s1 = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'S001-SUMMARY.md');
229
+ const s2 = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S002', 'S002-SUMMARY.md');
230
+ assert.ok(fs.existsSync(s1));
231
+ assert.ok(fs.existsSync(s2));
232
+ });
233
+
234
+ test('EM-10: finalize-slice marks tasks without SUMMARY.md but does not fail', () => {
235
+ const sandbox = makeSandbox();
236
+ seedRoadmapYaml(sandbox, _roadmap());
237
+ seedMilestoneDir(sandbox, 1, {});
238
+ seedSliceDir(sandbox, 1, 1, {});
239
+ _seedTask(sandbox, 1, 1, 1, ['src/a.ts']);
240
+ const dir = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'tasks', 'T0001');
241
+ fs.rmSync(path.join(dir, 'T0001-SUMMARY.md'));
242
+
243
+ const cap = _capture();
244
+ subcmd.run(['finalize-slice', '1', '1'], { cwd: sandbox, stdout: cap.stub });
245
+ const out = JSON.parse(cap.get());
246
+ assert.equal(out.task_count, 1);
247
+ const body = fs.readFileSync(out.summary_path, 'utf-8');
248
+ assert.match(body, /No T<NNNN>-SUMMARY.md file present/);
249
+ });
@@ -163,12 +163,65 @@ function _extractTasksFromSlicePlan(planPath) {
163
163
  return out;
164
164
  }
165
165
 
166
+ function _escapeRegex(s) {
167
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
168
+ }
169
+
170
+ function _buildMilestoneRemap(mNum, cwd) {
171
+ const slices = layout.listSlices(mNum, cwd);
172
+ const remap = new Map();
173
+ for (const s of slices) {
174
+ const planPath = layout.slicePlanPath(mNum, s.number, cwd);
175
+ if (!fs.existsSync(planPath)) continue;
176
+ const tasks = _extractTasksFromSlicePlan(planPath);
177
+ tasks.forEach((t, idx) => {
178
+ const oldId = t.id;
179
+ if (!oldId) return;
180
+ const newId = layout.taskFullId(mNum, s.number, idx + 1);
181
+ if (oldId !== newId) remap.set(oldId, newId);
182
+ });
183
+ }
184
+ return remap;
185
+ }
186
+
187
+ function _rewriteSlicePlanIds(planPath, remap) {
188
+ if (remap.size === 0) return false;
189
+ const raw = fs.readFileSync(planPath, 'utf-8');
190
+ const keys = [...remap.keys()].sort((a, b) => b.length - a.length);
191
+ const re = new RegExp('\\b(' + keys.map(_escapeRegex).join('|') + ')\\b', 'g');
192
+ const next = raw.replace(re, (m) => remap.get(m) || m);
193
+ if (next === raw) return false;
194
+ atomicWriteFileSync(planPath, next);
195
+ return true;
196
+ }
197
+
198
+ function _normalizeMilestoneTaskIds(mNum, cwd) {
199
+ const remap = _buildMilestoneRemap(mNum, cwd);
200
+ if (remap.size === 0) return { changed: false, remap: {} };
201
+ const slices = layout.listSlices(mNum, cwd);
202
+ for (const s of slices) {
203
+ const planPath = layout.slicePlanPath(mNum, s.number, cwd);
204
+ if (!fs.existsSync(planPath)) continue;
205
+ _rewriteSlicePlanIds(planPath, remap);
206
+ }
207
+ return { changed: true, remap: Object.fromEntries(remap) };
208
+ }
209
+
166
210
  function _extractInnerTag(body, tag) {
167
211
  const re = new RegExp('<' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + tag + '>', 'i');
168
212
  const m = body.match(re);
169
213
  return m ? m[1].trim() : '';
170
214
  }
171
215
 
216
+ function _parseFilesList(body) {
217
+ const raw = _extractInnerTag(body, 'files_modified') || _extractInnerTag(body, 'files');
218
+ if (!raw) return [];
219
+ return raw
220
+ .split(/[,\n]/)
221
+ .map((s) => s.trim().replace(/^[-*]\s+/, '').trim())
222
+ .filter(Boolean);
223
+ }
224
+
172
225
  function _filesYaml(files) {
173
226
  if (!files.length) return '[]';
174
227
  return '\n' + files.map((f) => ' - ' + JSON.stringify(f)).join('\n');
@@ -184,8 +237,7 @@ function _renderTaskPlanMd(task, mNum, sNum) {
184
237
  const parsed = fullId.match(/T(\d{4,})$/);
185
238
  const taskNum = parsed ? Number(parsed[1]) : 0;
186
239
  const name = _extractInnerTag(task.body, 'name') || fullId;
187
- const files = _extractInnerTag(task.body, 'files');
188
- const filesList = files ? files.split(/[,\n]/).map((s) => s.trim()).filter(Boolean) : [];
240
+ const filesList = _parseFilesList(task.body);
189
241
  const readFirst = _extractInnerTag(task.body, 'read_first');
190
242
  const action = _extractInnerTag(task.body, 'action');
191
243
  const verify = _extractInnerTag(task.body, 'verify');
@@ -258,6 +310,13 @@ function _scaffoldSliceTasks(mNum, sNum, cwd) {
258
310
  if (!fs.existsSync(planPath)) {
259
311
  return { scaffolded: [], reason: 'no-slice-plan', slice: layout.sliceFullId(mNum, sNum) };
260
312
  }
313
+ const sliceRemap = new Map();
314
+ const preTasks = _extractTasksFromSlicePlan(planPath);
315
+ preTasks.forEach((t, idx) => {
316
+ const newId = layout.taskFullId(mNum, sNum, idx + 1);
317
+ if (t.id && t.id !== newId) sliceRemap.set(t.id, newId);
318
+ });
319
+ _rewriteSlicePlanIds(planPath, sliceRemap);
261
320
  const tasks = _extractTasksFromSlicePlan(planPath);
262
321
  if (tasks.length === 0) {
263
322
  return { scaffolded: [], reason: 'no-tasks-in-slice-plan', slice: layout.sliceFullId(mNum, sNum) };
@@ -293,12 +352,19 @@ function _scaffoldAllTasks(mNum, cwd) {
293
352
  if (slices.length === 0) {
294
353
  return { scaffolded: [], reason: 'no-slices', milestone: layout.mId(mNum) };
295
354
  }
355
+ const normalized = _normalizeMilestoneTaskIds(mNum, cwd);
296
356
  const per = [];
297
357
  for (const s of slices) {
298
358
  per.push(_scaffoldSliceTasks(mNum, s.number, cwd));
299
359
  }
300
360
  const total = per.reduce((acc, p) => acc + (p.task_count || 0), 0);
301
- return { scaffolded: per, reason: 'ok', milestone: layout.mId(mNum), total_tasks: total };
361
+ return {
362
+ scaffolded: per,
363
+ reason: 'ok',
364
+ milestone: layout.mId(mNum),
365
+ total_tasks: total,
366
+ normalized_ids: normalized.changed ? normalized.remap : {},
367
+ };
302
368
  }
303
369
 
304
370
  function _createMilestoneDir(mNum, cwd) {
@@ -207,3 +207,149 @@ test('PM-9: unknown verb throws', () => {
207
207
  (err) => err && err.code === 'plan-milestone-unknown-verb',
208
208
  );
209
209
  });
210
+
211
+ test('PM-10: scaffold-all-tasks renumbers task ids per slice when planner numbered globally', () => {
212
+ const sandbox = makeSandbox();
213
+ seedRoadmapYaml(sandbox, _roadmap());
214
+ seedMilestoneDir(sandbox, 1, {});
215
+ const plan1 = [
216
+ '---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
217
+ '<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
218
+ '<task id="M001-S001-T0002" wave="1" tier="sonnet" depends_on=""><name>B</name><action>A</action><done>D</done></task>',
219
+ '<task id="M001-S001-T0003" wave="1" tier="sonnet" depends_on=""><name>C</name><action>A</action><done>D</done></task>',
220
+ ].join('\n');
221
+ const plan2 = [
222
+ '---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
223
+ '<task id="M001-S002-T0004" wave="2" tier="sonnet" depends_on="M001-S001-T0003"><name>D</name><action>A</action><done>D</done></task>',
224
+ '<task id="M001-S002-T0005" wave="2" tier="sonnet" depends_on=""><name>E</name><action>A</action><done>D</done></task>',
225
+ ].join('\n');
226
+ const plan3 = [
227
+ '---', 'slice: "M001-S003"', 'milestone: "M001"', '---', '',
228
+ '<task id="M001-S003-T0006" wave="3" tier="sonnet" depends_on="M001-S002-T0004"><name>F</name><action>A</action><done>D</done></task>',
229
+ '<task id="M001-S003-T0007" wave="3" tier="sonnet" depends_on="M001-S002-T0005,M001-S001-T0001"><name>G</name><action>A</action><done>D</done></task>',
230
+ ].join('\n');
231
+ seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': plan1 });
232
+ seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
233
+ seedSliceDir(sandbox, 1, 3, { 'S003-PLAN.md': plan3 });
234
+
235
+ const cap = _capture();
236
+ subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: cap.stub });
237
+ const out = JSON.parse(cap.get().trim());
238
+ assert.equal(out.reason, 'ok');
239
+ assert.equal(out.total_tasks, 7);
240
+
241
+ const tasksBase = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices');
242
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0001')));
243
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0002')));
244
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0003')));
245
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0001')));
246
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0002')));
247
+ assert.ok(!fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0004')));
248
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0001')));
249
+ assert.ok(fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0002')));
250
+ assert.ok(!fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0006')));
251
+
252
+ const s2t1 = fs.readFileSync(path.join(tasksBase, 'S002', 'tasks', 'T0001', 'T0001-PLAN.md'), 'utf-8');
253
+ assert.match(s2t1, /id: "M001-S002-T0001"/);
254
+ assert.match(s2t1, /- "M001-S001-T0003"/);
255
+
256
+ const s3t1 = fs.readFileSync(path.join(tasksBase, 'S003', 'tasks', 'T0001', 'T0001-PLAN.md'), 'utf-8');
257
+ assert.match(s3t1, /id: "M001-S003-T0001"/);
258
+ assert.match(s3t1, /- "M001-S002-T0001"/);
259
+
260
+ const s3t2 = fs.readFileSync(path.join(tasksBase, 'S003', 'tasks', 'T0002', 'T0002-PLAN.md'), 'utf-8');
261
+ assert.match(s3t2, /id: "M001-S003-T0002"/);
262
+ assert.match(s3t2, /- "M001-S002-T0002"/);
263
+ assert.match(s3t2, /- "M001-S001-T0001"/);
264
+
265
+ const s2PlanRaw = fs.readFileSync(path.join(tasksBase, 'S002', 'S002-PLAN.md'), 'utf-8');
266
+ assert.match(s2PlanRaw, /id="M001-S002-T0001"/);
267
+ assert.match(s2PlanRaw, /id="M001-S002-T0002"/);
268
+ assert.doesNotMatch(s2PlanRaw, /T0004/);
269
+ assert.doesNotMatch(s2PlanRaw, /T0005/);
270
+
271
+ assert.deepEqual(out.normalized_ids['M001-S002-T0004'], 'M001-S002-T0001');
272
+ assert.deepEqual(out.normalized_ids['M001-S002-T0005'], 'M001-S002-T0002');
273
+ assert.deepEqual(out.normalized_ids['M001-S003-T0006'], 'M001-S003-T0001');
274
+ assert.deepEqual(out.normalized_ids['M001-S003-T0007'], 'M001-S003-T0002');
275
+ });
276
+
277
+ test('PM-11: scaffold-all-tasks is idempotent — already-normalized ids stay unchanged', () => {
278
+ const sandbox = makeSandbox();
279
+ seedRoadmapYaml(sandbox, _roadmap());
280
+ seedMilestoneDir(sandbox, 1, {});
281
+ const plan1 = [
282
+ '---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
283
+ '<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
284
+ ].join('\n');
285
+ const plan2 = [
286
+ '---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
287
+ '<task id="M001-S002-T0001" wave="2" tier="sonnet" depends_on="M001-S001-T0001"><name>B</name><action>A</action><done>D</done></task>',
288
+ ].join('\n');
289
+ seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': plan1 });
290
+ seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
291
+
292
+ subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: _capture().stub });
293
+ const cap = _capture();
294
+ subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: cap.stub });
295
+ const out = JSON.parse(cap.get().trim());
296
+ assert.deepEqual(out.normalized_ids, {});
297
+ });
298
+
299
+ test('PM-13: scaffold accepts <files_modified> tag and strips bullet prefixes', () => {
300
+ const sandbox = makeSandbox();
301
+ seedRoadmapYaml(sandbox, _roadmap());
302
+ seedMilestoneDir(sandbox, 1, {});
303
+ const slicePlan = [
304
+ '---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
305
+ '<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on="">',
306
+ ' <name>Multi-file task</name>',
307
+ ' <files_modified>',
308
+ ' - src/a.ts',
309
+ ' - src/b.ts',
310
+ ' - src/c.ts',
311
+ ' </files_modified>',
312
+ ' <action>Touch three files.</action>',
313
+ ' <done>Done.</done>',
314
+ '</task>',
315
+ ].join('\n');
316
+ seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': slicePlan });
317
+ subcmd.run(['scaffold-slice-tasks', '1', '1'], { cwd: sandbox, stdout: _capture().stub });
318
+ const body = fs.readFileSync(
319
+ path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'tasks', 'T0001', 'T0001-PLAN.md'),
320
+ 'utf-8',
321
+ );
322
+ assert.match(body, /- "src\/a.ts"/);
323
+ assert.match(body, /- "src\/b.ts"/);
324
+ assert.match(body, /- "src\/c.ts"/);
325
+ assert.doesNotMatch(body, /- "- /);
326
+ });
327
+
328
+ test('PM-12: scaffold-slice-tasks renumbers its own slice when ids do not start at T0001', () => {
329
+ const sandbox = makeSandbox();
330
+ seedRoadmapYaml(sandbox, _roadmap());
331
+ seedMilestoneDir(sandbox, 1, {});
332
+ const plan2 = [
333
+ '---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
334
+ '<task id="M001-S002-T0004" wave="2" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
335
+ '<task id="M001-S002-T0005" wave="2" tier="sonnet" depends_on=""><name>B</name><action>A</action><done>D</done></task>',
336
+ ].join('\n');
337
+ seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
338
+
339
+ const cap = _capture();
340
+ subcmd.run(['scaffold-slice-tasks', '1', '2'], { cwd: sandbox, stdout: cap.stub });
341
+ const out = JSON.parse(cap.get().trim());
342
+ assert.equal(out.task_count, 2);
343
+
344
+ const tasksBase = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S002', 'tasks');
345
+ assert.ok(fs.existsSync(path.join(tasksBase, 'T0001')));
346
+ assert.ok(fs.existsSync(path.join(tasksBase, 'T0002')));
347
+ assert.ok(!fs.existsSync(path.join(tasksBase, 'T0004')));
348
+
349
+ const planRaw = fs.readFileSync(
350
+ path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S002', 'S002-PLAN.md'),
351
+ 'utf-8',
352
+ );
353
+ assert.match(planRaw, /id="M001-S002-T0001"/);
354
+ assert.doesNotMatch(planRaw, /T0004/);
355
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
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": {
@@ -136,7 +136,16 @@ for WAVE_INDEX in 0 1 2 ...; do
136
136
  fi
137
137
  done
138
138
  # wait for all parallel executors in this wave to finish before next wave
139
+
140
+ # After every task in the slice committed: aggregate per-task summaries into
141
+ # the slice-level S<NNN>-SUMMARY.md so /np:validate-phase can audit it.
142
+ SLICE_NUM=$(echo "$WAVE" | node -e "process.stdin.on('data', d => console.log(JSON.parse(d).wave))")
143
+ node .nubos-pilot/bin/np-tools.cjs init execute-milestone finalize-slice "$PHASE" "$SLICE_NUM" >/dev/null
139
144
  done
145
+
146
+ # Milestone done — regenerate every slice summary so retroactive / resumed
147
+ # runs also end with a complete audit surface.
148
+ node .nubos-pilot/bin/np-tools.cjs init execute-milestone finalize-milestone "$PHASE" >/dev/null
140
149
  ```
141
150
 
142
151
  After every slice completes, point the operator at `/np:validate-phase $PHASE` to run the UAT per slice.