nubos-pilot 1.3.3 → 1.3.5

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.
@@ -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) {
@@ -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;
@@ -15,6 +15,14 @@ const BUILD_FIXER_AGENT = 'np-build-fixer';
15
15
 
16
16
  const RESEARCHER_AGENT = 'np-researcher';
17
17
 
18
+ // Round-1 preparation agents that run between the researcher swarm and the
19
+ // executor. Config-gated (agents.architect / agents.test_writer). They get
20
+ // Layer-C spawn-evidence stamps but are NOT Rule-9 audited (not in
21
+ // AUDITED_AGENTS) — the architect is advisory/read-only and the test-writer's
22
+ // quality is enforced downstream by the np-critic-tests axis.
23
+ const TASK_ARCHITECT_AGENT = 'np-task-architect';
24
+ const TEST_WRITER_AGENT = 'np-test-writer';
25
+
18
26
  const AUDITED_AGENTS = Object.freeze([
19
27
  EXECUTOR_AGENT,
20
28
  BUILD_FIXER_AGENT,
@@ -29,5 +37,7 @@ module.exports = {
29
37
  EXECUTOR_AGENT,
30
38
  BUILD_FIXER_AGENT,
31
39
  RESEARCHER_AGENT,
40
+ TASK_ARCHITECT_AGENT,
41
+ TEST_WRITER_AGENT,
32
42
  AUDITED_AGENTS,
33
43
  };
@@ -242,6 +242,8 @@ const NP_AGENTS = [
242
242
  { file: 'np-researcher-reconciler', expected_tier: 'sonnet' },
243
243
  { file: 'np-codebase-documenter', expected_tier: 'sonnet' },
244
244
  { file: 'np-architect', expected_tier: 'sonnet' },
245
+ { file: 'np-task-architect', expected_tier: 'sonnet' },
246
+ { file: 'np-test-writer', expected_tier: 'sonnet' },
245
247
  { file: 'np-build-fixer', expected_tier: 'sonnet' },
246
248
  { file: 'np-security-reviewer', expected_tier: 'sonnet' },
247
249
  { file: 'np-nyquist-auditor', expected_tier: 'haiku' },
@@ -18,6 +18,14 @@ const DEFAULT_AGENTS = Object.freeze({
18
18
  research: true,
19
19
  plan_checker: true,
20
20
  verifier: true,
21
+ // Per-task architecture step in the Nubosloop (np-task-architect). Runs in
22
+ // round 1 after the researcher swarm, before the test-writer and executor.
23
+ // Backfilled to true on install/update when absent (see bin/install.js).
24
+ architect: true,
25
+ // Per-task TDD step (np-test-writer). Runs in round 1 after the architect,
26
+ // before the executor — writes the tests the executor must make green.
27
+ // Backfilled to true on install/update when absent (see bin/install.js).
28
+ test_writer: true,
21
29
  // Economy axis level (off|lite|full|ultra). Default `lite` = prevention-first:
22
30
  // the climb-the-ladder discipline is on, the in-loop critic is opt-in (full/ultra).
23
31
  // Resolved via lib/economy-mode.cjs; legacy `economy_critic` bool still honoured.
@@ -33,6 +33,8 @@ const SCHEMA = Object.freeze({
33
33
  research: { type: 'boolean', optional: true },
34
34
  plan_checker: { type: 'boolean', optional: true },
35
35
  verifier: { type: 'boolean', optional: true },
36
+ architect: { type: 'boolean', optional: true },
37
+ test_writer: { type: 'boolean', optional: true },
36
38
  economy: { type: 'enum', values: VALID_ECONOMY_MODES, optional: true },
37
39
  economy_critic: { type: 'boolean', optional: true },
38
40
  },
package/lib/git.cjs CHANGED
@@ -58,7 +58,7 @@ function assertCommittablePaths(paths, opts) {
58
58
  return committable;
59
59
  }
60
60
 
61
- function commitTask(taskId, files, message) {
61
+ function commitTask(taskId, files, message, body) {
62
62
  const { committable, ignored } = classifyCommittablePaths(files);
63
63
  if (committable.length === 0) {
64
64
  if (ignored.length > 0) {
@@ -84,7 +84,11 @@ function commitTask(taskId, files, message) {
84
84
  });
85
85
  }
86
86
  execFileSync('git', ['add', '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
87
- execFileSync('git', ['commit', '-m', message, '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
87
+ const commitArgs = ['commit', '-m', message];
88
+ if (typeof body === 'string' && body.trim().length > 0) {
89
+ commitArgs.push('-m', body);
90
+ }
91
+ execFileSync('git', [...commitArgs, '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
88
92
  return {
89
93
  committed: true,
90
94
  files_committed: committable.slice(),
package/lib/git.test.cjs CHANGED
@@ -199,6 +199,34 @@ test('GIT-5: commitTask creates a single commit containing exactly the supplied
199
199
  });
200
200
  });
201
201
 
202
+ test('GIT-5b: commitTask attaches a multi-line body via a second -m when body is supplied', () => {
203
+ const root = makeRepo();
204
+ inRepo(root, () => {
205
+ writeFile(root, 'lib/git.cjs', '// stub');
206
+ git.commitTask(
207
+ 'M006-S001-T0001',
208
+ ['lib/git.cjs'],
209
+ 'task(M006-S001-T0001): add git helper',
210
+ 'Implements the git helper.\n\nTask: M006-S001-T0001',
211
+ );
212
+ const subject = execFileSync('git', ['log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
213
+ const fullBody = execFileSync('git', ['log', '-n', '1', '--format=%b'], { encoding: 'utf-8' });
214
+ assert.equal(subject, 'task(M006-S001-T0001): add git helper');
215
+ assert.match(fullBody, /Implements the git helper\./);
216
+ assert.match(fullBody, /Task: M006-S001-T0001/);
217
+ });
218
+ });
219
+
220
+ test('GIT-5c: commitTask omits the body -m when body is empty/whitespace (backward-compatible)', () => {
221
+ const root = makeRepo();
222
+ inRepo(root, () => {
223
+ writeFile(root, 'lib/git.cjs', '// stub');
224
+ git.commitTask('M006-S001-T0001', ['lib/git.cjs'], 'task(M006-S001-T0001): add git helper', ' ');
225
+ const fullBody = execFileSync('git', ['log', '-n', '1', '--format=%b'], { encoding: 'utf-8' }).trim();
226
+ assert.equal(fullBody, '');
227
+ });
228
+ });
229
+
202
230
  test('GIT-6: findCommitByTaskId returns 40-char SHA for known task commit', () => {
203
231
  const root = makeRepo();
204
232
  inRepo(root, () => {
package/lib/template.cjs CHANGED
@@ -3,15 +3,14 @@ const path = require('node:path');
3
3
  const { projectStateDir, NubosPilotError } = require('./core.cjs');
4
4
 
5
5
  const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
6
+ const PACKAGE_TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates');
6
7
 
7
8
  function templatesDir(cwd) {
8
9
  return path.join(projectStateDir(cwd), 'templates');
9
10
  }
10
11
 
11
- function loadTemplate(name, vars, cwd = process.cwd()) {
12
- const dir = templatesDir(cwd);
12
+ function resolveInDir(dir, name) {
13
13
  const filePath = path.resolve(dir, name + '.md');
14
-
15
14
  const dirWithSep = dir.endsWith(path.sep) ? dir : dir + path.sep;
16
15
  if (!filePath.startsWith(dirWithSep)) {
17
16
  throw new NubosPilotError(
@@ -20,21 +19,27 @@ function loadTemplate(name, vars, cwd = process.cwd()) {
20
19
  { template: name, path: filePath },
21
20
  );
22
21
  }
22
+ return filePath;
23
+ }
23
24
 
24
- let raw;
25
- try {
26
- raw = fs.readFileSync(filePath, 'utf-8');
27
- } catch (err) {
28
- if (err && err.code === 'ENOENT') {
29
- throw new NubosPilotError(
30
- 'template-not-found',
31
- `Template "${name}" not found at ${filePath}`,
32
- { template: name, path: filePath },
33
- );
34
- }
35
- throw err;
25
+ function loadTemplate(name, vars, cwd = process.cwd()) {
26
+ const localPath = resolveInDir(templatesDir(cwd), name);
27
+ const packagePath = resolveInDir(PACKAGE_TEMPLATES_DIR, name);
28
+
29
+ let filePath = null;
30
+ if (fs.existsSync(localPath)) filePath = localPath;
31
+ else if (fs.existsSync(packagePath)) filePath = packagePath;
32
+
33
+ if (filePath === null) {
34
+ throw new NubosPilotError(
35
+ 'template-not-found',
36
+ `Template "${name}" not found in project overlay (${localPath}) or package templates (${packagePath})`,
37
+ { template: name, path: localPath, packagePath },
38
+ );
36
39
  }
37
40
 
41
+ const raw = fs.readFileSync(filePath, 'utf-8');
42
+
38
43
  return raw.replace(PLACEHOLDER_RE, (_match, key) => {
39
44
  if (!(key in vars)) {
40
45
  throw new NubosPilotError(
@@ -144,6 +144,41 @@ test('TPL-12: listTemplates on sandbox without templates dir returns []', () =>
144
144
  assert.deepEqual(list, []);
145
145
  });
146
146
 
147
+ test('TPL-14: name absent in project overlay falls back to package template', () => {
148
+ const cwd = makeSandbox({});
149
+ const anyVars = new Proxy({}, { has: () => true, get: () => 'X' });
150
+ let out = null;
151
+ let thrown = null;
152
+ try {
153
+ out = tpl.loadTemplate('milestone/CONTEXT', anyVars, cwd);
154
+ } catch (err) {
155
+ thrown = err;
156
+ }
157
+ assert.equal(thrown, null, 'package fallback should resolve, not throw');
158
+ assert.equal(typeof out, 'string');
159
+ assert.ok(out.length > 0, 'package template rendered to non-empty output');
160
+ });
161
+
162
+ test('TPL-15: project overlay wins over package template of the same name', () => {
163
+ const cwd = makeSandbox({ RULES: 'LOCAL-OVERLAY {{x}}' });
164
+ const out = tpl.loadTemplate('RULES', { x: '1' }, cwd);
165
+ assert.equal(out, 'LOCAL-OVERLAY 1');
166
+ });
167
+
168
+ test('TPL-16: name absent in BOTH overlay and package → template-not-found with both paths', () => {
169
+ const cwd = makeSandbox({});
170
+ let thrown = null;
171
+ try {
172
+ tpl.loadTemplate('definitely-missing-xyz', {}, cwd);
173
+ } catch (err) {
174
+ thrown = err;
175
+ }
176
+ assert.ok(thrown, 'expected throw');
177
+ assert.equal(thrown.code, 'template-not-found');
178
+ assert.ok(thrown.details.path.endsWith(path.join('templates', 'definitely-missing-xyz.md')));
179
+ assert.ok(thrown.details.packagePath.endsWith(path.join('templates', 'definitely-missing-xyz.md')));
180
+ });
181
+
147
182
  test('TPL-13: cwd with no .nubos-pilot ancestor → projectStateDir throws not-in-project', () => {
148
183
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nubos-pilot-tpl-noroot-'));
149
184
  _sandboxes.push(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "Self-hosted AI pilot for any codebase. Researcher and critic agents plan, execute and verify each change.",
5
5
  "homepage": "https://pilot.nubos.cloud",
6
6
  "repository": {
@@ -79,7 +79,33 @@ Examples:
79
79
  -->
80
80
  - _TBD — fill with logging policy._
81
81
 
82
- ## Code Style
82
+ ## Conventions
83
+
84
+ > **How your code must be built.** These rules bind the architect (`np-task-architect`),
85
+ > the test-writer (`np-test-writer`), the executor, and the style critic
86
+ > (`np-critic-style`). They are read on every task. Each subsection is **MUST FILL** —
87
+ > use `- _none — <reason>_` only when a subsection genuinely does not apply.
88
+
89
+ ### Class / Module Structure
90
+
91
+ <!-- How classes, modules, and units are shaped. Examples:
92
+ - One public class per file; file name matches the class name.
93
+ - Constructor injection only — no service-locator / static singletons.
94
+ - Business logic lives in Services/Actions; controllers stay thin (no DB access).
95
+ - Public surface ≤ 5 methods; split when it grows past that.
96
+ -->
97
+ - _TBD — fill with class/module construction rules._
98
+
99
+ ### Naming
100
+
101
+ <!-- Identifier conventions. Examples:
102
+ - Classes PascalCase, methods camelCase, constants UPPER_SNAKE.
103
+ - Booleans read as predicates (`isActive`, `hasAccess`), never `flag`/`status`.
104
+ - Table names follow the framework's pluralization — never override.
105
+ -->
106
+ - _TBD — fill with naming conventions._
107
+
108
+ ### Code Style
83
109
 
84
110
  <!-- Format/lint/comment policy. Examples:
85
111
  - No comments inside source — names + tests carry intent.
@@ -88,6 +114,15 @@ Examples:
88
114
  -->
89
115
  - _TBD — fill with style policy._
90
116
 
117
+ ### Patterns & Paradigms
118
+
119
+ <!-- Architectural patterns that are required or banned. Examples:
120
+ - Required: Repository pattern for all persistence; Result objects over exceptions for control flow.
121
+ - Banned: anemic domain models; inheritance for code reuse (prefer composition).
122
+ - Errors are typed and surfaced — never swallowed or stringly-typed.
123
+ -->
124
+ - _TBD — fill with required/banned patterns._
125
+
91
126
  ## Out-of-Scope (Forever)
92
127
 
93
128
  <!-- Things this project explicitly will never do. Distinct from deferred ideas.