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.
- package/CHANGELOG.md +5 -2
- package/agents/np-critic-economy.md +103 -0
- package/agents/np-critic.md +11 -10
- package/agents/np-executor.md +14 -0
- package/agents/np-simplifier.md +83 -0
- package/agents/np-task-architect.md +95 -0
- package/agents/np-test-writer.md +89 -0
- package/bin/install.js +86 -0
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/commit-task.cjs +80 -6
- package/bin/np-tools/commit-task.test.cjs +133 -0
- package/bin/np-tools/doctor.cjs +1 -0
- package/bin/np-tools/economy-mode.cjs +47 -0
- package/bin/np-tools/loop-commands.test.cjs +121 -2
- package/bin/np-tools/loop-run-round.cjs +122 -6
- package/bin/np-tools/resolve-model.cjs +1 -0
- package/bin/np-tools/simplify-debt.cjs +91 -0
- package/bin/np-tools/simplify-debt.test.cjs +99 -0
- package/lib/agents-registry.cjs +12 -1
- package/lib/agents.test.cjs +4 -0
- package/lib/config-defaults.cjs +22 -1
- package/lib/config-defaults.test.cjs +9 -0
- package/lib/config-schema.cjs +6 -0
- package/lib/economy-debt.cjs +235 -0
- package/lib/economy-debt.test.cjs +131 -0
- package/lib/economy-mode.cjs +66 -0
- package/lib/economy-mode.test.cjs +85 -0
- package/lib/git.cjs +6 -2
- package/lib/git.test.cjs +28 -0
- package/lib/nubosloop.cjs +4 -0
- package/lib/nubosloop.test.cjs +1 -0
- package/np-tools.cjs +2 -0
- package/package.json +1 -1
- package/templates/RULES.md +36 -1
- package/workflows/execute-phase.md +154 -1
- package/workflows/plan-phase.md +17 -2
- package/workflows/simplify-debt.md +93 -0
- 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
|
-
|
|
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)
|
|
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
|
|
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']);
|
package/bin/np-tools/doctor.cjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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:
|
|
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':
|
|
576
|
-
case 'post-researcher':
|
|
577
|
-
case 'post-
|
|
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;
|