kenaz 0.3.20 → 0.3.22

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 (60) hide show
  1. package/core/.claude/commands/Ansuz.md +3 -0
  2. package/core/.claude/commands/architect.md +3 -0
  3. package/core/.claude/commands/incident.md +3 -0
  4. package/core/.claude/commands/review.md +3 -0
  5. package/core/.claude/commands/security.md +3 -0
  6. package/core/.claude/commands/spec.md +3 -0
  7. package/core/.claude/hooks/__tests__/peer-session-fill.test.js +37 -0
  8. package/core/.claude/hooks/_ansuz-conversation.js +32 -1
  9. package/core/.claude/hooks/peer-optin.test.js +74 -0
  10. package/core/.claude/hooks/peer-session-fill.js +38 -0
  11. package/core/.claude/hooks/turn-context.js +5 -4
  12. package/core/.claude/settings.json +9 -0
  13. package/core/.claude/skills/architect_agent/SKILL.md +1 -1
  14. package/core/.claude/skills/assessment_agent/SKILL.md +1 -1
  15. package/core/.claude/skills/code_reviewer/SKILL.md +1 -1
  16. package/core/.claude/skills/incident_response/SKILL.md +1 -1
  17. package/core/.claude/skills/security_agent/SKILL.md +1 -1
  18. package/core/.claude/skills/spec_optimizer/SKILL.md +1 -1
  19. package/core/.formation/bifrost/codex-hooks-repair.js +115 -0
  20. package/core/.formation/bifrost/codex-hooks-repair.test.js +94 -0
  21. package/core/.formation/bifrost/openai/codex-hook-shim.js +26 -7
  22. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +2 -0
  23. package/core/.formation/bifrost/openai/codex-peer-pull.test.js +42 -0
  24. package/core/.formation/bifrost/provider-launch.js +2 -1
  25. package/core/.formation/bifrost/setup-codex.js +22 -12
  26. package/core/.specialist/Ansuz/RULES.md +7 -0
  27. package/core/KENAZ_CORE_VERSION +1 -1
  28. package/core/dashboard/scripts/install-kenaz-cli.js +456 -0
  29. package/core/dev/scripts/relay-admin-ui.js +154 -0
  30. package/core/dev/scripts/relay-admin-ui.test.js +112 -0
  31. package/core/dev/scripts/relay-admin.js +19 -10
  32. package/core/dev/scripts/skill-routing-replay.js +80 -0
  33. package/core/dev/scripts/skill-routing-replay.test.js +24 -0
  34. package/core/dev/specs/peer/codex-hook-shim-peer.test.js +17 -5
  35. package/core/plugins/kenaz/INVENTORY.md +1 -1
  36. package/core/plugins/kenaz/commands/Ansuz.md +3 -0
  37. package/core/plugins/kenaz/commands/architect.md +3 -0
  38. package/core/plugins/kenaz/commands/incident.md +3 -0
  39. package/core/plugins/kenaz/commands/review.md +3 -0
  40. package/core/plugins/kenaz/commands/security.md +3 -0
  41. package/core/plugins/kenaz/commands/spec.md +3 -0
  42. package/core/plugins/kenaz/guidance.md +1 -0
  43. package/core/plugins/kenaz/hooks/hooks.json +9 -0
  44. package/core/plugins/kenaz/hooks/kenaz-hook.js +1 -0
  45. package/core/plugins/kenaz/scripts/kenaz-mode.js +5 -0
  46. package/core/plugins/kenaz/scripts/sync.js +1 -1
  47. package/core/plugins/kenaz/scripts/ygg.js +40 -4
  48. package/core/plugins/kenaz/scripts/ygg.test.js +63 -3
  49. package/core/plugins/kenaz/skills/architect_agent/SKILL.md +1 -1
  50. package/core/plugins/kenaz/skills/assessment_agent/SKILL.md +1 -1
  51. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +1 -1
  52. package/core/plugins/kenaz/skills/incident_response/SKILL.md +1 -1
  53. package/core/plugins/kenaz/skills/security_agent/SKILL.md +1 -1
  54. package/core/plugins/kenaz/skills/spec_optimizer/SKILL.md +1 -1
  55. package/core/plugins/kenaz-peer/hooks/register.test.ts +59 -2
  56. package/core/plugins/kenaz-peer/hooks/register.ts +24 -1
  57. package/lib/cli.js +4 -0
  58. package/package.json +6 -6
  59. package/core/plugins/kenaz/skills/feature_agent/SKILL.md +0 -289
  60. /package/core/{.claude → dev/archive}/skills/feature_agent/SKILL.md +0 -0
