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.
- package/CHANGELOG.md +10 -0
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/_elision-proxy-entry.cjs +13 -0
- package/bin/np-tools/doctor.cjs +25 -3
- package/bin/np-tools/elision-bench.cjs +67 -0
- package/bin/np-tools/elision-get.cjs +48 -0
- package/bin/np-tools/elision-get.test.cjs +66 -0
- package/bin/np-tools/loop-run-round.cjs +25 -11
- package/bin/np-tools/plan-milestone.cjs +1 -0
- package/bin/np-tools/research-phase.cjs +1 -1
- package/bin/np-tools/resume-work.cjs +9 -0
- package/bin/np-tools/resume-work.test.cjs +21 -1
- package/bin/np-tools/spawn-headless.cjs +62 -9
- package/lib/cache-align.cjs +78 -0
- package/lib/cache-align.test.cjs +69 -0
- package/lib/checkpoint-reconcile.cjs +42 -0
- package/lib/checkpoint-reconcile.test.cjs +106 -0
- package/lib/compress.cjs +495 -0
- package/lib/compress.test.cjs +267 -0
- package/lib/config-defaults.cjs +39 -0
- package/lib/config-schema.cjs +40 -4
- package/lib/elision-bench.cjs +409 -0
- package/lib/elision-bench.test.cjs +89 -0
- package/lib/elision-proxy.cjs +158 -0
- package/lib/elision-proxy.test.cjs +243 -0
- package/lib/elision.cjs +163 -0
- package/lib/elision.test.cjs +143 -0
- package/lib/git.cjs +4 -2
- package/lib/nubosloop.cjs +1 -1
- package/lib/output-steering.cjs +68 -0
- package/lib/output-steering.test.cjs +74 -0
- package/lib/researcher-swarm.cjs +14 -3
- package/lib/runtime/agent-loop.cjs +36 -6
- package/lib/runtime/agent-loop.test.cjs +105 -0
- package/lib/runtime/dispatch.cjs +6 -6
- package/lib/runtime/dispatch.test.cjs +17 -3
- package/lib/runtime/providers/openai-compat.cjs +2 -1
- package/lib/runtime/providers/openai-compat.test.cjs +9 -0
- package/lib/runtime/tools/index.cjs +33 -1
- package/lib/runtime/tools/index.test.cjs +24 -0
- package/lib/schemas/data/elision-entry.v1.json +16 -0
- package/lib/token-cost.cjs +46 -0
- package/lib/token-cost.test.cjs +42 -0
- package/np-tools.cjs +2 -0
- package/package.json +1 -1
- 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
|
+
});
|
package/bin/np-tools/doctor.cjs
CHANGED
|
@@ -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
|
|
555
|
-
+ 'Likely a crash
|
|
556
|
-
+ 'Run `np-tools undo-task ' + taskId + '` to clean up, or
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|