nubos-pilot 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/agents/np-executor.md +20 -0
  3. package/agents/np-security-reviewer.md +49 -3
  4. package/bin/install.js +7 -2
  5. package/bin/np-tools/_commands.cjs +2 -0
  6. package/bin/np-tools/doctor.cjs +15 -2
  7. package/bin/np-tools/graph-impact.cjs +111 -0
  8. package/bin/np-tools/graph-impact.test.cjs +119 -0
  9. package/bin/np-tools/scan-codebase.cjs +21 -1
  10. package/bin/np-tools/security.cjs +177 -0
  11. package/bin/np-tools/security.test.cjs +82 -0
  12. package/lib/checkpoint.cjs +3 -0
  13. package/lib/codebase-graph.cjs +0 -0
  14. package/lib/codebase-graph.test.cjs +174 -0
  15. package/lib/codebase-manifest.cjs +3 -0
  16. package/lib/config-defaults.cjs +23 -0
  17. package/lib/config-defaults.test.cjs +15 -0
  18. package/lib/config-schema.cjs +19 -0
  19. package/lib/config-schema.test.cjs +58 -0
  20. package/lib/install/claude-hooks.cjs +100 -7
  21. package/lib/install/claude-hooks.test.cjs +96 -0
  22. package/lib/learnings.cjs +19 -95
  23. package/lib/memory.cjs +38 -33
  24. package/lib/messaging.cjs +12 -6
  25. package/lib/metrics-aggregate.cjs +14 -2
  26. package/lib/migrate.cjs +29 -0
  27. package/lib/migrate.test.cjs +91 -0
  28. package/lib/schemas/data/checkpoint.v1.json +13 -0
  29. package/lib/schemas/data/codebase-manifest.v1.json +22 -0
  30. package/lib/schemas/data/learnings.v1.json +28 -0
  31. package/lib/schemas/data/memory-manifest.v1.json +14 -0
  32. package/lib/schemas/data/memory-record.v1.json +16 -0
  33. package/lib/schemas/data/message.v1.json +19 -0
  34. package/lib/schemas/data/metrics-record.v1.json +11 -0
  35. package/lib/security/ledger.cjs +203 -0
  36. package/lib/security/ledger.test.cjs +139 -0
  37. package/lib/security/patterns.cjs +119 -0
  38. package/lib/security/review.cjs +220 -0
  39. package/lib/security/review.test.cjs +143 -0
  40. package/lib/security/scan.cjs +180 -0
  41. package/lib/security/scan.test.cjs +137 -0
  42. package/lib/validate.cjs +301 -0
  43. package/lib/validate.test.cjs +242 -0
  44. package/np-tools.cjs +2 -0
  45. package/package.json +3 -1
  46. package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
  47. package/workflows/execute-phase.md +11 -1
