nubos-pilot 0.9.4 → 0.9.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.
@@ -11,27 +11,53 @@ const { deleteCheckpoint, readCheckpoint } = require('../../lib/checkpoint.cjs')
11
11
 
12
12
  const BYPASS_FLAG = '--bypass-nubosloop';
13
13
 
14
+ // Evidence-based gate: a complete Nubosloop run accumulates fields on the
15
+ // checkpoint envelope (cache_hit from preflight, verify_exit_code from
16
+ // post-executor, findings from post-critics, committed_at from commit). A
17
+ // gamed run that only invokes `loop-run-round --phase commit` directly leaves
18
+ // verify_exit_code and findings undefined. Checking last_phase alone is not
19
+ // enough — we require the cumulative signature.
14
20
  function _assertLoopGate(taskId, cwd, bypass, stderr) {
15
21
  const cp = readCheckpoint(taskId, cwd);
16
- const last = cp && cp.nubosloop && cp.nubosloop.last_phase;
17
- if (last === 'commit') return { bypassed: false, last_phase: last };
18
- const reason = !cp ? 'no-checkpoint' : 'last-phase-mismatch';
19
- const observed = last || (cp ? 'none' : 'no-checkpoint');
22
+ const np = (cp && cp.nubosloop) || null;
23
+ const last = np && np.last_phase;
24
+ const checks = [
25
+ { ok: !!cp, reason: 'no-checkpoint', missing: 'checkpoint', observed: 'no-checkpoint' },
26
+ { ok: last === 'commit', reason: 'last-phase-mismatch', missing: 'last_phase=commit', observed: last || 'none' },
27
+ { ok: np && np.verify_exit_code === 0, reason: 'post-executor-not-green', missing: 'verify_exit_code=0', observed: np && np.verify_exit_code !== undefined ? String(np.verify_exit_code) : 'undefined' },
28
+ { ok: np && Array.isArray(np.findings), reason: 'post-critics-missing', missing: 'findings (array)', observed: np && np.findings !== undefined ? JSON.stringify(np.findings).slice(0, 60) : 'undefined' },
29
+ { ok: np && !!np.committed_at, reason: 'commit-phase-not-stamped', missing: 'committed_at', observed: (np && np.committed_at) || 'undefined' },
30
+ ];
31
+ const failed = checks.find((c) => !c.ok);
32
+ if (!failed) {
33
+ return { bypassed: false, last_phase: last, forced_commit_phase: !!(np && np.forced_commit_phase) };
34
+ }
20
35
  if (bypass) {
21
36
  stderr.write(
22
37
  '[nubos-pilot] WARNING: commit-task ' + taskId +
23
- ' bypassing Nubosloop gate (' + BYPASS_FLAG + '; observed=' + observed +
38
+ ' bypassing Nubosloop gate (' + BYPASS_FLAG +
39
+ '; reason=' + failed.reason + '; missing=' + failed.missing +
40
+ '; observed=' + failed.observed +
24
41
  '). Single-pass commit, no critic review enforced.\n',
25
42
  );
26
- return { bypassed: true, last_phase: last || null };
43
+ return { bypassed: true, last_phase: last || null, forced_commit_phase: !!(np && np.forced_commit_phase) };
27
44
  }
28
45
  throw new NubosPilotError(
29
46
  'commit-task-loop-bypass-violation',
30
- 'commit-task refused: Nubosloop did not reach phase=commit for ' + taskId +
31
- ' (observed nubosloop.last_phase=' + observed + '). ' +
32
- 'Run `loop-run-round ' + taskId + ' --phase commit` first, or pass ' + BYPASS_FLAG +
47
+ 'commit-task refused: Nubosloop sequence incomplete for ' + taskId +
48
+ ' (reason=' + failed.reason + '; missing=' + failed.missing +
49
+ '; observed=' + failed.observed + '). ' +
50
+ 'Run the full loop (preflight → post-executor verify-green → post-critics → commit) first, or pass ' + BYPASS_FLAG +
33
51
  ' for an explicit single-pass override.',
34
- { taskId, reason, last_phase: last || null },
52
+ {
53
+ taskId,
54
+ reason: failed.reason,
55
+ missing: failed.missing,
56
+ observed_last_phase: last || null,
57
+ observed_verify_exit_code: np && np.verify_exit_code !== undefined ? np.verify_exit_code : null,
58
+ observed_findings_is_array: !!(np && Array.isArray(np.findings)),
59
+ observed_committed_at: (np && np.committed_at) || null,
60
+ },
35
61
  );
36
62
  }
