kenaz 0.3.7 → 0.3.9
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/core/.claude/commands/Ansuz.md +7 -1
- package/core/.claude/hooks/autonomy-stop.js +20 -0
- package/core/.claude/hooks/turn-context.js +7 -2
- package/core/.claude/settings.json +10 -0
- package/core/.collaboration/core/reconcile.js +3 -1
- package/core/.collaboration/core/task-2404-reconcile.test.js +8 -0
- package/core/.formation/bifrost/openai/codex-hook-shim.js +12 -1
- package/core/.formation/bifrost/openai/codex-hook-shim.test.js +1 -1
- package/core/.formation/tools/__tests__/dispatch-model.test.js +58 -0
- package/core/.formation/tools/dispatch-model.js +91 -0
- package/core/.formation/tools/dispatch.js +8 -6
- package/core/.specialist/Ansuz/RULES.md +36 -14
- package/core/.specialist/Ansuz/elixir.yaml +7 -5
- package/core/.specialist/tools/__tests__/autonomy.test.js +91 -0
- package/core/.specialist/tools/__tests__/rea-detect.test.js +71 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +1 -0
- package/core/.specialist/tools/autonomy.js +88 -0
- package/core/.specialist/tools/rea-detect.js +67 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/plugins/kenaz/commands/Ansuz.md +7 -1
- package/core/plugins/kenaz/hooks/hooks.json +2 -1
- package/core/plugins/kenaz/hooks/kenaz-hook.js +1 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +1 -1
- package/core/plugins/kenaz/scripts/ygg.js +10 -2
- package/core/plugins/kenaz-peer/hooks/register.test.ts +52 -0
- package/core/plugins/kenaz-peer/hooks/register.ts +31 -1
- package/lib/cli.js +59 -1
- package/lib/cli.test.js +26 -0
- package/package.json +6 -6
- package/core/k-cli/README.md +0 -330
- package/core/k-cli/package.json +0 -21
- package/core/k-cli/templates/k-cli.md +0 -91
- package/core/k-cli/test/TEST_SPEC.md +0 -117
- package/core/k-cli/test/run.sh +0 -403
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Ansuz autonomy grant (user 2026-10-11: "once granted, work the project on your own; do not ask or
|
|
5
|
+
* stop to wait unless it is necessary"). The grant lives in a file so it survives context compaction,
|
|
6
|
+
* and the Stop hook (.claude/hooks/autonomy-stop.js) keeps the session working while it is on.
|
|
7
|
+
*
|
|
8
|
+
* node autonomy.js grant --session <id> [--scope "<what the user granted>"]
|
|
9
|
+
* node autonomy.js pause --session <id> --reason "<what the user must do or decide>"
|
|
10
|
+
* node autonomy.js done --session <id> --reason "<why the state is sufficient>"
|
|
11
|
+
* node autonomy.js revoke --session <id>
|
|
12
|
+
* node autonomy.js status --session <id>
|
|
13
|
+
*
|
|
14
|
+
* States: on (Stop is blocked) | paused (one hard stop is waiting on the user; the next user prompt
|
|
15
|
+
* turns it back on) | done (work judged sufficient; the next user prompt does NOT resume it) | off.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
// Consecutive blocked Stops without the agent recording anything: a runaway guard, not a budget.
|
|
22
|
+
const MAX_BLOCKS = 25;
|
|
23
|
+
// Absolute, quoted: on an npm install the core is ~/.kenaz/core and the model cannot guess `<core>`.
|
|
24
|
+
const CMD = `node "${__filename}"`;
|
|
25
|
+
|
|
26
|
+
const dir = (env = process.env) => env.KENAZ_AUTONOMY_DIR || path.join(os.homedir(), '.kenaz', 'autonomy');
|
|
27
|
+
const safeId = (sid) => String(sid || '').replace(/[^A-Za-z0-9_.-]/g, '');
|
|
28
|
+
const file = (sid, env) => path.join(dir(env), safeId(sid) + '.json');
|
|
29
|
+
|
|
30
|
+
function read(sid, env) {
|
|
31
|
+
if (!safeId(sid)) return null;
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(file(sid, env), 'utf8')); } catch (_) { return null; }
|
|
33
|
+
}
|
|
34
|
+
function write(sid, st, env) {
|
|
35
|
+
fs.mkdirSync(dir(env), { recursive: true });
|
|
36
|
+
const f = file(sid, env);
|
|
37
|
+
fs.writeFileSync(f + '.tmp', JSON.stringify({ ...st, updated_at: new Date().toISOString() }, null, 2));
|
|
38
|
+
fs.renameSync(f + '.tmp', f);
|
|
39
|
+
return st;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setState(sid, state, extra = {}, env) {
|
|
43
|
+
if (!safeId(sid)) throw new Error('--session <id> is required (the [session] session_id from Turn Context)');
|
|
44
|
+
const prev = read(sid, env) || {};
|
|
45
|
+
return write(sid, { ...prev, ...extra, state, blocks: 0, ...(state === 'on' && !prev.granted_at ? { granted_at: new Date().toISOString() } : {}) }, env);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** UserPromptSubmit: a reply to a paused question resumes the grant. Returns the Turn Context line or ''. */
|
|
49
|
+
function onUserPrompt(sid, env) {
|
|
50
|
+
const st = read(sid, env);
|
|
51
|
+
if (!st) return '';
|
|
52
|
+
if (st.state === 'paused') write(sid, { ...st, state: 'on', blocks: 0, reason: '' }, env);
|
|
53
|
+
else if (st.state === 'on' && st.blocks) write(sid, { ...st, blocks: 0 }, env);
|
|
54
|
+
const now = read(sid, env).state;
|
|
55
|
+
return now === 'on'
|
|
56
|
+
? '[autonomy] ON (user grant' + (st.scope ? ': ' + st.scope : '') + '): decide and keep going, no check-in questions; stop only via ' + CMD + ' pause|done --session ' + safeId(sid) + ' --reason "..." (RULES.md Autonomy)'
|
|
57
|
+
: '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stop hook decision: null = allow the stop, else the reason to keep going. */
|
|
61
|
+
function onStop(sid, env) {
|
|
62
|
+
const st = read(sid, env);
|
|
63
|
+
if (!st || st.state !== 'on') return null;
|
|
64
|
+
if ((st.blocks || 0) >= MAX_BLOCKS) { write(sid, { ...st, state: 'paused', reason: `runaway guard: ${MAX_BLOCKS} stops in a row with no pause/done` }, env); return null; }
|
|
65
|
+
write(sid, { ...st, blocks: (st.blocks || 0) + 1 }, env);
|
|
66
|
+
return 'Autonomy grant is ON: do not end the turn to wait for the user. Take the next item yourself '
|
|
67
|
+
+ '(decide forks, dispatch, verify, merge, next). If the work is genuinely sufficient, run '
|
|
68
|
+
+ '`' + CMD + ' done --session ' + safeId(sid) + ' --reason "..."` and give the final report; '
|
|
69
|
+
+ 'if a necessary stop needs the user (RULES.md Autonomy), run `' + CMD + ' pause --session ' + safeId(sid) + ' --reason "..."` and ask once.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (require.main === module) {
|
|
73
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
74
|
+
const arg = (k) => { const i = rest.indexOf('--' + k); return i >= 0 ? rest[i + 1] : undefined; };
|
|
75
|
+
// Codex shells carry CODEX_SESSION_ID; Claude passes the Turn Context session_id explicitly.
|
|
76
|
+
const sid = arg('session') || process.env.CODEX_SESSION_ID;
|
|
77
|
+
try {
|
|
78
|
+
if (cmd === 'grant') setState(sid, 'on', { scope: arg('scope') || '', reason: '' });
|
|
79
|
+
else if (cmd === 'pause' || cmd === 'done') {
|
|
80
|
+
if (!arg('reason')) throw new Error(`${cmd} needs --reason`);
|
|
81
|
+
setState(sid, cmd === 'pause' ? 'paused' : 'done', { reason: arg('reason') });
|
|
82
|
+
} else if (cmd === 'revoke') setState(sid, 'off', { reason: 'revoked by the user' });
|
|
83
|
+
else if (cmd !== 'status') throw new Error('usage: autonomy.js grant|pause|done|revoke|status --session <id> [--scope s] [--reason r]');
|
|
84
|
+
console.log(JSON.stringify(read(sid) || { state: 'off' }));
|
|
85
|
+
} catch (e) { console.error('[autonomy] ' + e.message); process.exit(1); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { setState, read, onUserPrompt, onStop, MAX_BLOCKS };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* REA (https://github.com/morluto/rea, MCP server `rea`) detection for the Ansuz boot banner and
|
|
5
|
+
* `kenaz doctor`. REA is optional and installed by the user (`npx rea-agents setup`); Kenaz never
|
|
6
|
+
* installs it. Read-only except `decline`, which records a per-project "do not suggest again".
|
|
7
|
+
*
|
|
8
|
+
* node rea-detect.js [--project-path <dir>] print the banner line (nothing when declined)
|
|
9
|
+
* node rea-detect.js decline [--project-path <dir>] stop suggesting REA in this project
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const SETUP_CMD = 'npx rea-agents setup';
|
|
16
|
+
const DECLINE_FILE = path.join('.kenaz', 'rea.json');
|
|
17
|
+
|
|
18
|
+
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return null; } };
|
|
19
|
+
const readText = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch (_) { return ''; } };
|
|
20
|
+
const hasRea = (servers) => !!(servers && typeof servers === 'object' && servers.rea);
|
|
21
|
+
const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
22
|
+
|
|
23
|
+
/** REA's own floor: Node 22.x >= 22.19, 24.x >= 24.11, or 26+ (23 and 25 are not supported). */
|
|
24
|
+
function reaNodeOk(version = process.versions.node) {
|
|
25
|
+
const [maj, min] = version.split('.').map(Number);
|
|
26
|
+
return maj >= 26 || (maj === 24 && min >= 11) || (maj === 22 && min >= 19);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where `rea` is registered: Claude Code (user ~/.claude.json, its per-project entry, project .mcp.json) and Codex config.toml. */
|
|
30
|
+
function detectRea({ home = os.homedir(), projectPath = process.cwd(), env = process.env } = {}) {
|
|
31
|
+
const cj = readJson(path.join(home, '.claude.json')) || {};
|
|
32
|
+
const projEntry = Object.entries(cj.projects || {}).find(([k]) => norm(k) === norm(projectPath));
|
|
33
|
+
const claude = hasRea(cj.mcpServers) || hasRea(projEntry && projEntry[1].mcpServers)
|
|
34
|
+
|| hasRea((readJson(path.join(projectPath, '.mcp.json')) || {}).mcpServers);
|
|
35
|
+
const codexHome = env.CODEX_HOME || path.join(home, '.codex');
|
|
36
|
+
const codex = /^\s*\[mcp_servers\.rea\]\s*$/m.test(readText(path.join(codexHome, 'config.toml')));
|
|
37
|
+
const declined = !!(readJson(path.join(projectPath, DECLINE_FILE)) || {}).declined;
|
|
38
|
+
return { claude, codex, installed: claude || codex, declined, nodeOk: reaNodeOk(env.KENAZ_REA_NODE_VERSION || process.versions.node) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One banner line, or null when the user declined in this project. */
|
|
42
|
+
function reaBannerLine(d) {
|
|
43
|
+
if (d.installed) {
|
|
44
|
+
const where = [d.claude && 'claude', d.codex && 'codex'].filter(Boolean).join('/');
|
|
45
|
+
return `[rea] REA MCP on (${where}): a reference product/feature in the request -> offer to analyse it with REA (RULES.md "REA")`;
|
|
46
|
+
}
|
|
47
|
+
if (d.declined) return null;
|
|
48
|
+
const node = d.nodeOk ? '' : `; needs Node >=22.19/24.11 (found ${process.versions.node}), upgrade Node first`;
|
|
49
|
+
return `[rea] not installed: when the user names a reference product, suggest once \`${SETUP_CMD}\`${node}; on "no" run node "${__filename}" decline (RULES.md "REA")`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decline(projectPath) {
|
|
53
|
+
const f = path.join(projectPath, DECLINE_FILE);
|
|
54
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
55
|
+
fs.writeFileSync(f, JSON.stringify({ declined: true, at: new Date().toISOString() }, null, 2) + '\n');
|
|
56
|
+
return f;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
const argv = process.argv.slice(2);
|
|
61
|
+
const i = argv.indexOf('--project-path');
|
|
62
|
+
const projectPath = i >= 0 && argv[i + 1] ? path.resolve(argv[i + 1]) : process.cwd();
|
|
63
|
+
if (argv[0] === 'decline') console.log(`[rea] will not suggest REA again in this project (${decline(projectPath)})`);
|
|
64
|
+
else { const l = reaBannerLine(detectRea({ projectPath })); if (l) console.log(l); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { detectRea, reaBannerLine, reaNodeOk, decline, SETUP_CMD };
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.9
|
|
@@ -37,4 +37,10 @@ Kenaz 的工具、hook、安裝、peer 訊息、派工擋住或拖慢工作時
|
|
|
37
37
|
- 一個問題只報一次;報完照常用替代做法把工作完成,並在回報裡告訴用戶已送出 wish。
|
|
38
38
|
- 用戶直接說「回報這個問題」時也用同一個工具(`source`=`user`)。
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
## 自主模式
|
|
41
|
+
用戶明確授權自主(「自主」「不用問我」「自己判斷著做」)時:立刻執行 `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/autonomy.js grant --session <id> --scope "<授權範圍>"`,再讀 RULES.md 的 Autonomy 一節。之後 Turn Context 會顯示 `[autonomy] ON`,結束回合會被 Stop hook 擋回:自己決定、一路做完,不在回合結尾問「要我…嗎?」;只有 RULES.md 列的必要停點才 `pause`,做完才 `done`。
|
|
42
|
+
|
|
43
|
+
## 參考產品(REA)
|
|
44
|
+
banner 的 `[rea]` 行說明 REA 是否可用:用戶提到想參考某產品的功能時,可用就提議用 REA 分析;沒裝就提示一次 `npx rea-agents setup`,用戶說不要就照 `[rea]` 行的 decline 指令記下來。範圍見 RULES.md 的 REA 一節。
|
|
45
|
+
|
|
46
|
+
需要更多規則時(用戶授權自主、派工前、合併前、提到參考產品時)讀 `${CLAUDE_PLUGIN_ROOT}/../../.specialist/Ansuz/RULES.md`。
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
|
|
22
22
|
"Stop": [{ "hooks": [
|
|
23
23
|
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" },
|
|
24
|
-
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-stop" }
|
|
24
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-stop" },
|
|
25
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" autonomy-stop" }
|
|
25
26
|
] }]
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -28,6 +28,7 @@ const HOOKS = {
|
|
|
28
28
|
'guidance': { kind: 'guidance', minMode: 'full' },
|
|
29
29
|
'prompt-runner': { kind: 'script', file: '_run-user-prompt.js', minMode: 'full' },
|
|
30
30
|
'session-stop': { kind: 'core-script', rel: '.specialist/tools/session.js', args: ['stop'], minMode: 'full' },
|
|
31
|
+
'autonomy-stop': { kind: 'script', file: 'autonomy-stop.js', call: 'main', minMode: 'full' },
|
|
31
32
|
'synapse-first': { kind: 'script', file: 'synapse-first-investigation.js', minMode: 'full' },
|
|
32
33
|
'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
|
|
33
34
|
'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
|
|
@@ -58,7 +58,7 @@ test('ygg status fixture: masked key, room, peers, mail (TASK_2366)', () => {
|
|
|
58
58
|
const key = 'ygk_SECRETSECRETSECRET1234';
|
|
59
59
|
const s = buildStatus(
|
|
60
60
|
{ enabled: true, effective_project_key: 'acme/app', node_name: 'laptop', publishable_key: key, key_source: 'project', collaboration_projects: ['acme/app'] },
|
|
61
|
-
{ connected: true, nodes: [{ node_name: 'laptop', is_online: true }, { node_name: 'desk', is_online: true }, { node_name: 'old', is_online: false }] },
|
|
61
|
+
{ connected: true, nodes: [{ node_name: 'laptop', is_online: true, updated_at: new Date().toISOString() }, { node_name: 'desk', is_online: true, updated_at: new Date().toISOString() }, { node_name: 'old', is_online: false }, { node_name: 'BA-Cheng', is_online: true, updated_at: '2026-06-30T00:00:00Z' }] },
|
|
62
62
|
{ pending_count: 2, undeliverable_count: 1 });
|
|
63
63
|
const out = renderStatus(s);
|
|
64
64
|
assert.ok(!out.includes('SECRETSECRET') && !JSON.stringify(s).includes('SECRETSECRET'));
|
|
@@ -47,9 +47,17 @@ async function relayNodes(cfg) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// TASK_2383: same online rule as kenaz_peer_send (is_online AND updated_at fresh, NODE_STALE_SECS):
|
|
51
|
+
// the relay row can stay is_online forever after a node drops.
|
|
52
|
+
const NODE_STALE_MS = 300 * 1000;
|
|
53
|
+
function nodeIsOnline(n, now) {
|
|
54
|
+
const t = Date.parse(n && n.updated_at);
|
|
55
|
+
return !!(n && n.is_online) && Number.isFinite(t) && (now || Date.now()) - t < NODE_STALE_MS;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
function buildStatus(cfg, relay, peer) {
|
|
51
59
|
const self = cfg.node_name;
|
|
52
|
-
const online = relay.nodes.filter((n) => n
|
|
60
|
+
const online = relay.nodes.filter((n) => nodeIsOnline(n) && n.node_name !== self).map((n) => n.node_name);
|
|
53
61
|
return {
|
|
54
62
|
room: cfg.effective_project_key || '(none)',
|
|
55
63
|
node_name: self || '(none)',
|
|
@@ -165,5 +173,5 @@ async function main(argv) {
|
|
|
165
173
|
console.log(json ? JSON.stringify(s, null, 2) : renderStatus(s));
|
|
166
174
|
}
|
|
167
175
|
|
|
168
|
-
module.exports = { maskKey, buildStatus, renderStatus, join };
|
|
176
|
+
module.exports = { nodeIsOnline, maskKey, buildStatus, renderStatus, join };
|
|
169
177
|
if (require.main === module) main(process.argv.slice(2));
|
|
@@ -51,6 +51,58 @@ test('heartbeat precedes submit, ack carries the submitted id, second cycle is e
|
|
|
51
51
|
expect(calls.filter(c => c.includes('/ack')).length).toBe(1)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
function uiSetup(on: any, rows: unknown[], statusOk = true) {
|
|
55
|
+
const status: unknown[] = []
|
|
56
|
+
const toasts: string[] = []
|
|
57
|
+
on('session.start', async (_$: any, e: any) => ({ cwd: e.cwd }))
|
|
58
|
+
on('session.id', async () => ({ value: 'sess-1' }))
|
|
59
|
+
on('session.cwd', async () => ({ value: '/tmp/proj' }))
|
|
60
|
+
on('session.messages', async () => ({ value: rows }))
|
|
61
|
+
on('fs.read', async () => ({ deny: 'no pin' }))
|
|
62
|
+
let delivered = false
|
|
63
|
+
on('http.fetch', async (_$: any, e: any) => {
|
|
64
|
+
const url = String(e.url)
|
|
65
|
+
const reply = (o: unknown) => ({ value: { status: 200, ok: true, headers: {}, text: JSON.stringify(o) } })
|
|
66
|
+
if (url.includes('/pending')) return reply({ messages: delivered ? [] : [{ id: 'm1', framed: 'hi', from_node: 'node-b' }] })
|
|
67
|
+
if (url.includes('/ack')) { delivered = true; return reply({ acked: ['m1'] }) }
|
|
68
|
+
if (url.includes('/status')) return statusOk ? reply({ pending_count: 2, sessions: [{ delivery_mode: 'push' }, { delivery_mode: 'none' }] }) : { value: { status: 500, ok: false, headers: {}, text: '' } }
|
|
69
|
+
return reply({ ok: true })
|
|
70
|
+
})
|
|
71
|
+
on('prompt.submit', async (_$: any, e: any) => e)
|
|
72
|
+
on('ui.status', async (_$: any, e: any) => { status.push(e.text); return {} })
|
|
73
|
+
on('ui.toast', async (_$: any, e: any) => { toasts.push(e.text); return {} })
|
|
74
|
+
return { status, toasts }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('Ansuz session: status line from /api/peer/status and a toast naming the sender node', async ($, on) => {
|
|
78
|
+
const clock = mock.clock(on)
|
|
79
|
+
const { status, toasts } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }])
|
|
80
|
+
await $.session.start(START)
|
|
81
|
+
await clock.advance(5_000)
|
|
82
|
+
await clock.advance(5_000)
|
|
83
|
+
expect(status.some(s => String(s).includes('proj') && String(s).includes('sessions 1') && String(s).includes('mail 2') && String(s).includes('server ok'))).toBe(true)
|
|
84
|
+
expect(toasts.length).toBe(1)
|
|
85
|
+
expect(toasts[0]).toContain('node-b')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('Ansuz session with the server down shows kenaz-server down', async ($, on) => {
|
|
89
|
+
const clock = mock.clock(on)
|
|
90
|
+
const { status } = uiSetup(on, [{ role: 'user', content: '# Ansuz -- Specialist Prompt' }], false)
|
|
91
|
+
await $.session.start(START)
|
|
92
|
+
await clock.advance(5_000)
|
|
93
|
+
await clock.advance(5_000)
|
|
94
|
+
expect(status.some(s => String(s).includes('kenaz-server down'))).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('control: non-Ansuz session gets no status and no toast', async ($, on) => {
|
|
98
|
+
const clock = mock.clock(on)
|
|
99
|
+
const { status, toasts } = uiSetup(on, [{ role: 'user', content: 'hello' }])
|
|
100
|
+
await $.session.start(START)
|
|
101
|
+
await clock.advance(15_000)
|
|
102
|
+
expect(status.length).toBe(0)
|
|
103
|
+
expect(toasts.length).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
|
|
54
106
|
test('plugin-namespaced /kenaz:Ansuz also activates delivery', async ($, on) => {
|
|
55
107
|
const clock = mock.clock(on)
|
|
56
108
|
const { calls, submits } = setup(on, () => [{ role: 'user', content: [{ type: 'text', text: '<command-name>/kenaz:Ansuz</command-name>' }] }])
|
|
@@ -98,6 +98,33 @@ async function ack($: any): Promise<void> {
|
|
|
98
98
|
if (res && !res.error) for (const id of ids) unacked.delete(id)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// TASK_2399: one status line + a toast per message batch. Reads only
|
|
102
|
+
// GET /api/peer/status (pending_count, sessions) and the already-peeked pending rows.
|
|
103
|
+
const STATUS_MS = 10_000
|
|
104
|
+
let lastStatus = 0
|
|
105
|
+
|
|
106
|
+
async function refreshStatus($: any): Promise<void> {
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
if (now - lastStatus < STATUS_MS) return
|
|
109
|
+
lastStatus = now
|
|
110
|
+
try {
|
|
111
|
+
const root = await project($)
|
|
112
|
+
const st = await jget($, `${BACKEND}/api/peer/status?projectPath=${encodeURIComponent(root)}&limit=1`)
|
|
113
|
+
if (!st || typeof st !== 'object') { await $.ui.status('Kenaz: kenaz-server down'); return }
|
|
114
|
+
const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || root
|
|
115
|
+
const sessions = Array.isArray(st.sessions) ? st.sessions.filter((s: any) => s?.delivery_mode !== 'none') : []
|
|
116
|
+
const pending = Number(st.pending_count) || 0
|
|
117
|
+
await $.ui.status(`Kenaz ${name} | sessions ${sessions.length} | mail ${pending} | server ok`)
|
|
118
|
+
} catch { /* UI is best-effort */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function notify($: any, msgs: any[]): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
const from = [...new Set(msgs.map(m => String(m.from_node ?? 'unknown')))].join(', ')
|
|
124
|
+
await $.ui.toast(`Kenaz: ${msgs.length} peer message${msgs.length > 1 ? 's' : ''} from ${from}`)
|
|
125
|
+
} catch { /* best-effort */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
function frame(msgs: any[]): string {
|
|
102
129
|
return msgs.map(m => String(m.framed ?? m.body ?? '')).filter(Boolean).join('\n')
|
|
103
130
|
}
|
|
@@ -108,10 +135,12 @@ async function cycle($: any): Promise<void> {
|
|
|
108
135
|
try {
|
|
109
136
|
if (!(await isAnsuz($))) return // non-Ansuz: no heartbeat, no peek, no submit
|
|
110
137
|
await ack($) // retry an ack that failed earlier
|
|
111
|
-
if (!beaten && !(await beat($))) return // never submit before a heartbeat has landed
|
|
138
|
+
if (!beaten && !(await beat($))) { await refreshStatus($); return } // never submit before a heartbeat has landed
|
|
139
|
+
void refreshStatus($)
|
|
112
140
|
const msgs = await peek($)
|
|
113
141
|
if (msgs.length === 0) return
|
|
114
142
|
for (const m of msgs) unacked.add(m.id)
|
|
143
|
+
void notify($, msgs)
|
|
115
144
|
try {
|
|
116
145
|
await $.prompt.submit({ text: frame(msgs) })
|
|
117
146
|
} catch {
|
|
@@ -141,6 +170,7 @@ export const register: Register = on => {
|
|
|
141
170
|
if (msgs.length === 0) return next(e)
|
|
142
171
|
for (const m of msgs) unacked.add(m.id)
|
|
143
172
|
await ack($)
|
|
173
|
+
void notify($, msgs)
|
|
144
174
|
return next({ ...e, context: [...(e.context ?? []), frame(msgs)] })
|
|
145
175
|
})
|
|
146
176
|
}
|
package/lib/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ const dash = require('./dashboard');
|
|
|
14
14
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
15
15
|
const PKG = require('../package.json');
|
|
16
16
|
const MIN_NODE = 18;
|
|
17
|
-
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg status|ygg leave> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
17
|
+
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg status|ygg leave|alaya status|alaya register|model [set <id>|unset] [--rigor r]> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
18
18
|
|
|
19
19
|
function parseArgs(argv) {
|
|
20
20
|
const o = { cmd: argv[0], flags: {} };
|
|
@@ -33,6 +33,16 @@ function installer() {
|
|
|
33
33
|
throw new Error('install-kenaz-cli.js not found (run `npm run stage` in packages/kenaz)');
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** A core module from the installed core, else this checkout; null when neither is there. */
|
|
37
|
+
function coreModule(p, ...rel) {
|
|
38
|
+
for (const f of [path.join(p.core, ...rel), path.join(__dirname, '..', '..', '..', ...rel)]) {
|
|
39
|
+
if (fs.existsSync(f)) return require(f);
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const reaDetect = (p) => coreModule(p, '.specialist', 'tools', 'rea-detect.js');
|
|
44
|
+
const dispatchModel = (p) => coreModule(p, '.formation', 'tools', 'dispatch-model.js');
|
|
45
|
+
|
|
36
46
|
function paths(home) {
|
|
37
47
|
const dir = path.join(home, '.kenaz');
|
|
38
48
|
return { home, dir, core: path.join(dir, 'core'), bin: path.join(dir, 'bin'), meta: path.join(dir, 'install.json') };
|
|
@@ -263,6 +273,19 @@ async function doctor(p, flags, out, deps = {}) {
|
|
|
263
273
|
add('codex mcp', url ? 'ok' : 'FAIL', url || 'kenaz-dashboard entry missing in config.toml');
|
|
264
274
|
add('codex hooks', miss.length ? 'FAIL' : 'ok', miss.length ? 'missing: ' + miss.join(', ') : 'registered (trust once via /hooks in codex)');
|
|
265
275
|
}
|
|
276
|
+
const dm = dispatchModel(p);
|
|
277
|
+
if (dm) {
|
|
278
|
+
const d = dm.describe(process.cwd());
|
|
279
|
+
const uniq = [...new Set(d.rows.map((r) => r.model))];
|
|
280
|
+
add('dispatch model', 'ok', (uniq.length === 1 ? uniq[0] : d.rows.map((r) => `${r.rigor}=${r.model}`).join(' ')) + (d.file ? '' : ' (built-in default)') + '; change with `kenaz model set <id>`');
|
|
281
|
+
}
|
|
282
|
+
// Optional and user-installed: never a failure.
|
|
283
|
+
const rea = reaDetect(p);
|
|
284
|
+
if (rea) {
|
|
285
|
+
const r = rea.detectRea({ home: p.home, projectPath: process.cwd() });
|
|
286
|
+
add('rea (optional)', r.installed ? 'ok' : 'skip', r.installed ? 'MCP registered in ' + [r.claude && 'claude', r.codex && 'codex'].filter(Boolean).join(', ')
|
|
287
|
+
: `not installed; \`${rea.SETUP_CMD}\` lets Ansuz analyse reference products` + (r.nodeOk ? '' : ` (needs Node >=22.19, found ${process.versions.node})`));
|
|
288
|
+
}
|
|
266
289
|
for (const [l, s, d] of rows) out(`${s.padEnd(4)} ${l.padEnd(20)} ${d}`);
|
|
267
290
|
return rows.some((r) => r[1] === 'FAIL') ? 1 : 0;
|
|
268
291
|
}
|
|
@@ -279,7 +302,42 @@ function passthrough(argv, deps) {
|
|
|
279
302
|
return r.status === null ? 1 : r.status;
|
|
280
303
|
}
|
|
281
304
|
|
|
305
|
+
// TASK_2406: `kenaz alaya status|register` talks to the local kenaz-server. Register needs no login:
|
|
306
|
+
// the server registers by machine key (empty body).
|
|
307
|
+
function httpJson(method, port, urlPath, body) {
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
310
|
+
const req = http.request({ host: '127.0.0.1', port, path: urlPath, method, timeout: 30000,
|
|
311
|
+
headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
|
|
312
|
+
let b = ''; res.on('data', (d) => { b += d; });
|
|
313
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (_) { /* non-JSON */ } resolve({ status: res.statusCode, json: j, text: b }); });
|
|
314
|
+
});
|
|
315
|
+
req.on('timeout', () => { req.destroy(); resolve({ status: 0, text: 'timeout' }); });
|
|
316
|
+
req.on('error', (e) => resolve({ status: 0, text: `${e.code || e.message}` }));
|
|
317
|
+
if (data) req.write(data);
|
|
318
|
+
req.end();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function alaya(argv, out = console.log) {
|
|
323
|
+
const sub = argv[1];
|
|
324
|
+
if (sub !== 'status' && sub !== 'register') { out('usage: kenaz alaya <status|register>'); return 2; }
|
|
325
|
+
const port = process.env.KENAZ_HTTP_PORT || '18080';
|
|
326
|
+
const r = sub === 'status' ? await httpJson('GET', port, '/api/rust/alaya/status') : await httpJson('POST', port, '/api/rust/alaya/register', {});
|
|
327
|
+
if (!r.status) { out(`kenaz-server not reachable on 127.0.0.1:${port} (${r.text}); run \`kenaz doctor\``); return 1; }
|
|
328
|
+
if (r.status >= 400) { out(`alaya ${sub} failed (HTTP ${r.status}): ${(r.json && r.json.error) || r.text}`); return 1; }
|
|
329
|
+
const j = r.json || {};
|
|
330
|
+
out(sub === 'status' ? `alaya: registered=${j.registered} outbox_depth=${j.outbox_depth}` : `alaya: registered (already_registered=${!!j.already_registered}); queued reports will drain`);
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
282
334
|
function run(argv, out = console.log, deps = {}) {
|
|
335
|
+
if (argv[0] === 'alaya') return alaya(argv, out);
|
|
336
|
+
if (argv[0] === 'model') {
|
|
337
|
+
const dm = dispatchModel(paths(os.homedir()));
|
|
338
|
+
if (!dm) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
|
|
339
|
+
return dm.main(argv.slice(1), out);
|
|
340
|
+
}
|
|
283
341
|
if (argv[0] === 'ygg' || argv[0] === 'init') return passthrough(argv, deps);
|
|
284
342
|
const { cmd, flags } = parseArgs(argv);
|
|
285
343
|
const fn = { install, uninstall, status, doctor }[cmd];
|
package/lib/cli.test.js
CHANGED
|
@@ -58,6 +58,32 @@ test('kenaz ygg / init pass through to the core scripts with the remaining args'
|
|
|
58
58
|
assert.deepStrictEqual(calls[1].slice(1), ['init']);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
test('kenaz alaya register posts an empty body (machine-key path); status reads the registered flag', async () => {
|
|
62
|
+
const http = require('http');
|
|
63
|
+
const seen = [];
|
|
64
|
+
const srv = http.createServer((req, res) => {
|
|
65
|
+
let b = ''; req.on('data', (d) => { b += d; });
|
|
66
|
+
req.on('end', () => {
|
|
67
|
+
seen.push([req.method, req.url, b]);
|
|
68
|
+
res.setHeader('content-type', 'application/json');
|
|
69
|
+
res.end(req.url.endsWith('status') ? '{"registered":true,"outbox_depth":0}' : '{"registered":true,"already_registered":false}');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
73
|
+
const old = process.env.KENAZ_HTTP_PORT; process.env.KENAZ_HTTP_PORT = String(srv.address().port);
|
|
74
|
+
try {
|
|
75
|
+
const lines = [];
|
|
76
|
+
assert.strictEqual(await run(['alaya', 'register'], (l) => lines.push(l)), 0);
|
|
77
|
+
assert.strictEqual(await run(['alaya', 'status'], (l) => lines.push(l)), 0);
|
|
78
|
+
assert.deepStrictEqual(seen[0], ['POST', '/api/rust/alaya/register', '{}']);
|
|
79
|
+
assert.match(lines[1], /registered=true outbox_depth=0/);
|
|
80
|
+
assert.strictEqual(await run(['alaya', 'bogus'], () => {}), 2);
|
|
81
|
+
} finally {
|
|
82
|
+
if (old === undefined) delete process.env.KENAZ_HTTP_PORT; else process.env.KENAZ_HTTP_PORT = old;
|
|
83
|
+
srv.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
61
87
|
test('reinstall stops only the kenaz-server running from the managed path (exact exe match, by PID)', () => {
|
|
62
88
|
const { stopOwnServer } = require('./cli');
|
|
63
89
|
const killed = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kenaz": "bin/kenaz.js"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"test": "node --test lib/cli.test.js"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@kenazai/server-darwin-arm64": "0.3.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.9",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.9",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.9",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.9",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.9"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|