@@ -16,8 +16,21 @@ const { atomicWriteFileSync, NubosPilotError, withFileLock } = require('../core.
16
16
 
17
17
  const STATUSLINE_REL = '.claude/nubos-pilot/hooks/np-statusline.cjs';
18
18
  const CTX_MONITOR_REL = '.claude/nubos-pilot/hooks/np-ctx-monitor.cjs';
19
+ const SECURITY_HOOK_REL = '.claude/nubos-pilot/hooks/np-security-hook.cjs';
19
20
  const NP_STATUSLINE_MARKER = 'np-statusline.';
20
21
  const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.';
22
+ const NP_SECURITY_MARKER = 'np-security-hook.';
23
+
24
+ // ADR-0020: in-session security review layer. One DRY hook script, registered
25
+ // against five Claude Code lifecycle events, differentiated by a trailing verb.
26
+ const SECURITY_HOOKS = Object.freeze([
27
+ { verb: 'session-start', event: 'SessionStart', matcher: undefined },
28
+ { verb: 'baseline', event: 'UserPromptSubmit', matcher: undefined },
29
+ { verb: 'scan', event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit|NotebookEdit' },
30
+ { verb: 'review', event: 'Stop', matcher: undefined },
31
+ { verb: 'commit', event: 'PostToolUse', matcher: 'Bash' },
32
+ ]);
33
+ const SECURITY_EVENTS = Object.freeze(['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse']);
21
34
 
22
35
  function _settingsPath(scope, projectRoot) {
23
36
  if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
@@ -101,6 +114,64 @@ function _installPostToolUse(settings, cmd) {
101
114
  return { action: 'installed' };
102
115
  }
103
116
 
117
+ function _verbOf(command) {
118
+ const m = String(command).match(/"\s+([a-z-]+)\s*$/);
119
+ return m ? m[1] : null;
120
+ }
121
+
122
+ function _installVerbHook(settings, eventName, matcher, cmd, verb) {
123
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
124
+ if (!Array.isArray(settings.hooks[eventName])) settings.hooks[eventName] = [];
125
+ const list = settings.hooks[eventName];
126
+ for (const entry of list) {
127
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
128
+ for (const h of hooks) {
129
+ if (h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER) && _verbOf(h.command) === verb) {
130
+ h.command = cmd;
131
+ h.type = 'command';
132
+ if (matcher !== undefined) entry.matcher = matcher;
133
+ return { action: 'updated' };
134
+ }
135
+ }
136
+ }
137
+ const entry = matcher !== undefined
138
+ ? { matcher, hooks: [{ type: 'command', command: cmd }] }
139
+ : { hooks: [{ type: 'command', command: cmd }] };
140
+ list.push(entry);
141
+ return { action: 'installed' };
142
+ }
143
+
144
+ function _installSecurity(settings, scope, projectRoot) {
145
+ const base = _hookCommand(SECURITY_HOOK_REL, scope, projectRoot);
146
+ const results = {};
147
+ for (const h of SECURITY_HOOKS) {
148
+ results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb);
149
+ }
150
+ return results;
151
+ }
152
+
153
+ function _removeSecurity(settings) {
154
+ if (!settings.hooks || typeof settings.hooks !== 'object') return { action: 'absent' };
155
+ let removed = 0;
156
+ for (const eventName of SECURITY_EVENTS) {
157
+ if (!Array.isArray(settings.hooks[eventName])) continue;
158
+ const filtered = [];
159
+ for (const entry of settings.hooks[eventName]) {
160
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
161
+ const kept = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_SECURITY_MARKER)));
162
+ if (kept.length > 0) {
163
+ filtered.push(kept.length === hooks.length ? entry : Object.assign({}, entry, { hooks: kept }));
164
+ } else {
165
+ removed++;
166
+ }
167
+ }
168
+ settings.hooks[eventName] = filtered;
169
+ if (filtered.length === 0) delete settings.hooks[eventName];
170
+ }
171
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
172
+ return { action: removed > 0 ? 'removed' : 'absent' };
173
+ }
174
+
104
175
  function _removeStatusLine(settings) {
105
176
  const existing = settings.statusLine;
106
177
  if (existing && typeof existing === 'object'
@@ -140,13 +211,19 @@ function installClaudeHooks(opts) {
140
211
  const which = o.which || 'both';
141
212
  const settingsPath = _settingsPath(scope, projectRoot);
142
213
 
214
+ const wantStatusline = which === 'statusline' || which === 'both' || which === 'all';
215
+ const wantCtxMonitor = which === 'ctx-monitor' || which === 'both' || which === 'all';
216
+ const wantSecurity = which === 'security' || which === 'all';
217
+
143
218
  const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
144
219
  const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
145
220
 
146
- const statuslineAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, STATUSLINE_REL);
147
- const ctxMonitorAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, CTX_MONITOR_REL);
221
+ const base = scope === 'global' ? os.homedir() : projectRoot;
222
+ const statuslineAbs = path.join(base, STATUSLINE_REL);
223
+ const ctxMonitorAbs = path.join(base, CTX_MONITOR_REL);
224
+ const securityAbs = path.join(base, SECURITY_HOOK_REL);
148
225
 
149
- if (which === 'statusline' || which === 'both') {
226
+ if (wantStatusline) {
150
227
  if (!fs.existsSync(statuslineAbs)) {
151
228
  throw new NubosPilotError(
152
229
  'claude-hooks-script-missing',
@@ -155,7 +232,7 @@ function installClaudeHooks(opts) {
155
232
  );
156
233
  }
157
234
  }
158
- if (which === 'ctx-monitor' || which === 'both') {
235
+ if (wantCtxMonitor) {
159
236
  if (!fs.existsSync(ctxMonitorAbs)) {
160
237
  throw new NubosPilotError(
161
238
  'claude-hooks-script-missing',
@@ -164,6 +241,15 @@ function installClaudeHooks(opts) {
164
241
  );
165
242
  }
166
243
  }
244
+ if (wantSecurity) {
245
+ if (!fs.existsSync(securityAbs)) {
246
+ throw new NubosPilotError(
247
+ 'claude-hooks-script-missing',
248
+ 'Security hook script not found: ' + securityAbs,
249
+ { script: securityAbs },
250
+ );
251
+ }
252
+ }
167
253
 
168
254
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
169
255
 
@@ -171,12 +257,15 @@ function installClaudeHooks(opts) {
171
257
  const settings = _readJsonSafe(settingsPath);
172
258
  const results = {};
173
259
 
174
- if (which === 'statusline' || which === 'both') {
260
+ if (wantStatusline) {
175
261
  results.statusline = _installStatusLine(settings, statuslineCmd, force);
176
262
  }
177
- if (which === 'ctx-monitor' || which === 'both') {
263
+ if (wantCtxMonitor) {
178
264
  results.ctxMonitor = _installPostToolUse(settings, ctxMonitorCmd);
179
265
  }
266
+ if (wantSecurity) {
267
+ results.security = _installSecurity(settings, scope, projectRoot);
268
+ }
180
269
 
181
270
  if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
182
271
 
@@ -190,13 +279,14 @@ function uninstallClaudeHooks(opts) {
190
279
  const projectRoot = o.projectRoot || process.cwd();
191
280
  const scope = o.scope === 'global' ? 'global' : 'local';
192
281
  const settingsPath = _settingsPath(scope, projectRoot);
193
- if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' } } };
282
+ if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' }, security: { action: 'absent' } } };
194
283
 
195
284
  return withFileLock(settingsPath, () => {
196
285
  const settings = _readJsonSafe(settingsPath);
197
286
  const results = {
198
287
  statusline: _removeStatusLine(settings),
199
288
  ctxMonitor: _removePostToolUse(settings),
289
+ security: _removeSecurity(settings),
200
290
  };
201
291
  if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
202
292
  atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
@@ -209,8 +299,11 @@ module.exports = {
209
299
  uninstallClaudeHooks,
210
300
  STATUSLINE_REL,
211
301
  CTX_MONITOR_REL,
302
+ SECURITY_HOOK_REL,
212
303
  NP_STATUSLINE_MARKER,
213
304
  NP_CTX_MONITOR_MARKER,
305
+ NP_SECURITY_MARKER,
306
+ SECURITY_HOOKS,
214
307
  _settingsPath,
215
308
  _hookCommand,
216
309
  };
@@ -225,3 +225,99 @@ test('claude-hooks: settings file with only whitespace is treated as empty', ()
225
225
  fs.rmSync(dir, { recursive: true, force: true });
226
226
  }
227
227
  });
228
+
229
+ function _mkSandboxAll() {
230
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-all-'));
231
+ const hooksDir = path.join(dir, '.claude', 'nubos-pilot', 'hooks');
232
+ fs.mkdirSync(hooksDir, { recursive: true });
233
+ for (const f of ['np-statusline.cjs', 'np-ctx-monitor.cjs', 'np-security-hook.cjs']) {
234
+ fs.writeFileSync(path.join(hooksDir, f), '// stub\n');
235
+ }
236
+ return dir;
237
+ }
238
+
239
+ test('claude-hooks SEC: which=all registers all five security lifecycle hooks', () => {
240
+ const dir = _mkSandboxAll();
241
+ try {
242
+ const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
243
+ const r = res.results.security;
244
+ assert.equal(r['session-start'].action, 'installed');
245
+ assert.equal(r.baseline.action, 'installed');
246
+ assert.equal(r.scan.action, 'installed');
247
+ assert.equal(r.review.action, 'installed');
248
+ assert.equal(r.commit.action, 'installed');
249
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
250
+ assert.equal(s.hooks.SessionStart[0].hooks[0].command.replace(/.*np-security-hook\.cjs"\s*/, '').trim(), 'session-start');
251
+ assert.equal(s.hooks.UserPromptSubmit[0].hooks[0].command.trim().endsWith('baseline'), true);
252
+ assert.equal(s.hooks.Stop[0].hooks[0].command.trim().endsWith('review'), true);
253
+ const ptu = s.hooks.PostToolUse;
254
+ const scan = ptu.find((e) => e.matcher === 'Edit|Write|MultiEdit|NotebookEdit');
255
+ const commit = ptu.find((e) => e.matcher === 'Bash');
256
+ assert.ok(scan && scan.hooks[0].command.trim().endsWith('scan'));
257
+ assert.ok(commit && commit.hooks[0].command.trim().endsWith('commit'));
258
+ assert.ok(ptu.find((e) => e.matcher === '.*'), 'ctx-monitor still present under all');
259
+ } finally {
260
+ fs.rmSync(dir, { recursive: true, force: true });
261
+ }
262
+ });
263
+
264
+ test('claude-hooks SEC: re-install is idempotent (no duplicate entries)', () => {
265
+ const dir = _mkSandboxAll();
266
+ try {
267
+ mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
268
+ const res2 = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
269
+ assert.equal(res2.results.security.scan.action, 'updated');
270
+ const s = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
271
+ assert.equal(s.hooks.SessionStart.length, 1);
272
+ assert.equal(s.hooks.Stop.length, 1);
273
+ assert.equal(s.hooks.PostToolUse.filter((e) => e.hooks[0].command.includes('np-security-hook.')).length, 2);
274
+ } finally {
275
+ fs.rmSync(dir, { recursive: true, force: true });
276
+ }
277
+ });
278
+
279
+ test('claude-hooks SEC: which=both does NOT install security hooks (legacy unchanged)', () => {
280
+ const dir = _mkSandboxAll();
281
+ try {
282
+ const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'both' });
283
+ assert.equal(res.results.security, undefined);
284
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
285
+ assert.ok(!s.hooks.SessionStart);
286
+ assert.ok(!s.hooks.Stop);
287
+ } finally {
288
+ fs.rmSync(dir, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('claude-hooks SEC: uninstall removes security hooks but preserves foreign ones', () => {
293
+ const dir = _mkSandboxAll();
294
+ try {
295
+ const settingsPath = path.join(dir, '.claude', 'settings.local.json');
296
+ fs.writeFileSync(settingsPath, JSON.stringify({
297
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo foreign-stop' }] }] },
298
+ }));
299
+ mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
300
+ const res = mod.uninstallClaudeHooks({ projectRoot: dir, scope: 'local' });
301
+ assert.equal(res.results.security.action, 'removed');
302
+ const s = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
303
+ assert.equal(s.hooks.Stop.length, 1);
304
+ assert.equal(s.hooks.Stop[0].hooks[0].command, 'echo foreign-stop');
305
+ assert.ok(!s.hooks.SessionStart, 'our SessionStart removed');
306
+ assert.ok(!s.hooks.PostToolUse || !s.hooks.PostToolUse.some((e) => e.hooks[0].command.includes('np-security-hook.')));
307
+ } finally {
308
+ fs.rmSync(dir, { recursive: true, force: true });
309
+ }
310
+ });
311
+
312
+ test('claude-hooks SEC: which=security missing script throws structured error', () => {
313
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-nosec-'));
314
+ fs.mkdirSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks'), { recursive: true });
315
+ try {
316
+ assert.throws(
317
+ () => mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'security' }),
318
+ (err) => err && err.code === 'claude-hooks-script-missing',
319
+ );
320
+ } finally {
321
+ fs.rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ });
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) {
package/lib/memory.cjs CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const crypto = require('node:crypto');
6
6
  const { atomicWriteFileSync, appendJsonl, withFileLockAsync, NubosPilotError, projectStateDir } = require('./core.cjs');
7
+ const { validate } = require('./validate.cjs');
7
8
 
8
9
  let _memLog;
9
10
  function _log() {
@@ -15,6 +16,19 @@ const TYPE_ENUM = new Set(['learning', 'handoff', 'critic', 'research']);
15
16
  const PROVENANCE_ENUM = new Set(['VERIFIED', 'CITED', 'ASSUMED', 'CACHED']);
16
17
  const SCHEMA_VERSION = 1;
17
18
 
19
+ const RECORD_SCHEMA = 'memory-record.v1';
20
+ const MANIFEST_SCHEMA = 'memory-manifest.v1';
21
+
22
+ const _RECORD_CODE_BY_FIELD = Object.freeze({
23
+ type: 'memory-invalid-type',
24
+ title: 'memory-missing-title',
25
+ body: 'memory-missing-body',
26
+ tags: 'memory-invalid-tags',
27
+ provenance: 'memory-invalid-provenance',
28
+ id: 'memory-invalid-id',
29
+ phase: 'memory-invalid-phase',
30
+ });
31
+
18
32
  function _memoryRoot(cwd) {
19
33
  return path.join(projectStateDir(cwd || process.cwd()), 'memory');
20
34
  }
@@ -24,38 +38,14 @@ function _indexPath(cwd) { return path.join(_memoryRoot(cwd), 'index.usearch');
24
38
  function _manifestPath(cwd) { return path.join(_memoryRoot(cwd), 'manifest.json'); }
25
39
 
26
40
  function _validateRecord(record) {
27
- if (!record || typeof record !== 'object') {
41
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
28
42
  throw new NubosPilotError('memory-invalid-record', 'record must be object', {});
29
43
  }
30
- if (typeof record.type !== 'string' || !TYPE_ENUM.has(record.type)) {
31
- throw new NubosPilotError(
32
- 'memory-invalid-type',
33
- `type must be one of [${[...TYPE_ENUM].join(', ')}]`,
34
- { type: record.type },
35
- );
36
- }
37
- if (typeof record.title !== 'string' || record.title.length === 0) {
38
- throw new NubosPilotError('memory-missing-title', 'title required as non-empty string', {});
39
- }
40
- if (typeof record.body !== 'string') {
41
- throw new NubosPilotError('memory-missing-body', 'body required as string', { type: typeof record.body });
42
- }
43
- if (record.tags !== undefined && record.tags !== null && !Array.isArray(record.tags)) {
44
- throw new NubosPilotError('memory-invalid-tags', 'tags must be array of strings or null', {});
45
- }
46
- if (record.provenance !== undefined && record.provenance !== null && !PROVENANCE_ENUM.has(record.provenance)) {
47
- throw new NubosPilotError(
48
- 'memory-invalid-provenance',
49
- `provenance must be one of [${[...PROVENANCE_ENUM].join(', ')}]`,
50
- { provenance: record.provenance },
51
- );
52
- }
53
- if (record.id !== undefined && record.id !== null && typeof record.id !== 'string') {
54
- throw new NubosPilotError('memory-invalid-id', 'id must be string or null', { id: record.id });
55
- }
56
- if (record.phase !== undefined && record.phase !== null && typeof record.phase !== 'string') {
57
- throw new NubosPilotError('memory-invalid-phase', 'phase must be string or null', { phase: record.phase });
58
- }
44
+ const errors = validate(record, RECORD_SCHEMA);
45
+ if (errors.length === 0) return;
46
+ const first = errors[0];
47
+ const code = _RECORD_CODE_BY_FIELD[first.field] || 'memory-invalid-record';
48
+ throw new NubosPilotError(code, first.message, { field: first.field, value: record[first.field] });
59
49
  }
60
50
 
61
51
  function _readRecordsJsonlWithStats(cwd) {
@@ -66,8 +56,11 @@ function _readRecordsJsonlWithStats(cwd) {
66
56
  let skipped = 0;
67
57
  for (const line of raw.split('\n')) {
68
58
  if (!line.trim()) continue;
69
- try { records.push(JSON.parse(line)); }
70
- catch { skipped += 1; }
59
+ let rec;
60
+ try { rec = JSON.parse(line); }
61
+ catch { skipped += 1; continue; }
62
+ if (validate(rec, RECORD_SCHEMA).length > 0) { skipped += 1; continue; }
63
+ records.push(rec);
71
64
  }
72
65
  if (skipped > 0) {
73
66
  _log().warn('skipped corrupt records.jsonl lines', {
@@ -92,7 +85,19 @@ function _writeManifest(cwd, manifest) {
92
85
  function _readManifest(cwd) {
93
86
  const p = _manifestPath(cwd);
94
87
  if (!fs.existsSync(p)) return null;
95
- try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
88
+ let obj;
89
+ try { obj = JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
90
+ const errors = validate(obj, MANIFEST_SCHEMA);
91
+ if (errors.length > 0) {
92
+ _log().warn('ignoring corrupt memory manifest', {
93
+ event: 'memory-manifest-corrupt',
94
+ file: 'manifest.json',
95
+ violation: errors[0].message,
96
+ hint: 'manifest will be rewritten on next index/rebuild; run `memory-rebuild` if model/dim changed',
97
+ });
98
+ return null;
99
+ }
100
+ return obj;
96
101
  }
97
102
 
98
103
  function _embeddingText(record) {
package/lib/messaging.cjs CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const crypto = require('node:crypto');
6
6
  const { atomicWriteFileSync, atomicCreateExclusiveSync, fsyncDir, appendJsonl, withFileLock, NubosPilotError, projectStateDir } = require('./core.cjs');
7
+ const { validate, assertValid } = require('./validate.cjs');
7
8
 
8
9
  function _runId() {
9
10
  try { return require('./run-context.cjs').getRunId(); }
@@ -13,6 +14,8 @@ function _runId() {
13
14
  const KIND_ENUM = new Set(['request', 'response', 'notify']);
14
15
  const AGENT_RE = /^[a-zA-Z0-9_-]+$/;
15
16
  const ID_RE = /^\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
17
+ const MAX_BODY_BYTES = 256 * 1024;
18
+ const MESSAGE_SCHEMA = 'message.v1';
16
19
 
17
20
  function _messagesRoot(cwd) {
18
21
  return path.join(projectStateDir(cwd || process.cwd()), 'messages');
@@ -81,11 +84,11 @@ function _appendManifest(cwd, event) {
81
84
  function _readMessageFile(filePath) {
82
85
  let raw;
83
86
  try { raw = fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
84
- try {
85
- const parsed = JSON.parse(raw);
86
- Object.defineProperty(parsed, '__path', { value: filePath, enumerable: false });
87
- return parsed;
88
- } catch { return null; }
87
+ let parsed;
88
+ try { parsed = JSON.parse(raw); } catch { return null; }
89
+ if (validate(parsed, MESSAGE_SCHEMA).length > 0) return null;
90
+ Object.defineProperty(parsed, '__path', { value: filePath, enumerable: false });
91
+ return parsed;
89
92
  }
90
93
 
91
94
  function _scanDir(dir) {
@@ -118,7 +121,6 @@ function send(opts, cwd) {
118
121
  if (typeof o.body !== 'string') {
119
122
  throw new NubosPilotError('messages-missing-body', 'body required (string, may be empty)', {});
120
123
  }
121
- const MAX_BODY_BYTES = 256 * 1024;
122
124
  if (Buffer.byteLength(o.body, 'utf-8') > MAX_BODY_BYTES) {
123
125
  throw new NubosPilotError(
124
126
  'messages-body-too-large',
@@ -157,6 +159,7 @@ function send(opts, cwd) {
157
159
  in_reply_to: o.in_reply_to || null,
158
160
  created_at: createdAt,
159
161
  };
162
+ assertValid(message, MESSAGE_SCHEMA, 'messages-invalid-record', { id });
160
163
 
161
164
  const workingDir = cwd || process.cwd();
162
165
  const dir = _inboxDir(workingDir, o.to);
@@ -472,4 +475,7 @@ module.exports = {
472
475
  pendingReplies,
473
476
  sweepTaskOnCommit,
474
477
  KIND_ENUM,
478
+ AGENT_RE,
479
+ ID_RE,
480
+ MAX_BODY_BYTES,
475
481
  };
@@ -5,6 +5,9 @@ const path = require('node:path');
5
5
  const readline = require('node:readline');
6
6
  const { NubosPilotError, findProjectRoot } = require('./core.cjs');
7
7
  const { SAFE_PHASE_RE } = require('./metrics.cjs');
8
+ const { validate } = require('./validate.cjs');
9
+
10
+ const RECORD_SCHEMA = 'metrics-record.v1';
8
11
 
9
12
  let _maLog;
10
13
  function _log() {
@@ -41,15 +44,24 @@ function _readJsonlLines(filePath, onRecord) {
41
44
  rl.on('line', (raw) => {
42
45
  const line = String(raw).trim();
43
46
  if (!line) return;
47
+ let rec;
44
48
  try {
45
- const rec = JSON.parse(line);
46
- onRecord(rec);
49
+ rec = JSON.parse(line);
47
50
  } catch (_err) {
48
51
  _log().warn('skipping malformed JSONL line', {
49
52
  event: 'metrics-aggregate-malformed-line',
50
53
  file: require('node:path').basename(filePath),
51
54
  });
55
+ return;
56
+ }
57
+ if (validate(rec, RECORD_SCHEMA).length > 0) {
58
+ _log().warn('skipping schema-invalid JSONL line', {
59
+ event: 'metrics-aggregate-invalid-line',
60
+ file: require('node:path').basename(filePath),
61
+ });
62
+ return;
52
63
  }
64
+ onRecord(rec);
53
65
  });
54
66
  rl.on('close', () => resolve());
55
67
  rl.on('error', (err) => reject(err));
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const { assertValid } = require('./validate.cjs');
4
+
5
+ const MAX_HOPS = 100;
6
+
7
+ function runMigrators(obj, opts) {
8
+ const o = opts || {};
9
+ const versionField = o.versionField || 'version';
10
+ const target = o.targetVersion;
11
+ const migrators = o.migrators || {};
12
+ let cur = obj;
13
+ let hops = 0;
14
+ while (cur && typeof cur === 'object' && !Array.isArray(cur) && cur[versionField] !== target) {
15
+ if (hops >= MAX_HOPS) return null;
16
+ hops += 1;
17
+ const key = cur[versionField];
18
+ if (!Object.prototype.hasOwnProperty.call(migrators, key)) return null;
19
+ const fn = migrators[key];
20
+ if (typeof fn !== 'function') return null;
21
+ cur = fn(cur);
22
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return null;
23
+ }
24
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return null;
25
+ if (o.schema) assertValid(cur, o.schema, o.code || 'data-migration-invalid', o.details || {});
26
+ return cur;
27
+ }
28
+
29
+ module.exports = { runMigrators, MAX_HOPS };