nubos-pilot 1.3.0 → 1.3.2

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/np-tools/_commands.cjs +2 -0
  3. package/bin/np-tools/_elision-proxy-entry.cjs +13 -0
  4. package/bin/np-tools/doctor.cjs +25 -3
  5. package/bin/np-tools/elision-bench.cjs +67 -0
  6. package/bin/np-tools/elision-get.cjs +48 -0
  7. package/bin/np-tools/elision-get.test.cjs +66 -0
  8. package/bin/np-tools/loop-run-round.cjs +25 -11
  9. package/bin/np-tools/plan-milestone.cjs +1 -0
  10. package/bin/np-tools/research-phase.cjs +1 -1
  11. package/bin/np-tools/resume-work.cjs +9 -0
  12. package/bin/np-tools/resume-work.test.cjs +21 -1
  13. package/bin/np-tools/spawn-headless.cjs +62 -9
  14. package/lib/cache-align.cjs +78 -0
  15. package/lib/cache-align.test.cjs +69 -0
  16. package/lib/checkpoint-reconcile.cjs +42 -0
  17. package/lib/checkpoint-reconcile.test.cjs +106 -0
  18. package/lib/compress.cjs +495 -0
  19. package/lib/compress.test.cjs +267 -0
  20. package/lib/config-defaults.cjs +39 -0
  21. package/lib/config-schema.cjs +40 -4
  22. package/lib/elision-bench.cjs +409 -0
  23. package/lib/elision-bench.test.cjs +89 -0
  24. package/lib/elision-proxy.cjs +158 -0
  25. package/lib/elision-proxy.test.cjs +243 -0
  26. package/lib/elision.cjs +163 -0
  27. package/lib/elision.test.cjs +143 -0
  28. package/lib/git.cjs +4 -2
  29. package/lib/nubosloop.cjs +1 -1
  30. package/lib/output-steering.cjs +68 -0
  31. package/lib/output-steering.test.cjs +74 -0
  32. package/lib/researcher-swarm.cjs +14 -3
  33. package/lib/runtime/agent-loop.cjs +36 -6
  34. package/lib/runtime/agent-loop.test.cjs +105 -0
  35. package/lib/runtime/dispatch.cjs +6 -6
  36. package/lib/runtime/dispatch.test.cjs +17 -3
  37. package/lib/runtime/providers/openai-compat.cjs +2 -1
  38. package/lib/runtime/providers/openai-compat.test.cjs +9 -0
  39. package/lib/runtime/tools/index.cjs +33 -1
  40. package/lib/runtime/tools/index.test.cjs +24 -0
  41. package/lib/schemas/data/elision-entry.v1.json +16 -0
  42. package/lib/token-cost.cjs +46 -0
  43. package/lib/token-cost.test.cjs +42 -0
  44. package/np-tools.cjs +2 -0
  45. package/package.json +1 -1
  46. package/workflows/execute-phase.md +10 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to nubos-pilot are documented in this file. Format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
5
5
  follows [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.3.3] — 2026-06-24
8
+
9
+ A finished milestone can no longer block the start of the next one with a stale checkpoint.
10
+
11
+ - `init resume-work` now reconciles every checkpoint against git before deciding orphan: a checkpoint whose task already has a `task(<id>):` commit is a tombstone left behind when the checkpoint was never unlinked (a crash between commit and unlink, or a commit made outside `commit-task`). Those are pruned silently and reported in `pruned_checkpoints`; only genuinely uncommitted checkpoints still surface as `orphan`. Git is the source of truth, so a committed task is never mistaken for in-flight work.
12
+ - `np:doctor` is git-aware for the same case: a committed-but-unlinked checkpoint is reported as `info` / `fixable: auto` with the commit sha, not as a manual-fix `warn`.
13
+ - The `execute-phase` orphan-checkpoint guard's two remediation options are now wired — "reset-slice" and "resume" were previously no-op `case` branches that left the file in place, so the prompt re-fired on every run.
14
+
15
+ Full documentation at <https://pilot.nubos.cloud>.
16
+
7
17
  ## [1.3.0] — 2026-06-17
8
18
 
9
19
  Run any agent on any model, not only Claude.
