nubos-pilot 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -1
- package/agents/np-architect.md +2 -0
- package/agents/np-executor.md +1 -1
- package/agents/np-learnings-extractor.md +54 -0
- package/agents/np-planner.md +1 -1
- package/agents/np-security-reviewer.md +9 -0
- package/bin/np-tools/_commands.cjs +5 -0
- package/bin/np-tools/derive-tier.cjs +86 -0
- package/bin/np-tools/derive-tier.test.cjs +83 -0
- package/bin/np-tools/doctor.cjs +15 -2
- package/bin/np-tools/graph-impact.cjs +111 -0
- package/bin/np-tools/graph-impact.test.cjs +119 -0
- package/bin/np-tools/learnings.cjs +105 -0
- package/bin/np-tools/learnings.test.cjs +66 -0
- package/bin/np-tools/loop-run-round.cjs +7 -1
- package/bin/np-tools/scan-codebase.cjs +21 -1
- package/bin/np-tools/skill-audit.cjs +79 -0
- package/bin/np-tools/skill-audit.test.cjs +86 -0
- package/bin/np-tools/verify-reliability.cjs +65 -0
- package/bin/np-tools/verify-reliability.test.cjs +69 -0
- package/lib/agents.test.cjs +1 -0
- package/lib/checkpoint.cjs +3 -0
- package/lib/codebase-graph.cjs +0 -0
- package/lib/codebase-graph.test.cjs +174 -0
- package/lib/codebase-manifest.cjs +3 -0
- package/lib/config-defaults.cjs +13 -0
- package/lib/config-schema.cjs +11 -0
- package/lib/eval-reliability.cjs +63 -0
- package/lib/eval-reliability.test.cjs +56 -0
- package/lib/install/claude-hooks-learnings.test.cjs +82 -0
- package/lib/install/claude-hooks.cjs +65 -4
- package/lib/install/claude-hooks.test.cjs +5 -2
- package/lib/learnings/capture-ledger.cjs +80 -0
- package/lib/learnings/capture-ledger.test.cjs +54 -0
- package/lib/learnings/extract.cjs +191 -0
- package/lib/learnings/extract.test.cjs +115 -0
- package/lib/learnings.cjs +19 -95
- package/lib/memory.cjs +38 -33
- package/lib/messaging.cjs +12 -6
- package/lib/metrics-aggregate.cjs +14 -2
- package/lib/migrate.cjs +29 -0
- package/lib/migrate.test.cjs +91 -0
- package/lib/nubosloop-audit.cjs +104 -0
- package/lib/nubosloop-skill-audit.test.cjs +98 -0
- package/lib/nubosloop.cjs +9 -0
- package/lib/schemas/data/checkpoint.v1.json +13 -0
- package/lib/schemas/data/codebase-manifest.v1.json +22 -0
- package/lib/schemas/data/learnings.v1.json +28 -0
- package/lib/schemas/data/memory-manifest.v1.json +14 -0
- package/lib/schemas/data/memory-record.v1.json +16 -0
- package/lib/schemas/data/message.v1.json +19 -0
- package/lib/schemas/data/metrics-record.v1.json +11 -0
- package/lib/tier-classify.cjs +67 -0
- package/lib/tier-classify.test.cjs +67 -0
- package/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +5 -0
- package/package.json +3 -1
- package/skills/np-access-control/SKILL.md +42 -0
- package/skills/np-accessibility-audit/SKILL.md +41 -0
- package/skills/np-adr/SKILL.md +37 -0
- package/skills/np-api-design/SKILL.md +34 -0
- package/skills/np-caching-strategy/SKILL.md +38 -0
- package/skills/np-data-modeling/SKILL.md +37 -0
- package/skills/np-data-privacy/SKILL.md +39 -0
- package/skills/np-dependency-audit/SKILL.md +47 -0
- package/skills/np-encryption/SKILL.md +47 -0
- package/skills/np-error-handling/SKILL.md +37 -0
- package/skills/np-incident-response/SKILL.md +38 -0
- package/skills/np-llm-app-architecture/SKILL.md +50 -0
- package/skills/np-observability/SKILL.md +39 -0
- package/skills/np-performance/SKILL.md +38 -0
- package/skills/np-queue-design/SKILL.md +32 -0
- package/skills/np-rag-design/SKILL.md +43 -0
- package/skills/np-refactoring/SKILL.md +35 -0
- package/skills/np-resilience-patterns/SKILL.md +39 -0
- package/skills/np-secure-code-review/SKILL.md +46 -0
- package/skills/np-secure-design/SKILL.md +44 -0
- package/skills/np-service-boundary/SKILL.md +35 -0
- package/skills/np-system-design/SKILL.md +40 -0
- package/skills/np-test-strategy/SKILL.md +46 -0
- package/skills/np-threat-model/SKILL.md +42 -0
- package/templates/claude/payload/hooks/np-learnings-hook.cjs +55 -0
- package/workflows/architect-phase.md +21 -1
- package/workflows/execute-phase.md +66 -4
- package/workflows/verify-work.md +17 -4
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 };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { runMigrators, MAX_HOPS } = require('./migrate.cjs');
|
|
7
|
+
const { NubosPilotError } = require('./core.cjs');
|
|
8
|
+
|
|
9
|
+
test('MIG-1: object already at target version is returned unchanged', () => {
|
|
10
|
+
const obj = { version: 2, payload: 'x' };
|
|
11
|
+
const out = runMigrators(obj, { targetVersion: 2, migrators: {} });
|
|
12
|
+
assert.equal(out, obj);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('MIG-2: single hop runs the matching migrator', () => {
|
|
16
|
+
const out = runMigrators({ version: 1, n: 1 }, {
|
|
17
|
+
targetVersion: 2,
|
|
18
|
+
migrators: { 1: (v) => ({ version: 2, n: v.n + 1 }) },
|
|
19
|
+
});
|
|
20
|
+
assert.deepEqual(out, { version: 2, n: 2 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('MIG-3: multi-hop chain runs each hop until target', () => {
|
|
24
|
+
const out = runMigrators({ version: 0, steps: [] }, {
|
|
25
|
+
targetVersion: 3,
|
|
26
|
+
migrators: {
|
|
27
|
+
0: (v) => ({ version: 1, steps: [...v.steps, 'a'] }),
|
|
28
|
+
1: (v) => ({ version: 2, steps: [...v.steps, 'b'] }),
|
|
29
|
+
2: (v) => ({ version: 3, steps: [...v.steps, 'c'] }),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(out.steps, ['a', 'b', 'c']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('MIG-4: missing migrator for a version returns null', () => {
|
|
36
|
+
const out = runMigrators({ version: 99 }, { targetVersion: 1, migrators: {} });
|
|
37
|
+
assert.equal(out, null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('MIG-5: migrator returning a non-object returns null', () => {
|
|
41
|
+
const out = runMigrators({ version: 0 }, { targetVersion: 1, migrators: { 0: () => null } });
|
|
42
|
+
assert.equal(out, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('MIG-6: hop cap prevents an infinite migrator cycle', () => {
|
|
46
|
+
const out = runMigrators({ version: 0 }, {
|
|
47
|
+
targetVersion: 999,
|
|
48
|
+
migrators: { 0: () => ({ version: 0 }) },
|
|
49
|
+
});
|
|
50
|
+
assert.equal(out, null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('MIG-7: pure function — input object is not mutated', () => {
|
|
54
|
+
const input = { version: 0, n: 1 };
|
|
55
|
+
runMigrators(input, { targetVersion: 1, migrators: { 0: (v) => ({ version: 1, n: v.n + 1 }) } });
|
|
56
|
+
assert.deepEqual(input, { version: 0, n: 1 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('MIG-8: migrated shape is validated against the given schema', () => {
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => runMigrators({ version: 0, learnings: [] }, {
|
|
62
|
+
targetVersion: 1,
|
|
63
|
+
migrators: { 0: () => ({ version: 1, learnings: [{ fingerprint: 'NOTHEX', occurrence: 1 }] }) },
|
|
64
|
+
schema: 'learnings.v1',
|
|
65
|
+
code: 'learnings-store-corrupt',
|
|
66
|
+
details: { path: '<test>' },
|
|
67
|
+
}),
|
|
68
|
+
(err) => err instanceof NubosPilotError && err.code === 'learnings-store-corrupt',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('MIG-9: custom versionField is honoured', () => {
|
|
73
|
+
const out = runMigrators({ schema_version: 1, ok: true }, {
|
|
74
|
+
versionField: 'schema_version',
|
|
75
|
+
targetVersion: 1,
|
|
76
|
+
migrators: {},
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(out, { schema_version: 1, ok: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('MIG-10: MAX_HOPS is a finite positive cap', () => {
|
|
82
|
+
assert.ok(Number.isInteger(MAX_HOPS) && MAX_HOPS > 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('MIG-11: a version matching an Object.prototype member returns null, not a raw throw', () => {
|
|
86
|
+
for (const poison of ['valueOf', 'hasOwnProperty', 'toString', 'constructor', '__proto__']) {
|
|
87
|
+
let out;
|
|
88
|
+
assert.doesNotThrow(() => { out = runMigrators({ version: poison }, { targetVersion: 1, migrators: {} }); });
|
|
89
|
+
assert.equal(out, null);
|
|
90
|
+
}
|
|
91
|
+
});
|
package/lib/nubosloop-audit.cjs
CHANGED
|
@@ -88,6 +88,106 @@ function searchEvidenceForRound(taskId, round, cwd) {
|
|
|
88
88
|
return evidence.filter((e) => e && (Number(e.round) || 1) === target);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ── Skill-bar consultation evidence (additive; mirrors search-evidence) ──────
|
|
92
|
+
// The orchestrator records the skills it injected for a task (`recordExpectedSkills`);
|
|
93
|
+
// the executor stamps each skill it actually consulted (`recordSkillEvidence`, via
|
|
94
|
+
// `skill-audit ack`). skillFindingsFromState turns an unmet expectation into a
|
|
95
|
+
// `skill-bar-unconsulted` finding (ROUTE_TABLE → executor), round-stamped and
|
|
96
|
+
// emitted at most once per round via `skill_routed_rounds` — same anti-re-route
|
|
97
|
+
// guarantee as the Rule-9 path. The Rule-9 functions below are left untouched.
|
|
98
|
+
|
|
99
|
+
function _normSkillName(s) {
|
|
100
|
+
const v = String(s || '').trim();
|
|
101
|
+
// A path like `.claude/skills/<skill>/SKILL.md` names the skill by its directory.
|
|
102
|
+
const dir = v.match(/([^/]+)\/SKILL\.md$/i);
|
|
103
|
+
if (dir) return dir[1];
|
|
104
|
+
// Otherwise a bare name (optionally with a stray .md): take the basename.
|
|
105
|
+
return v.replace(/^.*\//, '').replace(/\.md$/i, '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function recordSkillEvidence(taskId, skill, cwd) {
|
|
109
|
+
if (!TASK_ID_RE.test(taskId)) return null;
|
|
110
|
+
const name = _normSkillName(skill);
|
|
111
|
+
if (!name) return null;
|
|
112
|
+
let stampedRound = 1;
|
|
113
|
+
checkpoint.mergeCheckpoint(
|
|
114
|
+
taskId,
|
|
115
|
+
(cur) => {
|
|
116
|
+
const prev = (cur && cur.nubosloop) || {};
|
|
117
|
+
stampedRound = Number(prev.round) || 1;
|
|
118
|
+
const evidence = Array.isArray(prev.skill_evidence) ? prev.skill_evidence.slice() : [];
|
|
119
|
+
evidence.push({ round: stampedRound, skill: name, recorded_at: new Date().toISOString() });
|
|
120
|
+
return { nubosloop: safeAssign({}, prev, { skill_evidence: evidence }) };
|
|
121
|
+
},
|
|
122
|
+
cwd,
|
|
123
|
+
);
|
|
124
|
+
return { task_id: taskId, round: stampedRound, skill: name };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recordExpectedSkills(taskId, skills, cwd) {
|
|
128
|
+
if (!TASK_ID_RE.test(taskId)) return null;
|
|
129
|
+
const names = (Array.isArray(skills) ? skills : []).map(_normSkillName).filter(Boolean);
|
|
130
|
+
if (names.length === 0) return { task_id: taskId, expected: [] };
|
|
131
|
+
let stampedRound = 1;
|
|
132
|
+
checkpoint.mergeCheckpoint(
|
|
133
|
+
taskId,
|
|
134
|
+
(cur) => {
|
|
135
|
+
const prev = (cur && cur.nubosloop) || {};
|
|
136
|
+
stampedRound = Number(prev.round) || 1;
|
|
137
|
+
const expect = Array.isArray(prev.skill_expect) ? prev.skill_expect.slice() : [];
|
|
138
|
+
expect.push({ round: stampedRound, skills: names, recorded_at: new Date().toISOString() });
|
|
139
|
+
return { nubosloop: safeAssign({}, prev, { skill_expect: expect }) };
|
|
140
|
+
},
|
|
141
|
+
cwd,
|
|
142
|
+
);
|
|
143
|
+
return { task_id: taskId, round: stampedRound, expected: names };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _collectForRound(listVal, round, key) {
|
|
147
|
+
const out = [];
|
|
148
|
+
if (!Array.isArray(listVal)) return out;
|
|
149
|
+
for (const e of listVal) {
|
|
150
|
+
if (!e || (Number(e.round) || 1) !== round) continue;
|
|
151
|
+
const v = e[key];
|
|
152
|
+
if (Array.isArray(v)) out.push(...v);
|
|
153
|
+
else if (v) out.push(v);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pure: derive skill-bar findings from a checkpoint's nubosloop sub-object.
|
|
159
|
+
function skillFindingsFromState(prevNubosloop, round, taskId) {
|
|
160
|
+
const prev = prevNubosloop || {};
|
|
161
|
+
const t = Number(round);
|
|
162
|
+
if (!Number.isFinite(t) || t < 1) return [];
|
|
163
|
+
const routed = Array.isArray(prev.skill_routed_rounds) ? prev.skill_routed_rounds : [];
|
|
164
|
+
if (routed.includes(t)) return [];
|
|
165
|
+
const expected = Array.from(new Set(_collectForRound(prev.skill_expect, t, 'skills')));
|
|
166
|
+
if (expected.length === 0) return [];
|
|
167
|
+
const acked = new Set(_collectForRound(prev.skill_evidence, t, 'skill'));
|
|
168
|
+
const missing = expected.filter((s) => !acked.has(s));
|
|
169
|
+
if (missing.length === 0) return [];
|
|
170
|
+
return [{
|
|
171
|
+
category: 'skill-bar-unconsulted',
|
|
172
|
+
severity: 'fail',
|
|
173
|
+
file: '-',
|
|
174
|
+
line: null,
|
|
175
|
+
remediation: 'Spawn was given Nubos skills as the quality bar for this task but did not consult '
|
|
176
|
+
+ (missing.length === 1 ? 'it' : 'them') + ': [' + missing.join(', ') + ']. For each, `Read` '
|
|
177
|
+
+ '`.claude/skills/<skill>/SKILL.md`, satisfy its "Verification bar" in the diff, then stamp '
|
|
178
|
+
+ '`node np-tools.cjs skill-audit ack --task ' + taskId + ' --skill <skill>` — before editing.',
|
|
179
|
+
raw: { missing_skills: missing, expected_skills: expected },
|
|
180
|
+
}];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function markSkillFindingsRoutedInArray(routedRounds, round) {
|
|
184
|
+
const t = Number(round);
|
|
185
|
+
const arr = Array.isArray(routedRounds) ? routedRounds.slice() : [];
|
|
186
|
+
if (!Number.isFinite(t) || t < 1 || arr.includes(t)) return arr;
|
|
187
|
+
arr.push(t);
|
|
188
|
+
return arr;
|
|
189
|
+
}
|
|
190
|
+
|
|
91
191
|
function auditToolUse(taskId, agent, toolUseLog, cwd) {
|
|
92
192
|
if (!TASK_ID_RE.test(taskId)) {
|
|
93
193
|
throw new NubosPilotError(
|
|
@@ -232,6 +332,10 @@ module.exports = {
|
|
|
232
332
|
auditToolUse,
|
|
233
333
|
recordSearchEvidence,
|
|
234
334
|
searchEvidenceForRound,
|
|
335
|
+
recordSkillEvidence,
|
|
336
|
+
recordExpectedSkills,
|
|
337
|
+
skillFindingsFromState,
|
|
338
|
+
markSkillFindingsRoutedInArray,
|
|
235
339
|
readToolUseAudit,
|
|
236
340
|
auditFindingsForRound,
|
|
237
341
|
auditFindingsFromAudits,
|
|
@@ -0,0 +1,98 @@
|
|
|
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 loop = require('./nubosloop.cjs');
|
|
9
|
+
const checkpoint = require('./checkpoint.cjs');
|
|
10
|
+
|
|
11
|
+
function _mkRoot() {
|
|
12
|
+
const r = fs.mkdtempSync(path.join(os.tmpdir(), 'np-skill-audit-'));
|
|
13
|
+
fs.mkdirSync(path.join(r, '.nubos-pilot', 'checkpoints'), { recursive: true });
|
|
14
|
+
fs.writeFileSync(
|
|
15
|
+
path.join(r, '.nubos-pilot', 'STATE.md'),
|
|
16
|
+
'---\nschema_version: 2\ncurrent_phase: null\ncurrent_plan: null\ncurrent_task: null\n---\n',
|
|
17
|
+
'utf-8',
|
|
18
|
+
);
|
|
19
|
+
return r;
|
|
20
|
+
}
|
|
21
|
+
const TID = 'M001-S001-T0001';
|
|
22
|
+
function _nubosloop(r) { return (checkpoint.readCheckpoint(TID, r) || {}).nubosloop || {}; }
|
|
23
|
+
|
|
24
|
+
test('SA-1: expected-but-unacked skill → skill-bar-unconsulted finding', () => {
|
|
25
|
+
const r = _mkRoot();
|
|
26
|
+
try {
|
|
27
|
+
checkpoint.startTask({ id: TID }, r);
|
|
28
|
+
loop.recordExpectedSkills(TID, ['np-secure-code-review', 'np-api-design'], r);
|
|
29
|
+
loop.recordSkillEvidence(TID, 'np-api-design', r); // only one acked
|
|
30
|
+
const findings = loop.skillFindingsFromState(_nubosloop(r), 1, TID);
|
|
31
|
+
assert.equal(findings.length, 1);
|
|
32
|
+
assert.equal(findings[0].category, 'skill-bar-unconsulted');
|
|
33
|
+
assert.deepEqual(findings[0].raw.missing_skills, ['np-secure-code-review']);
|
|
34
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('SA-2: all expected skills acked → no finding', () => {
|
|
38
|
+
const r = _mkRoot();
|
|
39
|
+
try {
|
|
40
|
+
checkpoint.startTask({ id: TID }, r);
|
|
41
|
+
loop.recordExpectedSkills(TID, ['np-api-design'], r);
|
|
42
|
+
loop.recordSkillEvidence(TID, 'np-api-design', r);
|
|
43
|
+
assert.equal(loop.skillFindingsFromState(_nubosloop(r), 1, TID).length, 0);
|
|
44
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('SA-3: no expected skills → no finding (skill block was correctly omitted)', () => {
|
|
48
|
+
const r = _mkRoot();
|
|
49
|
+
try {
|
|
50
|
+
checkpoint.startTask({ id: TID }, r);
|
|
51
|
+
assert.equal(loop.skillFindingsFromState(_nubosloop(r), 1, TID).length, 0);
|
|
52
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('SA-4: ack tolerates a SKILL.md path, not just a bare name', () => {
|
|
56
|
+
const r = _mkRoot();
|
|
57
|
+
try {
|
|
58
|
+
checkpoint.startTask({ id: TID }, r);
|
|
59
|
+
loop.recordExpectedSkills(TID, ['np-encryption'], r);
|
|
60
|
+
loop.recordSkillEvidence(TID, '.claude/skills/np-encryption/SKILL.md', r);
|
|
61
|
+
assert.equal(loop.skillFindingsFromState(_nubosloop(r), 1, TID).length, 0);
|
|
62
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('SA-5: routed round is not re-emitted (anti-spurious-loop)', () => {
|
|
66
|
+
const r = _mkRoot();
|
|
67
|
+
try {
|
|
68
|
+
checkpoint.startTask({ id: TID }, r);
|
|
69
|
+
loop.recordExpectedSkills(TID, ['np-secure-code-review'], r);
|
|
70
|
+
assert.equal(loop.skillFindingsFromState(_nubosloop(r), 1, TID).length, 1);
|
|
71
|
+
// simulate the loop marking round 1 routed
|
|
72
|
+
checkpoint.mergeCheckpoint(TID, (cur) => {
|
|
73
|
+
const prev = (cur && cur.nubosloop) || {};
|
|
74
|
+
return { nubosloop: Object.assign({}, prev, { skill_routed_rounds: [1] }) };
|
|
75
|
+
}, r);
|
|
76
|
+
assert.equal(loop.skillFindingsFromState(_nubosloop(r), 1, TID).length, 0);
|
|
77
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('SA-6: a skill finding routes to executor, never stuck (ROUTE_TABLE wired)', () => {
|
|
81
|
+
const r = _mkRoot();
|
|
82
|
+
try {
|
|
83
|
+
checkpoint.startTask({ id: TID }, r);
|
|
84
|
+
loop.recordExpectedSkills(TID, ['np-secure-code-review'], r);
|
|
85
|
+
const findings = loop.skillFindingsFromState(_nubosloop(r), 1, TID);
|
|
86
|
+
const evalRes = loop.evaluateLoop({ round: 1 }, [], { maxRounds: 3, auditFindings: findings });
|
|
87
|
+
assert.equal(evalRes.next_action, 'executor');
|
|
88
|
+
assert.equal(evalRes.stuck, false);
|
|
89
|
+
// and the merged finding kept its category (not downgraded to unknown→stuck)
|
|
90
|
+
assert.ok(evalRes.findings.some((f) => f.category === 'skill-bar-unconsulted' && f.route === 'executor'));
|
|
91
|
+
} finally { fs.rmSync(r, { recursive: true, force: true }); }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('SA-7: markSkillFindingsRoutedInArray is idempotent', () => {
|
|
95
|
+
assert.deepEqual(loop.markSkillFindingsRoutedInArray([], 1), [1]);
|
|
96
|
+
assert.deepEqual(loop.markSkillFindingsRoutedInArray([1], 1), [1]);
|
|
97
|
+
assert.deepEqual(loop.markSkillFindingsRoutedInArray([1], 2), [1, 2]);
|
|
98
|
+
});
|
package/lib/nubosloop.cjs
CHANGED
|
@@ -24,6 +24,7 @@ const ROUTE_TABLE = {
|
|
|
24
24
|
'lint-violation': 'executor',
|
|
25
25
|
'critic-error': 'stuck',
|
|
26
26
|
'rule-9-violation': 'executor',
|
|
27
|
+
'skill-bar-unconsulted': 'executor',
|
|
27
28
|
'missing-test': 'executor',
|
|
28
29
|
'edge-case-gap': 'executor',
|
|
29
30
|
'weak-assertion': 'executor',
|
|
@@ -311,6 +312,10 @@ const {
|
|
|
311
312
|
auditToolUse,
|
|
312
313
|
recordSearchEvidence,
|
|
313
314
|
searchEvidenceForRound,
|
|
315
|
+
recordSkillEvidence,
|
|
316
|
+
recordExpectedSkills,
|
|
317
|
+
skillFindingsFromState,
|
|
318
|
+
markSkillFindingsRoutedInArray,
|
|
314
319
|
readToolUseAudit,
|
|
315
320
|
auditFindingsForRound,
|
|
316
321
|
auditFindingsFromAudits,
|
|
@@ -502,6 +507,10 @@ module.exports = {
|
|
|
502
507
|
auditToolUse,
|
|
503
508
|
recordSearchEvidence,
|
|
504
509
|
searchEvidenceForRound,
|
|
510
|
+
recordSkillEvidence,
|
|
511
|
+
recordExpectedSkills,
|
|
512
|
+
skillFindingsFromState,
|
|
513
|
+
markSkillFindingsRoutedInArray,
|
|
505
514
|
readToolUseAudit,
|
|
506
515
|
auditFindingsForRound,
|
|
507
516
|
auditFindingsFromAudits,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "checkpoint.v1",
|
|
3
|
+
"title": "Task checkpoint (checkpoints/<task_id>.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"task_id": { "type": "string" },
|
|
9
|
+
"status": { "type": "string" },
|
|
10
|
+
"files_touched": { "type": "array" },
|
|
11
|
+
"nubosloop": { "type": "object" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "codebase-manifest.v1",
|
|
3
|
+
"title": "Codebase hash manifest (codebase/.hashes.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"generated_at": { "type": ["string", "null"] },
|
|
9
|
+
"files": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["sha256"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"sha256": { "type": "string" },
|
|
16
|
+
"size": { "type": "integer", "minimum": 0 },
|
|
17
|
+
"ext": { "type": "string" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "learnings.v1",
|
|
3
|
+
"title": "Learnings store (knowledge/learnings.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["version", "learnings"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"learnings": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["fingerprint", "pattern", "outcome", "occurrence", "first_seen", "last_seen"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"fingerprint": { "type": "string", "pattern": "^[a-f0-9]{16}$" },
|
|
15
|
+
"pattern": { "type": "string", "maxBytes": 4096 },
|
|
16
|
+
"outcome": { "type": "string", "maxBytes": 4096 },
|
|
17
|
+
"occurrence": { "type": "integer", "minimum": 1 },
|
|
18
|
+
"first_seen": { "type": "string" },
|
|
19
|
+
"last_seen": { "type": "string" },
|
|
20
|
+
"tokens": { "type": "array", "items": { "type": "string" } },
|
|
21
|
+
"task_ids": { "type": "array", "items": { "type": "string" } },
|
|
22
|
+
"milestone_ids": { "type": "array", "items": { "type": "string" } },
|
|
23
|
+
"outcome_history": { "type": "array" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "memory-manifest.v1",
|
|
3
|
+
"title": "Memory manifest (memory/manifest.json)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schema_version", "model", "dim"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schema_version": { "type": "integer", "minimum": 1 },
|
|
8
|
+
"model": { "type": "string", "minLength": 1 },
|
|
9
|
+
"dim": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"alpha": { "type": "number" },
|
|
11
|
+
"created_at": { "type": "string" },
|
|
12
|
+
"rebuilt_at": { "type": "string" }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "memory-record.v1",
|
|
3
|
+
"title": "Memory record (memory/records.jsonl line)",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["type", "title", "body"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"type": { "enum": ["learning", "handoff", "critic", "research"] },
|
|
8
|
+
"title": { "type": "string", "minLength": 1 },
|
|
9
|
+
"body": { "type": "string" },
|
|
10
|
+
"tags": { "type": ["array", "null"] },
|
|
11
|
+
"provenance": { "enum": ["VERIFIED", "CITED", "ASSUMED", "CACHED", null] },
|
|
12
|
+
"id": { "type": ["string", "null"] },
|
|
13
|
+
"phase": { "type": ["string", "null"] },
|
|
14
|
+
"created_at": { "type": ["string", "null"] }
|
|
15
|
+
}
|
|
16
|
+
}
|