nubos-pilot 1.3.2 → 1.3.4

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 (38) hide show
  1. package/CHANGELOG.md +5 -2
  2. package/agents/np-critic-economy.md +103 -0
  3. package/agents/np-critic.md +11 -10
  4. package/agents/np-executor.md +14 -0
  5. package/agents/np-simplifier.md +83 -0
  6. package/agents/np-task-architect.md +95 -0
  7. package/agents/np-test-writer.md +89 -0
  8. package/bin/install.js +86 -0
  9. package/bin/np-tools/_commands.cjs +2 -0
  10. package/bin/np-tools/commit-task.cjs +80 -6
  11. package/bin/np-tools/commit-task.test.cjs +133 -0
  12. package/bin/np-tools/doctor.cjs +1 -0
  13. package/bin/np-tools/economy-mode.cjs +47 -0
  14. package/bin/np-tools/loop-commands.test.cjs +121 -2
  15. package/bin/np-tools/loop-run-round.cjs +122 -6
  16. package/bin/np-tools/resolve-model.cjs +1 -0
  17. package/bin/np-tools/simplify-debt.cjs +91 -0
  18. package/bin/np-tools/simplify-debt.test.cjs +99 -0
  19. package/lib/agents-registry.cjs +12 -1
  20. package/lib/agents.test.cjs +4 -0
  21. package/lib/config-defaults.cjs +22 -1
  22. package/lib/config-defaults.test.cjs +9 -0
  23. package/lib/config-schema.cjs +6 -0
  24. package/lib/economy-debt.cjs +235 -0
  25. package/lib/economy-debt.test.cjs +131 -0
  26. package/lib/economy-mode.cjs +66 -0
  27. package/lib/economy-mode.test.cjs +85 -0
  28. package/lib/git.cjs +6 -2
  29. package/lib/git.test.cjs +28 -0
  30. package/lib/nubosloop.cjs +4 -0
  31. package/lib/nubosloop.test.cjs +1 -0
  32. package/np-tools.cjs +2 -0
  33. package/package.json +1 -1
  34. package/templates/RULES.md +36 -1
  35. package/workflows/execute-phase.md +154 -1
  36. package/workflows/plan-phase.md +17 -2
  37. package/workflows/simplify-debt.md +93 -0
  38. package/workflows/simplify-review.md +103 -0
@@ -91,19 +91,90 @@ function _resolveSafe(root, p) {
91
91
  }
92
92
 
93
93
  const _COMMIT_NAME_MAX = 200;
94
+ const _COMMIT_BODY_MAX = 2000;
95
+ const _TASK_ID_PREFIX_RE = /^\s*M\d{3,}-S\d{3,}-T\d{4,}\s*[—:-]\s*/;
96
+ const _PLACEHOLDER_RE = /^\s*(?:\{\{.*\}\}|_?TBD\b.*|_none\b.*)\s*$/i;
97
+
94
98
  function _sanitizeCommitName(s) {
95
99
  return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, _COMMIT_NAME_MAX);
96
100
  }
97
101
 
