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.
Files changed (34) hide show
  1. package/core/.claude/commands/Ansuz.md +7 -1
  2. package/core/.claude/hooks/autonomy-stop.js +20 -0
  3. package/core/.claude/hooks/turn-context.js +7 -2
  4. package/core/.claude/settings.json +10 -0
  5. package/core/.collaboration/core/reconcile.js +3 -1
  6. package/core/.collaboration/core/task-2404-reconcile.test.js +8 -0
  7. package/core/.formation/bifrost/openai/codex-hook-shim.js +12 -1
  8. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +1 -1
  9. package/core/.formation/tools/__tests__/dispatch-model.test.js +58 -0
  10. package/core/.formation/tools/dispatch-model.js +91 -0
  11. package/core/.formation/tools/dispatch.js +8 -6
  12. package/core/.specialist/Ansuz/RULES.md +36 -14
  13. package/core/.specialist/Ansuz/elixir.yaml +7 -5
  14. package/core/.specialist/tools/__tests__/autonomy.test.js +91 -0
  15. package/core/.specialist/tools/__tests__/rea-detect.test.js +71 -0
  16. package/core/.specialist/tools/ansuz-boot-fast.js +1 -0
  17. package/core/.specialist/tools/autonomy.js +88 -0
  18. package/core/.specialist/tools/rea-detect.js +67 -0
  19. package/core/KENAZ_CORE_VERSION +1 -1
  20. package/core/plugins/kenaz/commands/Ansuz.md +7 -1
  21. package/core/plugins/kenaz/hooks/hooks.json +2 -1
  22. package/core/plugins/kenaz/hooks/kenaz-hook.js +1 -0
  23. package/core/plugins/kenaz/scripts/kenaz.test.js +1 -1
  24. package/core/plugins/kenaz/scripts/ygg.js +10 -2
  25. package/core/plugins/kenaz-peer/hooks/register.test.ts +52 -0
  26. package/core/plugins/kenaz-peer/hooks/register.ts +31 -1
  27. package/lib/cli.js +59 -1
  28. package/lib/cli.test.js +26 -0
  29. package/package.json +6 -6
  30. package/core/k-cli/README.md +0 -330
  31. package/core/k-cli/package.json +0 -21
  32. package/core/k-cli/templates/k-cli.md +0 -91
  33. package/core/k-cli/test/TEST_SPEC.md +0 -117
  34. 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 };
@@ -1 +1 @@
1
- 0.3.7
1
+ 0.3.9
@@ -37,4 +37,10 @@ Kenaz 的工具、hook、安裝、peer 訊息、派工擋住或拖慢工作時
37
37
  - 一個問題只報一次;報完照常用替代做法把工作完成,並在回報裡告訴用戶已送出 wish。
38
38
  - 用戶直接說「回報這個問題」時也用同一個工具(`source`=`user`)。
39
39
 
40
- 需要更多規則時(用戶授權自主、派工前、合併前)讀 `${CLAUDE_PLUGIN_ROOT}/../../.specialist/Ansuz/RULES.md`。
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.is_online && n.node_name !== self).map((n) => n.node_name);
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.7",
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.7",
24
- "@kenazai/server-darwin-x64": "0.3.7",
25
- "@kenazai/server-linux-arm64": "0.3.7",
26
- "@kenazai/server-linux-x64": "0.3.7",
27
- "@kenazai/server-win32-x64": "0.3.7"
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": {