@@ -89,6 +89,8 @@ const COMMANDS = [
89
89
  { name: 'knowledge-index', category: 'Utility', description: 'Build BM25-light index over .nubos-pilot/**/*.md → .nubos-pilot/state/knowledge-index.json', description_de: 'Baut BM25-Light-Index über .nubos-pilot/**/*.md → .nubos-pilot/state/knowledge-index.json' },
90
90
  { name: 'knowledge-search', category: 'Utility', description: 'Query the knowledge index; returns top-N JSON hits (rel_path + lines + score + preview). Pass --task <id> inside a Nubosloop task to record Rule 9 audit evidence', description_de: 'Sucht im Knowledge-Index; liefert Top-N-JSON-Treffer (rel_path + Zeilen + Score + Preview). --task <id> innerhalb eines Nubosloop-Tasks schreibt den Rule-9-Audit-Nachweis' },
91
91
  { name: 'knowledge-stats', category: 'Utility', description: 'Print knowledge-index size + grouping (auto-builds if missing)', description_de: 'Gibt Knowledge-Index-Größe + Gruppierung aus (baut auto bei Fehlen)' },
92
+ { name: 'elision-get', category: 'Utility', description: 'Retrieve the original text behind a ⟦elided:<hash>⟧ compression marker. Reversible context compression (ADR-Elision). Positional <hash> or --hash; --json for envelope', description_de: 'Holt den Originaltext hinter einem ⟦elided:<hash>⟧ Kompressions-Marker. Reversible Kontext-Kompression (ADR-Elision). Positional <hash> oder --hash; --json für Envelope' },
93
+ { name: 'elision-bench', category: 'Utility', description: 'Measure elision compression: deterministic fidelity (ratio + critical-line preservation + byte-exact reversibility) over a fixture corpus; --with-model adds answer-equivalence (raw vs compressed). --size <small|medium|large> runs a scaled corpus with a deterministic --holdout control group (--max-cases N) and an estimated token saving (--price-per-mtok <p> [--currency EUR] adds a cost estimate). --json, --tier <name>', description_de: 'Misst die Elision-Kompression: deterministische Fidelity (Ratio + Erhalt kritischer Zeilen + byte-exakte Reversibilität) über ein Fixture-Korpus; --with-model ergänzt Antwort-Äquivalenz (roh vs. komprimiert). --size <small|medium|large> fährt ein skaliertes Korpus mit deterministischer --holdout-Kontrollgruppe (--max-cases N) und geschätzter Token-Ersparnis (--price-per-mtok <p> [--currency EUR] ergänzt eine Kostenschätzung). --json, --tier <name>' },
92
94
  { name: 'context-stats', category: 'Utility', description: 'Aggregated context-budget stats (file counts + bytes per group, knowledge-index size)', description_de: 'Aggregierte Context-Budget-Stats (Dateien/Bytes pro Gruppe, Knowledge-Index-Größe)' },
93
95
  { name: 'session-snapshot-write', category: 'Utility', description: 'Capture session snapshot (current_task + recent commits + open handoffs) for resume', description_de: 'Erfasst Session-Snapshot (current_task + letzte Commits + offene Handoffs) für Resume' },
94
96
  { name: 'session-snapshot-read', category: 'Utility', description: 'Print last session snapshot as JSON', description_de: 'Gibt letzten Session-Snapshot als JSON aus' },
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const proxy = require('../../lib/elision-proxy.cjs');
4
+
5
+ proxy.start({
6
+ cwd: process.env.ELISION_PROXY_CWD || process.cwd(),
7
+ upstream: process.env.ELISION_PROXY_UPSTREAM || undefined,
8
+ }).then(({ baseUrl }) => {
9
+ if (process.send) process.send({ ready: true, baseUrl });
10
+ }).catch((err) => {
11
+ if (process.send) process.send({ ready: false, error: String(err && err.message) });
12
+ process.exit(1);
13
+ });
@@ -543,6 +543,28 @@ function _checkOrphanCheckpoints(projectRoot) {
543
543
  catch { continue; }
544
544
  if (!cp || cp.status !== 'in-progress') continue;
545
545
  if (currentTask === taskId) continue;
546
+
547
+ let committedSha = null;
548
+ try { committedSha = require('../../lib/checkpoint-reconcile.cjs').committedSha(taskId, projectRoot); }
549
+ catch { committedSha = null; }
550
+
551
+ if (committedSha) {
552
+ issues.push({
553
+ id: 'orphan-checkpoint',
554
+ severity: 'info',
555
+ fixable: 'auto',
556
+ details: {
557
+ task_id: taskId,
558
+ checkpoint: path.relative(projectRoot, cpPath),
559
+ current_task: currentTask,
560
+ committed_sha: committedSha,
561
+ hint: 'Task is already committed (' + committedSha.slice(0, 8) + ') but its checkpoint was never unlinked. '
562
+ + 'A stale tombstone, not in-flight work. `np-tools resume-work` reconciles it against git and prunes it automatically.',
563
+ },
564
+ });
565
+ continue;
566
+ }
567
+
546
568
  issues.push({
547
569
  id: 'orphan-checkpoint',
548
570
  severity: 'warn',
@@ -551,9 +573,9 @@ function _checkOrphanCheckpoints(projectRoot) {
551
573
  task_id: taskId,
552
574
  checkpoint: path.relative(projectRoot, cpPath),
553
575
  current_task: currentTask,
554
- hint: 'Checkpoint marks task as in-progress but STATE.md.current_task does not match. '
555
- + 'Likely a crash during finishTask between STATE-clear and checkpoint-unlink. '
556
- + 'Run `np-tools undo-task ' + taskId + '` to clean up, or delete manually after verifying the task is genuinely done.',
576
+ hint: 'Checkpoint marks task as in-progress, no matching commit exists, and STATE.md.current_task does not match. '
577
+ + 'Likely a genuine crash mid-flight. '
578
+ + 'Run `np-tools undo-task ' + taskId + '` to clean up, or resume the task, after verifying its state.',
557
579
  },
558
580
  });
559
581
  }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const bench = require('../../lib/elision-bench.cjs');
4
+
5
+ function _parseArgs(args) {
6
+ const out = { json: false, withModel: false, tier: 'frontier', size: null, holdout: 0.2, maxCases: null, charsPerToken: null, pricePerMTok: null, currency: null };
7
+ for (let i = 0; i < (args || []).length; i += 1) {
8
+ const a = args[i];
9
+ if (a === '--json') { out.json = true; continue; }
10
+ if (a === '--with-model') { out.withModel = true; continue; }
11
+ if (a === '--tier') { out.tier = args[++i] || out.tier; continue; }
12
+ if (a === '--size') { out.size = args[++i] || out.size; continue; }
13
+ if (a === '--holdout') { const v = parseFloat(args[++i]); if (Number.isFinite(v)) out.holdout = v; continue; }
14
+ if (a === '--max-cases') { const v = parseInt(args[++i], 10); if (Number.isFinite(v)) out.maxCases = v; continue; }
15
+ if (a === '--chars-per-token') { const v = parseFloat(args[++i]); if (Number.isFinite(v)) out.charsPerToken = v; continue; }
16
+ if (a === '--price-per-mtok') { const v = parseFloat(args[++i]); if (Number.isFinite(v)) out.pricePerMTok = v; continue; }
17
+ if (a === '--currency') { out.currency = args[++i] || out.currency; continue; }
18
+ }
19
+ return out;
20
+ }
21
+
22
+ async function run(args, ctx) {
23
+ const context = ctx || {};
24
+ const cwd = context.cwd || process.cwd();
25
+ const stdout = context.stdout || process.stdout;
26
+ const parsed = _parseArgs(args || []);
27
+
28
+ if (parsed.size) {
29
+ const scale = bench.runScale({ cwd, size: parsed.size, holdoutRatio: parsed.holdout, maxCases: parsed.maxCases, charsPerToken: parsed.charsPerToken, pricePerMTok: parsed.pricePerMTok, currency: parsed.currency });
30
+ stdout.write((parsed.json ? JSON.stringify({ scale }) : bench.formatScale(scale)) + '\n');
31
+ return scale.summary.invariants_ok ? 0 : 1;
32
+ }
33
+
34
+ const fidelity = bench.runFidelity({ cwd });
35
+ let equivalence = null;
36
+
37
+ if (parsed.withModel) {
38
+ const { resolveFromConfig } = require('./resolve-model.cjs');
39
+ const { chat } = require('../../lib/runtime/providers/openai-compat.cjs');
40
+ const res = resolveFromConfig({ agentOrTier: parsed.tier, cwd, format: 'json' });
41
+ if (!res || !res.model || !res.baseUrl) {
42
+ stdout.write((parsed.json ? JSON.stringify({ fidelity, equivalence: null, error: 'no-provider' }) : 'No off-host provider resolved for tier "' + parsed.tier + '" — equivalence skipped.') + '\n');
43
+ return fidelity.summary.invariants_ok ? 0 : 1;
44
+ }
45
+ const provider = { baseUrl: res.baseUrl, apiKeyEnv: res.apiKeyEnv, model: res.model };
46
+ equivalence = await bench.runEquivalence({ chatImpl: chat, provider });
47
+ }
48
+
49
+ if (parsed.json) {
50
+ stdout.write(JSON.stringify({ fidelity, equivalence }) + '\n');
51
+ } else {
52
+ stdout.write(bench.formatReport(fidelity) + '\n');
53
+ if (equivalence) {
54
+ stdout.write('\nAnswer equivalence — ' + equivalence.summary.equivalent + '/' + equivalence.summary.cases
55
+ + ' equivalent, regressions: ' + (equivalence.summary.regressions.length ? equivalence.summary.regressions.join(', ') : 'none') + '\n');
56
+ for (const c of equivalence.cases) {
57
+ stdout.write(' ' + (c.regression ? '✗' : '✓') + ' ' + c.name + ' (raw '
58
+ + (c.raw_ok ? 'ok' : 'miss') + ', compressed ' + (c.compressed_ok ? 'ok' : 'miss') + ')\n');
59
+ }
60
+ }
61
+ }
62
+
63
+ const ok = fidelity.summary.invariants_ok && (!equivalence || equivalence.summary.no_regression);
64
+ return ok ? 0 : 1;
65
+ }
66
+
67
+ module.exports = { run, _parseArgs };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { NubosPilotError } = require('../../lib/core.cjs');
4
+ const elision = require('../../lib/elision.cjs');
5
+
6
+ function _parseArgs(args) {
7
+ const out = { hash: null, json: false };
8
+ const positional = [];
9
+ for (let i = 0; i < args.length; i++) {
10
+ const a = args[i];
11
+ if (a === '--hash') { out.hash = args[++i] || null; continue; }
12
+ if (a === '--json') { out.json = true; continue; }
13
+ positional.push(a);
14
+ }
15
+ if (out.hash == null) out.hash = positional.join('').trim() || null;
16
+ return out;
17
+ }
18
+
19
+ function run(args, ctx) {
20
+ const context = ctx || {};
21
+ const cwd = context.cwd || process.cwd();
22
+ const stdout = context.stdout || process.stdout;
23
+ const parsed = _parseArgs(args || []);
24
+ if (!parsed.hash) {
25
+ throw new NubosPilotError(
26
+ 'elision-get-missing-hash',
27
+ 'elision-get requires a hash (positional or --hash)',
28
+ { hint: 'hash is the 12-char id from a ⟦elided:…⟧ marker' },
29
+ );
30
+ }
31
+ const result = elision.retrieve(parsed.hash, cwd);
32
+ if (parsed.json) {
33
+ stdout.write(JSON.stringify(result));
34
+ return result.status === 'ok' ? 0 : 1;
35
+ }
36
+ if (result.status === 'ok') {
37
+ stdout.write(result.original);
38
+ return 0;
39
+ }
40
+ if (result.status === 'expired') {
41
+ stdout.write('[elision ' + parsed.hash + ' expired — original no longer cached (ttl elapsed)]\n');
42
+ return 1;
43
+ }
44
+ stdout.write('[elision ' + parsed.hash + ' not found]\n');
45
+ return 1;
46
+ }
47
+
48
+ module.exports = { run, _parseArgs };
@@ -0,0 +1,66 @@
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
+
9
+ const elisionGet = require('./elision-get.cjs');
10
+ const elision = require('../../lib/elision.cjs');
11
+ const { NubosPilotError } = require('../../lib/core.cjs');
12
+
13
+ function sandbox() {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-elisionget-'));
15
+ fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
16
+ return root;
17
+ }
18
+
19
+ function capture() {
20
+ const chunks = [];
21
+ return { stream: { write: (s) => chunks.push(s) }, text: () => chunks.join('') };
22
+ }
23
+
24
+ test('ELISIONGET-1: prints the original for a known hash', () => {
25
+ const root = sandbox();
26
+ try {
27
+ const hash = elision.store('the original payload', { type: 'plain' }, root);
28
+ const out = capture();
29
+ const code = elisionGet.run([hash], { cwd: root, stdout: out.stream });
30
+ assert.equal(code, 0);
31
+ assert.equal(out.text(), 'the original payload');
32
+ } finally {
33
+ fs.rmSync(root, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test('ELISIONGET-2: unknown hash exits 1 with a not-found notice', () => {
38
+ const root = sandbox();
39
+ try {
40
+ const out = capture();
41
+ const code = elisionGet.run(['aaaaaaaaaaaa'], { cwd: root, stdout: out.stream });
42
+ assert.equal(code, 1);
43
+ assert.match(out.text(), /not found/);
44
+ } finally {
45
+ fs.rmSync(root, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('ELISIONGET-3: --json emits the retrieval envelope', () => {
50
+ const root = sandbox();
51
+ try {
52
+ const hash = elision.store('payload', { type: 'plain' }, root);
53
+ const out = capture();
54
+ const code = elisionGet.run([hash, '--json'], { cwd: root, stdout: out.stream });
55
+ assert.equal(code, 0);
56
+ const env = JSON.parse(out.text());
57
+ assert.equal(env.status, 'ok');
58
+ assert.equal(env.original, 'payload');
59
+ } finally {
60
+ fs.rmSync(root, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ test('ELISIONGET-4: missing hash throws', () => {
65
+ assert.throws(() => elisionGet.run([], { cwd: process.cwd(), stdout: { write() {} } }), NubosPilotError);
66
+ });
@@ -6,16 +6,37 @@ const path = require('node:path');
6
6
  const { NubosPilotError, safeAssign } = require('../../lib/core.cjs');
7
7
  const safePath = require('../../lib/safe-path.cjs');
8
8
 
9
- function _resolveInsideCwdOrTmp(p, cwd, label, errorCode) {
10
- return safePath.assertInsideCwdOrTmp(p, cwd, label, errorCode);
11
- }
12
9
  const checkpoint = require('../../lib/checkpoint.cjs');
13
10
  const nubosloop = require('../../lib/nubosloop.cjs');
14
11
  const messaging = require('../../lib/messaging.cjs');
12
+ const compress = require('../../lib/compress.cjs');
13
+ const elision = require('../../lib/elision.cjs');
14
+ const logger = require('../../lib/logger.cjs').child('loop-run-round');
15
15
  const args = require('./_args.cjs');
16
16
 
17
17
  const { TASK_ID_RE } = require('../../lib/ids.cjs');
18
18
 
19
+ function _resolveInsideCwdOrTmp(p, cwd, label, errorCode) {
20
+ return safePath.assertInsideCwdOrTmp(p, cwd, label, errorCode);
21
+ }
22
+
23
+ function _verifyExcerpt(verifyOutput, cwd) {
24
+ const s = String(verifyOutput);
25
+ const cx = elision.compressionContext(cwd);
26
+ const maxBytes = cx.verifyMaxBytes;
27
+ if (s.length <= maxBytes) return s;
28
+ if (cx.enabled) {
29
+ try {
30
+ const crushed = compress.crushLogToBudget(s, maxBytes);
31
+ if (crushed && crushed.length < s.length) {
32
+ const hash = cx.store ? cx.store(s, 'log') : null;
33
+ return crushed + (hash ? '\n' + compress.marker(hash, 'full verify log') : '');
34
+ }
35
+ } catch (err) { logger.warn('verify compression skipped', { cause: err && err.message }); }
36
+ }
37
+ return '…[truncated head, original ' + s.length + ' bytes — see VERIFY_LOG]\n' + s.slice(-maxBytes);
38
+ }
39
+
19
40
  const VALID_PHASES = new Set([
20
41
  'preflight',
21
42
  'post-researcher',
@@ -182,16 +203,9 @@ function _runPostExecutor(taskId, list, cwd) {
182
203
  }
183
204
  }
184
205
  const green = code === 0;
185
- const VERIFY_TAIL_BYTES = 2000;
186
206
  let verifyExcerpt = null;
187
207
  if (verifyOutput) {
188
- const s = String(verifyOutput);
189
- if (s.length > VERIFY_TAIL_BYTES) {
190
- verifyExcerpt = '…[truncated head, original ' + s.length + ' bytes — see VERIFY_LOG]\n'
191
- + s.slice(-VERIFY_TAIL_BYTES);
192
- } else {
193
- verifyExcerpt = s;
194
- }
208
+ verifyExcerpt = _verifyExcerpt(verifyOutput, cwd);
195
209
  }
196
210
  const merged = checkpoint.mergeCheckpoint(
197
211
  taskId,
@@ -95,6 +95,7 @@ async function _initPayload(mNum, cwd, opts) {
95
95
  const spawnSpecs = swarm.buildSpawnSpecs(
96
96
  { milestone: mNum, milestone_id: layout.mId(mNum), goal: def.goal || '' },
97
97
  swarmOpts.k,
98
+ { cwd },
98
99
  );
99
100
  let cacheHit = null;
100
101
  let cacheMiss = null;
@@ -107,7 +107,7 @@ async function run(args, ctx) {
107
107
  const tmDetail = textMode.resolveTextModeDetail(cwd);
108
108
 
109
109
  const swarmOpts = swarm.resolveSwarmOpts(cwd);
110
- const spawnSpecs = swarm.buildSpawnSpecs({ milestone: mNum, milestone_id: mIdStr, goal: def.goal || '' }, swarmOpts.k);
110
+ const spawnSpecs = swarm.buildSpawnSpecs({ milestone: mNum, milestone_id: mIdStr, goal: def.goal || '' }, swarmOpts.k, { cwd });
111
111
 
112
112
  let cacheHit = null;
113
113
  let cacheMiss = null;
@@ -3,6 +3,7 @@ const path = require('node:path');
3
3
  const { NubosPilotError } = require('../../lib/core.cjs');
4
4
  const { readState } = require('../../lib/state.cjs');
5
5
  const { readCheckpoint, listCheckpoints } = require('../../lib/checkpoint.cjs');
6
+ const { reconcileCommittedCheckpoints } = require('../../lib/checkpoint-reconcile.cjs');
6
7
  const { TASK_ID_RE } = require('../../lib/ids.cjs');
7
8
  const textMode = require('../../lib/text-mode.cjs');
8
9
  const layout = require('../../lib/layout.cjs');
@@ -48,6 +49,12 @@ function run(_args, ctx) {
48
49
 
49
50
  const state = _safeReadState(cwd);
50
51
  const currentTask = state && state.frontmatter ? state.frontmatter.current_task : null;
52
+
53
+ let pruned = [];
54
+ try {
55
+ pruned = reconcileCommittedCheckpoints(cwd, { exclude: currentTask }).pruned;
56
+ } catch { pruned = []; }
57
+
51
58
  const cpFiles = listCheckpoints(cwd);
52
59
 
53
60
  let payload;
@@ -104,6 +111,8 @@ function run(_args, ctx) {
104
111
  };
105
112
  }
106
113
 
114
+ if (pruned.length > 0) payload.pruned_checkpoints = pruned;
115
+
107
116
  const tmDetail = textMode.resolveTextModeDetail(cwd);
108
117
  payload.text_mode = tmDetail.enabled;
109
118
  payload.text_mode_source = tmDetail.source;
@@ -3,9 +3,10 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const os = require('node:os');
6
+ const { execFileSync } = require('node:child_process');
6
7
 
7
8
  const subcmd = require('./resume-work.cjs');
8
- const { startTask } = require('../../lib/checkpoint.cjs');
9
+ const { startTask, listCheckpoints } = require('../../lib/checkpoint.cjs');
9
10
 
10
11
  const _roots = [];
11
12
 
@@ -78,6 +79,25 @@ test('RW-3: orphan when checkpoint files exist but no matching STATE.current_tas
78
79
  assert.ok(p.checkpoint_ids.includes('M006-S001-T0005'));
79
80
  });
80
81
 
82
+ test('RW-5: committed orphan is reconciled against git and pruned (not surfaced)', () => {
83
+ const root = makeRoot(null);
84
+ execFileSync('git', ['init', '-q', '-b', 'main', root], { stdio: 'pipe' });
85
+ execFileSync('git', ['-C', root, 'config', 'user.email', 'test@nubos.local']);
86
+ execFileSync('git', ['-C', root, 'config', 'user.name', 'nubos-test']);
87
+ execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-q', '-m', 'chore: init'], { stdio: 'pipe' });
88
+ startTask({ id: 'M013-S005-T0002', phase: 6, plan: '06-01', wave: 1 }, root);
89
+ execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-q', '-m', 'task(M013-S005-T0002): demo'], { stdio: 'pipe' });
90
+ const statePath = path.join(root, '.nubos-pilot', 'STATE.md');
91
+ fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8').replace(/current_task:.*/, 'current_task: null'), 'utf-8');
92
+
93
+ const cap = _capture();
94
+ const p = subcmd.run([], { cwd: root, stdout: cap.stub });
95
+ assert.equal(p.status, 'clean');
96
+ assert.ok(Array.isArray(p.pruned_checkpoints));
97
+ assert.equal(p.pruned_checkpoints[0].task_id, 'M013-S005-T0002');
98
+ assert.deepEqual(listCheckpoints(root), []);
99
+ });
100
+
81
101
  test('RW-4: malformed checkpoint → checkpoint-schema-mismatch (T-06-12)', () => {
82
102
  const root = makeRoot('M006-S001-T0009');
83
103
  const cpDir = path.join(root, '.nubos-pilot', 'checkpoints');
@@ -9,8 +9,35 @@ const { NubosPilotError, atomicWriteFileSync, appendJsonl, findProjectRoot } = r
9
9
  const runContext = require('../../lib/run-context.cjs');
10
10
  const safePath = require('../../lib/safe-path.cjs');
11
11
  const headlessGuard = require('../../lib/headless-guard.cjs');
12
+ const compress = require('../../lib/compress.cjs');
13
+ const elision = require('../../lib/elision.cjs');
14
+ const elisionProxy = require('../../lib/elision-proxy.cjs');
15
+ const steering = require('../../lib/output-steering.cjs');
16
+ const logger = require('../../lib/logger.cjs').child('spawn-headless');
12
17
  const args = require('./_args.cjs');
13
18
 
19
+ const PROXY_START_TIMEOUT_MS = 5000;
20
+
21
+ function _startCompressionProxy(cwd) {
22
+ const upstream = process.env.ANTHROPIC_BASE_URL || '';
23
+ return new Promise((resolve, reject) => {
24
+ const proc = child_process.fork(path.join(__dirname, '_elision-proxy-entry.cjs'), [], {
25
+ env: Object.assign({}, process.env, { ELISION_PROXY_CWD: cwd, ELISION_PROXY_UPSTREAM: upstream }),
26
+ stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
27
+ });
28
+ const timer = setTimeout(() => {
29
+ try { proc.kill(); } catch { /* already gone */ }
30
+ reject(new NubosPilotError('elision-proxy-start-timeout', 'compression proxy did not report ready in time', {}));
31
+ }, PROXY_START_TIMEOUT_MS);
32
+ proc.once('message', (m) => {
33
+ clearTimeout(timer);
34
+ if (m && m.ready) resolve({ proc, baseUrl: m.baseUrl });
35
+ else { try { proc.kill(); } catch { /* already gone */ } reject(new NubosPilotError('elision-proxy-start-failed', String(m && m.error), {})); }
36
+ });
37
+ proc.once('error', (err) => { clearTimeout(timer); reject(err); });
38
+ });
39
+ }
40
+
14
41
  function _sha256(s) {
15
42
  return crypto.createHash('sha256').update(s == null ? '' : String(s)).digest('hex');
16
43
  }
@@ -159,6 +186,20 @@ function _composePrompt(agentBody, userPrompt) {
159
186
  return agentBody.trimEnd() + '\n\n---\n\n' + userPrompt.trimEnd() + '\n';
160
187
  }
161
188
 
189
+ function _maybeCompressPrompt(prompt, cwd) {
190
+ const none = { prompt, stats: null };
191
+ const cx = elision.compressionContext(cwd);
192
+ if (!cx.enabled) return none;
193
+ try {
194
+ const out = compress.compressPrompt(prompt, { minBlockBytes: cx.minBlockBytes, store: cx.store });
195
+ if (!out || out.stats.blocks_compressed === 0) return none;
196
+ return { prompt: out.text, stats: out.stats };
197
+ } catch (err) {
198
+ logger.warn('compression skipped', { cause: err && err.message });
199
+ return none;
200
+ }
201
+ }
202
+
162
203
  function _stripFrontmatter(md) {
163
204
  if (!md.startsWith('---\n')) return md;
164
205
  const end = md.indexOf('\n---\n', 4);
@@ -304,20 +345,15 @@ async function run(argv, ctx) {
304
345
  }
305
346
 
306
347
  const agentPath = _resolveAgentPath(agent, cwd);
307
- const agentBody = _stripFrontmatter(fs.readFileSync(agentPath, 'utf-8'));
348
+ let agentBody = _stripFrontmatter(fs.readFileSync(agentPath, 'utf-8'));
349
+ const _os = elision.compressionContext(cwd).outputSteering;
350
+ if (_os && _os.enabled) agentBody = steering.enrichSystemPrompt(agentBody, _os.profile);
308
351
  const userPrompt = _readPromptFile(promptPath, cwd);
309
- const composedPrompt = _composePrompt(agentBody, userPrompt);
352
+ let composedPrompt = _composePrompt(agentBody, userPrompt);
310
353
  const resolvedOutput = _ensureOutputDir(outputPath, cwd);
311
354
 
312
355
  const runId = runContext.getRunId();
313
356
 
314
- // ADR-0021: off-host routing. If the agent routes to an openai-compat provider,
315
- // run the nubos-pilot dispatch loop instead of `claude -p`, writing the result
316
- // in the same {result,...} envelope `claude --output-format json` produces so the
317
- // callers (review/extract parseXxxOutput → outer.result) parse it unchanged. The
318
- // native path below is untouched. run() is async; the CLI dispatcher already
319
- // awaits returned promises (handleRunResult), and the two in-process callers
320
- // (review.cjs / extract.cjs) await runReview/runExtract.
321
357
  const resolveKind = context.resolveImpl || _defaultResolveKind;
322
358
  let routed;
323
359
  try { routed = resolveKind(agent, cwd); } catch { routed = { kind: 'native' }; }
@@ -329,6 +365,9 @@ async function run(argv, ctx) {
329
365
  });
330
366
  }
331
367
 
368
+ const compression = _maybeCompressPrompt(composedPrompt, cwd);
369
+ composedPrompt = compression.prompt;
370
+
332
371
  let lockRoot;
333
372
  try { lockRoot = findProjectRoot(cwd); }
334
373
  catch { lockRoot = cwd; }
@@ -344,6 +383,17 @@ async function run(argv, ctx) {
344
383
  const childEnv = _filterSpawnEnv(process.env);
345
384
  Object.assign(childEnv, headlessGuard.childSpawnEnv(process.env));
346
385
 
386
+ let proxyProc = null;
387
+ if (elisionProxy.proxyEnabled(cwd)) {
388
+ try {
389
+ const started = await _startCompressionProxy(cwd);
390
+ proxyProc = started.proc;
391
+ childEnv.ANTHROPIC_BASE_URL = started.baseUrl;
392
+ } catch (err) {
393
+ logger.warn('compression proxy unavailable; spawning without it', { cause: err && err.message });
394
+ }
395
+ }
396
+
347
397
  const bin = _claudeBinary();
348
398
  const claudeArgs = ['-p', '--output-format', 'json'];
349
399
  const startedAt = new Date().toISOString();
@@ -365,6 +415,7 @@ async function run(argv, ctx) {
365
415
  { bin, cause: err && err.code },
366
416
  );
367
417
  } finally {
418
+ if (proxyProc) { try { proxyProc.kill(); } catch { /* already exited */ } }
368
419
  lock.release();
369
420
  }
370
421
  if (result.error && result.error.code === 'ENOENT') {
@@ -406,6 +457,7 @@ async function run(argv, ctx) {
406
457
  tokens_in: claudeMeta.tokens_in == null ? null : claudeMeta.tokens_in,
407
458
  tokens_out: claudeMeta.tokens_out == null ? null : claudeMeta.tokens_out,
408
459
  payload_parse_ok: claudeMeta.parse_ok,
460
+ compression: compression.stats || null,
409
461
  };
410
462
  try { appendJsonl(spawnTrailPath, spawnRecord, { maxLineBytes: 16 * 1024, mode: 0o600 }); }
411
463
  catch (err) {
@@ -431,6 +483,7 @@ async function run(argv, ctx) {
431
483
  tokens_in: spawnRecord.tokens_in,
432
484
  tokens_out: spawnRecord.tokens_out,
433
485
  payload_parse_ok: spawnRecord.payload_parse_ok,
486
+ compression: compression.stats || null,
434
487
  };
435
488
  stdout.write(JSON.stringify(payload) + '\n');
436
489
  if (exitCode !== 0) return 2;
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
4
+ const ISO_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
5
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/;
6
+ const HEX_RE = /\b[0-9a-f]{32,64}\b/i;
7
+ const VOLATILE = Object.freeze([
8
+ ['uuid', UUID_RE],
9
+ ['iso_datetime', ISO_RE],
10
+ ['jwt', JWT_RE],
11
+ ['hex_hash', HEX_RE],
12
+ ]);
13
+
14
+ function _systemText(system) {
15
+ if (typeof system === 'string') return system;
16
+ if (Array.isArray(system)) {
17
+ return system.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join('\n');
18
+ }
19
+ return '';
20
+ }
21
+
22
+ function detectVolatile(system) {
23
+ const text = _systemText(system);
24
+ const findings = [];
25
+ for (const [kind, re] of VOLATILE) {
26
+ const m = text.match(re);
27
+ if (m) findings.push({ kind, sample_len: m[0].length });
28
+ }
29
+ return findings;
30
+ }
31
+
32
+ function _sortKeys(v) {
33
+ if (Array.isArray(v)) return v.map(_sortKeys);
34
+ if (v && typeof v === 'object') {
35
+ const out = {};
36
+ for (const k of Object.keys(v).sort()) out[k] = _sortKeys(v[k]);
37
+ return out;
38
+ }
39
+ return v;
40
+ }
41
+
42
+ function _byCodepoint(a, b) {
43
+ return a < b ? -1 : a > b ? 1 : 0;
44
+ }
45
+
46
+ function normalizeTools(tools) {
47
+ return tools
48
+ .map(_sortKeys)
49
+ .map((t) => ({ t, name: String(t && t.name), body: JSON.stringify(t) }))
50
+ .sort((a, b) => _byCodepoint(a.name, b.name) || _byCodepoint(a.body, b.body))
51
+ .map((x) => x.t);
52
+ }
53
+
54
+ function _anyCacheControl(system, tools) {
55
+ if (Array.isArray(system) && system.some((b) => b && b.cache_control)) return true;
56
+ if (Array.isArray(tools) && tools.some((t) => t && t.cache_control)) return true;
57
+ return false;
58
+ }
59
+
60
+ function alignAnthropicBody(body) {
61
+ const findings = detectVolatile(body && body.system);
62
+ if (!body || !Array.isArray(body.tools) || body.tools.length === 0) {
63
+ return { body, findings, applied: false };
64
+ }
65
+ if (_anyCacheControl(body.system, body.tools)) {
66
+ return { body, findings, applied: false };
67
+ }
68
+ const tools = normalizeTools(body.tools);
69
+ const last = tools.length - 1;
70
+ tools[last] = Object.assign({}, tools[last], { cache_control: { type: 'ephemeral' } });
71
+ return { body: Object.assign({}, body, { tools }), findings, applied: true };
72
+ }
73
+
74
+ module.exports = {
75
+ detectVolatile,
76
+ normalizeTools,
77
+ alignAnthropicBody,
78
+ };