@@ -5,6 +5,8 @@
5
5
  // /api/yggdrasil/config + /api/peer/status (same per-project/global key rules as the Dashboard,
6
6
  // TASK_2321/2349). k-cli `ygg` shells out to this file - the one implementation.
7
7
  const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
8
10
 
9
11
  const BASE = () => 'http://127.0.0.1:' + (process.env.KENAZ_HTTP_PORT || process.env.KENAZ_BACKEND_PORT || '18080');
10
12
 
@@ -86,6 +88,33 @@ function buildStatus(cfg, relay, peer) {
86
88
  };
87
89
  }
88
90
 
91
+ // TASK_2461: push needs the kenaz-peer plugin loaded by Claude Code (only in Ansuz sessions). join/create
92
+ // leave the node ready: register kenaz-peer in ~/.claude/settings.json when missing (same writer as the
93
+ // installer). Returns {state: 'ok'|'added'|'missing'|'skipped'|'error', note}. Never throws.
94
+ function loadInstaller() {
95
+ for (const p of ['../../../dashboard/scripts/install-kenaz-cli.js', '../../../packages/kenaz/vendor/install-kenaz-cli.js']) {
96
+ try { return require(path.join(__dirname, p)); } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') throw e; }
97
+ }
98
+ return null;
99
+ }
100
+ function peerPlugin(home, register, version) {
101
+ try {
102
+ const inst = loadInstaller();
103
+ if (!inst) return { state: 'skipped', note: 'installer not found' };
104
+ const settings = path.join(home, '.claude', 'settings.json');
105
+ if (!fs.existsSync(path.join(home, '.claude'))) return { state: 'skipped', note: 'claude is not installed' };
106
+ const ver = version === undefined ? inst.claudeVersion() : version;
107
+ if (ver && inst.cmpVersion(ver, inst.MIN_CLI) < 0) return { state: 'skipped', note: 'claude ' + ver.join('.') + ' < ' + inst.MIN_CLI.join('.') + ' (run `claude update`)' };
108
+ const core = path.resolve(process.env.KENAZ_CORE_DIR || path.join(__dirname, '..', '..', '..'));
109
+ const dir = path.join(core, 'plugins', 'kenaz-peer');
110
+ const list = inst.entriesOf(inst.loadSettings(settings));
111
+ if (list.some((e) => inst.norm(e) === inst.norm(dir))) return { state: 'ok', note: 'kenaz-peer plugin is registered' };
112
+ if (!register || !fs.existsSync(dir)) return { state: 'missing', note: 'kenaz-peer plugin is not registered in ' + settings + (fs.existsSync(dir) ? ' (run `kenaz ygg join <key>` or `kenaz install`)' : ' (run `npx kenaz@latest install`)') };
113
+ inst.registerPlugin(settings, dir, false);
114
+ return { state: 'added', note: 'registered the kenaz-peer plugin in ' + settings + '; restart Claude Code (new session) for it to load, then run /Ansuz for idle push' };
115
+ } catch (e) { return { state: 'error', note: 'could not check the kenaz-peer plugin: ' + e.message }; }
116
+ }
117
+
89
118
  function renderStatus(s) {
90
119
  const L = [
91
120
  'Yggdrasil room: ' + s.room,
@@ -102,14 +131,19 @@ function renderStatus(s) {
102
131
  'Last poll: ' + (s.last_poll_at ? s.last_poll_at + (s.last_poll_ok === false ? ' (FAILED)' : ' (ok)') : 'none yet'),
103
132
  'Delivery: ' + s.delivery_mode + (s.sessions.length ? '' : ' (no live sessions)'),
104
133
  ];
105
- for (const x of s.sessions) L.push(' - ' + x.agent + ' ' + String(x.session_id).slice(0, 8) + ': ' + x.delivery_mode);
134
+ for (const x of s.sessions) L.push(' ' + (x.name || x.agent) + ' (' + String(x.session_id).slice(0, 8) + ') - ' + x.delivery_mode);
106
135
  if (s.created) {
107
136
  L.push('', 'New team key (shown once, save it): ' + s.created.key + (s.created.team_name ? ' [' + s.created.team_name + ']' : ''));
108
137
  L.push('share with teammates: kenaz ygg join ' + s.created.key);
109
138
  }
110
139
  if (s.same_name_warning) L.push('WARNING: ' + s.same_name_warning);
111
140
  if (s.room_warning) L.push('WARNING: ' + s.room_warning);
112
- if (s.delivery_note) L.push('Note: ' + s.delivery_note);
141
+ const pl = s.plugin;
142
+ if (pl && ['added', 'missing', 'error'].includes(pl.state)) L.push('Push plugin: ' + pl.note);
143
+ // The server cannot know whether the plugin is registered; when this machine knows it is, do not repeat "register it".
144
+ const stale = pl && (pl.state === 'ok' || pl.state === 'added') && /register the kenaz-peer plugin|may not be registered/.test(s.delivery_note || '');
145
+ if (stale) L.push('Note: No push channel yet: the kenaz-peer plugin is registered but only serves Ansuz sessions. Run /Ansuz or /kenaz:Ansuz in this session' + (pl.state === 'added' ? ' after restarting Claude Code' : '') + ' for idle push; until then messages arrive on your next prompt.');
146
+ else if (s.delivery_note) L.push('Note: ' + s.delivery_note);
113
147
  return L.join('\n');
114
148
  }
115
149
 
@@ -200,7 +234,7 @@ async function leave(dir) {
200
234
  }
201
235
 
202
236
  async function main(argv) {
203
- const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false; let name = '';
237
+ const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false; let name = ''; let home = os.homedir();
204
238
  for (let i = 0; i < argv.length; i++) {
205
239
  const a = argv[i];
206
240
  if (a === '--project-path' && argv[i + 1]) dir = argv[++i];
@@ -208,6 +242,7 @@ async function main(argv) {
208
242
  else if (a === '--global') scope = 'global';
209
243
  else if (a === '--project') scope = 'project';
210
244
  else if (a === '--json') json = true;
245
+ else if (a === '--home' && argv[i + 1]) home = argv[++i];
211
246
  else if (a === '--name' && argv[i + 1]) name = argv[++i];
212
247
  else if (a.startsWith('--name=')) name = a.slice(7);
213
248
  else rest.push(a);
@@ -221,6 +256,7 @@ async function main(argv) {
221
256
  else if (sub === 'leave') s = await leave(dir);
222
257
  else if (sub === 'status') s = await status(dir);
223
258
  else throw new Error('unknown ygg subcommand: ' + sub + ' (join <key> | create [--name N] | status | leave)');
259
+ s.plugin = peerPlugin(path.resolve(home), sub === 'join' || sub === 'create');
224
260
  } catch (e) {
225
261
  console.error('ygg: ' + e.message + (/fetch failed|timeout|abort/i.test(e.message) ? ' (is the Kenaz backend running on ' + BASE() + '?)' : ''));
226
262
  process.exit(1);
@@ -228,5 +264,5 @@ async function main(argv) {
228
264
  console.log(json ? JSON.stringify(s, null, 2) : renderStatus(s));
229
265
  }
230
266
 
231
- module.exports = { nodeIsOnline, maskKey, buildStatus, renderStatus, join, createTeam };
267
+ module.exports = { peerPlugin, nodeIsOnline, maskKey, buildStatus, renderStatus, join, createTeam };
232
268
  if (require.main === module) main(process.argv.slice(2));
@@ -29,10 +29,10 @@ async function withBackend(persist, fn) {
29
29
  try { return await fn(srv.address().port); } finally { srv.close(); }
30
30
  }
31
31
 
32
- function cli(port, dir) {
32
+ function cli(port, dir, home = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg-home-'))) { // never the real ~/.claude
33
33
  return new Promise((resolve) => {
34
- const c = spawn(process.execPath, [YGG, 'join', 'ygk_abc', '--project-path', dir],
35
- { env: Object.assign({}, process.env, { KENAZ_HTTP_PORT: String(port) }) });
34
+ const c = spawn(process.execPath, [YGG, 'join', 'ygk_abc', '--project-path', dir].concat(['--home', home]),
35
+ { env: Object.assign({}, process.env, { KENAZ_HTTP_PORT: String(port), KENAZ_CORE_DIR: CORE }) });
36
36
  let out = ''; let err = '';
37
37
  c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { err += d; });
38
38
  c.on('close', (code) => resolve({ code, out, err }));
@@ -70,3 +70,63 @@ test('join refuses when the backend reports no effective_project_key (nothing to
70
70
  assert.match(r.err, /cannot work out this project's room/);
71
71
  } finally { srv.close(); }
72
72
  });
73
+
74
+ // TASK_2461: join leaves the node push-ready (kenaz-peer registered), touching only the --home it is given.
75
+ // a temp core: the checkout may itself be a .kenaz-worktrees path, which the installer refuses to register
76
+ const CORE = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-core-'));
77
+ const CORE_PEER = path.join(CORE, 'plugins', 'kenaz-peer');
78
+ fs.mkdirSync(CORE_PEER, { recursive: true });
79
+ function tempHome(settings) {
80
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
81
+ fs.mkdirSync(path.join(home, '.claude'));
82
+ const f = path.join(home, '.claude', 'settings.json');
83
+ fs.writeFileSync(f, settings);
84
+ return { home, f };
85
+ }
86
+
87
+ test('join registers a missing kenaz-peer in the temp settings.json, names the restart, and is idempotent', async () => {
88
+ const { home, f } = tempHome(JSON.stringify({ env: { CLAUDE_CODE_PLUGIN_DIRS: '/other/plugin' } }));
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
90
+ await withBackend(true, async (port) => {
91
+ const r = await cli(port, dir, home);
92
+ assert.strictEqual(r.code, 0, r.err);
93
+ assert.ok(JSON.parse(fs.readFileSync(f, 'utf8')).env.CLAUDE_CODE_PLUGIN_DIRS.split(path.delimiter).includes(CORE_PEER));
94
+ assert.match(r.out, /restart Claude Code/);
95
+ const after = fs.readFileSync(f, 'utf8');
96
+ const r2 = await cli(port, dir, home);
97
+ assert.strictEqual(r2.code, 0, r2.err);
98
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), after);
99
+ assert.doesNotMatch(r2.out, /restart Claude Code/);
100
+ });
101
+ });
102
+
103
+ test('join leaves settings.json byte-identical when kenaz-peer is already registered', async () => {
104
+ const txt = JSON.stringify({ env: { CLAUDE_CODE_PLUGIN_DIRS: CORE_PEER } });
105
+ const { home, f } = tempHome(txt);
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
107
+ await withBackend(true, async (port) => {
108
+ const r = await cli(port, dir, home);
109
+ assert.strictEqual(r.code, 0, r.err);
110
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), txt);
111
+ });
112
+ });
113
+
114
+ test('rendered status: registered but not Ansuz never says to register the plugin; unregistered says what to do', () => {
115
+ const { renderStatus, buildStatus } = require('./ygg.js');
116
+ const base = buildStatus({ effective_project_key: 'r', node_name: 'n', enabled: true, collaboration_projects: ['r'] }, { connected: true, nodes: [] },
117
+ { delivery_mode: 'pull-on-prompt', delivery_note: 'No push channel. No kenaz-peer plugin heartbeat seen: register the kenaz-peer plugin, then run /Ansuz' });
118
+ const ok = renderStatus(Object.assign({}, base, { plugin: { state: 'ok', note: 'x' } }));
119
+ assert.doesNotMatch(ok, /register the kenaz-peer plugin/);
120
+ assert.match(ok, /only serves Ansuz sessions/);
121
+ const miss = renderStatus(Object.assign({}, base, { plugin: { state: 'missing', note: 'kenaz-peer plugin is not registered in S (run `kenaz ygg join <key>` or `kenaz install`)' } }));
122
+ assert.match(miss, /Push plugin:.*not registered/);
123
+ });
124
+
125
+ test('TASK_2463: rendered status shows each session as name (short id) - mode', () => {
126
+ const { renderStatus, buildStatus } = require('./ygg.js');
127
+ const st = buildStatus({ effective_project_key: 'r', node_name: 'n', enabled: true, collaboration_projects: ['r'] }, { connected: true, nodes: [] },
128
+ { delivery_mode: 'push', sessions: [{ session_id: 'abcdef123456', agent: 'claude', name: 'my-work', delivery_mode: 'push' }, { session_id: 'zzzzzzzz9999', agent: 'codex', delivery_mode: 'pull-on-prompt' }] });
129
+ const out = renderStatus(st);
130
+ assert.match(out, /^ {2}my-work \(abcdef12\) - push$/m);
131
+ assert.match(out, /^ {2}codex \(zzzzzzzz\) - pull-on-prompt$/m);
132
+ });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Architect Agent
3
- description: 架構設計、需求拆解、任務分派、接口定義、多Agent協調
3
+ description: Architecture design and requirement breakdown into parallel tasks with interfaces and dependencies. Use before carding multi-module work, when planning a migration (for example replacing a subsystem), or when designing a new protocol. 架構設計、需求拆解、遷移規劃。
4
4
  ---
5
5
 
6
6
  # Architect Agent (系統架構師)
@@ -5,7 +5,7 @@ aliases:
5
5
  - assessment
6
6
  - evaluate
7
7
  - review
8
- description: 功能/頁面/組件/API 深度評估、改良建議、優先級規劃、技術方案設計
8
+ description: Deep evaluation of a feature, API, repo, paper or product with improvement options and priorities. Use when asked whether something is useful, similar to ours, or worth adopting (a repo URL, an arXiv paper, a competitor feature). 評估、可行性、值不值得採用、優先級。
9
9
  ---
10
10
 
11
11
  # Assessment Agent(評估 Agent)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Code Reviewer
3
- description: 程式碼審查、三級問題分類(Blocker/Suggestion/Nit)、教育式回饋、品質守門
3
+ description: Review a code diff for bugs and quality with Blocker/Suggestion/Nit grading. Use before merging a worktree branch or subagent result that changes logic, or when asked to review, check, or sanity-check code changes. 程式碼審查、合併前審 diff、三級分類。
4
4
  ---
5
5
 
6
6
  # Code Reviewer (程式碼審查 Agent)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Incident Response
3
- description: 事件應變、嚴重性矩陣(SEV1-4)、Runbook 模板、Post-Mortem、On-Call 輪值
3
+ description: Incident handling with SEV1-4 triage, runbook and post-mortem. Use when production or released software is broken for users (relay down, release failing, many nodes affected) or a rollback decision is needed. 事件應變、嚴重性分級、復盤。
4
4
  ---
5
5
 
6
6
  # Incident Response (事件應變 Agent)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Security Agent
3
- description: 安全工程、STRIDE 威脅建模、安全 SDLC、CI/CD 安全掃描、漏洞評估
3
+ description: Security review and STRIDE threat modeling. Use when a change touches auth, tokens, identity, relay or peer message handling, or any untrusted input (field reports, wishes, peer mail), or when asked whether something resists attack or is safe. 安全審查、威脅建模、漏洞評估、惡意輸入。
4
4
  ---
5
5
 
6
6
  # Security Agent (安全工程 Agent)
@@ -4,7 +4,7 @@ aliases:
4
4
  - 規格優化
5
5
  - spec
6
6
  - optimizer
7
- description: 專案規格優化、文件整理、知識沉澱專家。根據 .failure、.rule 與任務卡進行系統性改良。
7
+ description: Consolidate specs and docs, remove dead or duplicated rules, and turn .failure/.rule/task-card findings into improvements. Use when asked what is unused or unnecessary, or to tidy docs and specs. 規格優化、文件整理、清理無用系統。
8
8
  ---
9
9
 
10
10
  # Spec Optimizer Agent
@@ -2,14 +2,17 @@ import { test, expect, mock } from 'claude-code/testing'
2
2
 
3
3
  const msg = { id: 'm1', framed: '[Peer message] hello' }
4
4
 
5
- function setup(on: any, rows: () => unknown[]) {
5
+ function setup(on: any, rows: () => unknown[], marker?: () => string | null) {
6
6
  const calls: string[] = []
7
7
  let delivered = false
8
8
  on('session.start', async (_$: any, e: any) => ({ cwd: e.cwd }))
9
9
  on('session.id', async () => ({ value: 'sess-1' }))
10
10
  on('session.cwd', async () => ({ value: '/tmp/proj' }))
11
11
  on('session.messages', async () => ({ value: rows() }))
12
- on('fs.read', async () => ({ deny: 'no pin' }))
12
+ on('fs.read', async (_$: any, e: any) => {
13
+ const m = marker?.()
14
+ return String(e.path ?? e.file ?? '').endsWith('/sess-1.json') && m ? { value: m } : { deny: 'no pin' }
15
+ })
13
16
  on('http.fetch', async (_$: any, e: any) => {
14
17
  const url = String(e.url)
15
18
  calls.push(`${e.init?.method ?? 'GET'} ${url}`)
@@ -144,3 +147,57 @@ test('plugin-namespaced /kenaz:Ansuz also activates delivery', async ($, on) =>
144
147
  expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
145
148
  expect(submits.length).toBe(1)
146
149
  })
150
+
151
+ // TASK_2465: collaboration opt-in (marker written by peer-session-fill.js, or KENAZ_PEER_OPTIN=1 from kenaz launch).
152
+ const PLAIN = [{ role: 'user', content: 'hello there' }]
153
+ const MARKER_ENV = { CLAUDE_CODE_ENTRYPOINT: 'cli', KENAZ_PEER_OPTIN_DIR: '/m' }
154
+
155
+ test('TASK_2465 privacy: plain session without opt-in never heartbeats, peeks or submits', async ($, on) => {
156
+ const clock = mock.clock(on)
157
+ mock.env(on, MARKER_ENV)
158
+ const { calls, submits } = setup(on, () => PLAIN, () => null)
159
+ await $.session.start(START)
160
+ await clock.advance(15_000)
161
+ expect(calls.length).toBe(0)
162
+ expect(submits.length).toBe(0)
163
+ })
164
+
165
+ test('TASK_2465: opt-in marker makes a plain session heartbeat and receive push', async ($, on) => {
166
+ const clock = mock.clock(on)
167
+ mock.env(on, MARKER_ENV)
168
+ const { calls, submits } = setup(on, () => PLAIN, () => JSON.stringify({ ts: Date.now() }))
169
+ await $.session.start(START)
170
+ await clock.advance(5_000)
171
+ await clock.advance(5_000)
172
+ expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
173
+ expect(submits.length).toBe(1)
174
+ })
175
+
176
+ test('TASK_2465: an expired marker is not an opt-in', async ($, on) => {
177
+ const clock = mock.clock(on)
178
+ mock.env(on, MARKER_ENV)
179
+ const { calls, submits } = setup(on, () => PLAIN, () => JSON.stringify({ ts: Date.now() - 25 * 3600 * 1000 }))
180
+ await $.session.start(START)
181
+ await clock.advance(15_000)
182
+ expect(calls.length).toBe(0)
183
+ expect(submits.length).toBe(0)
184
+ })
185
+
186
+ test('TASK_2465: KENAZ_PEER_OPTIN=1 does not opt in a claude -p worker (sdk-cli)', async ($, on) => {
187
+ const clock = mock.clock(on)
188
+ mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'sdk-cli', KENAZ_PEER_OPTIN: '1' })
189
+ const { calls } = setup(on, () => PLAIN)
190
+ await $.session.start(START)
191
+ await clock.advance(15_000)
192
+ expect(calls.length).toBe(0)
193
+ })
194
+
195
+ test('TASK_2465: KENAZ_PEER_OPTIN=1 (kenaz launch) opts a plain session in', async ($, on) => {
196
+ const clock = mock.clock(on)
197
+ mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'cli', KENAZ_PEER_OPTIN: '1' })
198
+ const { calls } = setup(on, () => PLAIN)
199
+ await $.session.start(START)
200
+ await clock.advance(5_000)
201
+ await clock.advance(5_000)
202
+ expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
203
+ })
@@ -20,6 +20,8 @@ let cwd = ''
20
20
  let beaten = false
21
21
  let busy = false
22
22
  let ansuz = false // sticky: once a session is Ansuz it never flips back
23
+ let entrypoint = '' // CLAUDE_CODE_ENTRYPOINT; the env opt-in counts only for the interactive 'cli'
24
+ let optinLive = false // TASK_2465: opt-in marker seen on the last check (expires, so not sticky)
23
25
  // Same markers as .claude/hooks/_ansuz-conversation.js (turn-context's gate).
24
26
  const MARKERS = [
25
27
  '<command-name>/Ansuz</command-name>',
@@ -65,8 +67,28 @@ async function project($: any): Promise<string> {
65
67
  }
66
68
 
67
69
  // Only USER-role text counts (strings or text blocks); tool results do not.
70
+ // TASK_2465: opt-in marker written by .claude/hooks/peer-session-fill.js (kenaz_peer_send / ygg join|create) under
71
+ // <KENAZ_PEER_OPTIN_DIR | ~/.kenaz/peer-optin>/<session id>.json, or KENAZ_PEER_OPTIN=1 (kenaz launch). 24 h expiry.
72
+ const OPTIN_TTL_MS = 24 * 3600 * 1000
73
+ async function optedIn($: any): Promise<boolean> {
74
+ try {
75
+ if (entrypoint === 'cli' && (await $.env.get('KENAZ_PEER_OPTIN')) === '1') return true
76
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return false
77
+ let dir = (await $.env.get('KENAZ_PEER_OPTIN_DIR')) ?? ''
78
+ if (!dir) {
79
+ const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
80
+ if (!home) return false
81
+ dir = `${home}/.kenaz/peer-optin`
82
+ }
83
+ const f = await $.fs.read(`${dir}/${sessionId}.json`)
84
+ const age = Date.now() - Number(JSON.parse(typeof f === 'string' ? f : f.text).ts)
85
+ return age >= 0 && age < OPTIN_TTL_MS
86
+ } catch { return false }
87
+ }
88
+
68
89
  async function isAnsuz($: any): Promise<boolean> {
69
90
  if (ansuz) return true
91
+ if ((optinLive = await optedIn($))) return true
70
92
  try {
71
93
  const rows = await $.session.messages({ as: 'api' })
72
94
  for (const m of rows) {
@@ -163,10 +185,11 @@ export const register: Register = on => {
163
185
  ep = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? ''
164
186
  optIn = (await $.env.get('KENAZ_IN_SDK')) ?? ''
165
187
  } catch { /* env unreadable: behave as an interactive session */ }
188
+ entrypoint = ep
166
189
  if (ep.startsWith('sdk-') && ep !== 'sdk-cli' && optIn !== '1') return next(e)
167
190
  sessionId = String(await $.session.id())
168
191
  cwd = String(await $.session.cwd())
169
- $.clock.every(HEARTBEAT_MS, () => { if (ansuz) void beat($) })
192
+ $.clock.every(HEARTBEAT_MS, () => { if (ansuz || optinLive) void beat($) })
170
193
  $.clock.every(POLL_MS, () => { void cycle($) })
171
194
  return next(e)
172
195
  })
package/lib/cli.js CHANGED
@@ -283,6 +283,10 @@ async function doctor(p, flags, out, deps = {}) {
283
283
  const cx = codexModule(p), url = cx.getRegisteredMcpUrl(), miss = cx.missingHookEvents();
284
284
  add('codex mcp', url ? 'ok' : 'FAIL', url || 'kenaz-dashboard entry missing in config.toml');
285
285
  add('codex hooks', miss.length ? 'FAIL' : 'ok', miss.length ? 'missing: ' + miss.join(', ') : 'registered (trust once via /hooks in codex)');
286
+ // TASK_2460: entries in hooks.json / config.toml that run a script which no longer exists fail on every prompt
287
+ let stale = null;
288
+ try { const rep = require(path.join(p.core, '.formation', 'bifrost', 'codex-hooks-repair.js')); stale = rep.describe(rep.scan(cx.getCodexHome())); } catch (_) { /* older core without the module */ }
289
+ add('codex hook files', stale ? 'FAIL' : 'ok', stale || 'every Kenaz hook entry points at an existing file');
286
290
  }
287
291
  const dm = dispatchModel(p);
288
292
  if (dm) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
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.20",
24
- "@kenazai/server-darwin-x64": "0.3.20",
25
- "@kenazai/server-linux-arm64": "0.3.20",
26
- "@kenazai/server-linux-x64": "0.3.20",
27
- "@kenazai/server-win32-x64": "0.3.20"
23
+ "@kenazai/server-darwin-arm64": "0.3.22",
24
+ "@kenazai/server-darwin-x64": "0.3.22",
25
+ "@kenazai/server-linux-arm64": "0.3.22",
26
+ "@kenazai/server-linux-x64": "0.3.22",
27
+ "@kenazai/server-win32-x64": "0.3.22"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {