nubos-pilot 1.2.1 → 1.2.3
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 +43 -1
- package/agents/np-architect.md +2 -0
- package/agents/np-executor.md +1 -1
- package/agents/np-learnings-extractor.md +54 -0
- package/agents/np-planner.md +1 -1
- package/agents/np-security-reviewer.md +9 -0
- package/bin/np-tools/_commands.cjs +5 -0
- package/bin/np-tools/derive-tier.cjs +86 -0
- package/bin/np-tools/derive-tier.test.cjs +83 -0
- package/bin/np-tools/doctor.cjs +15 -2
- package/bin/np-tools/graph-impact.cjs +111 -0
- package/bin/np-tools/graph-impact.test.cjs +119 -0
- package/bin/np-tools/learnings.cjs +105 -0
- package/bin/np-tools/learnings.test.cjs +66 -0
- package/bin/np-tools/loop-run-round.cjs +7 -1
- package/bin/np-tools/scan-codebase.cjs +21 -1
- package/bin/np-tools/skill-audit.cjs +79 -0
- package/bin/np-tools/skill-audit.test.cjs +86 -0
- package/bin/np-tools/verify-reliability.cjs +65 -0
- package/bin/np-tools/verify-reliability.test.cjs +69 -0
- package/lib/agents.test.cjs +1 -0
- package/lib/checkpoint.cjs +3 -0
- package/lib/codebase-graph.cjs +0 -0
- package/lib/codebase-graph.test.cjs +174 -0
- package/lib/codebase-manifest.cjs +3 -0
- package/lib/config-defaults.cjs +13 -0
- package/lib/config-schema.cjs +11 -0
- package/lib/eval-reliability.cjs +63 -0
- package/lib/eval-reliability.test.cjs +56 -0
- package/lib/install/claude-hooks-learnings.test.cjs +82 -0
- package/lib/install/claude-hooks.cjs +65 -4
- package/lib/install/claude-hooks.test.cjs +5 -2
- package/lib/learnings/capture-ledger.cjs +80 -0
- package/lib/learnings/capture-ledger.test.cjs +54 -0
- package/lib/learnings/extract.cjs +191 -0
- package/lib/learnings/extract.test.cjs +115 -0
- package/lib/learnings.cjs +19 -95
- package/lib/memory.cjs +38 -33
- package/lib/messaging.cjs +12 -6
- package/lib/metrics-aggregate.cjs +14 -2
- package/lib/migrate.cjs +29 -0
- package/lib/migrate.test.cjs +91 -0
- package/lib/nubosloop-audit.cjs +104 -0
- package/lib/nubosloop-skill-audit.test.cjs +98 -0
- package/lib/nubosloop.cjs +9 -0
- package/lib/schemas/data/checkpoint.v1.json +13 -0
- package/lib/schemas/data/codebase-manifest.v1.json +22 -0
- package/lib/schemas/data/learnings.v1.json +28 -0
- package/lib/schemas/data/memory-manifest.v1.json +14 -0
- package/lib/schemas/data/memory-record.v1.json +16 -0
- package/lib/schemas/data/message.v1.json +19 -0
- package/lib/schemas/data/metrics-record.v1.json +11 -0
- package/lib/tier-classify.cjs +67 -0
- package/lib/tier-classify.test.cjs +67 -0
- package/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +5 -0
- package/package.json +3 -1
- package/skills/np-access-control/SKILL.md +42 -0
- package/skills/np-accessibility-audit/SKILL.md +41 -0
- package/skills/np-adr/SKILL.md +37 -0
- package/skills/np-api-design/SKILL.md +34 -0
- package/skills/np-caching-strategy/SKILL.md +38 -0
- package/skills/np-data-modeling/SKILL.md +37 -0
- package/skills/np-data-privacy/SKILL.md +39 -0
- package/skills/np-dependency-audit/SKILL.md +47 -0
- package/skills/np-encryption/SKILL.md +47 -0
- package/skills/np-error-handling/SKILL.md +37 -0
- package/skills/np-incident-response/SKILL.md +38 -0
- package/skills/np-llm-app-architecture/SKILL.md +50 -0
- package/skills/np-observability/SKILL.md +39 -0
- package/skills/np-performance/SKILL.md +38 -0
- package/skills/np-queue-design/SKILL.md +32 -0
- package/skills/np-rag-design/SKILL.md +43 -0
- package/skills/np-refactoring/SKILL.md +35 -0
- package/skills/np-resilience-patterns/SKILL.md +39 -0
- package/skills/np-secure-code-review/SKILL.md +46 -0
- package/skills/np-secure-design/SKILL.md +44 -0
- package/skills/np-service-boundary/SKILL.md +35 -0
- package/skills/np-system-design/SKILL.md +40 -0
- package/skills/np-test-strategy/SKILL.md +46 -0
- package/skills/np-threat-model/SKILL.md +42 -0
- package/templates/claude/payload/hooks/np-learnings-hook.cjs +55 -0
- package/workflows/architect-phase.md +21 -1
- package/workflows/execute-phase.md +66 -4
- package/workflows/verify-work.md +17 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
// Rate-limit ledger for Stop-hook learning auto-capture. Mirrors the ADR-0020
|
|
8
|
+
// security ledger's posture (sliding per-hour window + consecutive-stop streak)
|
|
9
|
+
// but is its own concern and its own file. Per-session JSON under the OS temp
|
|
10
|
+
// dir; a session that never stops leaves nothing behind worth cleaning.
|
|
11
|
+
|
|
12
|
+
const DIR = path.join(os.tmpdir(), 'nubos-pilot-learnings');
|
|
13
|
+
|
|
14
|
+
function sanitizeSid(sid) {
|
|
15
|
+
return String(sid || 'nosid').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ledgerPath(sid) {
|
|
19
|
+
return path.join(DIR, sanitizeSid(sid) + '.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _read(sid) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(ledgerPath(sid), 'utf-8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return { session_id: sanitizeSid(sid), created_at: Date.now(), capture_times: [], stop_streak: 0 };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _write(sid, l) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
33
|
+
fs.writeFileSync(ledgerPath(sid), JSON.stringify(l), 'utf-8');
|
|
34
|
+
} catch { /* a rate-limit ledger must never break the session */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record an attempt to auto-capture on this session's Stop. Returns whether the
|
|
39
|
+
* capture is allowed under both caps. The per-hour window prevents runaway cost;
|
|
40
|
+
* the in-a-row streak prevents back-to-back Stops (e.g. a tight edit loop) each
|
|
41
|
+
* firing an extraction.
|
|
42
|
+
* @returns {{allowed: boolean, count: number, streak: number, reason?: string}}
|
|
43
|
+
*/
|
|
44
|
+
function tryRecordCapture(sid, opts) {
|
|
45
|
+
const maxPerHour = opts && Number.isFinite(opts.maxPerHour) ? opts.maxPerHour : 10;
|
|
46
|
+
const maxStreak = opts && Number.isFinite(opts.maxStreak) ? opts.maxStreak : 3;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const hourAgo = now - 3600 * 1000;
|
|
49
|
+
|
|
50
|
+
const l = _read(sid);
|
|
51
|
+
l.capture_times = (Array.isArray(l.capture_times) ? l.capture_times : []).filter((t) => t > hourAgo);
|
|
52
|
+
l.stop_streak = Number.isFinite(l.stop_streak) ? l.stop_streak : 0;
|
|
53
|
+
|
|
54
|
+
if (l.capture_times.length >= maxPerHour) {
|
|
55
|
+
_write(sid, l);
|
|
56
|
+
return { allowed: false, count: l.capture_times.length, streak: l.stop_streak, reason: 'per-hour-cap' };
|
|
57
|
+
}
|
|
58
|
+
if (l.stop_streak >= maxStreak) {
|
|
59
|
+
_write(sid, l);
|
|
60
|
+
return { allowed: false, count: l.capture_times.length, streak: l.stop_streak, reason: 'streak-cap' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
l.capture_times.push(now);
|
|
64
|
+
l.stop_streak += 1;
|
|
65
|
+
_write(sid, l);
|
|
66
|
+
return { allowed: true, count: l.capture_times.length, streak: l.stop_streak };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reset the consecutive-stop streak — call after a user prompt (real activity). */
|
|
70
|
+
function resetStreak(sid) {
|
|
71
|
+
const l = _read(sid);
|
|
72
|
+
l.stop_streak = 0;
|
|
73
|
+
_write(sid, l);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeLedger(sid) {
|
|
77
|
+
try { fs.unlinkSync(ledgerPath(sid)); } catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { tryRecordCapture, resetStreak, removeLedger, ledgerPath, sanitizeSid, _read };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const { tryRecordCapture, resetStreak, removeLedger } = require('./capture-ledger.cjs');
|
|
6
|
+
|
|
7
|
+
function _sid(name) { return 'np-test-ledger-' + name + '-' + process.pid; }
|
|
8
|
+
|
|
9
|
+
test('CL-1: streak cap blocks after maxStreak consecutive stops', () => {
|
|
10
|
+
const sid = _sid('streak');
|
|
11
|
+
removeLedger(sid);
|
|
12
|
+
try {
|
|
13
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
14
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
15
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
16
|
+
const blocked = tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 });
|
|
17
|
+
assert.strictEqual(blocked.allowed, false);
|
|
18
|
+
assert.strictEqual(blocked.reason, 'streak-cap');
|
|
19
|
+
} finally { removeLedger(sid); }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('CL-2: resetStreak clears the streak so capture is allowed again', () => {
|
|
23
|
+
const sid = _sid('reset');
|
|
24
|
+
removeLedger(sid);
|
|
25
|
+
try {
|
|
26
|
+
tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 });
|
|
27
|
+
tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 });
|
|
28
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 }).allowed, false);
|
|
29
|
+
resetStreak(sid);
|
|
30
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 }).allowed, true);
|
|
31
|
+
} finally { removeLedger(sid); }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('CL-3: per-hour cap blocks regardless of streak resets', () => {
|
|
35
|
+
const sid = _sid('hour');
|
|
36
|
+
removeLedger(sid);
|
|
37
|
+
try {
|
|
38
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 }).allowed, true);
|
|
39
|
+
resetStreak(sid);
|
|
40
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 }).allowed, true);
|
|
41
|
+
resetStreak(sid);
|
|
42
|
+
const blocked = tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 });
|
|
43
|
+
assert.strictEqual(blocked.allowed, false);
|
|
44
|
+
assert.strictEqual(blocked.reason, 'per-hour-cap');
|
|
45
|
+
} finally { removeLedger(sid); }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('CL-4: a fresh session starts allowed', () => {
|
|
49
|
+
const sid = _sid('fresh');
|
|
50
|
+
removeLedger(sid);
|
|
51
|
+
try {
|
|
52
|
+
assert.strictEqual(tryRecordCapture(sid, {}).allowed, true);
|
|
53
|
+
} finally { removeLedger(sid); }
|
|
54
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const git = require('../git.cjs');
|
|
9
|
+
const knowledgeAdapter = require('../knowledge-adapter.cjs');
|
|
10
|
+
|
|
11
|
+
// Stop-hook learning auto-capture (ECC continuous-learning, np-native). A
|
|
12
|
+
// background worker spawns the read-only np-learnings-extractor headlessly over
|
|
13
|
+
// the turn's diff; it returns atomic {pattern, outcome} candidates which we fold
|
|
14
|
+
// into the existing learnings store via the knowledge adapter — the same store
|
|
15
|
+
// /np:execute-phase already auto-logs into. Mirrors lib/security/review.cjs.
|
|
16
|
+
|
|
17
|
+
const EXTRACTOR_AGENT = 'np-learnings-extractor';
|
|
18
|
+
const MAX_DIFF_BYTES = 64 * 1024;
|
|
19
|
+
const MAX_UNTRACKED_BYTES = 12 * 1024;
|
|
20
|
+
const MAX_CANDIDATES = 5;
|
|
21
|
+
const MAX_PATTERN_LEN = 2000;
|
|
22
|
+
const MAX_OUTCOME_LEN = 2000;
|
|
23
|
+
const VALID_OUTCOMES = new Set(['verified', 'failed', 'reverted', 'partial']);
|
|
24
|
+
|
|
25
|
+
function isRepo(cwd) {
|
|
26
|
+
const r = git.runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
27
|
+
return r.ok && String(r.stdout).trim() === 'true';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _lines(stdout) {
|
|
31
|
+
return String(stdout || '').split(/\r?\n/).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// "What changed this session": last commit (git show HEAD) plus any uncommitted
|
|
35
|
+
// working changes and untracked files, each capped. No baseline tracking —
|
|
36
|
+
// learnings are advisory, so a slightly wider window is acceptable.
|
|
37
|
+
function computeTurnDiff(cwd, maxFiles) {
|
|
38
|
+
const cap = Number.isFinite(maxFiles) ? maxFiles : 30;
|
|
39
|
+
const committedNames = git.runGit(['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', 'HEAD'], { cwd });
|
|
40
|
+
const workingNames = git.runGit(['--no-pager', 'diff', '--name-only'], { cwd });
|
|
41
|
+
const untracked = git.runGit(['ls-files', '--others', '--exclude-standard'], { cwd });
|
|
42
|
+
|
|
43
|
+
const files = [...new Set([
|
|
44
|
+
..._lines(committedNames.stdout),
|
|
45
|
+
..._lines(workingNames.stdout),
|
|
46
|
+
..._lines(untracked.stdout),
|
|
47
|
+
])];
|
|
48
|
+
const uniqueFiles = files.slice(0, cap);
|
|
49
|
+
const truncatedFiles = files.length > cap;
|
|
50
|
+
|
|
51
|
+
let diffText = '';
|
|
52
|
+
const show = git.runGit(['--no-pager', 'show', '--no-color', 'HEAD'], { cwd });
|
|
53
|
+
if (show.ok) diffText += String(show.stdout || '').slice(0, MAX_DIFF_BYTES);
|
|
54
|
+
const working = git.runGit(['--no-pager', 'diff', '--no-color'], { cwd });
|
|
55
|
+
if (working.ok && diffText.length < MAX_DIFF_BYTES) {
|
|
56
|
+
diffText += '\n' + String(working.stdout || '').slice(0, MAX_DIFF_BYTES - diffText.length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let untrackedBudget = MAX_UNTRACKED_BYTES;
|
|
60
|
+
for (const f of _lines(untracked.stdout)) {
|
|
61
|
+
if (untrackedBudget <= 0) break;
|
|
62
|
+
let body = '';
|
|
63
|
+
try { body = fs.readFileSync(path.join(cwd, f), 'utf-8'); } catch { continue; }
|
|
64
|
+
const chunk = '\n--- new file: ' + f + ' ---\n' + body.slice(0, untrackedBudget);
|
|
65
|
+
diffText += chunk;
|
|
66
|
+
untrackedBudget -= chunk.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { files: uniqueFiles, truncatedFiles, diffText };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildExtractorPrompt(opts) {
|
|
73
|
+
const o = opts || {};
|
|
74
|
+
const parts = [];
|
|
75
|
+
parts.push('<learning_capture>');
|
|
76
|
+
parts.push('You are running in learning-capture mode. Read the diff below — the work this session produced — and extract at most ' + MAX_CANDIDATES + ' ATOMIC, REUSABLE engineering learnings.');
|
|
77
|
+
parts.push('');
|
|
78
|
+
parts.push('A good learning is a durable, transferable rule a future agent on a SIMILAR task would benefit from — a convention discovered, a pitfall avoided, a fix that generalises. NOT a narration of what changed, NOT project-specific trivia, NOT anything obvious from reading the code.');
|
|
79
|
+
parts.push('');
|
|
80
|
+
parts.push('Each learning is one {pattern, outcome} pair:');
|
|
81
|
+
parts.push('- pattern: the reusable rule, imperative and self-contained (e.g. "use jose for JWT verification, never hand-roll HS256").');
|
|
82
|
+
parts.push('- outcome: one of verified | failed | reverted | partial — how it played out THIS session.');
|
|
83
|
+
parts.push('');
|
|
84
|
+
parts.push('If nothing meets the bar, return an empty list. Quality over quantity — zero is a valid, common answer.');
|
|
85
|
+
parts.push('');
|
|
86
|
+
parts.push('Changed files (' + o.files.length + (o.truncatedFiles ? '+, truncated' : '') + '):');
|
|
87
|
+
parts.push(o.files.map((f) => '- ' + f).join('\n'));
|
|
88
|
+
parts.push('');
|
|
89
|
+
parts.push('Diff:');
|
|
90
|
+
parts.push('```diff');
|
|
91
|
+
parts.push(o.diffText);
|
|
92
|
+
parts.push('```');
|
|
93
|
+
parts.push('');
|
|
94
|
+
parts.push('Output ONLY a single JSON object (no prose, no markdown fence):');
|
|
95
|
+
parts.push('{"learnings":[{"pattern":"...","outcome":"verified|failed|reverted|partial"}]}');
|
|
96
|
+
parts.push('</learning_capture>');
|
|
97
|
+
return parts.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _tryParseJson(s) { try { return JSON.parse(s); } catch { return null; } }
|
|
101
|
+
function _stripFence(s) {
|
|
102
|
+
const m = String(s).match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
103
|
+
return m ? m[1] : s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseExtractorOutput(raw) {
|
|
107
|
+
if (!raw || typeof raw !== 'string') return { candidates: [], parse_ok: false };
|
|
108
|
+
let resultText = raw;
|
|
109
|
+
const outer = _tryParseJson(raw);
|
|
110
|
+
if (outer && typeof outer === 'object' && typeof outer.result === 'string') resultText = outer.result;
|
|
111
|
+
|
|
112
|
+
let env = _tryParseJson(resultText);
|
|
113
|
+
if (!env) env = _tryParseJson(_stripFence(resultText));
|
|
114
|
+
if (!env || typeof env !== 'object' || !Array.isArray(env.learnings)) {
|
|
115
|
+
return { candidates: [], parse_ok: false };
|
|
116
|
+
}
|
|
117
|
+
const candidates = env.learnings
|
|
118
|
+
.filter((l) => l && typeof l === 'object' && typeof l.pattern === 'string' && l.pattern.trim())
|
|
119
|
+
.map((l) => ({
|
|
120
|
+
pattern: l.pattern.trim().slice(0, MAX_PATTERN_LEN),
|
|
121
|
+
outcome: VALID_OUTCOMES.has(String(l.outcome)) ? String(l.outcome) : 'verified',
|
|
122
|
+
}))
|
|
123
|
+
.slice(0, MAX_CANDIDATES);
|
|
124
|
+
return { candidates, parse_ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _defaultSpawn(promptText, opts) {
|
|
128
|
+
const spawnHeadless = require('../../bin/np-tools/spawn-headless.cjs');
|
|
129
|
+
const tmp = os.tmpdir();
|
|
130
|
+
const tag = process.pid + '-' + crypto.randomBytes(4).toString('hex');
|
|
131
|
+
const promptPath = path.join(tmp, 'np-learn-prompt-' + tag + '.txt');
|
|
132
|
+
const outputPath = path.join(tmp, 'np-learn-out-' + tag + '.json');
|
|
133
|
+
fs.writeFileSync(promptPath, promptText, 'utf-8');
|
|
134
|
+
try {
|
|
135
|
+
spawnHeadless.run(
|
|
136
|
+
['--agent', EXTRACTOR_AGENT, '--prompt-path', promptPath, '--output-path', outputPath,
|
|
137
|
+
'--timeout-ms', String(opts.timeoutMs)],
|
|
138
|
+
{ cwd: opts.cwd, stdout: { write: () => {} } },
|
|
139
|
+
);
|
|
140
|
+
return fs.readFileSync(outputPath, 'utf-8');
|
|
141
|
+
} finally {
|
|
142
|
+
try { fs.unlinkSync(promptPath); } catch {}
|
|
143
|
+
try { fs.unlinkSync(outputPath); } catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function runExtract(opts) {
|
|
148
|
+
const o = opts || {};
|
|
149
|
+
const cwd = o.cwd || process.cwd();
|
|
150
|
+
const config = o.config || {};
|
|
151
|
+
const spawn = typeof o.spawnImpl === 'function' ? o.spawnImpl : _defaultSpawn;
|
|
152
|
+
const logImpl = typeof o.logImpl === 'function'
|
|
153
|
+
? o.logImpl
|
|
154
|
+
: (cand) => knowledgeAdapter.getAdapter(cwd).log({ pattern: cand.pattern, outcome: cand.outcome });
|
|
155
|
+
|
|
156
|
+
if (!isRepo(cwd)) return { ran: false, reason: 'not-a-repo', logged: 0 };
|
|
157
|
+
|
|
158
|
+
const maxFiles = Number.isFinite(config.max_files) ? config.max_files : 30;
|
|
159
|
+
const diff = computeTurnDiff(cwd, maxFiles);
|
|
160
|
+
if (!String(diff.diffText).trim()) {
|
|
161
|
+
return { ran: true, logged: 0, reason: 'empty-diff' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const promptText = buildExtractorPrompt(diff);
|
|
165
|
+
let raw = '';
|
|
166
|
+
try {
|
|
167
|
+
raw = spawn(promptText, { cwd, timeoutMs: config.timeout_ms || 120000 });
|
|
168
|
+
} catch {
|
|
169
|
+
return { ran: true, logged: 0, reason: 'spawn-failed' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const parsed = parseExtractorOutput(raw);
|
|
173
|
+
if (!parsed.parse_ok) return { ran: true, logged: 0, reason: 'parse-failed' };
|
|
174
|
+
|
|
175
|
+
let logged = 0;
|
|
176
|
+
for (const cand of parsed.candidates) {
|
|
177
|
+
try { logImpl(cand); logged += 1; } catch { /* one bad candidate must not abort the rest */ }
|
|
178
|
+
}
|
|
179
|
+
return { ran: true, logged, candidates: parsed.candidates.length, reason: 'ok' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
EXTRACTOR_AGENT,
|
|
184
|
+
isRepo,
|
|
185
|
+
computeTurnDiff,
|
|
186
|
+
buildExtractorPrompt,
|
|
187
|
+
parseExtractorOutput,
|
|
188
|
+
runExtract,
|
|
189
|
+
MAX_CANDIDATES,
|
|
190
|
+
VALID_OUTCOMES,
|
|
191
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const cp = require('node:child_process');
|
|
9
|
+
const extract = require('./extract.cjs');
|
|
10
|
+
|
|
11
|
+
function _gitRepo(withCommit) {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-extract-'));
|
|
13
|
+
const run = (args) => cp.spawnSync('git', args, { cwd: dir, encoding: 'utf-8' });
|
|
14
|
+
run(['init', '-q']);
|
|
15
|
+
run(['config', 'user.email', 'test@example.com']);
|
|
16
|
+
run(['config', 'user.name', 'Test']);
|
|
17
|
+
run(['config', 'commit.gpgsign', 'false']);
|
|
18
|
+
if (withCommit) {
|
|
19
|
+
fs.writeFileSync(path.join(dir, 'a.js'), 'function add(a,b){return a+b;}\n');
|
|
20
|
+
run(['add', '-A']);
|
|
21
|
+
run(['commit', '-q', '-m', 'add helper']);
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('EX-1: buildExtractorPrompt frames a learning_capture block with diff + files', () => {
|
|
27
|
+
const p = extract.buildExtractorPrompt({ files: ['a.js'], truncatedFiles: false, diffText: '+ code' });
|
|
28
|
+
assert.match(p, /<learning_capture>/);
|
|
29
|
+
assert.match(p, /a\.js/);
|
|
30
|
+
assert.match(p, /```diff/);
|
|
31
|
+
assert.match(p, /"learnings"/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('EX-2: parseExtractorOutput unwraps {result} envelope', () => {
|
|
35
|
+
const raw = JSON.stringify({ result: JSON.stringify({ learnings: [{ pattern: 'use jose for jwt', outcome: 'verified' }] }) });
|
|
36
|
+
const r = extract.parseExtractorOutput(raw);
|
|
37
|
+
assert.strictEqual(r.parse_ok, true);
|
|
38
|
+
assert.strictEqual(r.candidates.length, 1);
|
|
39
|
+
assert.strictEqual(r.candidates[0].pattern, 'use jose for jwt');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('EX-3: parseExtractorOutput strips a markdown fence', () => {
|
|
43
|
+
const raw = '```json\n{"learnings":[{"pattern":"p","outcome":"failed"}]}\n```';
|
|
44
|
+
const r = extract.parseExtractorOutput(raw);
|
|
45
|
+
assert.strictEqual(r.candidates.length, 1);
|
|
46
|
+
assert.strictEqual(r.candidates[0].outcome, 'failed');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('EX-4: invalid outcome defaults to verified; empty pattern dropped', () => {
|
|
50
|
+
const raw = JSON.stringify({ learnings: [
|
|
51
|
+
{ pattern: 'good', outcome: 'banana' },
|
|
52
|
+
{ pattern: ' ', outcome: 'verified' },
|
|
53
|
+
] });
|
|
54
|
+
const r = extract.parseExtractorOutput(raw);
|
|
55
|
+
assert.strictEqual(r.candidates.length, 1);
|
|
56
|
+
assert.strictEqual(r.candidates[0].outcome, 'verified');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('EX-5: caps candidates at MAX_CANDIDATES', () => {
|
|
60
|
+
const many = Array.from({ length: 9 }, (_, i) => ({ pattern: 'p' + i, outcome: 'verified' }));
|
|
61
|
+
const r = extract.parseExtractorOutput(JSON.stringify({ learnings: many }));
|
|
62
|
+
assert.strictEqual(r.candidates.length, extract.MAX_CANDIDATES);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('EX-6: non-JSON output → parse_ok false', () => {
|
|
66
|
+
assert.strictEqual(extract.parseExtractorOutput('totally not json').parse_ok, false);
|
|
67
|
+
assert.strictEqual(extract.parseExtractorOutput('').parse_ok, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('EX-7: runExtract on a non-repo returns not-a-repo, logs nothing', () => {
|
|
71
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-norepo-'));
|
|
72
|
+
try {
|
|
73
|
+
const logged = [];
|
|
74
|
+
const r = extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: (c) => logged.push(c) });
|
|
75
|
+
assert.strictEqual(r.ran, false);
|
|
76
|
+
assert.strictEqual(r.reason, 'not-a-repo');
|
|
77
|
+
assert.strictEqual(logged.length, 0);
|
|
78
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('EX-8: runExtract on empty repo (no commit, no changes) → empty-diff', () => {
|
|
82
|
+
const dir = _gitRepo(false);
|
|
83
|
+
try {
|
|
84
|
+
const r = extract.runExtract({ cwd: dir, spawnImpl: () => '{}', logImpl: () => {} });
|
|
85
|
+
assert.strictEqual(r.ran, true);
|
|
86
|
+
assert.strictEqual(r.reason, 'empty-diff');
|
|
87
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('EX-9: runExtract over a commit logs parsed candidates', () => {
|
|
91
|
+
const dir = _gitRepo(true);
|
|
92
|
+
try {
|
|
93
|
+
const logged = [];
|
|
94
|
+
const r = extract.runExtract({
|
|
95
|
+
cwd: dir,
|
|
96
|
+
spawnImpl: () => JSON.stringify({ result: JSON.stringify({ learnings: [
|
|
97
|
+
{ pattern: 'keep add() pure and total', outcome: 'verified' },
|
|
98
|
+
] }) }),
|
|
99
|
+
logImpl: (c) => logged.push(c),
|
|
100
|
+
});
|
|
101
|
+
assert.strictEqual(r.ran, true);
|
|
102
|
+
assert.strictEqual(r.logged, 1);
|
|
103
|
+
assert.strictEqual(logged[0].pattern, 'keep add() pure and total');
|
|
104
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('EX-10: runExtract with unparseable spawn output → parse-failed, no log', () => {
|
|
108
|
+
const dir = _gitRepo(true);
|
|
109
|
+
try {
|
|
110
|
+
const logged = [];
|
|
111
|
+
const r = extract.runExtract({ cwd: dir, spawnImpl: () => 'garbage', logImpl: (c) => logged.push(c) });
|
|
112
|
+
assert.strictEqual(r.reason, 'parse-failed');
|
|
113
|
+
assert.strictEqual(logged.length, 0);
|
|
114
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
115
|
+
});
|
package/lib/learnings.cjs
CHANGED
|
@@ -6,6 +6,10 @@ const crypto = require('node:crypto');
|
|
|
6
6
|
|
|
7
7
|
const { projectStateDir, atomicWriteFileSync, withFileLock, NubosPilotError, safeAssign } = require('./core.cjs');
|
|
8
8
|
const { TASK_ID_RE, MILESTONE_ID_RE } = require('./ids.cjs');
|
|
9
|
+
const { assertValid } = require('./validate.cjs');
|
|
10
|
+
const { runMigrators } = require('./migrate.cjs');
|
|
11
|
+
|
|
12
|
+
const STORE_SCHEMA = 'learnings.v1';
|
|
9
13
|
|
|
10
14
|
const STOPWORDS = new Set([
|
|
11
15
|
'the','a','an','of','to','in','on','for','and','or','is','are','was','were',
|
|
@@ -96,14 +100,7 @@ function _readStore(cwd) {
|
|
|
96
100
|
);
|
|
97
101
|
}
|
|
98
102
|
if (obj.version === STORE_VERSION) {
|
|
99
|
-
|
|
100
|
-
throw new NubosPilotError(
|
|
101
|
-
'learnings-store-corrupt',
|
|
102
|
-
'learnings.json missing learnings[] array',
|
|
103
|
-
{ path: p, version: obj.version },
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
_assertLearningRecords(obj.learnings, p);
|
|
103
|
+
assertValid(obj, STORE_SCHEMA, 'learnings-store-corrupt', { path: p });
|
|
107
104
|
return obj;
|
|
108
105
|
}
|
|
109
106
|
const migrated = _migrate(obj, p);
|
|
@@ -120,99 +117,26 @@ function _readStore(cwd) {
|
|
|
120
117
|
);
|
|
121
118
|
}
|
|
122
119
|
|
|
123
|
-
const _REQUIRED_LEARNING_FIELDS = ['fingerprint', 'pattern', 'outcome', 'occurrence', 'first_seen', 'last_seen'];
|
|
124
|
-
|
|
125
120
|
function _assertLearningRecords(records, p) {
|
|
126
|
-
|
|
127
|
-
const r = records[i];
|
|
128
|
-
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
|
129
|
-
throw new NubosPilotError(
|
|
130
|
-
'learnings-store-corrupt',
|
|
131
|
-
'learnings[' + i + '] is not a JSON object',
|
|
132
|
-
{ path: p, index: i },
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
for (const field of _REQUIRED_LEARNING_FIELDS) {
|
|
136
|
-
if (!(field in r)) {
|
|
137
|
-
throw new NubosPilotError(
|
|
138
|
-
'learnings-store-corrupt',
|
|
139
|
-
'learnings[' + i + '] missing required field "' + field + '"',
|
|
140
|
-
{ path: p, index: i, field, required: _REQUIRED_LEARNING_FIELDS.slice() },
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (typeof r.fingerprint !== 'string' || !/^[a-f0-9]{16}$/.test(r.fingerprint)) {
|
|
145
|
-
throw new NubosPilotError(
|
|
146
|
-
'learnings-store-corrupt',
|
|
147
|
-
'learnings[' + i + '].fingerprint must be a 16-char hex string',
|
|
148
|
-
{ path: p, index: i, got: r.fingerprint },
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
if (typeof r.occurrence !== 'number' || r.occurrence < 1) {
|
|
152
|
-
throw new NubosPilotError(
|
|
153
|
-
'learnings-store-corrupt',
|
|
154
|
-
'learnings[' + i + '].occurrence must be a positive integer',
|
|
155
|
-
{ path: p, index: i, got: r.occurrence },
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
if (typeof r.pattern !== 'string') {
|
|
159
|
-
throw new NubosPilotError(
|
|
160
|
-
'learnings-store-corrupt',
|
|
161
|
-
'learnings[' + i + '].pattern must be a string',
|
|
162
|
-
{ path: p, index: i, got: typeof r.pattern },
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (Buffer.byteLength(r.pattern, 'utf-8') > MAX_PATTERN_BYTES) {
|
|
166
|
-
throw new NubosPilotError(
|
|
167
|
-
'learnings-store-corrupt',
|
|
168
|
-
'learnings[' + i + '].pattern exceeds MAX_PATTERN_BYTES',
|
|
169
|
-
{ path: p, index: i, max: MAX_PATTERN_BYTES },
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
if (typeof r.outcome !== 'string') {
|
|
173
|
-
throw new NubosPilotError(
|
|
174
|
-
'learnings-store-corrupt',
|
|
175
|
-
'learnings[' + i + '].outcome must be a string',
|
|
176
|
-
{ path: p, index: i, got: typeof r.outcome },
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
if ('tokens' in r && !Array.isArray(r.tokens)) {
|
|
180
|
-
throw new NubosPilotError(
|
|
181
|
-
'learnings-store-corrupt',
|
|
182
|
-
'learnings[' + i + '].tokens, when present, must be an array',
|
|
183
|
-
{ path: p, index: i, got: typeof r.tokens },
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
for (const arrField of ['task_ids', 'milestone_ids']) {
|
|
187
|
-
if (arrField in r && !Array.isArray(r[arrField])) {
|
|
188
|
-
throw new NubosPilotError(
|
|
189
|
-
'learnings-store-corrupt',
|
|
190
|
-
'learnings[' + i + '].' + arrField + ', when present, must be an array',
|
|
191
|
-
{ path: p, index: i, field: arrField, got: typeof r[arrField] },
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function _migrate(obj, p, migrators) {
|
|
199
|
-
const reg = migrators || MIGRATORS;
|
|
200
|
-
let cur = obj;
|
|
201
|
-
while (cur && cur.version !== STORE_VERSION) {
|
|
202
|
-
const next = reg[cur.version];
|
|
203
|
-
if (typeof next !== 'function') return null;
|
|
204
|
-
cur = next(cur);
|
|
205
|
-
if (!cur || typeof cur !== 'object') return null;
|
|
206
|
-
}
|
|
207
|
-
if (!Array.isArray(cur.learnings)) {
|
|
121
|
+
if (!Array.isArray(records)) {
|
|
208
122
|
throw new NubosPilotError(
|
|
209
123
|
'learnings-store-corrupt',
|
|
210
|
-
'
|
|
124
|
+
'learnings[] must be an array',
|
|
211
125
|
{ path: p },
|
|
212
126
|
);
|
|
213
127
|
}
|
|
214
|
-
|
|
215
|
-
|
|
128
|
+
assertValid({ version: STORE_VERSION, learnings: records }, STORE_SCHEMA, 'learnings-store-corrupt', { path: p });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _migrate(obj, p, migrators) {
|
|
132
|
+
return runMigrators(obj, {
|
|
133
|
+
versionField: 'version',
|
|
134
|
+
targetVersion: STORE_VERSION,
|
|
135
|
+
migrators: migrators || MIGRATORS,
|
|
136
|
+
schema: STORE_SCHEMA,
|
|
137
|
+
code: 'learnings-store-corrupt',
|
|
138
|
+
details: { path: p },
|
|
139
|
+
});
|
|
216
140
|
}
|
|
217
141
|
|
|
218
142
|
function _evictIfOverCap(store, opts) {
|