37
63
 
@@ -141,6 +167,7 @@ function run(args, ctx) {
141
167
  files: safeFiles,
142
168
  files_source: filesSource,
143
169
  nubosloop_bypassed: gate.bypassed,
170
+ nubosloop_forced_commit_phase: !!gate.forced_commit_phase,
144
171
  };
145
172
  stdout.write(JSON.stringify(payload));
146
173
  return payload;
@@ -82,9 +82,10 @@ function _capture() {
82
82
  return { stub, get: () => buf };
83
83
  }
84
84
 
85
- // Seed a checkpoint that satisfies the Nubosloop gate (last_phase=commit) so
86
- // commit-task accepts it. Tests that exercise the gate explicitly bypass this
87
- // helper. Optional `extra` overrides any field on the envelope.
85
+ // Seed a checkpoint that satisfies the full Nubosloop gate (sequence-integrity).
86
+ // A real loop accumulates evidence on the envelope; the gate refuses unless
87
+ // every required marker is present. Tests that exercise game-paths build their
88
+ // own partial fixtures.
88
89
  function seedLoopReadyCheckpoint(root, taskId, extra) {
89
90
  const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', taskId + '.json');
90
91
  fs.mkdirSync(path.dirname(cpPath), { recursive: true });
@@ -94,12 +95,21 @@ function seedLoopReadyCheckpoint(root, taskId, extra) {
94
95
  status: 'pre-commit',
95
96
  files_touched: [],
96
97
  nubosloop: {
98
+ round: 1,
99
+ cache_hit: false,
97
100
  last_phase: 'commit',
98
101
  last_action: 'commit',
102
+ verify_exit_code: 0,
103
+ findings: [],
99
104
  committed_at: '2026-05-04T12:00:00Z',
100
105
  },
101
106
  };
102
- fs.writeFileSync(cpPath, JSON.stringify(Object.assign(base, extra || {})), 'utf-8');
107
+ // Allow the test to override individual fields (incl. nubosloop sub-fields).
108
+ const merged = Object.assign({}, base, extra || {});
109
+ if (extra && extra.nubosloop) {
110
+ merged.nubosloop = Object.assign({}, base.nubosloop, extra.nubosloop);
111
+ }
112
+ fs.writeFileSync(cpPath, JSON.stringify(merged), 'utf-8');
103
113
  return cpPath;
104
114
  }
105
115
 
