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.
Files changed (86) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/agents/np-architect.md +2 -0
  3. package/agents/np-executor.md +1 -1
  4. package/agents/np-learnings-extractor.md +54 -0
  5. package/agents/np-planner.md +1 -1
  6. package/agents/np-security-reviewer.md +9 -0
  7. package/bin/np-tools/_commands.cjs +5 -0
  8. package/bin/np-tools/derive-tier.cjs +86 -0
  9. package/bin/np-tools/derive-tier.test.cjs +83 -0
  10. package/bin/np-tools/doctor.cjs +15 -2
  11. package/bin/np-tools/graph-impact.cjs +111 -0
  12. package/bin/np-tools/graph-impact.test.cjs +119 -0
  13. package/bin/np-tools/learnings.cjs +105 -0
  14. package/bin/np-tools/learnings.test.cjs +66 -0
  15. package/bin/np-tools/loop-run-round.cjs +7 -1
  16. package/bin/np-tools/scan-codebase.cjs +21 -1
  17. package/bin/np-tools/skill-audit.cjs +79 -0
  18. package/bin/np-tools/skill-audit.test.cjs +86 -0
  19. package/bin/np-tools/verify-reliability.cjs +65 -0
  20. package/bin/np-tools/verify-reliability.test.cjs +69 -0
  21. package/lib/agents.test.cjs +1 -0
  22. package/lib/checkpoint.cjs +3 -0
  23. package/lib/codebase-graph.cjs +0 -0
  24. package/lib/codebase-graph.test.cjs +174 -0
  25. package/lib/codebase-manifest.cjs +3 -0
  26. package/lib/config-defaults.cjs +13 -0
  27. package/lib/config-schema.cjs +11 -0
  28. package/lib/eval-reliability.cjs +63 -0
  29. package/lib/eval-reliability.test.cjs +56 -0
  30. package/lib/install/claude-hooks-learnings.test.cjs +82 -0
  31. package/lib/install/claude-hooks.cjs +65 -4
  32. package/lib/install/claude-hooks.test.cjs +5 -2
  33. package/lib/learnings/capture-ledger.cjs +80 -0
  34. package/lib/learnings/capture-ledger.test.cjs +54 -0
  35. package/lib/learnings/extract.cjs +191 -0
  36. package/lib/learnings/extract.test.cjs +115 -0
  37. package/lib/learnings.cjs +19 -95
  38. package/lib/memory.cjs +38 -33
  39. package/lib/messaging.cjs +12 -6
  40. package/lib/metrics-aggregate.cjs +14 -2
  41. package/lib/migrate.cjs +29 -0
  42. package/lib/migrate.test.cjs +91 -0
  43. package/lib/nubosloop-audit.cjs +104 -0
  44. package/lib/nubosloop-skill-audit.test.cjs +98 -0
  45. package/lib/nubosloop.cjs +9 -0
  46. package/lib/schemas/data/checkpoint.v1.json +13 -0
  47. package/lib/schemas/data/codebase-manifest.v1.json +22 -0
  48. package/lib/schemas/data/learnings.v1.json +28 -0
  49. package/lib/schemas/data/memory-manifest.v1.json +14 -0
  50. package/lib/schemas/data/memory-record.v1.json +16 -0
  51. package/lib/schemas/data/message.v1.json +19 -0
  52. package/lib/schemas/data/metrics-record.v1.json +11 -0
  53. package/lib/tier-classify.cjs +67 -0
  54. package/lib/tier-classify.test.cjs +67 -0
  55. package/lib/validate.cjs +301 -0
  56. package/lib/validate.test.cjs +242 -0
  57. package/np-tools.cjs +5 -0
  58. package/package.json +3 -1
  59. package/skills/np-access-control/SKILL.md +42 -0
  60. package/skills/np-accessibility-audit/SKILL.md +41 -0
  61. package/skills/np-adr/SKILL.md +37 -0
  62. package/skills/np-api-design/SKILL.md +34 -0
  63. package/skills/np-caching-strategy/SKILL.md +38 -0
  64. package/skills/np-data-modeling/SKILL.md +37 -0
  65. package/skills/np-data-privacy/SKILL.md +39 -0
  66. package/skills/np-dependency-audit/SKILL.md +47 -0
  67. package/skills/np-encryption/SKILL.md +47 -0
  68. package/skills/np-error-handling/SKILL.md +37 -0
  69. package/skills/np-incident-response/SKILL.md +38 -0
  70. package/skills/np-llm-app-architecture/SKILL.md +50 -0
  71. package/skills/np-observability/SKILL.md +39 -0
  72. package/skills/np-performance/SKILL.md +38 -0
  73. package/skills/np-queue-design/SKILL.md +32 -0
  74. package/skills/np-rag-design/SKILL.md +43 -0
  75. package/skills/np-refactoring/SKILL.md +35 -0
  76. package/skills/np-resilience-patterns/SKILL.md +39 -0
  77. package/skills/np-secure-code-review/SKILL.md +46 -0
  78. package/skills/np-secure-design/SKILL.md +44 -0
  79. package/skills/np-service-boundary/SKILL.md +35 -0
  80. package/skills/np-system-design/SKILL.md +40 -0
  81. package/skills/np-test-strategy/SKILL.md +46 -0
  82. package/skills/np-threat-model/SKILL.md +42 -0
  83. package/templates/claude/payload/hooks/np-learnings-hook.cjs +55 -0
  84. package/workflows/architect-phase.md +21 -1
  85. package/workflows/execute-phase.md +66 -4
  86. 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
- if (!Array.isArray(obj.learnings)) {
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
- for (let i = 0; i < records.length; i += 1) {
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
- 'migrator produced invalid shape (missing learnings[])',
124
+ 'learnings[] must be an array',
211
125
  { path: p },
212
126
  );
213
127
  }
214
- _assertLearningRecords(cur.learnings, p);
215
- return cur;
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) {