102
+ // The scaffolded H1 is "# <task-id> — <name>"; without a `name:` frontmatter
103
+ // field the body regex captures the whole heading, producing the duplicated
104
+ // "task(ID): ID — desc" subject. Strip a leading task-id prefix so the subject
105
+ // reads "task(ID): desc".
98
106
  function _extractName(frontmatter, body) {
107
+ // Strip a leading task-id prefix, but fall through to the next source when the
108
+ // strip empties the candidate — a `name:` of just "M001-S001-T0001 —" or an H1
109
+ // with no description must not produce a bare "task(ID): " subject.
99
110
  if (typeof frontmatter.name === 'string' && frontmatter.name.length > 0) {
100
- return _sanitizeCommitName(frontmatter.name);
111
+ const stripped = _sanitizeCommitName(String(frontmatter.name).replace(_TASK_ID_PREFIX_RE, ''));
112
+ if (stripped) return stripped;
101
113
  }
102
114
  const m = String(body || '').match(/^#\s+(?:Task:\s*)?(.+?)\s*$/m);
103
- if (m) return _sanitizeCommitName(m[1]);
115
+ if (m) {
116
+ const stripped = _sanitizeCommitName(m[1].replace(_TASK_ID_PREFIX_RE, ''));
117
+ if (stripped) return stripped;
118
+ }
104
119
  return _sanitizeCommitName(frontmatter.id || 'task');
105
120
  }
106
121
 
122
+ function _innerTag(body, tag) {
123
+ const m = String(body || '').match(new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>'));
124
+ if (!m) return '';
125
+ const inner = m[1].trim();
126
+ // A still-unfilled placeholder may carry a Markdown bullet prefix
127
+ // (`- _TBD — …`); test the de-bulleted form so it is recognised and omitted.
128
+ const candidate = inner.replace(/^[-*+]\s+/, '');
129
+ return _PLACEHOLDER_RE.test(candidate) ? '' : inner;
130
+ }
131
+
132
+ function _sanitizeCommitBody(s) {
133
+ return String(s == null ? '' : s)
134
+ .replace(/\r\n?/g, '\n')
135
+ .replace(/[ \t]+\n/g, '\n')
136
+ .replace(/\n{3,}/g, '\n\n')
137
+ .trim()
138
+ .slice(0, _COMMIT_BODY_MAX);
139
+ }
140
+
141
+ // Compose a descriptive commit body from the task's intent so the history is
142
+ // self-explanatory months later: what the task does (<action>), what it must
143
+ // satisfy (<acceptance_criteria>), the task id, and the files it touched.
144
+ function _dedupe(arr) {
145
+ const seen = new Set();
146
+ const out = [];
147
+ for (const x of arr) {
148
+ if (!seen.has(x)) { seen.add(x); out.push(x); }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // Test files np-test-writer wrote this task (ADR-0023). The test-writer chooses
154
+ // their paths at runtime — the planner-authored files_modified does not list
155
+ // them — so the post-test-writer phase records them in the checkpoint. Fold them
156
+ // into the commit set so the executor-greened tests land with their production
157
+ // code instead of being silently dropped.
158
+ function _tddTestFiles(taskId, cwd, root) {
159
+ const cp = readCheckpoint(taskId, cwd);
160
+ const np = cp && cp.nubosloop;
161
+ const tests = np && Array.isArray(np.tdd_tests) ? np.tdd_tests : [];
162
+ return tests.map((p) => _resolveSafe(root, p));
163
+ }
164
+
165
+ function _composeCommitBody(body, taskId, files) {
166
+ const action = _innerTag(body, 'action');
167
+ const accept = _innerTag(body, 'acceptance_criteria');
168
+ const parts = [];
169
+ if (action) parts.push(action);
170
+ if (accept) parts.push('Acceptance:\n' + accept);
171
+ parts.push('Task: ' + taskId);
172
+ if (Array.isArray(files) && files.length > 0) {
173
+ parts.push('Files: ' + files.join(', '));
174
+ }
175
+ return _sanitizeCommitBody(parts.join('\n\n'));
176
+ }
177
+
107
178
  function run(args, ctx) {
108
179
  const context = ctx || {};
109
180
  const cwd = context.cwd || process.cwd();
@@ -153,13 +224,16 @@ function run(args, ctx) {
153
224
  );
154
225
  }
155
226
  const root = findProjectRoot(cwd);
156
- const safeFiles = files.map((p) => _resolveSafe(root, p));
227
+ const declaredSafe = files.map((p) => _resolveSafe(root, p));
228
+ const safeFiles = _dedupe([...declaredSafe, ..._tddTestFiles(taskId, cwd, root)]);
157
229
  const name = _extractName(frontmatter, body);
158
230
  const message = 'task(' + taskId + '): ' + name;
231
+ // Compose the body from the paths that will actually be committed (commitTask
232
+ // drops gitignored entries), so `git log` never advertises a file the diff omits.
233
+ const { committable } = git.classifyCommittablePaths(safeFiles);
234
+ const commitBody = _composeCommitBody(body, taskId, committable);
159
235
 
160
-
161
-
162
- const result = commitTask(taskId, safeFiles, message);
236
+ const result = commitTask(taskId, safeFiles, message, commitBody);
163
237
 
164
238
  if (result.committed === false && result.reason === 'artifacts-gitignored') {
165
239
  try {
@@ -164,6 +164,139 @@ test('CT-3: commit-task emits JSON with sha + files on success', () => {
164
164
  assert.ok(subject.startsWith('task(M006-S001-T0001):'), 'subject: ' + subject);
165
165
  });
166
166
 
167
+ test('CT-3b: subject strips the duplicated task-id prefix from the H1 heading', () => {
168
+ const root = makeRepo();
169
+ const taskId = 'M006-S001-T0050';
170
+ const m = taskId.match(/^(M\d{3,})-(S\d{3,})-(T\d{4,})$/);
171
+ const [, mId, sId, tId] = m;
172
+ const taskDir = path.join(root, '.nubos-pilot', 'milestones', mId, 'slices', sId, 'tasks', tId);
173
+ fs.mkdirSync(taskDir, { recursive: true });
174
+ fs.writeFileSync(path.join(taskDir, tId + '-PLAN.md'), [
175
+ '---',
176
+ `id: ${taskId}`,
177
+ `milestone: ${mId}`,
178
+ `slice: ${mId}-${sId}`,
179
+ 'type: execute',
180
+ 'status: in-progress',
181
+ 'tier: sonnet',
182
+ 'owner: np-executor',
183
+ 'wave: 1',
184
+ 'depends_on: []',
185
+ 'files_modified:',
186
+ ' - src/a.ts',
187
+ 'autonomous: true',
188
+ 'must_haves: {}',
189
+ '---',
190
+ '',
191
+ `# ${taskId} — wire the outcome feedback loop`,
192
+ '',
193
+ '<action>',
194
+ 'Add the OutcomeRecorder service and persist verdicts.',
195
+ '</action>',
196
+ '',
197
+ '<acceptance_criteria>',
198
+ '- Verdicts persist across restarts',
199
+ '- API returns 201 on record',
200
+ '</acceptance_criteria>',
201
+ ].join('\n'), 'utf-8');
202
+ seedLoopReadyCheckpoint(root, taskId);
203
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
204
+ fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
205
+ const prev = process.cwd();
206
+ process.chdir(root);
207
+ const cap = _capture();
208
+ try {
209
+ subcmd.run([taskId], { cwd: root, stdout: cap.stub });
210
+ } finally {
211
+ process.chdir(prev);
212
+ }
213
+ const subject = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
214
+ assert.equal(subject, `task(${taskId}): wire the outcome feedback loop`,
215
+ 'subject must not repeat the task id after the colon');
216
+ const fullBody = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%b'], { encoding: 'utf-8' });
217
+ assert.match(fullBody, /Add the OutcomeRecorder service/, 'body should carry the <action> intent');
218
+ assert.match(fullBody, /Acceptance:/);
219
+ assert.match(fullBody, /Verdicts persist across restarts/);
220
+ assert.match(fullBody, new RegExp('Task: ' + taskId));
221
+ assert.match(fullBody, /Files: src\/a\.ts/);
222
+ });
223
+
224
+ test('CT-3c: test-writer files recorded in nubosloop.tdd_tests are folded into the commit', () => {
225
+ const root = makeRepo();
226
+ const taskId = 'M006-S001-T0060';
227
+ seedPlanAndTask(root, '06-01', taskId, ['src/a.ts']);
228
+ seedLoopReadyCheckpoint(root, taskId, { nubosloop: { tdd_tests: ['tests/a.test.ts'] } });
229
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
230
+ fs.mkdirSync(path.join(root, 'tests'), { recursive: true });
231
+ fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
232
+ fs.writeFileSync(path.join(root, 'tests', 'a.test.ts'), 'test("a", () => {});\n', 'utf-8');
233
+ const prev = process.cwd();
234
+ process.chdir(root);
235
+ try {
236
+ subcmd.run([taskId], { cwd: root, stdout: _capture().stub });
237
+ } finally {
238
+ process.chdir(prev);
239
+ }
240
+ const committed = execFileSync('git', ['-C', root, 'show', '--name-only', '--format=', 'HEAD'], { encoding: 'utf-8' });
241
+ assert.match(committed, /src\/a\.ts/, 'production file must be committed');
242
+ assert.match(committed, /tests\/a\.test\.ts/, 'tdd test file must be committed even though it is not in files_modified');
243
+ });
244
+
245
+ test('CT-3d: degenerate task name falls back to the id instead of an empty subject', () => {
246
+ const root = makeRepo();
247
+ const taskId = 'M006-S001-T0061';
248
+ const m = taskId.match(/^(M\d{3,})-(S\d{3,})-(T\d{4,})$/);
249
+ const [, mId, sId, tId] = m;
250
+ const taskDir = path.join(root, '.nubos-pilot', 'milestones', mId, 'slices', sId, 'tasks', tId);
251
+ fs.mkdirSync(taskDir, { recursive: true });
252
+ fs.writeFileSync(path.join(taskDir, tId + '-PLAN.md'), [
253
+ '---',
254
+ `id: ${taskId}`,
255
+ `milestone: ${mId}`,
256
+ `slice: ${mId}-${sId}`,
257
+ 'type: execute', 'status: in-progress', 'tier: sonnet', 'owner: np-executor',
258
+ 'wave: 1', 'depends_on: []',
259
+ 'files_modified:', ' - src/a.ts',
260
+ 'autonomous: true', 'must_haves: {}',
261
+ '---', '',
262
+ `# ${taskId} — `,
263
+ ].join('\n'), 'utf-8');
264
+ seedLoopReadyCheckpoint(root, taskId);
265
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
266
+ fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
267
+ const prev = process.cwd();
268
+ process.chdir(root);
269
+ try {
270
+ subcmd.run([taskId], { cwd: root, stdout: _capture().stub });
271
+ } finally {
272
+ process.chdir(prev);
273
+ }
274
+ const subject = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
275
+ assert.equal(subject, `task(${taskId}): ${taskId}`, 'empty name must fall back to the id, never a bare colon');
276
+ });
277
+
278
+ test('CT-3e: commit body Files: lists only committed paths, not gitignored ones', () => {
279
+ const root = makeRepo();
280
+ const taskId = 'M006-S001-T0062';
281
+ seedPlanAndTask(root, '06-01', taskId, ['src/a.ts', 'build/out.js']);
282
+ seedLoopReadyCheckpoint(root, taskId);
283
+ fs.writeFileSync(path.join(root, '.gitignore'), 'build/\n', 'utf-8');
284
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
285
+ fs.mkdirSync(path.join(root, 'build'), { recursive: true });
286
+ fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
287
+ fs.writeFileSync(path.join(root, 'build', 'out.js'), 'noise', 'utf-8');
288
+ const prev = process.cwd();
289
+ process.chdir(root);
290
+ try {
291
+ subcmd.run([taskId], { cwd: root, stdout: _capture().stub });
292
+ } finally {
293
+ process.chdir(prev);
294
+ }
295
+ const fullBody = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%b'], { encoding: 'utf-8' });
296
+ assert.match(fullBody, /Files: src\/a\.ts/);
297
+ assert.doesNotMatch(fullBody, /build\/out\.js/, 'gitignored path must not be advertised in the body');
298
+ });
299
+
167
300
  test('CT-4: commit-task SOFT-SKIPS when every files_modified entry is gitignored (artifacts-gitignored terminator)', () => {
168
301
  const root = makeRepo();
169
302
  seedPlanAndTask(root, '06-01', 'M006-S001-T0002', ['build/out.js']);
@@ -343,6 +343,7 @@ const NUBOSLOOP_CRITICS = [
343
343
  'np-critic-style', // axis module (Style)
344
344
  'np-critic-tests', // axis module (Tests)
345
345
  'np-critic-acceptance', // axis module (Acceptance)
346
+ 'np-critic-economy', // axis module (Economy)
346
347
  ];
347
348
 
348
349
  function _checkNubosloopCritics(projectRoot) {
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const { economyFlags } = require('../../lib/economy-mode.cjs');
4
+ const { emitErrorEnvelope } = require('./_args.cjs');
5
+
6
+ function _usage() {
7
+ return 'Usage:\n np-tools.cjs economy-mode [--json]';
8
+ }
9
+
10
+ function _readConfig(cwd) {
11
+ const { readConfig } = require('../../lib/config.cjs');
12
+ try {
13
+ const cfg = readConfig(cwd);
14
+ return cfg && Object.keys(cfg).length === 0 ? null : cfg;
15
+ } catch (err) {
16
+ if (err && err.code === 'not-in-project') return null;
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ function run(argv, ctx) {
22
+ const context = ctx || {};
23
+ const cwd = context.cwd || process.cwd();
24
+ const stdout = context.stdout || process.stdout;
25
+ const stderr = context.stderr || process.stderr;
26
+ const args = Array.isArray(argv) ? argv.slice() : [];
27
+ if (args.includes('--help') || args.includes('-h')) {
28
+ stdout.write(_usage() + '\n');
29
+ return 0;
30
+ }
31
+ const asJson = args.includes('--json');
32
+ try {
33
+ const config = _readConfig(cwd);
34
+ const flags = economyFlags(config || {});
35
+ stdout.write((asJson ? JSON.stringify(flags) : flags.mode) + '\n');
36
+ return 0;
37
+ } catch (err) {
38
+ emitErrorEnvelope(err, stderr, 'economy-mode-internal-error');
39
+ return 1;
40
+ }
41
+ }
42
+
43
+ module.exports = { run };
44
+
45
+ if (require.main === module) {
46
+ process.exit(run(process.argv.slice(2)));
47
+ }
@@ -426,6 +426,125 @@ function _seedSpawnEvidence(taskId, round, agents, cwd) {
426
426
  }
427
427
  }
428
428
 
429
+ test('LCLI-RR-2a: phase=post-architect refuses without np-task-architect spawn audit', async () => {
430
+ const r = _mkRoot();
431
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
432
+ require('../../lib/nubosloop.cjs').recordLoopState('M001-S001-T0001', { round: 1 }, r);
433
+ const loopRunRound = require('./loop-run-round.cjs');
434
+ await assert.rejects(
435
+ () => loopRunRound.run(
436
+ ['M001-S001-T0001', '--phase', 'post-architect'],
437
+ { cwd: r, stdout: _cap().stub },
438
+ ),
439
+ (err) => err && err.code === 'loop-post-architect-missing-spawn-audit',
440
+ );
441
+ });
442
+
443
+ test('LCLI-RR-2b: phase=post-architect with stamp → spawn-test-writer, no round bump', async () => {
444
+ const r = _mkRoot();
445
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
446
+ _seedSpawnEvidence('M001-S001-T0001', 1, ['np-task-architect'], r);
447
+ const cap = _cap();
448
+ const loopRunRound = require('./loop-run-round.cjs');
449
+ await loopRunRound.run(['M001-S001-T0001', '--phase', 'post-architect'], { cwd: r, stdout: cap.stub });
450
+ const out = JSON.parse(cap.get());
451
+ assert.equal(out.phase, 'post-architect');
452
+ assert.equal(out.next_action, 'spawn-test-writer');
453
+ const cp = checkpoint.readCheckpoint('M001-S001-T0001', r);
454
+ assert.equal(cp.nubosloop.last_phase, 'post-architect');
455
+ assert.equal(cp.nubosloop.round, 1, 'prep step must not bump the round counter');
456
+ });
457
+
458
+ test('LCLI-RR-2c: phase=post-architect --force-post-architect bypasses the audit gate', async () => {
459
+ const r = _mkRoot();
460
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
461
+ require('../../lib/nubosloop.cjs').recordLoopState('M001-S001-T0001', { round: 1 }, r);
462
+ const cap = _cap();
463
+ const loopRunRound = require('./loop-run-round.cjs');
464
+ await loopRunRound.run(
465
+ ['M001-S001-T0001', '--phase', 'post-architect', '--force-post-architect'],
466
+ { cwd: r, stdout: cap.stub },
467
+ );
468
+ const out = JSON.parse(cap.get());
469
+ assert.equal(out.forced, true);
470
+ assert.equal(out.next_action, 'spawn-test-writer');
471
+ });
472
+
473
+ test('LCLI-RR-2d: phase=post-test-writer refuses without np-test-writer spawn audit', async () => {
474
+ const r = _mkRoot();
475
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
476
+ require('../../lib/nubosloop.cjs').recordLoopState('M001-S001-T0001', { round: 1 }, r);
477
+ const loopRunRound = require('./loop-run-round.cjs');
478
+ await assert.rejects(
479
+ () => loopRunRound.run(
480
+ ['M001-S001-T0001', '--phase', 'post-test-writer'],
481
+ { cwd: r, stdout: _cap().stub },
482
+ ),
483
+ (err) => err && err.code === 'loop-post-test-writer-missing-spawn-audit',
484
+ );
485
+ });
486
+
487
+ test('LCLI-RR-2e: phase=post-test-writer with stamp → spawn-executor, no round bump', async () => {
488
+ const r = _mkRoot();
489
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
490
+ _seedSpawnEvidence('M001-S001-T0001', 1, ['np-test-writer'], r);
491
+ const cap = _cap();
492
+ const loopRunRound = require('./loop-run-round.cjs');
493
+ await loopRunRound.run(['M001-S001-T0001', '--phase', 'post-test-writer'], { cwd: r, stdout: cap.stub });
494
+ const out = JSON.parse(cap.get());
495
+ assert.equal(out.phase, 'post-test-writer');
496
+ assert.equal(out.next_action, 'spawn-executor');
497
+ const cp = checkpoint.readCheckpoint('M001-S001-T0001', r);
498
+ assert.equal(cp.nubosloop.last_phase, 'post-test-writer');
499
+ assert.equal(cp.nubosloop.round, 1);
500
+ });
501
+
502
+ test('LCLI-RR-2f: phase=post-test-writer --tests records the written paths in nubosloop.tdd_tests', async () => {
503
+ const r = _mkRoot();
504
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
505
+ _seedSpawnEvidence('M001-S001-T0001', 1, ['np-test-writer'], r);
506
+ const cap = _cap();
507
+ const loopRunRound = require('./loop-run-round.cjs');
508
+ await loopRunRound.run(
509
+ ['M001-S001-T0001', '--phase', 'post-test-writer', '--tests', 'tests/a.test.ts, tests/b.test.ts'],
510
+ { cwd: r, stdout: cap.stub },
511
+ );
512
+ const out = JSON.parse(cap.get());
513
+ assert.deepEqual(out.tdd_tests, ['tests/a.test.ts', 'tests/b.test.ts']);
514
+ const cp = checkpoint.readCheckpoint('M001-S001-T0001', r);
515
+ assert.deepEqual(cp.nubosloop.tdd_tests, ['tests/a.test.ts', 'tests/b.test.ts']);
516
+ });
517
+
518
+ test('LCLI-RR-2g: post-researcher next_action skips disabled prep steps (architect off → test-writer)', async () => {
519
+ const r = _mkRoot();
520
+ fs.writeFileSync(
521
+ path.join(r, '.nubos-pilot', 'config.json'),
522
+ JSON.stringify({ agents: { architect: false } }),
523
+ 'utf-8',
524
+ );
525
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
526
+ _seedSpawnEvidence('M001-S001-T0001', 1, ['np-researcher', 'np-researcher', 'np-researcher'], r);
527
+ const cap = _cap();
528
+ const loopRunRound = require('./loop-run-round.cjs');
529
+ await loopRunRound.run(['M001-S001-T0001', '--phase', 'post-researcher'], { cwd: r, stdout: cap.stub });
530
+ assert.equal(JSON.parse(cap.get()).next_action, 'spawn-test-writer');
531
+ });
532
+
533
+ test('LCLI-RR-2h: post-researcher next_action → executor when both prep steps are off', async () => {
534
+ const r = _mkRoot();
535
+ fs.writeFileSync(
536
+ path.join(r, '.nubos-pilot', 'config.json'),
537
+ JSON.stringify({ agents: { architect: false, test_writer: false } }),
538
+ 'utf-8',
539
+ );
540
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
541
+ _seedSpawnEvidence('M001-S001-T0001', 1, ['np-researcher', 'np-researcher', 'np-researcher'], r);
542
+ const cap = _cap();
543
+ const loopRunRound = require('./loop-run-round.cjs');
544
+ await loopRunRound.run(['M001-S001-T0001', '--phase', 'post-researcher'], { cwd: r, stdout: cap.stub });
545
+ assert.equal(JSON.parse(cap.get()).next_action, 'spawn-executor');
546
+ });
547
+
429
548
  test('LCLI-RR-3: loop-run-round phase=post-executor with verify-green → spawn-critic-schwarm', async () => {
430
549
  const r = _mkRoot();
431
550
  checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
@@ -1184,7 +1303,7 @@ test('LCLI-RR-35: post-researcher accepts when k=3 np-researcher audits exist (T
1184
1303
  { cwd: r, stdout: cap.stub },
1185
1304
  );
1186
1305
  const out = JSON.parse(cap.get());
1187
- assert.equal(out.next_action, 'spawn-executor');
1306
+ assert.equal(out.next_action, 'spawn-architect');
1188
1307
  assert.equal(out.forced, false);
1189
1308
  assert.equal(out.expected_researcher_count, 3);
1190
1309
  });
@@ -1207,7 +1326,7 @@ test('LCLI-RR-35b: post-researcher k-gate honors swarm.research.k config (Gap #6
1207
1326
  );
1208
1327
  const out = JSON.parse(cap.get());
1209
1328
  assert.equal(out.expected_researcher_count, 1);
1210
- assert.equal(out.next_action, 'spawn-executor');
1329
+ assert.equal(out.next_action, 'spawn-architect');
1211
1330
  });
1212
1331
 
1213
1332
  test('LCLI-RR-36: --force-post-researcher bypasses Layer-C gate + stamps flag (T2)', async () => {
@@ -7,6 +7,7 @@ const { NubosPilotError, safeAssign } = require('../../lib/core.cjs');
7
7
  const safePath = require('../../lib/safe-path.cjs');
8
8
 
9
9
  const checkpoint = require('../../lib/checkpoint.cjs');
10
+ const config = require('../../lib/config.cjs');
10
11
  const nubosloop = require('../../lib/nubosloop.cjs');
11
12
  const messaging = require('../../lib/messaging.cjs');
12
13
  const compress = require('../../lib/compress.cjs');
@@ -40,6 +41,8 @@ function _verifyExcerpt(verifyOutput, cwd) {
40
41
  const VALID_PHASES = new Set([
41
42
  'preflight',
42
43
  'post-researcher',
44
+ 'post-architect',
45
+ 'post-test-writer',
43
46
  'post-executor',
44
47
  'post-critics',
45
48
  'commit',
@@ -153,13 +156,124 @@ function _runPostResearcher(taskId, list, cwd) {
153
156
  );
154
157
  return {
155
158
  phase: 'post-researcher',
156
- next_action: 'spawn-executor',
159
+ next_action: _nextAfterResearcher(cwd),
157
160
  forced: force,
158
161
  expected_researcher_count: expectedK,
159
162
  round: merged.nubosloop ? merged.nubosloop.round : null,
160
163
  };
161
164
  }
162
165
 
166
+ // Round-1 preparation steps (architect, test-writer) that run between the
167
+ // researcher swarm and the executor. Each verifies its spawn was stamped via
168
+ // `loop-audit-tool-use` (Layer-C SKIP-GUARD) and records last_phase. They never
169
+ // bump the round counter — TDD writes tests once; build-fixer rounds iterate.
170
+ // Config-gated in the orchestrator: when agents.architect / agents.test_writer
171
+ // is off the step (and this phase) is simply never invoked.
172
+ function _checkPrepSpawnAudited(taskId, list, cwd, agent, forceFlag) {
173
+ const force = list.includes(forceFlag);
174
+ const cur = checkpoint.readCheckpoint(taskId, cwd) || {};
175
+ const round = Number((cur.nubosloop && cur.nubosloop.round)) || 1;
176
+ const satisfied = force
177
+ ? true
178
+ : nubosloop.assertSpawnsAuditedForRound(taskId, [agent], round, cwd).satisfied;
179
+ return { force, round, satisfied };
180
+ }
181
+
182
+ function _stampPrepPhase(taskId, cwd, phase, lastAction, forcedKey, force, extraFn) {
183
+ return checkpoint.mergeCheckpoint(
184
+ taskId,
185
+ (curCp) => {
186
+ const prev = (curCp && curCp.nubosloop) || {};
187
+ const partial = { last_phase: phase, last_action: lastAction };
188
+ if (force) partial[forcedKey] = true;
189
+ if (typeof extraFn === 'function') safeAssign(partial, extraFn(prev));
190
+ return { nubosloop: safeAssign({}, prev, partial) };
191
+ },
192
+ cwd,
193
+ );
194
+ }
195
+
196
+ // The round-1 prep steps are config-gated, so the emitted next_action hint must
197
+ // reflect which downstream steps are actually enabled — a consumer driving off
198
+ // the JSON hint (rather than the ACTION CONTRACT prose) must not skip an enabled
199
+ // architect/test-writer or stall on a disabled one.
200
+ function _nextEnabled(cwd, steps) {
201
+ for (const [path, action] of steps) {
202
+ if (config.tryReadConfigPath(cwd, path, true, { onWarn() {} })) return action;
203
+ }
204
+ return 'spawn-executor';
205
+ }
206
+
207
+ function _nextAfterResearcher(cwd) {
208
+ return _nextEnabled(cwd, [['agents.architect', 'spawn-architect'], ['agents.test_writer', 'spawn-test-writer']]);
209
+ }
210
+
211
+ function _nextAfterArchitect(cwd) {
212
+ return _nextEnabled(cwd, [['agents.test_writer', 'spawn-test-writer']]);
213
+ }
214
+
215
+ // `--tests "a, b, c"` → ['a','b','c']; the post-test-writer phase records these
216
+ // (the paths np-test-writer wrote) so commit-task can fold them into the commit.
217
+ function _parseTestPaths(raw) {
218
+ if (typeof raw !== 'string' || raw.trim() === '') return [];
219
+ return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
220
+ }
221
+
222
+ function _runPostArchitect(taskId, list, cwd) {
223
+ const g = _checkPrepSpawnAudited(taskId, list, cwd, 'np-task-architect', '--force-post-architect');
224
+ if (!g.satisfied) {
225
+ throw new NubosPilotError(
226
+ 'loop-post-architect-missing-spawn-audit',
227
+ 'phase=post-architect refused: no `loop-audit-tool-use` record for round=' + g.round +
228
+ ', agent=np-task-architect on ' + taskId + '. ' +
229
+ 'Spawn np-task-architect and call `loop-audit-tool-use ' + taskId +
230
+ ' --agent np-task-architect --tool-use-log <json>` after the spawn, ' +
231
+ 'or pass --force-post-architect for an explicit override.',
232
+ { taskId, round: g.round, missing: ['np-task-architect'], required: ['np-task-architect'] },
233
+ );
234
+ }
235
+ const merged = _stampPrepPhase(taskId, cwd, 'post-architect', 'architect-spawned', 'forced_post_architect', g.force);
236
+ return {
237
+ phase: 'post-architect',
238
+ next_action: _nextAfterArchitect(cwd),
239
+ forced: g.force,
240
+ round: merged.nubosloop ? merged.nubosloop.round : g.round,
241
+ };
242
+ }
243
+
244
+ function _runPostTestWriter(taskId, list, cwd) {
245
+ const g = _checkPrepSpawnAudited(taskId, list, cwd, 'np-test-writer', '--force-post-test-writer');
246
+ if (!g.satisfied) {
247
+ throw new NubosPilotError(
248
+ 'loop-post-test-writer-missing-spawn-audit',
249
+ 'phase=post-test-writer refused: no `loop-audit-tool-use` record for round=' + g.round +
250
+ ', agent=np-test-writer on ' + taskId + '. ' +
251
+ 'Spawn np-test-writer and call `loop-audit-tool-use ' + taskId +
252
+ ' --agent np-test-writer --tool-use-log <json>` after the spawn, ' +
253
+ 'or pass --force-post-test-writer for an explicit override.',
254
+ { taskId, round: g.round, missing: ['np-test-writer'], required: ['np-test-writer'] },
255
+ );
256
+ }
257
+ const testPaths = _parseTestPaths(args.getFlag(list, '--tests'));
258
+ const merged = _stampPrepPhase(
259
+ taskId, cwd, 'post-test-writer', 'test-writer-spawned', 'forced_post_test_writer', g.force,
260
+ (prev) => {
261
+ const prior = Array.isArray(prev.tdd_tests) ? prev.tdd_tests : [];
262
+ const seen = new Set(prior);
263
+ const tdd_tests = prior.slice();
264
+ for (const p of testPaths) { if (!seen.has(p)) { seen.add(p); tdd_tests.push(p); } }
265
+ return { tdd_tests };
266
+ },
267
+ );
268
+ return {
269
+ phase: 'post-test-writer',
270
+ next_action: 'spawn-executor',
271
+ forced: g.force,
272
+ tdd_tests: merged.nubosloop && Array.isArray(merged.nubosloop.tdd_tests) ? merged.nubosloop.tdd_tests : [],
273
+ round: merged.nubosloop ? merged.nubosloop.round : g.round,
274
+ };
275
+ }
276
+
163
277
  function _runPostExecutor(taskId, list, cwd) {
164
278
  const verifyExitCode = args.getFlag(list, '--verify-exit-code');
165
279
  if (verifyExitCode === undefined) {
@@ -311,7 +425,7 @@ function _runPostCritics(taskId, list, cwd) {
311
425
  'phase=post-critics refused: critic-schwarm spawn-evidence missing for round=' + gateRound +
312
426
  ' on ' + taskId + ' (missing audits: ' + verdict.missing.join(', ') + '). ' +
313
427
  'For each critic agent, call `loop-audit-tool-use ' + taskId +
314
- ' --agent <np-critic-style|np-critic-tests|np-critic-acceptance> --tool-use-log <json>` ' +
428
+ ' --agent <np-critic-style|np-critic-tests|np-critic-acceptance|np-critic-economy> --tool-use-log <json>` ' +
315
429
  'after the spawn, then re-run --phase post-critics. Pass --force-post-critics for an explicit override.',
316
430
  { taskId, round: gateRound, missing: verdict.missing.slice(), required: nubosloop.POST_CRITICS_EVIDENCE.slice() },
317
431
  );
@@ -557,7 +671,7 @@ async function run(argv, ctx) {
557
671
  if (!phase) {
558
672
  throw new NubosPilotError(
559
673
  'loop-run-round-missing-phase',
560
- 'loop-run-round requires --phase <preflight|post-researcher|post-executor|post-critics|commit|stuck>',
674
+ 'loop-run-round requires --phase <preflight|post-researcher|post-architect|post-test-writer|post-executor|post-critics|commit|stuck>',
561
675
  { hint: 'each phase corresponds to a non-LLM transition between LLM spawns' },
562
676
  );
563
677
  }
@@ -572,9 +686,11 @@ async function run(argv, ctx) {
572
686
  const tail = list.slice(1);
573
687
  let result;
574
688
  switch (phase) {
575
- case 'preflight': result = await _runPreflight(taskId, tail, cwd); break;
576
- case 'post-researcher': result = _runPostResearcher(taskId, tail, cwd); break;
577
- case 'post-executor': result = _runPostExecutor(taskId, tail, cwd); break;
689
+ case 'preflight': result = await _runPreflight(taskId, tail, cwd); break;
690
+ case 'post-researcher': result = _runPostResearcher(taskId, tail, cwd); break;
691
+ case 'post-architect': result = _runPostArchitect(taskId, tail, cwd); break;
692
+ case 'post-test-writer': result = _runPostTestWriter(taskId, tail, cwd); break;
693
+ case 'post-executor': result = _runPostExecutor(taskId, tail, cwd); break;
578
694
  case 'post-critics': result = _runPostCritics(taskId, tail, cwd); break;
579
695
  case 'commit': result = _runCommit(taskId, tail, cwd); break;
580
696
  case 'stuck': result = _runStuck(taskId, tail, cwd); break;
@@ -32,6 +32,7 @@ const CRITIC_TIER_OVERRIDES = {
32
32
  'np-critic-style': 'style_tier',
33
33
  'np-critic-tests': 'tests_tier',
34
34
  'np-critic-acceptance': 'acceptance_tier',
35
+ 'np-critic-economy': 'economy_tier',
35
36
  };
36
37
 
37
38
  function _criticTierOverride(config, agentName) {