@@ -249,10 +259,112 @@ test('CT-9: refuse commit when nubosloop.last_phase ≠ commit', () => {
249
259
  () => subcmd.run(['M006-S001-T0021'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
250
260
  (err) => err && err.code === 'commit-task-loop-bypass-violation'
251
261
  && err.details && err.details.reason === 'last-phase-mismatch'
252
- && err.details.last_phase === 'verifying',
262
+ && err.details.observed_last_phase === 'verifying',
263
+ );
264
+ });
265
+
266
+ test('CT-12: refuse gamed commit (last_phase=commit but no verify_exit_code)', () => {
267
+ const root = makeRepo();
268
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0030', ['src/g.ts']);
269
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
270
+ fs.writeFileSync(path.join(root, 'src', 'g.ts'), 'export const g = 7;\n', 'utf-8');
271
+ // Simulates an agent that ran ONLY `loop-run-round --phase commit` to game
272
+ // the gate, without going through preflight/post-executor/post-critics.
273
+ // verify_exit_code is undefined → post-executor never ran.
274
+ const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0030.json');
275
+ fs.mkdirSync(path.dirname(cpPath), { recursive: true });
276
+ fs.writeFileSync(cpPath, JSON.stringify({
277
+ schema_version: 1,
278
+ task_id: 'M006-S001-T0030',
279
+ status: 'pre-commit',
280
+ files_touched: [],
281
+ nubosloop: { last_phase: 'commit', last_action: 'commit', committed_at: '2026-05-04T12:00:00Z' },
282
+ }), 'utf-8');
283
+ const cap = _capture();
284
+ const stderr = _capture();
285
+ assert.throws(
286
+ () => subcmd.run(['M006-S001-T0030'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
287
+ (err) => err && err.code === 'commit-task-loop-bypass-violation'
288
+ && err.details && err.details.reason === 'post-executor-not-green',
289
+ );
290
+ });
291
+
292
+ test('CT-13: refuse gamed commit when verify ran but post-critics findings missing', () => {
293
+ const root = makeRepo();
294
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0031', ['src/h.ts']);
295
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
296
+ fs.writeFileSync(path.join(root, 'src', 'h.ts'), 'export const h = 8;\n', 'utf-8');
297
+ // verify ran (exit_code=0) but critics never produced findings — agent
298
+ // skipped the critic-schwarm step.
299
+ const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0031.json');
300
+ fs.mkdirSync(path.dirname(cpPath), { recursive: true });
301
+ fs.writeFileSync(cpPath, JSON.stringify({
302
+ schema_version: 1,
303
+ task_id: 'M006-S001-T0031',
304
+ status: 'pre-commit',
305
+ files_touched: [],
306
+ nubosloop: {
307
+ last_phase: 'commit', last_action: 'commit',
308
+ verify_exit_code: 0, // post-executor ran
309
+ committed_at: '2026-05-04T12:00:00Z',
310
+ // findings: missing → post-critics never ran
311
+ },
312
+ }), 'utf-8');
313
+ const cap = _capture();
314
+ const stderr = _capture();
315
+ assert.throws(
316
+ () => subcmd.run(['M006-S001-T0031'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
317
+ (err) => err && err.code === 'commit-task-loop-bypass-violation'
318
+ && err.details && err.details.reason === 'post-critics-missing',
319
+ );
320
+ });
321
+
322
+ test('CT-14: refuse when verify-red was recorded (post-executor failed)', () => {
323
+ const root = makeRepo();
324
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0032', ['src/i.ts']);
325
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
326
+ fs.writeFileSync(path.join(root, 'src', 'i.ts'), 'export const i = 9;\n', 'utf-8');
327
+ // Loop reached commit-stamp somehow but verify was red — must refuse.
328
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0032', {
329
+ nubosloop: { verify_exit_code: 1 },
330
+ });
331
+ const cap = _capture();
332
+ const stderr = _capture();
333
+ assert.throws(
334
+ () => subcmd.run(['M006-S001-T0032'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
335
+ (err) => err && err.code === 'commit-task-loop-bypass-violation'
336
+ && err.details && err.details.reason === 'post-executor-not-green'
337
+ && err.details.observed_verify_exit_code === 1,
253
338
  );
254
339
  });
255
340
 
341
+ test('CT-15: bypass on gamed commit logs precise reason in stderr', () => {
342
+ const root = makeRepo();
343
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0033', ['src/j.ts']);
344
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
345
+ fs.writeFileSync(path.join(root, 'src', 'j.ts'), 'export const j = 10;\n', 'utf-8');
346
+ const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0033.json');
347
+ fs.mkdirSync(path.dirname(cpPath), { recursive: true });
348
+ fs.writeFileSync(cpPath, JSON.stringify({
349
+ schema_version: 1, task_id: 'M006-S001-T0033', status: 'pre-commit', files_touched: [],
350
+ nubosloop: { last_phase: 'commit', last_action: 'commit', committed_at: 'z' },
351
+ }), 'utf-8');
352
+ const prev = process.cwd();
353
+ process.chdir(root);
354
+ const cap = _capture();
355
+ const stderr = _capture();
356
+ try {
357
+ subcmd.run(['M006-S001-T0033', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub });
358
+ } finally {
359
+ process.chdir(prev);
360
+ }
361
+ const payload = JSON.parse(cap.get());
362
+ assert.equal(payload.ok, true);
363
+ assert.equal(payload.nubosloop_bypassed, true);
364
+ assert.match(stderr.get(), /reason=post-executor-not-green/);
365
+ assert.match(stderr.get(), /missing=verify_exit_code=0/);
366
+ });
367
+
256
368
  test('CT-10: --bypass-nubosloop allows single-pass commit and warns on stderr', () => {
257
369
  const root = makeRepo();
258
370
  seedPlanAndTask(root, '06-01', 'M006-S001-T0022', ['src/e.ts']);
@@ -467,6 +467,79 @@ test('LCLI-RR-7: loop-run-round rejects unknown --phase', () => {
467
467
  );
468
468
  });
469
469
 
470
+ test('LCLI-RR-8: phase=commit refuses without verify_exit_code (post-executor never ran)', () => {
471
+ const r = _mkRoot();
472
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
473
+ const loopRunRound = require('./loop-run-round.cjs');
474
+ assert.throws(
475
+ () => loopRunRound.run(
476
+ ['M001-S001-T0001', '--phase', 'commit'],
477
+ { cwd: r, stdout: _cap().stub },
478
+ ),
479
+ (err) => err && err.code === 'loop-commit-precondition-missing'
480
+ && err.details && err.details.missing === 'verify_exit_code',
481
+ );
482
+ });
483
+
484
+ test('LCLI-RR-9: phase=commit refuses without findings (post-critics never ran)', () => {
485
+ const r = _mkRoot();
486
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
487
+ const nubosloop = require('../../lib/nubosloop.cjs');
488
+ // post-executor ran (verify-green) but critics never produced findings.
489
+ nubosloop.recordLoopState('M001-S001-T0001', { round: 1 }, r);
490
+ checkpoint.mergeCheckpoint('M001-S001-T0001', (cur) => ({
491
+ nubosloop: Object.assign({}, (cur && cur.nubosloop) || {}, { verify_exit_code: 0 }),
492
+ }), r);
493
+ const loopRunRound = require('./loop-run-round.cjs');
494
+ assert.throws(
495
+ () => loopRunRound.run(
496
+ ['M001-S001-T0001', '--phase', 'commit'],
497
+ { cwd: r, stdout: _cap().stub },
498
+ ),
499
+ (err) => err && err.code === 'loop-commit-precondition-missing'
500
+ && err.details && err.details.missing === 'findings',
501
+ );
502
+ });
503
+
504
+ test('LCLI-RR-10: phase=commit accepts complete loop state (verify-green + findings array)', () => {
505
+ const r = _mkRoot();
506
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
507
+ const nubosloop = require('../../lib/nubosloop.cjs');
508
+ nubosloop.recordLoopState('M001-S001-T0001', { round: 1 }, r);
509
+ checkpoint.mergeCheckpoint('M001-S001-T0001', (cur) => ({
510
+ nubosloop: Object.assign({}, (cur && cur.nubosloop) || {}, {
511
+ verify_exit_code: 0,
512
+ findings: [],
513
+ }),
514
+ }), r);
515
+ const cap = _cap();
516
+ const loopRunRound = require('./loop-run-round.cjs');
517
+ loopRunRound.run(['M001-S001-T0001', '--phase', 'commit'], { cwd: r, stdout: cap.stub });
518
+ const out = JSON.parse(cap.get());
519
+ assert.equal(out.next_action, 'commit-task');
520
+ assert.equal(out.forced, false);
521
+ const cp = checkpoint.readCheckpoint('M001-S001-T0001', r);
522
+ assert.equal(cp.nubosloop.last_phase, 'commit');
523
+ assert.equal(cp.nubosloop.forced_commit_phase, false);
524
+ });
525
+
526
+ test('LCLI-RR-11: phase=commit --force-commit-phase bypasses preconditions and stamps the override', () => {
527
+ const r = _mkRoot();
528
+ checkpoint.startTask({ id: 'M001-S001-T0001' }, r);
529
+ // Empty checkpoint — no verify, no findings. Force should still allow.
530
+ const cap = _cap();
531
+ const loopRunRound = require('./loop-run-round.cjs');
532
+ loopRunRound.run(
533
+ ['M001-S001-T0001', '--phase', 'commit', '--force-commit-phase'],
534
+ { cwd: r, stdout: cap.stub },
535
+ );
536
+ const out = JSON.parse(cap.get());
537
+ assert.equal(out.next_action, 'commit-task');
538
+ assert.equal(out.forced, true);
539
+ const cp = checkpoint.readCheckpoint('M001-S001-T0001', r);
540
+ assert.equal(cp.nubosloop.forced_commit_phase, true);
541
+ });
542
+
470
543
  test('LCLI-22: learning-match queries the local store', () => {
471
544
  const r = _mkRoot();
472
545
  const lr = require('../../lib/learnings.cjs');
@@ -164,6 +164,43 @@ function _runPostCritics(taskId, list, cwd) {
164
164
  }
165
165
 
166
166
  function _runCommit(taskId, list, cwd) {
167
+ // Sequence-integrity guard: the commit phase MUST follow a complete loop run.
168
+ // Stamping last_phase='commit' is what unlocks commit-task, so without this
169
+ // check an agent could shell out `loop-run-round --phase commit` directly,
170
+ // skip preflight/executor/critics, and bypass the entire Nubosloop. The
171
+ // commit-task gate then sees a satisfied last_phase and lets the commit
172
+ // through. Defense-in-depth: refuse here AND in commit-task.
173
+ //
174
+ // Required evidence on the checkpoint envelope:
175
+ // - verify_exit_code === 0 → post-executor ran AND verify was green
176
+ // - findings is an array → post-critics ran (empty array = passed)
177
+ //
178
+ // Bypass for legitimate test fixtures / migration: --force-commit-phase.
179
+ const force = list.includes('--force-commit-phase');
180
+ if (!force) {
181
+ const cur = checkpoint.readCheckpoint(taskId, cwd) || {};
182
+ const np = (cur && cur.nubosloop) || {};
183
+ if (np.verify_exit_code !== 0) {
184
+ throw new NubosPilotError(
185
+ 'loop-commit-precondition-missing',
186
+ 'phase=commit refused: post-executor did not record a verify-green run for ' + taskId +
187
+ ' (observed verify_exit_code=' + (np.verify_exit_code === undefined ? 'undefined' : np.verify_exit_code) + '). ' +
188
+ 'Run `loop-run-round ' + taskId + ' --phase post-executor --verify-exit-code 0 --verify-output-path ...` first, ' +
189
+ 'or pass --force-commit-phase for an explicit override.',
190
+ { taskId, missing: 'verify_exit_code', observed: np.verify_exit_code === undefined ? null : np.verify_exit_code },
191
+ );
192
+ }
193
+ if (!Array.isArray(np.findings)) {
194
+ throw new NubosPilotError(
195
+ 'loop-commit-precondition-missing',
196
+ 'phase=commit refused: post-critics did not produce a findings array for ' + taskId +
197
+ ' (observed findings=' + (np.findings === undefined ? 'undefined' : JSON.stringify(np.findings)) + '). ' +
198
+ 'Run `loop-run-round ' + taskId + ' --phase post-critics --critic-outputs <json>` first, ' +
199
+ 'or pass --force-commit-phase for an explicit override.',
200
+ { taskId, missing: 'findings', observed: np.findings === undefined ? null : np.findings },
201
+ );
202
+ }
203
+ }
167
204
  const pattern = args.getFlag(list, '--learning-pattern') || null;
168
205
  const outcome = args.getFlag(list, '--learning-outcome') || 'verified';
169
206
  let logged = null;
@@ -184,6 +221,7 @@ function _runCommit(taskId, list, cwd) {
184
221
  last_phase: 'commit',
185
222
  last_action: 'commit',
186
223
  committed_at: new Date().toISOString(),
224
+ forced_commit_phase: force ? true : (cur && cur.nubosloop && cur.nubosloop.forced_commit_phase) || false,
187
225
  }),
188
226
  }),
189
227
  cwd,
@@ -192,6 +230,7 @@ function _runCommit(taskId, list, cwd) {
192
230
  phase: 'commit',
193
231
  next_action: 'commit-task',
194
232
  learning_logged: logged,
233
+ forced: force,
195
234
  };
196
235
  }
197
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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": {