nubos-pilot 0.9.9 → 1.0.0
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/bin/np-tools/commit-task.cjs +43 -2
- package/bin/np-tools/commit-task.test.cjs +40 -5
- package/bin/np-tools/commit.cjs +9 -1
- package/bin/np-tools/commit.test.cjs +28 -0
- package/bin/np-tools/loop-run-round.cjs +2 -1
- package/bin/researcher-merge.cjs +103 -0
- package/bin/researcher-merge.test.cjs +142 -0
- package/lib/git.cjs +35 -17
- package/lib/git.test.cjs +78 -10
- package/lib/nubosloop.cjs +50 -10
- package/lib/nubosloop.test.cjs +56 -0
- package/package.json +1 -1
- package/workflows/execute-phase.md +43 -8
|
@@ -7,7 +7,7 @@ const { TASK_ID_RE, setTaskStatus } = require('../../lib/tasks.cjs');
|
|
|
7
7
|
const layout = require('../../lib/layout.cjs');
|
|
8
8
|
const git = require('../../lib/git.cjs');
|
|
9
9
|
const { commitTask, findCommitByTaskId } = git;
|
|
10
|
-
const { deleteCheckpoint, readCheckpoint } = require('../../lib/checkpoint.cjs');
|
|
10
|
+
const { deleteCheckpoint, readCheckpoint, mergeCheckpoint } = require('../../lib/checkpoint.cjs');
|
|
11
11
|
|
|
12
12
|
const BYPASS_FLAG = '--bypass-nubosloop';
|
|
13
13
|
|
|
@@ -162,7 +162,45 @@ function run(args, ctx) {
|
|
|
162
162
|
|
|
163
163
|
|
|
164
164
|
|
|
165
|
-
commitTask(taskId, safeFiles, message);
|
|
165
|
+
const result = commitTask(taskId, safeFiles, message);
|
|
166
|
+
|
|
167
|
+
if (result.committed === false && result.reason === 'artifacts-gitignored') {
|
|
168
|
+
// Soft-skip: every files_modified entry is gitignored. The task ran the
|
|
169
|
+
// full Nubosloop (preflight → executor → critic), edits landed locally,
|
|
170
|
+
// and the workflow already stamped `committed_at` via loop-run-round.
|
|
171
|
+
// We mark the task done WITHOUT a git commit, record the skip reason on
|
|
172
|
+
// the checkpoint for audit, and let the wave continue. Symmetric to
|
|
173
|
+
// commit_artifacts=false (commit.cjs:102) and to feedback_no_container_blocker:
|
|
174
|
+
// gitignore is a routing signal, never a hard stop.
|
|
175
|
+
try {
|
|
176
|
+
mergeCheckpoint(taskId, (cur) => ({
|
|
177
|
+
nubosloop: Object.assign({}, (cur && cur.nubosloop) || {}, {
|
|
178
|
+
commit_skipped: 'artifacts-gitignored',
|
|
179
|
+
files_ignored: result.files_ignored.slice(),
|
|
180
|
+
}),
|
|
181
|
+
}), cwd);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
process.stderr.write('[nubos-pilot warn] checkpoint stamp failed for ' + taskId + ': ' + (err && err.message) + '\n');
|
|
184
|
+
}
|
|
185
|
+
try { deleteCheckpoint(taskId, cwd); } catch {}
|
|
186
|
+
try { setTaskStatus(taskId, 'done', cwd); } catch (err) {
|
|
187
|
+
process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
|
|
188
|
+
}
|
|
189
|
+
const skipPayload = {
|
|
190
|
+
ok: true,
|
|
191
|
+
task_id: taskId,
|
|
192
|
+
committed: false,
|
|
193
|
+
skip_reason: 'artifacts-gitignored',
|
|
194
|
+
files: safeFiles,
|
|
195
|
+
files_ignored: result.files_ignored,
|
|
196
|
+
files_source: filesSource,
|
|
197
|
+
nubosloop_bypassed: gate.bypassed,
|
|
198
|
+
nubosloop_forced_commit_phase: !!gate.forced_commit_phase,
|
|
199
|
+
};
|
|
200
|
+
stdout.write(JSON.stringify(skipPayload));
|
|
201
|
+
return skipPayload;
|
|
202
|
+
}
|
|
203
|
+
|
|
166
204
|
const sha = findCommitByTaskId(taskId);
|
|
167
205
|
|
|
168
206
|
try { deleteCheckpoint(taskId, cwd); } catch { }
|
|
@@ -173,8 +211,11 @@ function run(args, ctx) {
|
|
|
173
211
|
const payload = {
|
|
174
212
|
ok: true,
|
|
175
213
|
task_id: taskId,
|
|
214
|
+
committed: true,
|
|
176
215
|
sha,
|
|
177
216
|
files: safeFiles,
|
|
217
|
+
files_committed: result.files_committed,
|
|
218
|
+
files_ignored: result.files_ignored,
|
|
178
219
|
files_source: filesSource,
|
|
179
220
|
nubosloop_bypassed: gate.bypassed,
|
|
180
221
|
nubosloop_forced_commit_phase: !!gate.forced_commit_phase,
|
|
@@ -164,24 +164,59 @@ 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-4: commit-task
|
|
167
|
+
test('CT-4: commit-task SOFT-SKIPS when every files_modified entry is gitignored (artifacts-gitignored terminator)', () => {
|
|
168
168
|
const root = makeRepo();
|
|
169
169
|
seedPlanAndTask(root, '06-01', 'M006-S001-T0002', ['build/out.js']);
|
|
170
170
|
seedLoopReadyCheckpoint(root, 'M006-S001-T0002');
|
|
171
171
|
fs.writeFileSync(path.join(root, '.gitignore'), 'build/\n', 'utf-8');
|
|
172
172
|
fs.mkdirSync(path.join(root, 'build'), { recursive: true });
|
|
173
173
|
fs.writeFileSync(path.join(root, 'build', 'out.js'), 'noise', 'utf-8');
|
|
174
|
+
const before = execFileSync('git', ['-C', root, 'log', '--format=%H'], { encoding: 'utf-8' }).trim().split('\n').filter(Boolean).length;
|
|
174
175
|
const prev = process.cwd();
|
|
175
176
|
process.chdir(root);
|
|
176
177
|
const cap = _capture();
|
|
178
|
+
let payload;
|
|
177
179
|
try {
|
|
178
|
-
|
|
179
|
-
() => subcmd.run(['M006-S001-T0002'], { cwd: root, stdout: cap.stub }),
|
|
180
|
-
(err) => err && err.code === 'commit-all-paths-gitignored',
|
|
181
|
-
);
|
|
180
|
+
payload = subcmd.run(['M006-S001-T0002'], { cwd: root, stdout: cap.stub });
|
|
182
181
|
} finally {
|
|
183
182
|
process.chdir(prev);
|
|
184
183
|
}
|
|
184
|
+
assert.equal(payload.ok, true);
|
|
185
|
+
assert.equal(payload.committed, false);
|
|
186
|
+
assert.equal(payload.skip_reason, 'artifacts-gitignored');
|
|
187
|
+
assert.deepEqual(payload.files_ignored, ['build/out.js']);
|
|
188
|
+
const after = execFileSync('git', ['-C', root, 'log', '--format=%H'], { encoding: 'utf-8' }).trim().split('\n').filter(Boolean).length;
|
|
189
|
+
assert.equal(after, before, 'soft-skip must not produce a commit');
|
|
190
|
+
const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0002.json');
|
|
191
|
+
assert.equal(fs.existsSync(cpPath), false, 'checkpoint must be deleted on terminal skip (symmetric to commit success)');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('CT-4b: commit-task commits the tracked subset on mixed paths (artifacts + real source)', () => {
|
|
195
|
+
const root = makeRepo();
|
|
196
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0003', ['src/a.ts', '.nubos-pilot/codebase/modules/x.md']);
|
|
197
|
+
seedLoopReadyCheckpoint(root, 'M006-S001-T0003');
|
|
198
|
+
fs.writeFileSync(path.join(root, '.gitignore'), '.nubos-pilot/codebase/\n', 'utf-8');
|
|
199
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
200
|
+
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const x = 1;', 'utf-8');
|
|
201
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot', 'codebase', 'modules'), { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(root, '.nubos-pilot', 'codebase', 'modules', 'x.md'), '# X', 'utf-8');
|
|
203
|
+
const prev = process.cwd();
|
|
204
|
+
process.chdir(root);
|
|
205
|
+
const cap = _capture();
|
|
206
|
+
let payload;
|
|
207
|
+
try {
|
|
208
|
+
payload = subcmd.run(['M006-S001-T0003'], { cwd: root, stdout: cap.stub });
|
|
209
|
+
} finally {
|
|
210
|
+
process.chdir(prev);
|
|
211
|
+
}
|
|
212
|
+
assert.equal(payload.ok, true);
|
|
213
|
+
assert.equal(payload.committed, true);
|
|
214
|
+
assert.deepEqual(payload.files_committed, ['src/a.ts']);
|
|
215
|
+
assert.deepEqual(payload.files_ignored, ['.nubos-pilot/codebase/modules/x.md']);
|
|
216
|
+
assert.ok(/^[0-9a-f]{40}$/.test(payload.sha));
|
|
217
|
+
const stat = execFileSync('git', ['-C', root, 'show', '--stat', '--format=', 'HEAD'], { encoding: 'utf-8' });
|
|
218
|
+
assert.match(stat, /src\/a\.ts/);
|
|
219
|
+
assert.doesNotMatch(stat, /codebase\/modules\/x\.md/);
|
|
185
220
|
});
|
|
186
221
|
|
|
187
222
|
test('CT-5: commit-task unknown task id → task-not-found', () => {
|
package/bin/np-tools/commit.cjs
CHANGED
|
@@ -107,7 +107,15 @@ function run(argv, ctx) {
|
|
|
107
107
|
const normalized = _normalizeFiles(files, cwd, root);
|
|
108
108
|
const committable = assertCommittablePaths(normalized, { cwd: root });
|
|
109
109
|
if (committable.length === 0) {
|
|
110
|
-
|
|
110
|
+
// All paths gitignored → soft-skip with structured payload (symmetric to
|
|
111
|
+
// commit_artifacts=false above). The earlier `commit-no-paths` throw
|
|
112
|
+
// turned a routing signal into a hard error.
|
|
113
|
+
stdout.write(JSON.stringify({
|
|
114
|
+
committed: false,
|
|
115
|
+
reason: 'artifacts-gitignored',
|
|
116
|
+
files_ignored: normalized,
|
|
117
|
+
}) + '\n');
|
|
118
|
+
return 0;
|
|
111
119
|
}
|
|
112
120
|
execFileSync('git', ['add', '--', ...committable], { cwd: root, stdio: 'pipe' });
|
|
113
121
|
execFileSync('git', ['commit', '-m', msg, '--', ...committable], { cwd: root, stdio: 'pipe' });
|
|
@@ -141,6 +141,34 @@ test('COMMIT-5: workflow.commit_artifacts=false skips commit silently with exit
|
|
|
141
141
|
assert.equal(logOut, '', 'expected no commits to be created');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
test('COMMIT-7: all-paths-gitignored soft-skips with structured payload (exit 0, no commit)', () => {
|
|
145
|
+
const sb = makeSandbox();
|
|
146
|
+
initGit(sb);
|
|
147
|
+
fs.writeFileSync(path.join(sb, '.gitignore'), 'build/\n');
|
|
148
|
+
fs.mkdirSync(path.join(sb, 'build'), { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(sb, 'build', 'out.js'), 'noise');
|
|
150
|
+
const stdout = makeSink();
|
|
151
|
+
const stderr = makeSink();
|
|
152
|
+
const origCwd = process.cwd();
|
|
153
|
+
process.chdir(sb);
|
|
154
|
+
let code;
|
|
155
|
+
try {
|
|
156
|
+
code = commitCli.run(['chore: artifact', '--files', 'build/out.js'], { stdout, stderr });
|
|
157
|
+
} finally {
|
|
158
|
+
process.chdir(origCwd);
|
|
159
|
+
}
|
|
160
|
+
assert.equal(code, 0, 'stderr=' + stderr.toString());
|
|
161
|
+
const payload = JSON.parse(stdout.toString().trim());
|
|
162
|
+
assert.equal(payload.committed, false);
|
|
163
|
+
assert.equal(payload.reason, 'artifacts-gitignored');
|
|
164
|
+
assert.deepEqual(payload.files_ignored, ['build/out.js']);
|
|
165
|
+
let logOut;
|
|
166
|
+
try {
|
|
167
|
+
logOut = execFileSync('git', ['log', '--format=%H'], { cwd: sb, encoding: 'utf-8' });
|
|
168
|
+
} catch { logOut = ''; }
|
|
169
|
+
assert.equal(logOut.trim(), '', 'expected no commits to be created');
|
|
170
|
+
});
|
|
171
|
+
|
|
144
172
|
test('COMMIT-6: workflow.commit_artifacts=true still commits normally', () => {
|
|
145
173
|
const sb = makeSandbox();
|
|
146
174
|
initGit(sb);
|
|
@@ -71,7 +71,7 @@ function _runPreflight(taskId, list, cwd) {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
-
const opts = {};
|
|
74
|
+
const opts = { taskId };
|
|
75
75
|
const t = args.getFlag(list, '--threshold');
|
|
76
76
|
if (t !== undefined) opts.threshold = Number(t);
|
|
77
77
|
const m = args.getFlag(list, '--min-occurrence');
|
|
@@ -103,6 +103,7 @@ function _runPreflight(taskId, list, cwd) {
|
|
|
103
103
|
cache_hit: result.hit,
|
|
104
104
|
bypass_swarm: result.bypass_swarm,
|
|
105
105
|
cache_miss_reason: result.cache_miss_reason,
|
|
106
|
+
swarm: result.swarm,
|
|
106
107
|
forced: force,
|
|
107
108
|
};
|
|
108
109
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const swarm = require('../lib/researcher-swarm.cjs');
|
|
8
|
+
|
|
9
|
+
const USAGE = [
|
|
10
|
+
'Usage:',
|
|
11
|
+
' researcher-merge.cjs <spawn-output-1.json> [spawn-output-2.json] [...] [options]',
|
|
12
|
+
' researcher-merge.cjs --stdin [options]',
|
|
13
|
+
'',
|
|
14
|
+
'Options:',
|
|
15
|
+
' --stdin Read a JSON array of spawn outputs from stdin instead of file args',
|
|
16
|
+
' --out <path> Write rendered consensus markdown to <path> (default: stdout)',
|
|
17
|
+
' --json Emit the merged consensus as JSON instead of markdown',
|
|
18
|
+
' --heading <text> Markdown heading (default: "Researcher-Schwarm Consensus")',
|
|
19
|
+
' -h, --help Show this help',
|
|
20
|
+
'',
|
|
21
|
+
'Each spawn output must be a JSON object with shape:',
|
|
22
|
+
' { decisions[], risks[], patterns[], open_questions[], sources[] }',
|
|
23
|
+
'',
|
|
24
|
+
'Exit codes:',
|
|
25
|
+
' 0 success, 2 invalid usage, 3 unreadable input, 4 invalid spawn output',
|
|
26
|
+
].join('\n');
|
|
27
|
+
|
|
28
|
+
function _hasFlag(argv, name) { return argv.includes(name); }
|
|
29
|
+
function _flag(argv, name) {
|
|
30
|
+
const i = argv.indexOf(name);
|
|
31
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _die(code, msg) {
|
|
35
|
+
process.stderr.write(msg + '\n');
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _readJson(file) {
|
|
40
|
+
let raw;
|
|
41
|
+
try { raw = fs.readFileSync(file, 'utf-8'); }
|
|
42
|
+
catch (err) { _die(3, 'researcher-merge: cannot read ' + file + ': ' + err.message); }
|
|
43
|
+
try { return JSON.parse(raw); }
|
|
44
|
+
catch (err) { _die(4, 'researcher-merge: invalid JSON in ' + file + ': ' + err.message); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _readStdin() {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let buf = '';
|
|
50
|
+
process.stdin.setEncoding('utf-8');
|
|
51
|
+
process.stdin.on('data', (chunk) => { buf += chunk; });
|
|
52
|
+
process.stdin.on('end', () => resolve(buf));
|
|
53
|
+
process.stdin.on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function _collectInputs(argv) {
|
|
58
|
+
if (_hasFlag(argv, '--stdin')) {
|
|
59
|
+
const raw = await _readStdin();
|
|
60
|
+
let parsed;
|
|
61
|
+
try { parsed = JSON.parse(raw); }
|
|
62
|
+
catch (err) { _die(4, 'researcher-merge: invalid JSON on stdin: ' + err.message); }
|
|
63
|
+
if (!Array.isArray(parsed)) {
|
|
64
|
+
_die(4, 'researcher-merge: --stdin payload must be a JSON array of spawn outputs');
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
const files = argv.filter((a) => !a.startsWith('-') && a !== _flag(argv, '--out') && a !== _flag(argv, '--heading'));
|
|
69
|
+
if (!files.length) _die(2, USAGE);
|
|
70
|
+
return files.map(_readJson);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
const argv = process.argv.slice(2);
|
|
75
|
+
if (_hasFlag(argv, '-h') || _hasFlag(argv, '--help')) {
|
|
76
|
+
process.stdout.write(USAGE + '\n');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
if (!argv.length) _die(2, USAGE);
|
|
80
|
+
const outPath = _flag(argv, '--out');
|
|
81
|
+
const heading = _flag(argv, '--heading');
|
|
82
|
+
const asJson = _hasFlag(argv, '--json');
|
|
83
|
+
const inputs = await _collectInputs(argv);
|
|
84
|
+
for (let i = 0; i < inputs.length; i += 1) {
|
|
85
|
+
const o = inputs[i];
|
|
86
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) {
|
|
87
|
+
_die(4, 'researcher-merge: spawn output #' + i + ' must be a JSON object');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const consensus = swarm.mergeConsensus(inputs);
|
|
91
|
+
const payload = asJson
|
|
92
|
+
? JSON.stringify(consensus, null, 2) + '\n'
|
|
93
|
+
: swarm.renderConsensusToMarkdown(consensus, heading ? { heading } : undefined);
|
|
94
|
+
if (outPath) {
|
|
95
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
96
|
+
fs.writeFileSync(outPath, payload, 'utf-8');
|
|
97
|
+
process.stdout.write(outPath + '\n');
|
|
98
|
+
} else {
|
|
99
|
+
process.stdout.write(payload);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
main().catch((err) => _die(1, 'researcher-merge: ' + (err && err.stack ? err.stack : String(err))));
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const CLI = path.resolve(__dirname, 'researcher-merge.cjs');
|
|
11
|
+
|
|
12
|
+
function _mkTmp() {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'np-merge-'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function _spawn(args, opts) {
|
|
17
|
+
return spawnSync('node', [CLI, ...args], Object.assign({ encoding: 'utf-8' }, opts || {}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _writeJson(dir, name, obj) {
|
|
21
|
+
const p = path.join(dir, name);
|
|
22
|
+
fs.writeFileSync(p, JSON.stringify(obj), 'utf-8');
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SAME_INPUT_OUTPUT = (extra) => Object.assign({
|
|
27
|
+
decisions: [{ claim: 'use jose for JWT', confidence: 'HIGH', provenance: '[VERIFIED]' }],
|
|
28
|
+
risks: [],
|
|
29
|
+
patterns: [{ name: 'verifyJwt', description: 'verifyJwt(token, jwks)' }],
|
|
30
|
+
open_questions: [],
|
|
31
|
+
sources: [],
|
|
32
|
+
}, extra || {});
|
|
33
|
+
|
|
34
|
+
test('RM-1: file-args produce consensus markdown with high agreement_score', () => {
|
|
35
|
+
const r = _mkTmp();
|
|
36
|
+
try {
|
|
37
|
+
const a = _writeJson(r, 'a.json', SAME_INPUT_OUTPUT());
|
|
38
|
+
const b = _writeJson(r, 'b.json', SAME_INPUT_OUTPUT());
|
|
39
|
+
const c = _writeJson(r, 'c.json', SAME_INPUT_OUTPUT({ risks: [{ description: 'key rotation', severity: 'MEDIUM' }] }));
|
|
40
|
+
const res = _spawn([a, b, c]);
|
|
41
|
+
assert.equal(res.status, 0, res.stderr);
|
|
42
|
+
assert.match(res.stdout, /<consensus_meta>/);
|
|
43
|
+
assert.match(res.stdout, /agreement_score: 1\.000/);
|
|
44
|
+
assert.match(res.stdout, /use jose for JWT.*agreement=3/);
|
|
45
|
+
assert.match(res.stdout, /verifyJwt.*agreement=3/);
|
|
46
|
+
assert.match(res.stdout, /\[MEDIUM\] key rotation/);
|
|
47
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('RM-2: --stdin reads JSON array', () => {
|
|
51
|
+
const inputs = [SAME_INPUT_OUTPUT(), SAME_INPUT_OUTPUT(), SAME_INPUT_OUTPUT()];
|
|
52
|
+
const res = _spawn(['--stdin'], { input: JSON.stringify(inputs) });
|
|
53
|
+
assert.equal(res.status, 0, res.stderr);
|
|
54
|
+
assert.match(res.stdout, /agreement_score: 1\.000/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('RM-3: --json emits structured consensus', () => {
|
|
58
|
+
const r = _mkTmp();
|
|
59
|
+
try {
|
|
60
|
+
const a = _writeJson(r, 'a.json', SAME_INPUT_OUTPUT());
|
|
61
|
+
const res = _spawn([a, '--json']);
|
|
62
|
+
assert.equal(res.status, 0, res.stderr);
|
|
63
|
+
const parsed = JSON.parse(res.stdout);
|
|
64
|
+
assert.equal(parsed.meta.k, 1);
|
|
65
|
+
assert.ok(Array.isArray(parsed.decisions));
|
|
66
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('RM-4: topic-split spawns produce ZERO majority decisions and EMPTY pattern intersection (the canonical bug we guard against)', () => {
|
|
70
|
+
const r = _mkTmp();
|
|
71
|
+
try {
|
|
72
|
+
const a = _writeJson(r, 'a.json', {
|
|
73
|
+
decisions: [{ claim: 'install Cashier 16 via composer require laravel/cashier', confidence: 'HIGH', provenance: '[CITED: cashier docs]' }],
|
|
74
|
+
risks: [], patterns: [{ name: 'composerInstall' }], open_questions: [], sources: [],
|
|
75
|
+
});
|
|
76
|
+
const b = _writeJson(r, 'b.json', {
|
|
77
|
+
decisions: [{ claim: 'use Pest for feature tests', confidence: 'HIGH', provenance: '[CITED: pest docs]' }],
|
|
78
|
+
risks: [], patterns: [{ name: 'pestFeatureTest' }], open_questions: [], sources: [],
|
|
79
|
+
});
|
|
80
|
+
const c = _writeJson(r, 'c.json', {
|
|
81
|
+
decisions: [{ claim: 'set CASHIER_CURRENCY in .env', confidence: 'HIGH', provenance: '[CITED: env docs]' }],
|
|
82
|
+
risks: [], patterns: [{ name: 'envConfig' }], open_questions: [], sources: [],
|
|
83
|
+
});
|
|
84
|
+
const res = _spawn([a, b, c, '--json']);
|
|
85
|
+
assert.equal(res.status, 0, res.stderr);
|
|
86
|
+
const parsed = JSON.parse(res.stdout);
|
|
87
|
+
assert.equal(parsed.decisions.length, 0, 'no claim reaches majority — every decision is seen by exactly one spawn');
|
|
88
|
+
assert.equal(parsed.flagged_decisions.length, 3, 'every decision lands in flagged because none crossed the majority threshold');
|
|
89
|
+
assert.equal(parsed.patterns.length, 0, 'no pattern reaches the k≥2 intersection threshold');
|
|
90
|
+
assert.equal(parsed.meta.flagged_count, 3);
|
|
91
|
+
assert.ok(
|
|
92
|
+
parsed.meta.agreement_score < 0.5,
|
|
93
|
+
'topic-split surfaces as low agreement_score (each decision seen by 1/3 spawns ≈ 0.333), unambiguously distinguishable from full consensus (1.0)',
|
|
94
|
+
);
|
|
95
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('RM-5: --out writes consensus to file and prints the path', () => {
|
|
99
|
+
const r = _mkTmp();
|
|
100
|
+
try {
|
|
101
|
+
const a = _writeJson(r, 'a.json', SAME_INPUT_OUTPUT());
|
|
102
|
+
const out = path.join(r, 'consensus.md');
|
|
103
|
+
const res = _spawn([a, '--out', out]);
|
|
104
|
+
assert.equal(res.status, 0, res.stderr);
|
|
105
|
+
assert.equal(res.stdout.trim(), out);
|
|
106
|
+
const written = fs.readFileSync(out, 'utf-8');
|
|
107
|
+
assert.match(written, /Researcher-Schwarm Consensus/);
|
|
108
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('RM-6: invalid JSON in spawn output exits 4', () => {
|
|
112
|
+
const r = _mkTmp();
|
|
113
|
+
try {
|
|
114
|
+
const bad = path.join(r, 'bad.json');
|
|
115
|
+
fs.writeFileSync(bad, 'NOT JSON', 'utf-8');
|
|
116
|
+
const res = _spawn([bad]);
|
|
117
|
+
assert.equal(res.status, 4);
|
|
118
|
+
assert.match(res.stderr, /invalid JSON/);
|
|
119
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('RM-7: no args exits 2 with usage', () => {
|
|
123
|
+
const res = _spawn([]);
|
|
124
|
+
assert.equal(res.status, 2);
|
|
125
|
+
assert.match(res.stderr, /Usage:/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('RM-8: --stdin with non-array exits 4', () => {
|
|
129
|
+
const res = _spawn(['--stdin'], { input: '{"not":"an array"}' });
|
|
130
|
+
assert.equal(res.status, 4);
|
|
131
|
+
assert.match(res.stderr, /must be a JSON array/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('RM-9: --heading overrides default consensus heading', () => {
|
|
135
|
+
const r = _mkTmp();
|
|
136
|
+
try {
|
|
137
|
+
const a = _writeJson(r, 'a.json', SAME_INPUT_OUTPUT());
|
|
138
|
+
const res = _spawn([a, '--heading', 'Cashier 16 Consensus']);
|
|
139
|
+
assert.equal(res.status, 0, res.stderr);
|
|
140
|
+
assert.match(res.stdout, /^# Cashier 16 Consensus/m);
|
|
141
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
142
|
+
});
|
package/lib/git.cjs
CHANGED
|
@@ -24,11 +24,11 @@ function isPathIgnored(p, opts) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function
|
|
27
|
+
function classifyCommittablePaths(paths, opts) {
|
|
28
28
|
if (!Array.isArray(paths)) {
|
|
29
29
|
throw new NubosPilotError(
|
|
30
30
|
'commit-paths-invalid',
|
|
31
|
-
'
|
|
31
|
+
'classifyCommittablePaths expects an array of paths',
|
|
32
32
|
{ got: typeof paths },
|
|
33
33
|
);
|
|
34
34
|
}
|
|
@@ -42,41 +42,58 @@ function assertCommittablePaths(paths, opts) {
|
|
|
42
42
|
} catch (err) {
|
|
43
43
|
if (_isFatalCheckIgnore(err)) {
|
|
44
44
|
if (err.status === 128) throw err;
|
|
45
|
-
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'commit-all-paths-gitignored',
|
|
53
|
-
`All target paths are gitignored: ${paths.join(', ')}`,
|
|
54
|
-
{ paths },
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (ignored.length > 0) {
|
|
48
|
+
const committable = paths.filter((p) => !ignored.includes(p));
|
|
49
|
+
return { committable, ignored };
|
|
50
|
+
}
|
|
58
51
|
|
|
52
|
+
function assertCommittablePaths(paths, opts) {
|
|
53
|
+
const { committable, ignored } = classifyCommittablePaths(paths, opts);
|
|
54
|
+
// Soft-skip on all-ignored: return [] and let the caller decide. The earlier
|
|
55
|
+
// throw turned a workflow-level routing decision (artifact-only tasks whose
|
|
56
|
+
// outputs land in gitignored paths — e.g. `.nubos-pilot/codebase/modules/*.md`
|
|
57
|
+
// when the operator opted out of versioning planning artifacts) into a hard
|
|
58
|
+
// crash. Symmetric to `feedback_no_container_blocker`: gitignore state is a
|
|
59
|
+
// routing signal, not a blocker.
|
|
60
|
+
if (ignored.length > 0 && committable.length > 0) {
|
|
59
61
|
process.stderr.write(
|
|
60
62
|
`[nubos-pilot warn] gitignored (skipping): ${ignored.join(', ')}\n`,
|
|
61
63
|
);
|
|
62
64
|
}
|
|
63
|
-
return
|
|
65
|
+
return committable;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function commitTask(taskId, files, message) {
|
|
67
|
-
const committable =
|
|
69
|
+
const { committable, ignored } = classifyCommittablePaths(files);
|
|
68
70
|
if (committable.length === 0) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
if (ignored.length > 0) {
|
|
72
|
+
return {
|
|
73
|
+
committed: false,
|
|
74
|
+
reason: 'artifacts-gitignored',
|
|
75
|
+
files_committed: [],
|
|
76
|
+
files_ignored: ignored.slice(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
72
79
|
throw new NubosPilotError(
|
|
73
80
|
'commit-no-paths',
|
|
74
81
|
'commitTask invoked with empty file list',
|
|
75
82
|
{ taskId },
|
|
76
83
|
);
|
|
77
84
|
}
|
|
85
|
+
if (ignored.length > 0) {
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[nubos-pilot warn] gitignored (skipping): ${ignored.join(', ')}\n`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
78
90
|
execFileSync('git', ['add', '--', ...committable], { stdio: 'pipe' });
|
|
79
91
|
execFileSync('git', ['commit', '-m', message, '--', ...committable], { stdio: 'pipe' });
|
|
92
|
+
return {
|
|
93
|
+
committed: true,
|
|
94
|
+
files_committed: committable.slice(),
|
|
95
|
+
files_ignored: ignored.slice(),
|
|
96
|
+
};
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
function findCommitByTaskId(id) {
|
|
@@ -257,6 +274,7 @@ function runGit(args, opts) {
|
|
|
257
274
|
module.exports = {
|
|
258
275
|
commitTask,
|
|
259
276
|
assertCommittablePaths,
|
|
277
|
+
classifyCommittablePaths,
|
|
260
278
|
revertCommit,
|
|
261
279
|
restoreFiles,
|
|
262
280
|
checkoutFromHead,
|
package/lib/git.test.cjs
CHANGED
|
@@ -82,21 +82,89 @@ test('GIT-2: assertCommittablePaths writes stderr warning for partial-ignored an
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
test('GIT-3: assertCommittablePaths
|
|
85
|
+
test('GIT-3: assertCommittablePaths SOFT-SKIPS when every path ignored (returns [], no throw, no stderr noise)', () => {
|
|
86
86
|
const root = makeRepo();
|
|
87
87
|
inRepo(root, () => {
|
|
88
88
|
writeFile(root, '.gitignore', '.env\nsecret.txt\n');
|
|
89
89
|
writeFile(root, '.env', 'X=1');
|
|
90
90
|
writeFile(root, 'secret.txt', 'shh');
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
const original = process.stderr.write;
|
|
92
|
+
let captured = '';
|
|
93
|
+
process.stderr.write = (chunk) => { captured += chunk; return true; };
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = git.assertCommittablePaths(['.env', 'secret.txt']);
|
|
97
|
+
} finally {
|
|
98
|
+
process.stderr.write = original;
|
|
99
|
+
}
|
|
100
|
+
assert.deepEqual(result, []);
|
|
101
|
+
assert.equal(captured, '', 'all-ignored is a routing signal, not a warning — caller surfaces the skip reason');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('GIT-3b: classifyCommittablePaths returns clean { committable, ignored } split without side effects', () => {
|
|
106
|
+
const root = makeRepo();
|
|
107
|
+
inRepo(root, () => {
|
|
108
|
+
writeFile(root, '.gitignore', 'build/\n.env\n');
|
|
109
|
+
writeFile(root, 'src/a.ts', 'x');
|
|
110
|
+
writeFile(root, 'src/b.ts', 'y');
|
|
111
|
+
writeFile(root, 'build/out.js', 'noise');
|
|
112
|
+
writeFile(root, '.env', 'X=1');
|
|
113
|
+
const original = process.stderr.write;
|
|
114
|
+
let captured = '';
|
|
115
|
+
process.stderr.write = (chunk) => { captured += chunk; return true; };
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = git.classifyCommittablePaths(['src/a.ts', 'build/out.js', 'src/b.ts', '.env']);
|
|
119
|
+
} finally {
|
|
120
|
+
process.stderr.write = original;
|
|
121
|
+
}
|
|
122
|
+
assert.deepEqual(res.committable, ['src/a.ts', 'src/b.ts']);
|
|
123
|
+
assert.deepEqual(res.ignored.sort(), ['.env', 'build/out.js']);
|
|
124
|
+
assert.equal(captured, '', 'classify is pure introspection — no stderr writes');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('GIT-3c: commitTask returns soft-skip payload when every path is gitignored (no commit created)', () => {
|
|
129
|
+
const root = makeRepo();
|
|
130
|
+
inRepo(root, () => {
|
|
131
|
+
writeFile(root, '.gitignore', 'build/\n');
|
|
132
|
+
writeFile(root, 'build/out.js', 'noise');
|
|
133
|
+
const before = execFileSync('git', ['log', '--format=%H'], { encoding: 'utf-8' }).trim().split('\n').filter(Boolean).length;
|
|
134
|
+
const result = git.commitTask('M006-S001-T0099', ['build/out.js'], 'task(M006-S001-T0099): doc update');
|
|
135
|
+
assert.equal(result.committed, false);
|
|
136
|
+
assert.equal(result.reason, 'artifacts-gitignored');
|
|
137
|
+
assert.deepEqual(result.files_committed, []);
|
|
138
|
+
assert.deepEqual(result.files_ignored, ['build/out.js']);
|
|
139
|
+
const after = execFileSync('git', ['log', '--format=%H'], { encoding: 'utf-8' }).trim().split('\n').filter(Boolean).length;
|
|
140
|
+
assert.equal(after, before, 'no new commit for soft-skip');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('GIT-3d: commitTask commits the tracked subset on mixed paths (warns about ignored entries)', () => {
|
|
145
|
+
const root = makeRepo();
|
|
146
|
+
inRepo(root, () => {
|
|
147
|
+
writeFile(root, '.gitignore', 'build/\n');
|
|
148
|
+
writeFile(root, 'src/a.ts', 'x');
|
|
149
|
+
writeFile(root, 'build/out.js', 'noise');
|
|
150
|
+
const original = process.stderr.write;
|
|
151
|
+
let captured = '';
|
|
152
|
+
process.stderr.write = (chunk) => { captured += chunk; return true; };
|
|
153
|
+
let result;
|
|
154
|
+
try {
|
|
155
|
+
result = git.commitTask('M006-S001-T0100', ['src/a.ts', 'build/out.js'], 'task(M006-S001-T0100): mixed');
|
|
156
|
+
} finally {
|
|
157
|
+
process.stderr.write = original;
|
|
158
|
+
}
|
|
159
|
+
assert.equal(result.committed, true);
|
|
160
|
+
assert.deepEqual(result.files_committed, ['src/a.ts']);
|
|
161
|
+
assert.deepEqual(result.files_ignored, ['build/out.js']);
|
|
162
|
+
assert.match(captured, /gitignored \(skipping\)/);
|
|
163
|
+
const subject = execFileSync('git', ['log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
|
|
164
|
+
assert.equal(subject, 'task(M006-S001-T0100): mixed');
|
|
165
|
+
const stat = execFileSync('git', ['show', '--stat', '--format=', 'HEAD'], { encoding: 'utf-8' });
|
|
166
|
+
assert.match(stat, /src\/a\.ts/);
|
|
167
|
+
assert.doesNotMatch(stat, /build\/out\.js/);
|
|
100
168
|
});
|
|
101
169
|
});
|
|
102
170
|
|
package/lib/nubosloop.cjs
CHANGED
|
@@ -636,31 +636,71 @@ function preflightCacheLookup(query, opts, cwd) {
|
|
|
636
636
|
);
|
|
637
637
|
}
|
|
638
638
|
const o = safeAssign({}, swarm.resolveSwarmOpts(cwd), opts || {});
|
|
639
|
+
const taskId = (opts && typeof opts.taskId === 'string') ? opts.taskId : null;
|
|
640
|
+
const spawnInput = taskId
|
|
641
|
+
? { task_id: taskId, task_query: query }
|
|
642
|
+
: { task_query: query };
|
|
643
|
+
// Symmetry with research-phase init: spawn_specs are built unconditionally
|
|
644
|
+
// and the consumer decides via bypass_swarm whether to ignore them. This
|
|
645
|
+
// closes the execute-phase gap where the orchestrator had no per-spawn
|
|
646
|
+
// contract and fell back to topic-splitting (FAIL-S from the 2026-05-05
|
|
647
|
+
// researcher-swarm review).
|
|
648
|
+
const spawnSpecs = swarm.buildSpawnSpecs(spawnInput, o.k);
|
|
649
|
+
const swarmBlock = {
|
|
650
|
+
k: o.k,
|
|
651
|
+
threshold: o.threshold,
|
|
652
|
+
min_occurrence: o.minOccurrence,
|
|
653
|
+
spawn_specs: spawnSpecs,
|
|
654
|
+
};
|
|
639
655
|
const SOFT_CACHE_FAILURES = new Set(['mcp-adapter-not-implemented', 'knowledge-adapter-unknown']);
|
|
640
656
|
let adapter;
|
|
641
657
|
try { adapter = getAdapter(cwd); }
|
|
642
658
|
catch (err) {
|
|
643
659
|
if (err && err.name === 'NubosPilotError' && SOFT_CACHE_FAILURES.has(err.code)) {
|
|
644
|
-
return {
|
|
660
|
+
return {
|
|
661
|
+
hit: null,
|
|
662
|
+
bypass_swarm: false,
|
|
663
|
+
cache_miss_reason: { code: err.code, message: err.message },
|
|
664
|
+
swarm: safeAssign({}, swarmBlock, {
|
|
665
|
+
cache_hit: null,
|
|
666
|
+
cache_miss_reason: { code: err.code, message: err.message },
|
|
667
|
+
bypass_swarm: false,
|
|
668
|
+
}),
|
|
669
|
+
};
|
|
645
670
|
}
|
|
646
671
|
throw err;
|
|
647
672
|
}
|
|
648
673
|
const m = adapter.match(query, { threshold: o.threshold, minOccurrence: o.minOccurrence });
|
|
649
674
|
if (m && m.best) {
|
|
675
|
+
const hit = {
|
|
676
|
+
adapter: adapter.name,
|
|
677
|
+
fingerprint: m.best.fingerprint,
|
|
678
|
+
pattern: m.best.pattern,
|
|
679
|
+
outcome: m.best.outcome,
|
|
680
|
+
occurrence: m.best.occurrence,
|
|
681
|
+
similarity: m.best.similarity,
|
|
682
|
+
};
|
|
650
683
|
return {
|
|
651
|
-
hit
|
|
652
|
-
adapter: adapter.name,
|
|
653
|
-
fingerprint: m.best.fingerprint,
|
|
654
|
-
pattern: m.best.pattern,
|
|
655
|
-
outcome: m.best.outcome,
|
|
656
|
-
occurrence: m.best.occurrence,
|
|
657
|
-
similarity: m.best.similarity,
|
|
658
|
-
},
|
|
684
|
+
hit,
|
|
659
685
|
bypass_swarm: true,
|
|
660
686
|
cache_miss_reason: null,
|
|
687
|
+
swarm: safeAssign({}, swarmBlock, {
|
|
688
|
+
cache_hit: hit,
|
|
689
|
+
cache_miss_reason: null,
|
|
690
|
+
bypass_swarm: true,
|
|
691
|
+
}),
|
|
661
692
|
};
|
|
662
693
|
}
|
|
663
|
-
return {
|
|
694
|
+
return {
|
|
695
|
+
hit: null,
|
|
696
|
+
bypass_swarm: false,
|
|
697
|
+
cache_miss_reason: null,
|
|
698
|
+
swarm: safeAssign({}, swarmBlock, {
|
|
699
|
+
cache_hit: null,
|
|
700
|
+
cache_miss_reason: null,
|
|
701
|
+
bypass_swarm: false,
|
|
702
|
+
}),
|
|
703
|
+
};
|
|
664
704
|
}
|
|
665
705
|
|
|
666
706
|
function _emptyHistogram(maxRounds) {
|
package/lib/nubosloop.test.cjs
CHANGED
|
@@ -753,6 +753,62 @@ test('NL-PF-5: preflightCacheLookup rejects empty query', () => {
|
|
|
753
753
|
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
+
test('NL-PF-6: preflightCacheLookup populates swarm.spawn_specs on cache miss (k entries, identical input, distinct seed_delta)', () => {
|
|
757
|
+
const r = _mkRoot();
|
|
758
|
+
try {
|
|
759
|
+
const out = loop.preflightCacheLookup('install Cashier 16 with Sanctum auth', { taskId: 'M001-S001-T0001' }, r);
|
|
760
|
+
assert.equal(out.bypass_swarm, false);
|
|
761
|
+
assert.ok(out.swarm, 'swarm block must be present on miss');
|
|
762
|
+
assert.equal(out.swarm.bypass_swarm, false);
|
|
763
|
+
assert.equal(out.swarm.k, 3);
|
|
764
|
+
assert.ok(Array.isArray(out.swarm.spawn_specs));
|
|
765
|
+
assert.equal(out.swarm.spawn_specs.length, 3);
|
|
766
|
+
const inputs = out.swarm.spawn_specs.map((s) => JSON.stringify(s.input));
|
|
767
|
+
assert.equal(new Set(inputs).size, 1, 'every spawn_spec must carry the IDENTICAL input — topic-split is the bug this guards');
|
|
768
|
+
assert.equal(out.swarm.spawn_specs[0].input.task_query, 'install Cashier 16 with Sanctum auth');
|
|
769
|
+
assert.equal(out.swarm.spawn_specs[0].input.task_id, 'M001-S001-T0001');
|
|
770
|
+
const deltas = out.swarm.spawn_specs.map((s) => s.seed_delta);
|
|
771
|
+
assert.equal(new Set(deltas).size, 3, 'each spawn_spec must carry a DISTINCT seed_delta');
|
|
772
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test('NL-PF-7: preflightCacheLookup still emits swarm block on cache hit (orchestrator may bypass; payload stays symmetric)', () => {
|
|
776
|
+
const r = _mkRoot();
|
|
777
|
+
try {
|
|
778
|
+
learnings.logLearning({ pattern: 'use jose for jwt verification', outcome: 'verified' }, r);
|
|
779
|
+
learnings.logLearning({ pattern: 'use jose for jwt verification', outcome: 'verified' }, r);
|
|
780
|
+
learnings.logLearning({ pattern: 'use jose for jwt verification', outcome: 'verified' }, r);
|
|
781
|
+
const out = loop.preflightCacheLookup('use jose for jwt verification', { threshold: 0.5, minOccurrence: 3 }, r);
|
|
782
|
+
assert.equal(out.bypass_swarm, true);
|
|
783
|
+
assert.ok(out.swarm);
|
|
784
|
+
assert.equal(out.swarm.bypass_swarm, true);
|
|
785
|
+
assert.ok(out.swarm.cache_hit);
|
|
786
|
+
assert.equal(out.swarm.spawn_specs.length, 3);
|
|
787
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('NL-PF-8: preflightCacheLookup honors swarm.research.k from config', () => {
|
|
791
|
+
const r = _mkRoot({ swarm: { research: { k: 5 } } });
|
|
792
|
+
try {
|
|
793
|
+
const out = loop.preflightCacheLookup('whatever query', {}, r);
|
|
794
|
+
assert.equal(out.swarm.k, 5);
|
|
795
|
+
assert.equal(out.swarm.spawn_specs.length, 5);
|
|
796
|
+
const deltas = out.swarm.spawn_specs.map((s) => s.seed_delta);
|
|
797
|
+
assert.equal(new Set(deltas).size, 5);
|
|
798
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('NL-PF-9: preflightCacheLookup soft-fail still includes swarm block (orchestrator can spawn while cache adapter is down)', () => {
|
|
802
|
+
const r = _mkRoot({ swarm: { knowledge_adapter: 'mcp' } });
|
|
803
|
+
try {
|
|
804
|
+
const out = loop.preflightCacheLookup('any query', {}, r);
|
|
805
|
+
assert.equal(out.cache_miss_reason.code, 'mcp-adapter-not-implemented');
|
|
806
|
+
assert.ok(out.swarm);
|
|
807
|
+
assert.equal(out.swarm.spawn_specs.length, 3);
|
|
808
|
+
assert.equal(out.swarm.cache_miss_reason.code, 'mcp-adapter-not-implemented');
|
|
809
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
810
|
+
});
|
|
811
|
+
|
|
756
812
|
test('NL-28: persisted tokens field is reused on match (no re-tokenize)', () => {
|
|
757
813
|
const r = _mkRoot();
|
|
758
814
|
try {
|
package/package.json
CHANGED
|
@@ -135,6 +135,15 @@ Every task runs through the **Nubosloop** ([ADR-0010](../docs/adr/0010-nubosloop
|
|
|
135
135
|
|
|
136
136
|
7. **Commit** — `loop-run-round --phase commit --learning-pattern "$CONSENSUS_PATTERN" --learning-outcome verified` stamps the checkpoint to `pre-commit` and auto-logs the learning (when `auto_log_learning=true`, default — feeds future Round-1 cache hits). Then `node .nubos-pilot/bin/np-tools.cjs commit-task "$TASK_ID"` performs the atomic commit per ADR-0004.
|
|
137
137
|
|
|
138
|
+
**Two terminal outcomes**, both exit 0 and complete the task:
|
|
139
|
+
|
|
140
|
+
| `committed` | `skip_reason` | When it fires | Wave handling |
|
|
141
|
+
|-------------|-------------------------|----------------------------------------------------------------------------|---------------|
|
|
142
|
+
| `true` | _(absent)_ | At least one `files_modified` entry is tracked → atomic commit lands | Continue |
|
|
143
|
+
| `false` | `artifacts-gitignored` | Every `files_modified` entry is gitignored (e.g. `.nubos-pilot/codebase/modules/*.md` when artifacts aren't versioned) | Continue — task is done, no commit produced |
|
|
144
|
+
|
|
145
|
+
The orchestrator checks `git check-ignore --quiet --` per file: exit 0 = ignored, exit 1 = tracked, exit ≥ 2 = real failure (propagate). Soft-skip is not a failure mode — `commit-task` deletes the checkpoint and sets task status to `done` symmetric to a real commit. **Mixed paths** (some tracked, some ignored) commit only the tracked subset and emit a `[nubos-pilot warn] gitignored (skipping): …` line; the task is `committed: true` with `files_ignored` populated for audit. Gitignore state is a routing signal, never a hard stop — symmetric to the container-state doctrine.
|
|
146
|
+
|
|
138
147
|
**Per-task loop control values (read once at wave start):**
|
|
139
148
|
|
|
140
149
|
```bash
|
|
@@ -211,9 +220,21 @@ for WAVE_INDEX in 0 1 2 ...; do
|
|
|
211
220
|
|
|
212
221
|
# === Step 2: Researcher-Schwarm (cache miss on R1, or re-route on R≥2) ===
|
|
213
222
|
# PARALLEL spawn of $SWARM_K agents/np-researcher.md (single message,
|
|
214
|
-
# $SWARM_K Agent blocks). Merge via lib/researcher-swarm.cjs::mergeConsensus
|
|
215
|
-
# Result is injected into the next
|
|
216
|
-
# with provenance ([VERIFIED] on
|
|
223
|
+
# $SWARM_K Agent blocks). Merge via lib/researcher-swarm.cjs::mergeConsensus
|
|
224
|
+
# (exposed as `bin/researcher-merge.cjs`). Result is injected into the next
|
|
225
|
+
# executor prompt as $CONSENSUS_PATTERN with provenance ([VERIFIED] on
|
|
226
|
+
# majority + spawn-citation, else [PROVISIONAL]).
|
|
227
|
+
#
|
|
228
|
+
# SPAWN CONTRACT — symmetric to research-phase Step 4. Every spawn receives
|
|
229
|
+
# the SAME `<task_query>` (the loop's `$TASK_QUERY`, identical for all k
|
|
230
|
+
# researchers — this is what makes the merge a CONSENSUS over the same
|
|
231
|
+
# question, not a divide-and-conquer over k different questions). The only
|
|
232
|
+
# per-spawn variation is a single `<seed_delta>` line read from
|
|
233
|
+
# `swarm.spawn_specs[i].seed_delta` (preflight payload). NO researcher
|
|
234
|
+
# knows the others exist. Topic-splitting the task into k subtopics is the
|
|
235
|
+
# canonical bypass — `bin/researcher-merge.cjs` will report agreement_score
|
|
236
|
+
# ≈ 0 and an empty pattern intersection if the orchestrator violates this.
|
|
237
|
+
#
|
|
217
238
|
# The orchestrator MUST stamp `loop-audit-tool-use --agent np-researcher`
|
|
218
239
|
# ONCE PER RESEARCHER SPAWN — Layer-C now requires `swarm.research.k`
|
|
219
240
|
# audit entries for the round (Gap #6 fix, 2026-05-05). The earlier
|
|
@@ -222,10 +243,24 @@ for WAVE_INDEX in 0 1 2 ...; do
|
|
|
222
243
|
# (Mehrheit/Union/Schnittmenge) into a fiction. With the k-gate the
|
|
223
244
|
# synthetic-consensus bypass is mechanically blocked.
|
|
224
245
|
if { [ "$ROUND" -eq 1 ] && [ "$CACHE_HIT" != "true" ]; } || [ "$NEXT_ACTION" = "researcher" ]; then
|
|
225
|
-
#
|
|
246
|
+
# Read swarm.spawn_specs[] from the preflight payload — one entry per
|
|
247
|
+
# spawn, each carrying { index, seed_delta, input } where input.task_query
|
|
248
|
+
# is identical across the k entries. The orchestrator MUST iterate
|
|
249
|
+
# `swarm.spawn_specs` and pass `<seed_delta>` per spawn. It MUST NOT
|
|
250
|
+
# rewrite or partition `input.task_query`.
|
|
251
|
+
SPAWN_SPECS=$(echo "$PREFLIGHT" | node -e \
|
|
252
|
+
'process.stdin.on("data",d=>{const j=JSON.parse(d);process.stdout.write(JSON.stringify((j.swarm&&j.swarm.spawn_specs)||[]))})')
|
|
253
|
+
# Spawn $SWARM_K np-researcher agents (parallel, single message). Each
|
|
254
|
+
# spawn prompt:
|
|
255
|
+
# <files_to_read>: task plan, slice plan, prior slice SUMMARYs, CONTEXT.md, codebase docs
|
|
256
|
+
# <task_query>: $TASK_QUERY (verbatim, identical for every spawn)
|
|
257
|
+
# <seed_delta>: swarm.spawn_specs[i].seed_delta (one line, per-spawn)
|
|
258
|
+
# After EACH spawn — exactly $SWARM_K audit calls per researcher round:
|
|
226
259
|
# loop-audit-tool-use "$TASK_ID" --agent np-researcher --tool-use-log <json>
|
|
227
|
-
#
|
|
228
|
-
|
|
260
|
+
# Collect the $SWARM_K structured outputs as files
|
|
261
|
+
# ($TMPDIR/np-spawn-${TASK_ID}-r${ROUND}-${i}.json) and merge:
|
|
262
|
+
CONSENSUS_PATTERN=$(node .nubos-pilot/bin/researcher-merge.cjs \
|
|
263
|
+
"${SPAWN_OUT_PATHS[@]}")
|
|
229
264
|
node .nubos-pilot/bin/np-tools.cjs loop-run-round "$TASK_ID" --phase post-researcher
|
|
230
265
|
elif [ "$CACHE_HIT" = "true" ] && [ -z "$CONSENSUS_PATTERN" ]; then
|
|
231
266
|
# Cache-hit branch: the cached pattern is ALREADY in the learning store —
|
|
@@ -495,8 +530,8 @@ After every slice completes, point the operator at `/np:validate-phase $PHASE` t
|
|
|
495
530
|
- Run `loop-run-round --phase post-executor` AFTER mechanical checks; honor `next_action: spawn-build-fixer` (verify-red short-circuit, skip critics this round).
|
|
496
531
|
- Run `loop-run-round --phase post-critics` AFTER critics return, to obtain the routing `next_action`.
|
|
497
532
|
- Run `loop-audit-tool-use` per round per spawn — for executor/build-fixer this drives Rule 9 enforcement, AND for `np-critic` this is the spawn-evidence required by the Layer-C audit-trail gate (`loop-post-executor-missing-spawn-audit` / `loop-post-critics-missing-critic-audit`). After the Single-Critic Revision (ADR-0010, 2026-05-05) the per-round audit count is **two** in rounds ≥ 2 (`np-build-fixer` + `np-critic`) and **`swarm.research.k` + 2** in round 1 (k × `np-researcher` + `np-executor` + `np-critic`). All audits in the active round are mandatory before the corresponding `loop-run-round --phase post-{researcher|executor|critics}` invocation.
|
|
498
|
-
- Route every commit through `node .nubos-pilot/bin/np-tools.cjs commit-task` so `
|
|
499
|
-
- Hard-stop the wave when `commit-task` returns non-zero, OR a task hits `stuck`/`plan-checker`.
|
|
533
|
+
- Route every commit through `node .nubos-pilot/bin/np-tools.cjs commit-task` so `classifyCommittablePaths` runs (gitignored entries are split into a `files_ignored` audit list; mixed paths commit only the tracked subset; all-ignored soft-skips with `skip_reason: artifacts-gitignored` and exit 0).
|
|
534
|
+
- Hard-stop the wave when `commit-task` returns non-zero, OR a task hits `stuck`/`plan-checker`. **Soft-skip is exit 0 — wave continues.**
|
|
500
535
|
|
|
501
536
|
**Don't:**
|
|
502
537
|
- Run tasks across slices in parallel — slices are serial.
|