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.
- package/CHANGELOG.md +33 -1
- package/agents/np-executor.md +20 -0
- package/agents/np-security-reviewer.md +49 -3
- package/bin/install.js +7 -2
- package/bin/np-tools/_commands.cjs +2 -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/scan-codebase.cjs +21 -1
- package/bin/np-tools/security.cjs +177 -0
- package/bin/np-tools/security.test.cjs +82 -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 +23 -0
- package/lib/config-defaults.test.cjs +15 -0
- package/lib/config-schema.cjs +19 -0
- package/lib/config-schema.test.cjs +58 -0
- package/lib/install/claude-hooks.cjs +100 -7
- package/lib/install/claude-hooks.test.cjs +96 -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/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/security/ledger.cjs +203 -0
- package/lib/security/ledger.test.cjs +139 -0
- package/lib/security/patterns.cjs +119 -0
- package/lib/security/review.cjs +220 -0
- package/lib/security/review.test.cjs +143 -0
- package/lib/security/scan.cjs +180 -0
- package/lib/security/scan.test.cjs +137 -0
- package/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +2 -0
- package/package.json +3 -1
- package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
- 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
|
|
147
|
-
const
|
|
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 (
|
|
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 (
|
|
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 (
|
|
260
|
+
if (wantStatusline) {
|
|
175
261
|
results.statusline = _installStatusLine(settings, statuslineCmd, force);
|
|
176
262
|
}
|
|
177
|
-
if (
|
|
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
|
-
|
|
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) {
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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));
|
package/lib/migrate.cjs
ADDED
|
@@ -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 };
|