nubos-pilot 0.9.3 → 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.
@@ -9,6 +9,58 @@ const git = require('../../lib/git.cjs');
9
9
  const { commitTask, findCommitByTaskId } = git;
10
10
  const { deleteCheckpoint, readCheckpoint } = require('../../lib/checkpoint.cjs');
11
11
 
12
+ const BYPASS_FLAG = '--bypass-nubosloop';
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.
20
+ function _assertLoopGate(taskId, cwd, bypass, stderr) {
21
+ const cp = readCheckpoint(taskId, cwd);
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
+ }
35
+ if (bypass) {
36
+ stderr.write(
37
+ '[nubos-pilot] WARNING: commit-task ' + taskId +
38
+ ' bypassing Nubosloop gate (' + BYPASS_FLAG +
39
+ '; reason=' + failed.reason + '; missing=' + failed.missing +
40
+ '; observed=' + failed.observed +
41
+ '). Single-pass commit, no critic review enforced.\n',
42
+ );
43
+ return { bypassed: true, last_phase: last || null, forced_commit_phase: !!(np && np.forced_commit_phase) };
44
+ }
45
+ throw new NubosPilotError(
46
+ 'commit-task-loop-bypass-violation',
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 +
51
+ ' for an explicit single-pass override.',
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
+ },
61
+ );
62
+ }
63
+
12
64
  function _resolveTaskFile(taskId, cwd) {
13
65
  const parsed = layout.parseTaskFullId(taskId);
14
66
  const filePath = layout.taskPlanPath(parsed.milestone, parsed.slice, parsed.task, cwd);
@@ -49,8 +101,11 @@ function run(args, ctx) {
49
101
  const context = ctx || {};
50
102
  const cwd = context.cwd || process.cwd();
51
103
  const stdout = context.stdout || process.stdout;
104
+ const stderr = context.stderr || process.stderr;
52
105
  const list = Array.isArray(args) ? args : [];
53
- const taskId = list[0];
106
+ const bypass = list.includes(BYPASS_FLAG);
107
+ const positional = list.filter((a) => !String(a).startsWith('--'));
108
+ const taskId = positional[0];
54
109
 
55
110
  if (!taskId) {
56
111
  throw new NubosPilotError(
@@ -67,6 +122,8 @@ function run(args, ctx) {
67
122
  );
68
123
  }
69
124
 
125
+ const gate = _assertLoopGate(taskId, cwd, bypass, stderr);
126
+
70
127
  const { filePath } = _resolveTaskFile(taskId, cwd);
71
128
  const raw = fs.readFileSync(filePath, 'utf-8');
72
129
  const { frontmatter, body } = extractFrontmatter(raw);
@@ -93,7 +150,7 @@ function run(args, ctx) {
93
150
  const name = _extractName(frontmatter, body);
94
151
  const message = 'task(' + taskId + '): ' + name;
95
152
 
96
-
153
+
97
154
 
98
155
  commitTask(taskId, safeFiles, message);
99
156
  const sha = findCommitByTaskId(taskId);
@@ -103,7 +160,15 @@ function run(args, ctx) {
103
160
  process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
104
161
  }
105
162
 
106
- const payload = { ok: true, task_id: taskId, sha, files: safeFiles, files_source: filesSource };
163
+ const payload = {
164
+ ok: true,
165
+ task_id: taskId,
166
+ sha,
167
+ files: safeFiles,
168
+ files_source: filesSource,
169
+ nubosloop_bypassed: gate.bypassed,
170
+ nubosloop_forced_commit_phase: !!gate.forced_commit_phase,
171
+ };
107
172
  stdout.write(JSON.stringify(payload));
108
173
  return payload;
109
174
  }
@@ -82,6 +82,37 @@ function _capture() {
82
82
  return { stub, get: () => buf };
83
83
  }
84
84
 
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.
89
+ function seedLoopReadyCheckpoint(root, taskId, extra) {
90
+ const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', taskId + '.json');
91
+ fs.mkdirSync(path.dirname(cpPath), { recursive: true });
92
+ const base = {
93
+ schema_version: 1,
94
+ task_id: taskId,
95
+ status: 'pre-commit',
96
+ files_touched: [],
97
+ nubosloop: {
98
+ round: 1,
99
+ cache_hit: false,
100
+ last_phase: 'commit',
101
+ last_action: 'commit',
102
+ verify_exit_code: 0,
103
+ findings: [],
104
+ committed_at: '2026-05-04T12:00:00Z',
105
+ },
106
+ };
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');
113
+ return cpPath;
114
+ }
115
+
85
116
  after(() => {
86
117
  while (_repos.length) {
87
118
  const r = _repos.pop();
@@ -110,6 +141,7 @@ test('CT-2: commit-task rejects invalid TASK_ID format (defense-in-depth)', () =
110
141
  test('CT-3: commit-task emits JSON with sha + files on success', () => {
111
142
  const root = makeRepo();
112
143
  seedPlanAndTask(root, '06-01', 'M006-S001-T0001', ['src/a.ts']);
144
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0001');
113
145
 
114
146
  fs.mkdirSync(path.join(root, 'src'), { recursive: true });
115
147
  fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
@@ -126,6 +158,7 @@ test('CT-3: commit-task emits JSON with sha + files on success', () => {
126
158
  assert.equal(payload.task_id, 'M006-S001-T0001');
127
159
  assert.ok(/^[0-9a-f]{40}$/.test(payload.sha));
128
160
  assert.deepEqual(payload.files, ['src/a.ts']);
161
+ assert.equal(payload.nubosloop_bypassed, false);
129
162
 
130
163
  const subject = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
131
164
  assert.ok(subject.startsWith('task(M006-S001-T0001):'), 'subject: ' + subject);
@@ -134,6 +167,7 @@ test('CT-3: commit-task emits JSON with sha + files on success', () => {
134
167
  test('CT-4: commit-task LOUD-FAILS when every files_modified entry is gitignored (D-25)', () => {
135
168
  const root = makeRepo();
136
169
  seedPlanAndTask(root, '06-01', 'M006-S001-T0002', ['build/out.js']);
170
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0002');
137
171
  fs.writeFileSync(path.join(root, '.gitignore'), 'build/\n', 'utf-8');
138
172
  fs.mkdirSync(path.join(root, 'build'), { recursive: true });
139
173
  fs.writeFileSync(path.join(root, 'build', 'out.js'), 'noise', 'utf-8');
@@ -152,9 +186,13 @@ test('CT-4: commit-task LOUD-FAILS when every files_modified entry is gitignored
152
186
 
153
187
  test('CT-5: commit-task unknown task id → task-not-found', () => {
154
188
  const root = makeRepo();
189
+ // Loop gate runs BEFORE task lookup (no checkpoint seeded), so we expect
190
+ // the bypass-violation here, not the task-not-found error. Using --bypass
191
+ // so we exercise the unknown-task path instead.
155
192
  const cap = _capture();
193
+ const stderr = _capture();
156
194
  assert.throws(
157
- () => subcmd.run(['M006-S099-T0099'], { cwd: root, stdout: cap.stub }),
195
+ () => subcmd.run(['M006-S099-T0099', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
158
196
  (err) => err && err.code === 'commit-task-not-found',
159
197
  );
160
198
  });
@@ -164,14 +202,8 @@ test('CT-6: empty files_modified falls back to checkpoint.files_touched', () =>
164
202
  seedPlanAndTask(root, '06-01', 'M006-S001-T0010', []);
165
203
  fs.mkdirSync(path.join(root, 'src'), { recursive: true });
166
204
  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');
205
+ // Checkpoint must satisfy the loop gate AND carry files_touched.
206
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0010', { files_touched: ['src/b.ts'] });
175
207
  const prev = process.cwd();
176
208
  process.chdir(root);
177
209
  const cap = _capture();
@@ -189,9 +221,190 @@ test('CT-6: empty files_modified falls back to checkpoint.files_touched', () =>
189
221
  test('CT-7: empty files_modified AND no checkpoint → commit-task-no-files', () => {
190
222
  const root = makeRepo();
191
223
  seedPlanAndTask(root, '06-01', 'M006-S001-T0011', []);
224
+ // No checkpoint → gate would normally refuse first; bypass to reach the
225
+ // no-files path that this test exercises.
192
226
  const cap = _capture();
227
+ const stderr = _capture();
193
228
  assert.throws(
194
- () => subcmd.run(['M006-S001-T0011'], { cwd: root, stdout: cap.stub }),
229
+ () => subcmd.run(['M006-S001-T0011', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
195
230
  (err) => err && err.code === 'commit-task-no-files',
196
231
  );
197
232
  });
233
+
234
+ test('CT-8: refuse commit when no checkpoint exists (Nubosloop gate)', () => {
235
+ const root = makeRepo();
236
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0020', ['src/c.ts']);
237
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
238
+ fs.writeFileSync(path.join(root, 'src', 'c.ts'), 'export const c = 3;\n', 'utf-8');
239
+ const cap = _capture();
240
+ const stderr = _capture();
241
+ assert.throws(
242
+ () => subcmd.run(['M006-S001-T0020'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
243
+ (err) => err && err.code === 'commit-task-loop-bypass-violation' && err.details && err.details.reason === 'no-checkpoint',
244
+ );
245
+ });
246
+
247
+ test('CT-9: refuse commit when nubosloop.last_phase ≠ commit', () => {
248
+ const root = makeRepo();
249
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0021', ['src/d.ts']);
250
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
251
+ fs.writeFileSync(path.join(root, 'src', 'd.ts'), 'export const d = 4;\n', 'utf-8');
252
+ // Checkpoint exists but loop only made it to verifying — gate must refuse.
253
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0021', {
254
+ nubosloop: { last_phase: 'verifying', last_action: 'verify-green' },
255
+ });
256
+ const cap = _capture();
257
+ const stderr = _capture();
258
+ assert.throws(
259
+ () => subcmd.run(['M006-S001-T0021'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
260
+ (err) => err && err.code === 'commit-task-loop-bypass-violation'
261
+ && err.details && err.details.reason === 'last-phase-mismatch'
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,
338
+ );
339
+ });
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
+
368
+ test('CT-10: --bypass-nubosloop allows single-pass commit and warns on stderr', () => {
369
+ const root = makeRepo();
370
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0022', ['src/e.ts']);
371
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
372
+ fs.writeFileSync(path.join(root, 'src', 'e.ts'), 'export const e = 5;\n', 'utf-8');
373
+ const prev = process.cwd();
374
+ process.chdir(root);
375
+ const cap = _capture();
376
+ const stderr = _capture();
377
+ try {
378
+ subcmd.run(['M006-S001-T0022', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub });
379
+ } finally {
380
+ process.chdir(prev);
381
+ }
382
+ const payload = JSON.parse(cap.get());
383
+ assert.equal(payload.ok, true);
384
+ assert.equal(payload.nubosloop_bypassed, true);
385
+ assert.match(stderr.get(), /WARNING: commit-task M006-S001-T0022 bypassing Nubosloop gate/);
386
+ assert.match(stderr.get(), /observed=no-checkpoint/);
387
+ });
388
+
389
+ test('CT-11: --bypass-nubosloop on partial loop state stamps the bypass reason', () => {
390
+ const root = makeRepo();
391
+ seedPlanAndTask(root, '06-01', 'M006-S001-T0023', ['src/f.ts']);
392
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
393
+ fs.writeFileSync(path.join(root, 'src', 'f.ts'), 'export const f = 6;\n', 'utf-8');
394
+ seedLoopReadyCheckpoint(root, 'M006-S001-T0023', {
395
+ nubosloop: { last_phase: 'post-critics', last_action: 'executor' },
396
+ });
397
+ const prev = process.cwd();
398
+ process.chdir(root);
399
+ const cap = _capture();
400
+ const stderr = _capture();
401
+ try {
402
+ subcmd.run(['M006-S001-T0023', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub });
403
+ } finally {
404
+ process.chdir(prev);
405
+ }
406
+ const payload = JSON.parse(cap.get());
407
+ assert.equal(payload.ok, true);
408
+ assert.equal(payload.nubosloop_bypassed, true);
409
+ assert.match(stderr.get(), /observed=post-critics/);
410
+ });
@@ -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.3",
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": {