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.
@@ -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 LOUD-FAILS when every files_modified entry is gitignored (D-25)', () => {
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
- assert.throws(
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', () => {
@@ -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
- throw new NubosPilotError('commit-no-paths', 'commit invoked with no committable paths', { files });
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 assertCommittablePaths(paths, opts) {
27
+ function classifyCommittablePaths(paths, opts) {
28
28
  if (!Array.isArray(paths)) {
29
29
  throw new NubosPilotError(
30
30
  'commit-paths-invalid',
31
- 'assertCommittablePaths expects an array of paths',
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
- if (ignored.length > 0 && ignored.length === paths.length) {
50
-
51
- throw new NubosPilotError(
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 paths.filter((p) => !ignored.includes(p));
65
+ return committable;
64
66
  }
65
67
 
66
68
  function commitTask(taskId, files, message) {
67
- const committable = assertCommittablePaths(files);
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 throws commit-all-paths-gitignored when every path ignored (D-25)', () => {
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
- assert.throws(
92
- () => git.assertCommittablePaths(['.env', 'secret.txt']),
93
- (err) => {
94
- return err.name === 'NubosPilotError'
95
- && err.code === 'commit-all-paths-gitignored'
96
- && Array.isArray(err.details.paths)
97
- && err.details.paths.includes('.env');
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 { hit: null, bypass_swarm: false, cache_miss_reason: { code: err.code, message: err.message } };
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 { hit: null, bypass_swarm: false, cache_miss_reason: null };
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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.9.9",
3
+ "version": "1.0.0",
4
4
  "description": "AI-driven planning and execution tool for code projects",
5
5
  "homepage": "https://github.com/Nubos-AI/nubos-pilot",
6
6
  "repository": {
@@ -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 executor prompt as $CONSENSUS_PATTERN
216
- # with provenance ([VERIFIED] on majority + spawn-citation, else [PROVISIONAL]).
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
- # Spawn $SWARM_K np-researcher agents (parallel). After EACH spawn:
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
- # i.e. exactly $SWARM_K audit calls per researcher round. Then merge:
228
- CONSENSUS_PATTERN=$(node .nubos-pilot/bin/researcher-merge.cjs ...) # real merged output
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 `assertCommittablePaths` (D-25) runs.
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.