nubos-pilot 1.2.2 → 1.2.4
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 +18 -0
- package/README.md +16 -0
- 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 +4 -0
- package/bin/np-tools/derive-tier.cjs +86 -0
- package/bin/np-tools/derive-tier.test.cjs +83 -0
- package/bin/np-tools/learnings.cjs +109 -0
- package/bin/np-tools/learnings.test.cjs +66 -0
- package/bin/np-tools/loop-run-round.cjs +7 -1
- package/bin/np-tools/security.cjs +3 -0
- package/bin/np-tools/skill-audit.cjs +79 -0
- package/bin/np-tools/skill-audit.test.cjs +86 -0
- package/bin/np-tools/spawn-headless.cjs +35 -1
- package/bin/np-tools/spawn-headless.test.cjs +135 -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/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/headless-guard.cjs +127 -0
- package/lib/headless-guard.test.cjs +119 -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/nubosloop-audit.cjs +104 -0
- package/lib/nubosloop-skill-audit.test.cjs +98 -0
- package/lib/nubosloop.cjs +9 -0
- package/lib/tier-classify.cjs +67 -0
- package/lib/tier-classify.test.cjs +67 -0
- package/np-tools.cjs +4 -0
- package/package.json +1 -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 +56 -0
- package/templates/claude/payload/hooks/np-security-hook.cjs +1 -0
- package/workflows/architect-phase.md +21 -1
- package/workflows/execute-phase.md +66 -4
- package/workflows/verify-work.md +17 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { atomicCreateExclusiveSync } = require('./core.cjs');
|
|
9
|
+
|
|
10
|
+
const HEADLESS_ENV = 'NUBOS_PILOT_HEADLESS';
|
|
11
|
+
const DEPTH_ENV = 'NUBOS_PILOT_HOOK_DEPTH';
|
|
12
|
+
const MAX_DEPTH_ENV = 'NUBOS_PILOT_MAX_HOOK_DEPTH';
|
|
13
|
+
const DEFAULT_MAX_DEPTH = 1;
|
|
14
|
+
const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function isHeadless(env) {
|
|
17
|
+
const e = env || process.env;
|
|
18
|
+
return e[HEADLESS_ENV] === '1';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function currentDepth(env) {
|
|
22
|
+
const e = env || process.env;
|
|
23
|
+
const n = parseInt(e[DEPTH_ENV], 10);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function maxDepth(env) {
|
|
28
|
+
const e = env || process.env;
|
|
29
|
+
const n = parseInt(e[MAX_DEPTH_ENV], 10);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_DEPTH;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function depthExceeded(env) {
|
|
34
|
+
return currentDepth(env) >= maxDepth(env);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function childSpawnEnv(env) {
|
|
38
|
+
const out = Object.create(null);
|
|
39
|
+
out[HEADLESS_ENV] = '1';
|
|
40
|
+
out[DEPTH_ENV] = String(currentDepth(env) + 1);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _isPidAlive(pid) {
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
46
|
+
try { process.kill(pid, 0); return true; }
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err && err.code === 'ESRCH') return false;
|
|
49
|
+
if (err && err.code === 'EPERM') return true;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _lockPath(root, agent) {
|
|
55
|
+
return path.join(root, '.nubos-pilot', 'run', 'headless-' + agent + '.lock');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _reclaimStaleLock(lockPath) {
|
|
59
|
+
const aside = lockPath + '.stale.' + process.pid + '.' + crypto.randomBytes(4).toString('hex');
|
|
60
|
+
try { fs.renameSync(lockPath, aside); }
|
|
61
|
+
catch { return; }
|
|
62
|
+
try { fs.unlinkSync(aside); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tryAcquireSpawnLock(root, agent, opts) {
|
|
66
|
+
const o = opts || {};
|
|
67
|
+
const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : DEFAULT_LOCK_STALE_MS;
|
|
68
|
+
const lockPath = _lockPath(root, agent);
|
|
69
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch {}
|
|
70
|
+
const payload = JSON.stringify({
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
agent,
|
|
73
|
+
hostname: os.hostname(),
|
|
74
|
+
acquiredAt: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
atomicCreateExclusiveSync(lockPath, payload);
|
|
80
|
+
let released = false;
|
|
81
|
+
return {
|
|
82
|
+
acquired: true,
|
|
83
|
+
lockPath,
|
|
84
|
+
release() {
|
|
85
|
+
if (released) return;
|
|
86
|
+
released = true;
|
|
87
|
+
let meta = null;
|
|
88
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
89
|
+
if (meta && meta.pid !== process.pid) return;
|
|
90
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!err || err.code !== 'EEXIST') {
|
|
95
|
+
return { acquired: false, error: (err && err.code) || 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
let meta = null;
|
|
98
|
+
let stat = null;
|
|
99
|
+
try { meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch {}
|
|
100
|
+
try { stat = fs.statSync(lockPath); } catch {}
|
|
101
|
+
const ageStale = !!stat && (Date.now() - stat.mtimeMs > staleMs);
|
|
102
|
+
const pidDead = !!meta && _isPidAlive(meta.pid) === false;
|
|
103
|
+
if (ageStale || pidDead) {
|
|
104
|
+
_reclaimStaleLock(lockPath);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return { acquired: false, holder: meta };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { acquired: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
HEADLESS_ENV,
|
|
115
|
+
DEPTH_ENV,
|
|
116
|
+
MAX_DEPTH_ENV,
|
|
117
|
+
DEFAULT_MAX_DEPTH,
|
|
118
|
+
DEFAULT_LOCK_STALE_MS,
|
|
119
|
+
isHeadless,
|
|
120
|
+
currentDepth,
|
|
121
|
+
maxDepth,
|
|
122
|
+
depthExceeded,
|
|
123
|
+
childSpawnEnv,
|
|
124
|
+
tryAcquireSpawnLock,
|
|
125
|
+
_isPidAlive,
|
|
126
|
+
_lockPath,
|
|
127
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test, afterEach } = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
|
|
9
|
+
const guard = require('./headless-guard.cjs');
|
|
10
|
+
|
|
11
|
+
const _sandboxes = [];
|
|
12
|
+
|
|
13
|
+
function _mkRoot() {
|
|
14
|
+
const r = fs.mkdtempSync(path.join(os.tmpdir(), 'np-headless-guard-'));
|
|
15
|
+
fs.mkdirSync(path.join(r, '.nubos-pilot'), { recursive: true });
|
|
16
|
+
_sandboxes.push(r);
|
|
17
|
+
return r;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (_sandboxes.length) {
|
|
22
|
+
const r = _sandboxes.pop();
|
|
23
|
+
try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('HG-1: isHeadless is true only when NUBOS_PILOT_HEADLESS=1', () => {
|
|
28
|
+
assert.equal(guard.isHeadless({}), false);
|
|
29
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '0' }), false);
|
|
30
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: 'yes' }), false);
|
|
31
|
+
assert.equal(guard.isHeadless({ NUBOS_PILOT_HEADLESS: '1' }), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('HG-2: currentDepth parses NUBOS_PILOT_HOOK_DEPTH, defaults to 0', () => {
|
|
35
|
+
assert.equal(guard.currentDepth({}), 0);
|
|
36
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: 'x' }), 0);
|
|
37
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '0' }), 0);
|
|
38
|
+
assert.equal(guard.currentDepth({ NUBOS_PILOT_HOOK_DEPTH: '2' }), 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('HG-3: depthExceeded honours default cap of 1 and the env override', () => {
|
|
42
|
+
assert.equal(guard.depthExceeded({}), false);
|
|
43
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1' }), true);
|
|
44
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '1', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), false);
|
|
45
|
+
assert.equal(guard.depthExceeded({ NUBOS_PILOT_HOOK_DEPTH: '2', NUBOS_PILOT_MAX_HOOK_DEPTH: '2' }), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('HG-4: childSpawnEnv marks headless and increments depth', () => {
|
|
49
|
+
assert.deepEqual({ ...guard.childSpawnEnv({}) }, { NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '1' });
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
{ ...guard.childSpawnEnv({ NUBOS_PILOT_HOOK_DEPTH: '1' }) },
|
|
52
|
+
{ NUBOS_PILOT_HEADLESS: '1', NUBOS_PILOT_HOOK_DEPTH: '2' },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('HG-5: tryAcquireSpawnLock acquires, then refuses a live concurrent holder', () => {
|
|
57
|
+
const r = _mkRoot();
|
|
58
|
+
const first = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
59
|
+
assert.equal(first.acquired, true);
|
|
60
|
+
assert.ok(fs.existsSync(first.lockPath));
|
|
61
|
+
|
|
62
|
+
const second = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
63
|
+
assert.equal(second.acquired, false, 'second concurrent acquire must be refused');
|
|
64
|
+
assert.ok(second.holder && second.holder.pid === process.pid);
|
|
65
|
+
|
|
66
|
+
first.release();
|
|
67
|
+
assert.equal(fs.existsSync(first.lockPath), false, 'release removes the lock');
|
|
68
|
+
|
|
69
|
+
const third = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
70
|
+
assert.equal(third.acquired, true, 'lock is re-acquirable after release');
|
|
71
|
+
third.release();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('HG-6: different agents get independent locks', () => {
|
|
75
|
+
const r = _mkRoot();
|
|
76
|
+
const a = guard.tryAcquireSpawnLock(r, 'np-security-reviewer');
|
|
77
|
+
const b = guard.tryAcquireSpawnLock(r, 'np-learnings-extractor');
|
|
78
|
+
assert.equal(a.acquired, true);
|
|
79
|
+
assert.equal(b.acquired, true, 'a second agent must not be blocked by the first');
|
|
80
|
+
a.release();
|
|
81
|
+
b.release();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('HG-7: a stale lock (old mtime) is reclaimed', () => {
|
|
85
|
+
const r = _mkRoot();
|
|
86
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
87
|
+
assert.equal(held.acquired, true);
|
|
88
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
89
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
90
|
+
|
|
91
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
92
|
+
assert.equal(next.acquired, true, 'a lock older than staleMs must be reclaimed');
|
|
93
|
+
next.release();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('HG-8: a dead-pid lock is reclaimed even when fresh', () => {
|
|
97
|
+
const r = _mkRoot();
|
|
98
|
+
const lockPath = guard._lockPath(r, 'np-test-critic');
|
|
99
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
100
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: 2147483646, hostname: os.hostname(), acquiredAt: new Date().toISOString() }), 'utf-8');
|
|
101
|
+
assert.equal(guard._isPidAlive(2147483646), false);
|
|
102
|
+
|
|
103
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
104
|
+
assert.equal(next.acquired, true, 'a lock owned by a dead pid must be reclaimed');
|
|
105
|
+
next.release();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('HG-9: stale reclaim leaves no .stale residue behind', () => {
|
|
109
|
+
const r = _mkRoot();
|
|
110
|
+
const held = guard.tryAcquireSpawnLock(r, 'np-test-critic');
|
|
111
|
+
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
112
|
+
fs.utimesSync(held.lockPath, past, past);
|
|
113
|
+
const next = guard.tryAcquireSpawnLock(r, 'np-test-critic', { staleMs: 1000 });
|
|
114
|
+
assert.equal(next.acquired, true);
|
|
115
|
+
next.release();
|
|
116
|
+
const runDir = path.join(r, '.nubos-pilot', 'run');
|
|
117
|
+
const residue = fs.readdirSync(runDir).filter((n) => n.includes('.stale.'));
|
|
118
|
+
assert.deepEqual(residue, [], 'rename-aside reclaim must clean up its temp file');
|
|
119
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
|
|
9
|
+
const mod = require('./claude-hooks.cjs');
|
|
10
|
+
|
|
11
|
+
function _mkSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-learn-hooks-'));
|
|
13
|
+
const hooksDir = path.join(dir, '.claude', 'nubos-pilot', 'hooks');
|
|
14
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(hooksDir, 'np-statusline.cjs'), '// stub\n');
|
|
16
|
+
fs.writeFileSync(path.join(hooksDir, 'np-ctx-monitor.cjs'), '// stub\n');
|
|
17
|
+
fs.writeFileSync(path.join(hooksDir, 'np-security-hook.cjs'), '// stub\n');
|
|
18
|
+
fs.writeFileSync(path.join(hooksDir, 'np-learnings-hook.cjs'), '// stub\n');
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('LH-1: which=learnings registers capture on Stop + reset on UserPromptSubmit', () => {
|
|
23
|
+
const dir = _mkSandbox();
|
|
24
|
+
try {
|
|
25
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'learnings' });
|
|
26
|
+
assert.equal(res.results.learnings.capture.action, 'installed');
|
|
27
|
+
assert.equal(res.results.learnings.reset.action, 'installed');
|
|
28
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
29
|
+
const stop = JSON.stringify(settings.hooks.Stop);
|
|
30
|
+
const ups = JSON.stringify(settings.hooks.UserPromptSubmit);
|
|
31
|
+
assert.ok(stop.includes('np-learnings-hook.cjs'));
|
|
32
|
+
assert.ok(stop.includes(' capture'));
|
|
33
|
+
assert.ok(ups.includes('np-learnings-hook.cjs'));
|
|
34
|
+
assert.ok(ups.includes(' reset'));
|
|
35
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('LH-2: which=all installs learnings alongside security', () => {
|
|
39
|
+
const dir = _mkSandbox();
|
|
40
|
+
try {
|
|
41
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
42
|
+
assert.ok(res.results.learnings);
|
|
43
|
+
assert.ok(res.results.security);
|
|
44
|
+
assert.equal(res.results.learnings.capture.action, 'installed');
|
|
45
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('LH-3: install is idempotent — second run updates, not duplicates', () => {
|
|
49
|
+
const dir = _mkSandbox();
|
|
50
|
+
try {
|
|
51
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'learnings' });
|
|
52
|
+
const res2 = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'learnings' });
|
|
53
|
+
assert.equal(res2.results.learnings.capture.action, 'updated');
|
|
54
|
+
const settings = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
|
|
55
|
+
const stopLearnings = settings.hooks.Stop.filter((e) =>
|
|
56
|
+
JSON.stringify(e).includes('np-learnings-hook.cjs'));
|
|
57
|
+
assert.equal(stopLearnings.length, 1);
|
|
58
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('LH-4: uninstall removes learnings hooks', () => {
|
|
62
|
+
const dir = _mkSandbox();
|
|
63
|
+
try {
|
|
64
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'all' });
|
|
65
|
+
const res = mod.uninstallClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
66
|
+
assert.equal(res.results.learnings.action, 'removed');
|
|
67
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
68
|
+
const dump = JSON.stringify(settings.hooks || {});
|
|
69
|
+
assert.ok(!dump.includes('np-learnings-hook.cjs'));
|
|
70
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('LH-5: missing learnings hook script throws claude-hooks-script-missing', () => {
|
|
74
|
+
const dir = _mkSandbox();
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks', 'np-learnings-hook.cjs'));
|
|
77
|
+
assert.throws(
|
|
78
|
+
() => mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'learnings' }),
|
|
79
|
+
(e) => e.code === 'claude-hooks-script-missing',
|
|
80
|
+
);
|
|
81
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
82
|
+
});
|
|
@@ -17,9 +17,11 @@ const { atomicWriteFileSync, NubosPilotError, withFileLock } = require('../core.
|
|
|
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
19
|
const SECURITY_HOOK_REL = '.claude/nubos-pilot/hooks/np-security-hook.cjs';
|
|
20
|
+
const LEARNINGS_HOOK_REL = '.claude/nubos-pilot/hooks/np-learnings-hook.cjs';
|
|
20
21
|
const NP_STATUSLINE_MARKER = 'np-statusline.';
|
|
21
22
|
const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.';
|
|
22
23
|
const NP_SECURITY_MARKER = 'np-security-hook.';
|
|
24
|
+
const NP_LEARNINGS_MARKER = 'np-learnings-hook.';
|
|
23
25
|
|
|
24
26
|
// ADR-0020: in-session security review layer. One DRY hook script, registered
|
|
25
27
|
// against five Claude Code lifecycle events, differentiated by a trailing verb.
|
|
@@ -32,6 +34,15 @@ const SECURITY_HOOKS = Object.freeze([
|
|
|
32
34
|
]);
|
|
33
35
|
const SECURITY_EVENTS = Object.freeze(['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse']);
|
|
34
36
|
|
|
37
|
+
// ADR-0010 / ECC continuous-learning: one DRY hook script. `capture` on Stop
|
|
38
|
+
// (rate-limited auto-extraction of the turn's learnings); `reset` on
|
|
39
|
+
// UserPromptSubmit (clears the consecutive-stop streak).
|
|
40
|
+
const LEARNINGS_HOOKS = Object.freeze([
|
|
41
|
+
{ verb: 'reset', event: 'UserPromptSubmit', matcher: undefined },
|
|
42
|
+
{ verb: 'capture', event: 'Stop', matcher: undefined },
|
|
43
|
+
]);
|
|
44
|
+
const LEARNINGS_EVENTS = Object.freeze(['UserPromptSubmit', 'Stop']);
|
|
45
|
+
|
|
35
46
|
function _settingsPath(scope, projectRoot) {
|
|
36
47
|
if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
|
|
37
48
|
return path.join(projectRoot, '.claude', 'settings.local.json');
|
|
@@ -119,14 +130,15 @@ function _verbOf(command) {
|
|
|
119
130
|
return m ? m[1] : null;
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
function _installVerbHook(settings, eventName, matcher, cmd, verb) {
|
|
133
|
+
function _installVerbHook(settings, eventName, matcher, cmd, verb, marker) {
|
|
134
|
+
const mark = marker || NP_SECURITY_MARKER;
|
|
123
135
|
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
124
136
|
if (!Array.isArray(settings.hooks[eventName])) settings.hooks[eventName] = [];
|
|
125
137
|
const list = settings.hooks[eventName];
|
|
126
138
|
for (const entry of list) {
|
|
127
139
|
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
128
140
|
for (const h of hooks) {
|
|
129
|
-
if (h && typeof h.command === 'string' && h.command.includes(
|
|
141
|
+
if (h && typeof h.command === 'string' && h.command.includes(mark) && _verbOf(h.command) === verb) {
|
|
130
142
|
h.command = cmd;
|
|
131
143
|
h.type = 'command';
|
|
132
144
|
if (matcher !== undefined) entry.matcher = matcher;
|
|
@@ -145,11 +157,42 @@ function _installSecurity(settings, scope, projectRoot) {
|
|
|
145
157
|
const base = _hookCommand(SECURITY_HOOK_REL, scope, projectRoot);
|
|
146
158
|
const results = {};
|
|
147
159
|
for (const h of SECURITY_HOOKS) {
|
|
148
|
-
results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb);
|
|
160
|
+
results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb, NP_SECURITY_MARKER);
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _installLearnings(settings, scope, projectRoot) {
|
|
166
|
+
const base = _hookCommand(LEARNINGS_HOOK_REL, scope, projectRoot);
|
|
167
|
+
const results = {};
|
|
168
|
+
for (const h of LEARNINGS_HOOKS) {
|
|
169
|
+
results[h.verb] = _installVerbHook(settings, h.event, h.matcher, base + ' ' + h.verb, h.verb, NP_LEARNINGS_MARKER);
|
|
149
170
|
}
|
|
150
171
|
return results;
|
|
151
172
|
}
|
|
152
173
|
|
|
174
|
+
function _removeLearnings(settings) {
|
|
175
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return { action: 'absent' };
|
|
176
|
+
let removed = 0;
|
|
177
|
+
for (const eventName of LEARNINGS_EVENTS) {
|
|
178
|
+
if (!Array.isArray(settings.hooks[eventName])) continue;
|
|
179
|
+
const filtered = [];
|
|
180
|
+
for (const entry of settings.hooks[eventName]) {
|
|
181
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
182
|
+
const kept = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_LEARNINGS_MARKER)));
|
|
183
|
+
if (kept.length > 0) {
|
|
184
|
+
filtered.push(kept.length === hooks.length ? entry : Object.assign({}, entry, { hooks: kept }));
|
|
185
|
+
} else {
|
|
186
|
+
removed++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
settings.hooks[eventName] = filtered;
|
|
190
|
+
if (filtered.length === 0) delete settings.hooks[eventName];
|
|
191
|
+
}
|
|
192
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
193
|
+
return { action: removed > 0 ? 'removed' : 'absent' };
|
|
194
|
+
}
|
|
195
|
+
|
|
153
196
|
function _removeSecurity(settings) {
|
|
154
197
|
if (!settings.hooks || typeof settings.hooks !== 'object') return { action: 'absent' };
|
|
155
198
|
let removed = 0;
|
|
@@ -214,6 +257,7 @@ function installClaudeHooks(opts) {
|
|
|
214
257
|
const wantStatusline = which === 'statusline' || which === 'both' || which === 'all';
|
|
215
258
|
const wantCtxMonitor = which === 'ctx-monitor' || which === 'both' || which === 'all';
|
|
216
259
|
const wantSecurity = which === 'security' || which === 'all';
|
|
260
|
+
const wantLearnings = which === 'learnings' || which === 'all';
|
|
217
261
|
|
|
218
262
|
const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
|
|
219
263
|
const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
|
|
@@ -222,6 +266,7 @@ function installClaudeHooks(opts) {
|
|
|
222
266
|
const statuslineAbs = path.join(base, STATUSLINE_REL);
|
|
223
267
|
const ctxMonitorAbs = path.join(base, CTX_MONITOR_REL);
|
|
224
268
|
const securityAbs = path.join(base, SECURITY_HOOK_REL);
|
|
269
|
+
const learningsAbs = path.join(base, LEARNINGS_HOOK_REL);
|
|
225
270
|
|
|
226
271
|
if (wantStatusline) {
|
|
227
272
|
if (!fs.existsSync(statuslineAbs)) {
|
|
@@ -250,6 +295,15 @@ function installClaudeHooks(opts) {
|
|
|
250
295
|
);
|
|
251
296
|
}
|
|
252
297
|
}
|
|
298
|
+
if (wantLearnings) {
|
|
299
|
+
if (!fs.existsSync(learningsAbs)) {
|
|
300
|
+
throw new NubosPilotError(
|
|
301
|
+
'claude-hooks-script-missing',
|
|
302
|
+
'Learnings hook script not found: ' + learningsAbs,
|
|
303
|
+
{ script: learningsAbs },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
253
307
|
|
|
254
308
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
255
309
|
|
|
@@ -266,6 +320,9 @@ function installClaudeHooks(opts) {
|
|
|
266
320
|
if (wantSecurity) {
|
|
267
321
|
results.security = _installSecurity(settings, scope, projectRoot);
|
|
268
322
|
}
|
|
323
|
+
if (wantLearnings) {
|
|
324
|
+
results.learnings = _installLearnings(settings, scope, projectRoot);
|
|
325
|
+
}
|
|
269
326
|
|
|
270
327
|
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
271
328
|
|
|
@@ -279,7 +336,7 @@ function uninstallClaudeHooks(opts) {
|
|
|
279
336
|
const projectRoot = o.projectRoot || process.cwd();
|
|
280
337
|
const scope = o.scope === 'global' ? 'global' : 'local';
|
|
281
338
|
const settingsPath = _settingsPath(scope, projectRoot);
|
|
282
|
-
if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' }, security: { action: 'absent' } } };
|
|
339
|
+
if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' }, security: { action: 'absent' }, learnings: { action: 'absent' } } };
|
|
283
340
|
|
|
284
341
|
return withFileLock(settingsPath, () => {
|
|
285
342
|
const settings = _readJsonSafe(settingsPath);
|
|
@@ -287,6 +344,7 @@ function uninstallClaudeHooks(opts) {
|
|
|
287
344
|
statusline: _removeStatusLine(settings),
|
|
288
345
|
ctxMonitor: _removePostToolUse(settings),
|
|
289
346
|
security: _removeSecurity(settings),
|
|
347
|
+
learnings: _removeLearnings(settings),
|
|
290
348
|
};
|
|
291
349
|
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
292
350
|
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
@@ -304,6 +362,9 @@ module.exports = {
|
|
|
304
362
|
NP_CTX_MONITOR_MARKER,
|
|
305
363
|
NP_SECURITY_MARKER,
|
|
306
364
|
SECURITY_HOOKS,
|
|
365
|
+
LEARNINGS_HOOK_REL,
|
|
366
|
+
NP_LEARNINGS_MARKER,
|
|
367
|
+
LEARNINGS_HOOKS,
|
|
307
368
|
_settingsPath,
|
|
308
369
|
_hookCommand,
|
|
309
370
|
};
|
|
@@ -230,7 +230,7 @@ function _mkSandboxAll() {
|
|
|
230
230
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-all-'));
|
|
231
231
|
const hooksDir = path.join(dir, '.claude', 'nubos-pilot', 'hooks');
|
|
232
232
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
233
|
-
for (const f of ['np-statusline.cjs', 'np-ctx-monitor.cjs', 'np-security-hook.cjs']) {
|
|
233
|
+
for (const f of ['np-statusline.cjs', 'np-ctx-monitor.cjs', 'np-security-hook.cjs', 'np-learnings-hook.cjs']) {
|
|
234
234
|
fs.writeFileSync(path.join(hooksDir, f), '// stub\n');
|
|
235
235
|
}
|
|
236
236
|
return dir;
|
|
@@ -269,7 +269,10 @@ test('claude-hooks SEC: re-install is idempotent (no duplicate entries)', () =>
|
|
|
269
269
|
assert.equal(res2.results.security.scan.action, 'updated');
|
|
270
270
|
const s = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
|
|
271
271
|
assert.equal(s.hooks.SessionStart.length, 1);
|
|
272
|
-
|
|
272
|
+
// Stop now carries the security 'review' hook + the learnings 'capture' hook (which=all installs both).
|
|
273
|
+
assert.equal(s.hooks.Stop.length, 2);
|
|
274
|
+
assert.equal(s.hooks.Stop.filter((e) => e.hooks[0].command.includes('np-security-hook.')).length, 1);
|
|
275
|
+
assert.equal(s.hooks.Stop.filter((e) => e.hooks[0].command.includes('np-learnings-hook.')).length, 1);
|
|
273
276
|
assert.equal(s.hooks.PostToolUse.filter((e) => e.hooks[0].command.includes('np-security-hook.')).length, 2);
|
|
274
277
|
} finally {
|
|
275
278
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
// Rate-limit ledger for Stop-hook learning auto-capture. Mirrors the ADR-0020
|
|
8
|
+
// security ledger's posture (sliding per-hour window + consecutive-stop streak)
|
|
9
|
+
// but is its own concern and its own file. Per-session JSON under the OS temp
|
|
10
|
+
// dir; a session that never stops leaves nothing behind worth cleaning.
|
|
11
|
+
|
|
12
|
+
const DIR = path.join(os.tmpdir(), 'nubos-pilot-learnings');
|
|
13
|
+
|
|
14
|
+
function sanitizeSid(sid) {
|
|
15
|
+
return String(sid || 'nosid').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ledgerPath(sid) {
|
|
19
|
+
return path.join(DIR, sanitizeSid(sid) + '.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _read(sid) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(ledgerPath(sid), 'utf-8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return { session_id: sanitizeSid(sid), created_at: Date.now(), capture_times: [], stop_streak: 0 };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _write(sid, l) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
33
|
+
fs.writeFileSync(ledgerPath(sid), JSON.stringify(l), 'utf-8');
|
|
34
|
+
} catch { /* a rate-limit ledger must never break the session */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record an attempt to auto-capture on this session's Stop. Returns whether the
|
|
39
|
+
* capture is allowed under both caps. The per-hour window prevents runaway cost;
|
|
40
|
+
* the in-a-row streak prevents back-to-back Stops (e.g. a tight edit loop) each
|
|
41
|
+
* firing an extraction.
|
|
42
|
+
* @returns {{allowed: boolean, count: number, streak: number, reason?: string}}
|
|
43
|
+
*/
|
|
44
|
+
function tryRecordCapture(sid, opts) {
|
|
45
|
+
const maxPerHour = opts && Number.isFinite(opts.maxPerHour) ? opts.maxPerHour : 10;
|
|
46
|
+
const maxStreak = opts && Number.isFinite(opts.maxStreak) ? opts.maxStreak : 3;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const hourAgo = now - 3600 * 1000;
|
|
49
|
+
|
|
50
|
+
const l = _read(sid);
|
|
51
|
+
l.capture_times = (Array.isArray(l.capture_times) ? l.capture_times : []).filter((t) => t > hourAgo);
|
|
52
|
+
l.stop_streak = Number.isFinite(l.stop_streak) ? l.stop_streak : 0;
|
|
53
|
+
|
|
54
|
+
if (l.capture_times.length >= maxPerHour) {
|
|
55
|
+
_write(sid, l);
|
|
56
|
+
return { allowed: false, count: l.capture_times.length, streak: l.stop_streak, reason: 'per-hour-cap' };
|
|
57
|
+
}
|
|
58
|
+
if (l.stop_streak >= maxStreak) {
|
|
59
|
+
_write(sid, l);
|
|
60
|
+
return { allowed: false, count: l.capture_times.length, streak: l.stop_streak, reason: 'streak-cap' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
l.capture_times.push(now);
|
|
64
|
+
l.stop_streak += 1;
|
|
65
|
+
_write(sid, l);
|
|
66
|
+
return { allowed: true, count: l.capture_times.length, streak: l.stop_streak };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reset the consecutive-stop streak — call after a user prompt (real activity). */
|
|
70
|
+
function resetStreak(sid) {
|
|
71
|
+
const l = _read(sid);
|
|
72
|
+
l.stop_streak = 0;
|
|
73
|
+
_write(sid, l);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeLedger(sid) {
|
|
77
|
+
try { fs.unlinkSync(ledgerPath(sid)); } catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { tryRecordCapture, resetStreak, removeLedger, ledgerPath, sanitizeSid, _read };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const { tryRecordCapture, resetStreak, removeLedger } = require('./capture-ledger.cjs');
|
|
6
|
+
|
|
7
|
+
function _sid(name) { return 'np-test-ledger-' + name + '-' + process.pid; }
|
|
8
|
+
|
|
9
|
+
test('CL-1: streak cap blocks after maxStreak consecutive stops', () => {
|
|
10
|
+
const sid = _sid('streak');
|
|
11
|
+
removeLedger(sid);
|
|
12
|
+
try {
|
|
13
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
14
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
15
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 }).allowed, true);
|
|
16
|
+
const blocked = tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 3 });
|
|
17
|
+
assert.strictEqual(blocked.allowed, false);
|
|
18
|
+
assert.strictEqual(blocked.reason, 'streak-cap');
|
|
19
|
+
} finally { removeLedger(sid); }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('CL-2: resetStreak clears the streak so capture is allowed again', () => {
|
|
23
|
+
const sid = _sid('reset');
|
|
24
|
+
removeLedger(sid);
|
|
25
|
+
try {
|
|
26
|
+
tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 });
|
|
27
|
+
tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 });
|
|
28
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 }).allowed, false);
|
|
29
|
+
resetStreak(sid);
|
|
30
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 100, maxStreak: 2 }).allowed, true);
|
|
31
|
+
} finally { removeLedger(sid); }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('CL-3: per-hour cap blocks regardless of streak resets', () => {
|
|
35
|
+
const sid = _sid('hour');
|
|
36
|
+
removeLedger(sid);
|
|
37
|
+
try {
|
|
38
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 }).allowed, true);
|
|
39
|
+
resetStreak(sid);
|
|
40
|
+
assert.strictEqual(tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 }).allowed, true);
|
|
41
|
+
resetStreak(sid);
|
|
42
|
+
const blocked = tryRecordCapture(sid, { maxPerHour: 2, maxStreak: 100 });
|
|
43
|
+
assert.strictEqual(blocked.allowed, false);
|
|
44
|
+
assert.strictEqual(blocked.reason, 'per-hour-cap');
|
|
45
|
+
} finally { removeLedger(sid); }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('CL-4: a fresh session starts allowed', () => {
|
|
49
|
+
const sid = _sid('fresh');
|
|
50
|
+
removeLedger(sid);
|
|
51
|
+
try {
|
|
52
|
+
assert.strictEqual(tryRecordCapture(sid, {}).allowed, true);
|
|
53
|
+
} finally { removeLedger(sid); }
|
|
54
|
+
});
|