nubos-pilot 0.9.3 → 0.9.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.
|
@@ -9,6 +9,32 @@ 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
|
+
function _assertLoopGate(taskId, cwd, bypass, stderr) {
|
|
15
|
+
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');
|
|
20
|
+
if (bypass) {
|
|
21
|
+
stderr.write(
|
|
22
|
+
'[nubos-pilot] WARNING: commit-task ' + taskId +
|
|
23
|
+
' bypassing Nubosloop gate (' + BYPASS_FLAG + '; observed=' + observed +
|
|
24
|
+
'). Single-pass commit, no critic review enforced.\n',
|
|
25
|
+
);
|
|
26
|
+
return { bypassed: true, last_phase: last || null };
|
|
27
|
+
}
|
|
28
|
+
throw new NubosPilotError(
|
|
29
|
+
'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 +
|
|
33
|
+
' for an explicit single-pass override.',
|
|
34
|
+
{ taskId, reason, last_phase: last || null },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
function _resolveTaskFile(taskId, cwd) {
|
|
13
39
|
const parsed = layout.parseTaskFullId(taskId);
|
|
14
40
|
const filePath = layout.taskPlanPath(parsed.milestone, parsed.slice, parsed.task, cwd);
|
|
@@ -49,8 +75,11 @@ function run(args, ctx) {
|
|
|
49
75
|
const context = ctx || {};
|
|
50
76
|
const cwd = context.cwd || process.cwd();
|
|
51
77
|
const stdout = context.stdout || process.stdout;
|
|
78
|
+
const stderr = context.stderr || process.stderr;
|
|
52
79
|
const list = Array.isArray(args) ? args : [];
|
|
53
|
-
const
|
|
80
|
+
const bypass = list.includes(BYPASS_FLAG);
|
|
81
|
+
const positional = list.filter((a) => !String(a).startsWith('--'));
|
|
82
|
+
const taskId = positional[0];
|
|
54
83
|
|
|
55
84
|
if (!taskId) {
|
|
56
85
|
throw new NubosPilotError(
|
|
@@ -67,6 +96,8 @@ function run(args, ctx) {
|
|
|
67
96
|
);
|
|
68
97
|
}
|
|
69
98
|
|
|
99
|
+
const gate = _assertLoopGate(taskId, cwd, bypass, stderr);
|
|
100
|
+
|
|
70
101
|
const { filePath } = _resolveTaskFile(taskId, cwd);
|
|
71
102
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
72
103
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
@@ -93,7 +124,7 @@ function run(args, ctx) {
|
|
|
93
124
|
const name = _extractName(frontmatter, body);
|
|
94
125
|
const message = 'task(' + taskId + '): ' + name;
|
|
95
126
|
|
|
96
|
-
|
|
127
|
+
|
|
97
128
|
|
|
98
129
|
commitTask(taskId, safeFiles, message);
|
|
99
130
|
const sha = findCommitByTaskId(taskId);
|
|
@@ -103,7 +134,14 @@ function run(args, ctx) {
|
|
|
103
134
|
process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
|
|
104
135
|
}
|
|
105
136
|
|
|
106
|
-
const payload = {
|
|
137
|
+
const payload = {
|
|
138
|
+
ok: true,
|
|
139
|
+
task_id: taskId,
|
|
140
|
+
sha,
|
|
141
|
+
files: safeFiles,
|
|
142
|
+
files_source: filesSource,
|
|
143
|
+
nubosloop_bypassed: gate.bypassed,
|
|
144
|
+
};
|
|
107
145
|
stdout.write(JSON.stringify(payload));
|
|
108
146
|
return payload;
|
|
109
147
|
}
|
|
@@ -82,6 +82,27 @@ 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.
|
|
88
|
+
function seedLoopReadyCheckpoint(root, taskId, extra) {
|
|
89
|
+
const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', taskId + '.json');
|
|
90
|
+
fs.mkdirSync(path.dirname(cpPath), { recursive: true });
|
|
91
|
+
const base = {
|
|
92
|
+
schema_version: 1,
|
|
93
|
+
task_id: taskId,
|
|
94
|
+
status: 'pre-commit',
|
|
95
|
+
files_touched: [],
|
|
96
|
+
nubosloop: {
|
|
97
|
+
last_phase: 'commit',
|
|
98
|
+
last_action: 'commit',
|
|
99
|
+
committed_at: '2026-05-04T12:00:00Z',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
fs.writeFileSync(cpPath, JSON.stringify(Object.assign(base, extra || {})), 'utf-8');
|
|
103
|
+
return cpPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
after(() => {
|
|
86
107
|
while (_repos.length) {
|
|
87
108
|
const r = _repos.pop();
|
|
@@ -110,6 +131,7 @@ test('CT-2: commit-task rejects invalid TASK_ID format (defense-in-depth)', () =
|
|
|
110
131
|
test('CT-3: commit-task emits JSON with sha + files on success', () => {
|
|
111
132
|
const root = makeRepo();
|
|
112
133
|
seedPlanAndTask(root, '06-01', 'M006-S001-T0001', ['src/a.ts']);
|
|
134
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0001');
|
|
113
135
|
|
|
114
136
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
115
137
|
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n', 'utf-8');
|
|
@@ -126,6 +148,7 @@ test('CT-3: commit-task emits JSON with sha + files on success', () => {
|
|
|
126
148
|
assert.equal(payload.task_id, 'M006-S001-T0001');
|
|
127
149
|
assert.ok(/^[0-9a-f]{40}$/.test(payload.sha));
|
|
128
150
|
assert.deepEqual(payload.files, ['src/a.ts']);
|
|
151
|
+
assert.equal(payload.nubosloop_bypassed, false);
|
|
129
152
|
|
|
130
153
|
const subject = execFileSync('git', ['-C', root, 'log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
|
|
131
154
|
assert.ok(subject.startsWith('task(M006-S001-T0001):'), 'subject: ' + subject);
|
|
@@ -134,6 +157,7 @@ test('CT-3: commit-task emits JSON with sha + files on success', () => {
|
|
|
134
157
|
test('CT-4: commit-task LOUD-FAILS when every files_modified entry is gitignored (D-25)', () => {
|
|
135
158
|
const root = makeRepo();
|
|
136
159
|
seedPlanAndTask(root, '06-01', 'M006-S001-T0002', ['build/out.js']);
|
|
160
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0002');
|
|
137
161
|
fs.writeFileSync(path.join(root, '.gitignore'), 'build/\n', 'utf-8');
|
|
138
162
|
fs.mkdirSync(path.join(root, 'build'), { recursive: true });
|
|
139
163
|
fs.writeFileSync(path.join(root, 'build', 'out.js'), 'noise', 'utf-8');
|
|
@@ -152,9 +176,13 @@ test('CT-4: commit-task LOUD-FAILS when every files_modified entry is gitignored
|
|
|
152
176
|
|
|
153
177
|
test('CT-5: commit-task unknown task id → task-not-found', () => {
|
|
154
178
|
const root = makeRepo();
|
|
179
|
+
// Loop gate runs BEFORE task lookup (no checkpoint seeded), so we expect
|
|
180
|
+
// the bypass-violation here, not the task-not-found error. Using --bypass
|
|
181
|
+
// so we exercise the unknown-task path instead.
|
|
155
182
|
const cap = _capture();
|
|
183
|
+
const stderr = _capture();
|
|
156
184
|
assert.throws(
|
|
157
|
-
() => subcmd.run(['M006-S099-T0099'], { cwd: root, stdout: cap.stub }),
|
|
185
|
+
() => subcmd.run(['M006-S099-T0099', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
|
|
158
186
|
(err) => err && err.code === 'commit-task-not-found',
|
|
159
187
|
);
|
|
160
188
|
});
|
|
@@ -164,14 +192,8 @@ test('CT-6: empty files_modified falls back to checkpoint.files_touched', () =>
|
|
|
164
192
|
seedPlanAndTask(root, '06-01', 'M006-S001-T0010', []);
|
|
165
193
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
166
194
|
fs.writeFileSync(path.join(root, 'src', 'b.ts'), 'export const b = 2;\n', 'utf-8');
|
|
167
|
-
|
|
168
|
-
|
|
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');
|
|
195
|
+
// Checkpoint must satisfy the loop gate AND carry files_touched.
|
|
196
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0010', { files_touched: ['src/b.ts'] });
|
|
175
197
|
const prev = process.cwd();
|
|
176
198
|
process.chdir(root);
|
|
177
199
|
const cap = _capture();
|
|
@@ -189,9 +211,88 @@ test('CT-6: empty files_modified falls back to checkpoint.files_touched', () =>
|
|
|
189
211
|
test('CT-7: empty files_modified AND no checkpoint → commit-task-no-files', () => {
|
|
190
212
|
const root = makeRepo();
|
|
191
213
|
seedPlanAndTask(root, '06-01', 'M006-S001-T0011', []);
|
|
214
|
+
// No checkpoint → gate would normally refuse first; bypass to reach the
|
|
215
|
+
// no-files path that this test exercises.
|
|
192
216
|
const cap = _capture();
|
|
217
|
+
const stderr = _capture();
|
|
193
218
|
assert.throws(
|
|
194
|
-
() => subcmd.run(['M006-S001-T0011'], { cwd: root, stdout: cap.stub }),
|
|
219
|
+
() => subcmd.run(['M006-S001-T0011', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
|
|
195
220
|
(err) => err && err.code === 'commit-task-no-files',
|
|
196
221
|
);
|
|
197
222
|
});
|
|
223
|
+
|
|
224
|
+
test('CT-8: refuse commit when no checkpoint exists (Nubosloop gate)', () => {
|
|
225
|
+
const root = makeRepo();
|
|
226
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0020', ['src/c.ts']);
|
|
227
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
228
|
+
fs.writeFileSync(path.join(root, 'src', 'c.ts'), 'export const c = 3;\n', 'utf-8');
|
|
229
|
+
const cap = _capture();
|
|
230
|
+
const stderr = _capture();
|
|
231
|
+
assert.throws(
|
|
232
|
+
() => subcmd.run(['M006-S001-T0020'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
|
|
233
|
+
(err) => err && err.code === 'commit-task-loop-bypass-violation' && err.details && err.details.reason === 'no-checkpoint',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('CT-9: refuse commit when nubosloop.last_phase ≠ commit', () => {
|
|
238
|
+
const root = makeRepo();
|
|
239
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0021', ['src/d.ts']);
|
|
240
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
241
|
+
fs.writeFileSync(path.join(root, 'src', 'd.ts'), 'export const d = 4;\n', 'utf-8');
|
|
242
|
+
// Checkpoint exists but loop only made it to verifying — gate must refuse.
|
|
243
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0021', {
|
|
244
|
+
nubosloop: { last_phase: 'verifying', last_action: 'verify-green' },
|
|
245
|
+
});
|
|
246
|
+
const cap = _capture();
|
|
247
|
+
const stderr = _capture();
|
|
248
|
+
assert.throws(
|
|
249
|
+
() => subcmd.run(['M006-S001-T0021'], { cwd: root, stdout: cap.stub, stderr: stderr.stub }),
|
|
250
|
+
(err) => err && err.code === 'commit-task-loop-bypass-violation'
|
|
251
|
+
&& err.details && err.details.reason === 'last-phase-mismatch'
|
|
252
|
+
&& err.details.last_phase === 'verifying',
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('CT-10: --bypass-nubosloop allows single-pass commit and warns on stderr', () => {
|
|
257
|
+
const root = makeRepo();
|
|
258
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0022', ['src/e.ts']);
|
|
259
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
260
|
+
fs.writeFileSync(path.join(root, 'src', 'e.ts'), 'export const e = 5;\n', 'utf-8');
|
|
261
|
+
const prev = process.cwd();
|
|
262
|
+
process.chdir(root);
|
|
263
|
+
const cap = _capture();
|
|
264
|
+
const stderr = _capture();
|
|
265
|
+
try {
|
|
266
|
+
subcmd.run(['M006-S001-T0022', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub });
|
|
267
|
+
} finally {
|
|
268
|
+
process.chdir(prev);
|
|
269
|
+
}
|
|
270
|
+
const payload = JSON.parse(cap.get());
|
|
271
|
+
assert.equal(payload.ok, true);
|
|
272
|
+
assert.equal(payload.nubosloop_bypassed, true);
|
|
273
|
+
assert.match(stderr.get(), /WARNING: commit-task M006-S001-T0022 bypassing Nubosloop gate/);
|
|
274
|
+
assert.match(stderr.get(), /observed=no-checkpoint/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('CT-11: --bypass-nubosloop on partial loop state stamps the bypass reason', () => {
|
|
278
|
+
const root = makeRepo();
|
|
279
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0023', ['src/f.ts']);
|
|
280
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
281
|
+
fs.writeFileSync(path.join(root, 'src', 'f.ts'), 'export const f = 6;\n', 'utf-8');
|
|
282
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0023', {
|
|
283
|
+
nubosloop: { last_phase: 'post-critics', last_action: 'executor' },
|
|
284
|
+
});
|
|
285
|
+
const prev = process.cwd();
|
|
286
|
+
process.chdir(root);
|
|
287
|
+
const cap = _capture();
|
|
288
|
+
const stderr = _capture();
|
|
289
|
+
try {
|
|
290
|
+
subcmd.run(['M006-S001-T0023', '--bypass-nubosloop'], { cwd: root, stdout: cap.stub, stderr: stderr.stub });
|
|
291
|
+
} finally {
|
|
292
|
+
process.chdir(prev);
|
|
293
|
+
}
|
|
294
|
+
const payload = JSON.parse(cap.get());
|
|
295
|
+
assert.equal(payload.ok, true);
|
|
296
|
+
assert.equal(payload.nubosloop_bypassed, true);
|
|
297
|
+
assert.match(stderr.get(), /observed=post-critics/);
|
|
298
|
+
});
|