kenaz 0.3.2 → 0.3.5

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 (71) hide show
  1. package/core/.claude/commands/Ansuz.md +36 -35
  2. package/core/.claude/hooks/__tests__/task-2395-project-registration.test.js +84 -0
  3. package/core/.claude/hooks/_ansuz-conversation.js +1 -0
  4. package/core/.claude/hooks/_ansuz-conversation.test.js +6 -0
  5. package/core/.claude/hooks/_run-user-prompt.js +0 -4
  6. package/core/.claude/hooks/_run-user-prompt.test.js +2 -3
  7. package/core/.claude/hooks/handoff-staleness.js +2 -2
  8. package/core/.claude/hooks/turn-context.js +50 -2
  9. package/core/.claude/settings.json +0 -37
  10. package/core/.collaboration/core/task-api.js +4 -12
  11. package/core/.formation/README.md +21 -146
  12. package/core/.formation/bifrost/codex-doctor.js +37 -2
  13. package/core/.formation/bifrost/codex-doctor.test.js +18 -0
  14. package/core/.formation/bifrost/launch.js +1 -1
  15. package/core/.formation/bifrost/openai/codex-hook-shim.js +6 -0
  16. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +11 -1
  17. package/core/.formation/bifrost/setup-codex.js +3 -0
  18. package/core/.formation/spec/task_operations.parity.test.js +0 -1
  19. package/core/.formation/tools/dashboard-models-chain.test.js +0 -6
  20. package/core/.formation/tools/dispatch-detail/cross-project-worktree.md +1 -9
  21. package/core/.formation/tools/dispatch-detail/worktree-subagent-mode.md +2 -2
  22. package/core/.formation/tools/dispatch.js +28 -549
  23. package/core/.formation/tools/mcp-allowlist.test.js +0 -1
  24. package/core/.formation/tools/post-complete/task2345.test.js +0 -5
  25. package/core/.formation/tools/pre-dispatch-snapshot.test.js +0 -100
  26. package/core/.formation/tools/prompt.js +66 -1607
  27. package/core/.formation/tools/prompt.test.js +43 -582
  28. package/core/.kenaz/claude-hooks-manifest.json +16 -16
  29. package/core/.specialist/Ansuz/RULES.md +6 -10
  30. package/core/.specialist/tools/__tests__/freya-cli-contract.test.js +0 -1
  31. package/core/.specialist/tools/__tests__/task-2218-boot-session-id.test.js +17 -1
  32. package/core/.specialist/tools/__tests__/task-2222-session-identity-interleave.test.js +17 -1
  33. package/core/.specialist/tools/__tests__/task-2254-handoff-archive-contract.test.js +6 -5
  34. package/core/.specialist/tools/ansuz-boot-fast.js +44 -100
  35. package/core/.specialist/tools/ansuz-boot-fast.test.js +45 -1
  36. package/core/KENAZ_CORE_VERSION +1 -1
  37. package/core/dev/plans/ANSUZ_SLIM_PLAN.md +68 -0
  38. package/core/plugins/kenaz/commands/Amelia.md +1 -1
  39. package/core/plugins/kenaz/commands/Ansuz.md +36 -35
  40. package/core/plugins/kenaz/hooks/hooks.json +1 -5
  41. package/core/plugins/kenaz/hooks/kenaz-hook.js +0 -4
  42. package/core/plugins/kenaz/scripts/kenaz-mode.js +11 -1
  43. package/core/plugins/kenaz/scripts/kenaz.test.js +1 -1
  44. package/core/plugins/kenaz/scripts/sync.js +6 -2
  45. package/core/plugins/kenaz/scripts/ygg.js +13 -1
  46. package/core/plugins/kenaz/skills/product_manager/SKILL.md +12 -12
  47. package/lib/cli.js +47 -6
  48. package/lib/cli.test.js +24 -0
  49. package/lib/dashboard.js +10 -0
  50. package/package.json +11 -7
  51. package/vendor/install-kenaz-cli.js +18 -7
  52. package/core/.formation/tools/broadcast.js +0 -103
  53. package/core/.formation/tools/dispatch-dryrun.test.js +0 -54
  54. package/core/.formation/tools/dispatch-notifier.js +0 -84
  55. package/core/.formation/tools/formations.js +0 -1203
  56. package/core/.formation/tools/headless-branch-recovery.test.js +0 -64
  57. package/core/.formation/tools/headless-dryrun.test.js +0 -79
  58. package/core/.formation/tools/headless-v2.js +0 -659
  59. package/core/.formation/tools/headless.js +0 -1071
  60. package/core/.formation/tools/legacy-scheduler.js +0 -1994
  61. package/core/.formation/tools/logger.js +0 -273
  62. package/core/.formation/tools/memory-loader.js +0 -422
  63. package/core/.formation/tools/process-state.js +0 -33
  64. package/core/.formation/tools/remote-conflict.js +0 -98
  65. package/core/.formation/tools/runner.js +0 -1332
  66. package/core/.formation/tools/task-utils.js +0 -344
  67. package/core/.formation/tools/task2028-headless-worktree.test.js +0 -112
  68. package/core/.formation/tools/tests/headless-mcp-args-plumbing.test.js +0 -123
  69. package/core/.formation/tools/worktree-leak.test.js +0 -162
  70. package/core/.specialist/tools/__tests__/task-1863-boot-lease-reuse.test.js +0 -99
  71. package/core/.specialist/tools/__tests__/task-1977-banner-cap.test.js +0 -129
@@ -12,10 +12,7 @@
12
12
  ],
13
13
  "PostToolUse": [
14
14
  { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] },
15
- { "matcher": "TaskCreate|TaskUpdate", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" task-bridge" }] },
16
- { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" snapshot-commit" }] },
17
- { "matcher": "AskUserQuestion", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" snapshot-question" }] },
18
- { "matcher": "mcp__kenaz-dashboard__synapse_inscribe", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" snapshot-decision" }] }
15
+ { "matcher": "TaskCreate|TaskUpdate", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" task-bridge" }] }
19
16
  ],
20
17
  "PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
21
18
  "Notification": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
@@ -24,7 +21,6 @@
24
21
  "SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
25
22
  "Stop": [{ "hooks": [
26
23
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" },
27
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" lease-release" },
28
24
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-stop" }
29
25
  ] }]
30
26
  }
@@ -27,14 +27,10 @@ const HOOKS = {
27
27
  'session-start': { kind: 'session-start', minMode: 'assist', backend: true },
28
28
  'guidance': { kind: 'guidance', minMode: 'full' },
29
29
  'prompt-runner': { kind: 'script', file: '_run-user-prompt.js', minMode: 'full' },
30
- 'lease-release': { kind: 'script', file: 'lease-release.js', minMode: 'full' },
31
30
  'session-stop': { kind: 'core-script', rel: '.specialist/tools/session.js', args: ['stop'], minMode: 'full' },
32
31
  'synapse-first': { kind: 'script', file: 'synapse-first-investigation.js', minMode: 'full' },
33
32
  'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
34
33
  'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
35
- 'snapshot-commit': { kind: 'script', file: 'handoff-snapshot-on-commit.js', minMode: 'assist' },
36
- 'snapshot-question': { kind: 'script', file: 'handoff-snapshot-on-question.js', minMode: 'assist' },
37
- 'snapshot-decision': { kind: 'script', file: 'handoff-snapshot-on-decision.js', minMode: 'assist' },
38
34
  };
39
35
 
40
36
  function readStdin() {
@@ -26,7 +26,17 @@ if (MODES.includes(arg)) {
26
26
  // TASK_2367: first-run guide. Optional steps are offered, never forced; the user (or the
27
27
  // assistant relaying this output) runs them. Backend probe tells whether they can work now.
28
28
  const port = process.env.KENAZ_HTTP_PORT || process.env.KENAZ_BACKEND_PORT || '18080';
29
- fetch('http://127.0.0.1:' + port + '/api/health', { signal: AbortSignal.timeout(2000) })
29
+ // TASK_2395: register the project in ~/.kenaz/projects.json (idempotent, written by the server's
30
+ // single registry writer) so peer routing by to_project finds it. Server down = skipped; the
31
+ // first turn of a session retries.
32
+ const registered = fetch('http://127.0.0.1:' + port + '/api/projects/register', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ projectPath: dir }),
36
+ signal: AbortSignal.timeout(2000),
37
+ }).then((r) => r.json(), () => null).then((j) => !!(j && j.success), () => false);
38
+ registered
39
+ .then(() => fetch('http://127.0.0.1:' + port + '/api/health', { signal: AbortSignal.timeout(2000) }))
30
40
  .then((r) => r.ok, () => false)
31
41
  .then((up) => {
32
42
  // Codex runs shell commands in a sandbox without network (it sets CODEX_SANDBOX_NETWORK_DISABLED=1),
@@ -27,7 +27,7 @@ test('full prints guidance (control for off)', () => {
27
27
 
28
28
  test('off: zero output from every hook, exit 0', () => {
29
29
  const dir = proj();
30
- for (const n of ['guidance', 'prompt-runner', 'memory-guard', 'synapse-first', 'flow-post', 'session-start', 'lease-release']) {
30
+ for (const n of ['guidance', 'prompt-runner', 'memory-guard', 'synapse-first', 'flow-post', 'session-start']) {
31
31
  const r = hook(n, dir, { KENAZ_MODE: 'off' });
32
32
  assert.strictEqual(r.status, 0, n);
33
33
  assert.strictEqual(r.stdout + r.stderr, '', n);
@@ -19,11 +19,15 @@ const SKILLS = [
19
19
  const COMMANDS = ['Amelia', 'Ansuz', 'architect', 'incident', 'review', 'security', 'spec', 'strategy', '產品', '設計', '評估'];
20
20
 
21
21
  // Core-relative references become plugin-relative / core-resolved.
22
+ // In a user's project (npm install) the core lives in ~/.kenaz/core, not the project folder, so a
23
+ // command's `node .specialist/...` / `.formation/...` / `.collaboration/...` runs through core-resolve,
24
+ // and a core doc it tells the model to read gets a path under the plugin's core (plugin root/../..).
22
25
  function rewrite(text) {
23
26
  return text
24
27
  .replace(/\.claude\/skills\//g, '${CLAUDE_PLUGIN_ROOT}/skills/')
25
- .replace(/node \.specialist\/tools\/(session|ansuz-boot-fast)\.js/g,
26
- 'node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/$1.js');
28
+ .replace(/node \.(specialist|formation|collaboration)\/([\w./-]+\.js)/g,
29
+ 'node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .$1/$2')
30
+ .replace(/`\.specialist\/([\w./-]+\.(?:md|yaml))`/g, '`${CLAUDE_PLUGIN_ROOT}/../../.specialist/$1`');
27
31
  }
28
32
 
29
33
  function copyDir(src, dst) {
@@ -77,6 +77,7 @@ function renderStatus(s) {
77
77
  const L = [
78
78
  'Yggdrasil room: ' + s.room,
79
79
  'Node name: ' + s.node_name,
80
+ ...(s.registered ? ['Registered: ' + s.registered] : []),
80
81
  'Key: ' + s.key + ' (source: ' + s.key_source + ')',
81
82
  'Collaboration: ' + (s.enabled && s.active_in_project ? 'active in this project'
82
83
  : 'not active in this project - run "ygg join <key>" (adds room "' + s.room + '" to collaboration_projects; presence is only sent for activated rooms)'),
@@ -92,13 +93,24 @@ function renderStatus(s) {
92
93
  return L.join('\n');
93
94
  }
94
95
 
96
+ // The poller and presence only know projects in ~/.kenaz/projects.json. A server-only machine has
97
+ // no Dashboard to register them, so before 0.3.4 a joined project never showed up in its room, with
98
+ // no error anywhere (field report). Registering is idempotent; status says when it had to.
99
+ async function ensureRegistered(dir) {
100
+ try {
101
+ const r = await call('POST', '/api/projects/register', { projectPath: dir });
102
+ return r && r.success ? (r.created ? 'registered now (it was missing: this machine could not be seen in the room or get mail for it)' : 'yes') : 'NO: ' + ((r && r.error) || 'unknown');
103
+ } catch (e) { return 'NO: ' + e.message + ' (kenaz-server too old? run npx kenaz@latest install)'; }
104
+ }
105
+
95
106
  async function status(dir) {
107
+ const registered = await ensureRegistered(dir);
96
108
  const cfg = await getConfig(dir);
97
109
  if (cfg.error) throw new Error(cfg.error);
98
110
  const relay = await relayNodes(cfg);
99
111
  let peer = null;
100
112
  try { peer = await call('GET', '/api/peer/status?projectPath=' + encodeURIComponent(dir)); } catch (e) { /* unknown */ }
101
- return buildStatus(cfg, relay, peer);
113
+ return Object.assign(buildStatus(cfg, relay, peer), { registered });
102
114
  }
103
115
 
104
116
  function withCollab(cfg, on) {
@@ -381,7 +381,7 @@ Scope 路徑: ✓ 合理
381
381
 
382
382
  ```bash
383
383
  # 使用 Task API 建立任務
384
- node .collaboration/core/task-api.js create --title "任務名稱" --priority P1
384
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "任務名稱" --priority P1
385
385
 
386
386
  # 或直接建立 YAML 文件到 .collaboration/tasks/backlog/
387
387
  ```
@@ -390,7 +390,7 @@ node .collaboration/core/task-api.js create --title "任務名稱" --priority P1
390
390
 
391
391
  ```bash
392
392
  # 驗證任務文件格式
393
- node .collaboration/core/task-api.js list
393
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js list
394
394
 
395
395
  # 檢查必填欄位
396
396
  # id, title, description, priority, status, role, tags, source, acceptance_criteria
@@ -415,7 +415,7 @@ node .collaboration/core/task-api.js list
415
415
  ls .collaboration/tasks/backlog/TASK_113_*.yaml
416
416
 
417
417
  # 使用 Task API 驗證
418
- node .collaboration/core/task-api.js list
418
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js list
419
419
 
420
420
  # 檢查必填欄位
421
421
  # id, title, description, priority, status, role, tags, source, acceptance_criteria
@@ -457,7 +457,7 @@ node .collaboration/core/task-api.js list
457
457
  **驗證任務**(必須執行):
458
458
  ```bash
459
459
  # 使用 Task API 驗證(新系統)
460
- node .collaboration/core/task-api.js list
460
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js list
461
461
  ```
462
462
 
463
463
  #### 8.4 批量更新 PROGRESS.md
@@ -688,7 +688,7 @@ metadata:
688
688
 
689
689
  ```bash
690
690
  # 使用 Task API 建立任務(新系統)
691
- node .collaboration/core/task-api.js create --title "任務名稱" --priority P1
691
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "任務名稱" --priority P1
692
692
 
693
693
  # 或直接建立 YAML 文件到 .collaboration/tasks/backlog/
694
694
  ```
@@ -766,9 +766,9 @@ node .collaboration/core/task-api.js create --title "任務名稱" --priority P1
766
766
 
767
767
  ```bash
768
768
  # 使用 Task API 建立任務
769
- node .collaboration/core/task-api.js create --title "實作撤銷機制" --priority P1
770
- node .collaboration/core/task-api.js create --title "新增 Undo/Redo UI" --priority P2
771
- node .collaboration/core/task-api.js create --title "整合快捷鍵" --priority P2
769
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "實作撤銷機制" --priority P1
770
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "新增 Undo/Redo UI" --priority P2
771
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "整合快捷鍵" --priority P2
772
772
  ```
773
773
 
774
774
  **Stage 8: Pipeline Integration**
@@ -1055,16 +1055,16 @@ Dev Agent 會按照優先級執行這些任務。
1055
1055
 
1056
1056
  ```bash
1057
1057
  # 列出所有任務
1058
- node .collaboration/core/task-api.js list
1058
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js list
1059
1059
 
1060
1060
  # 建立新任務
1061
- node .collaboration/core/task-api.js create --title "新功能" --priority P1
1061
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js create --title "新功能" --priority P1
1062
1062
 
1063
1063
  # 認領任務
1064
- node .collaboration/core/task-api.js claim TASK_XXX
1064
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js claim TASK_XXX
1065
1065
 
1066
1066
  # 完成任務
1067
- node .collaboration/core/task-api.js complete TASK_XXX
1067
+ node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .collaboration/core/task-api.js complete TASK_XXX
1068
1068
  ```
1069
1069
 
1070
1070
  **詳細文檔**:`.collaboration/README.md`
package/lib/cli.js CHANGED
@@ -55,7 +55,10 @@ function platformServer() {
55
55
  }
56
56
 
57
57
  function onPath(cmd) {
58
- return spawnSync(cmd, ['--version'], { encoding: 'utf8', shell: process.platform === 'win32', timeout: 15000 }).status === 0;
58
+ const r = process.platform === 'win32' // one string with shell (.cmd shims); args + shell:true is deprecated (DEP0190)
59
+ ? spawnSync(cmd + ' --version', { encoding: 'utf8', shell: true, timeout: 15000 })
60
+ : spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 15000 });
61
+ return r.status === 0;
59
62
  }
60
63
 
61
64
  /** setup-codex.js from the installed core (dry-run and doctor-before-install: from the package). */
@@ -76,6 +79,29 @@ function prepareCodexHome(p, flags) {
76
79
 
77
80
  function nodeOk() { return Number(process.versions.node.split('.')[0]) >= MIN_NODE; }
78
81
 
82
+ const samePath = (a, b) => {
83
+ const n = (x) => path.resolve(String(x || '')).replace(/\\/g, '/');
84
+ return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b);
85
+ };
86
+
87
+ /** Stop the kenaz-server running from exactly binPath (never by name). Returns the PIDs stopped. */
88
+ function stopOwnServer(binPath, adapter) {
89
+ if (!binPath || !adapter || typeof adapter.procs !== 'function') return [];
90
+ const pids = adapter.procs().filter((pr) => pr.exe && samePath(pr.exe, binPath)).map((pr) => pr.pid);
91
+ for (const pid of pids) { try { (adapter.kill || process.kill)(pid); } catch (_) { /* already gone */ } }
92
+ return pids;
93
+ }
94
+
95
+ /** Windows keeps an exe locked for a moment after its process exits: retry the copy briefly. */
96
+ function retryBusy(fn, tries = 10) {
97
+ for (let i = 1; ; i++) {
98
+ try { return fn(); } catch (e) {
99
+ if (i >= tries || !['EBUSY', 'EPERM', 'EPIPE', 'EACCES', 'ETXTBSY'].includes(e.code)) throw e;
100
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
101
+ }
102
+ }
103
+ }
104
+
79
105
  function install(p, flags, out, deps = {}) {
80
106
  const dry = !!flags['dry-run'];
81
107
  const tag = dry ? '[dry-run] ' : '';
@@ -96,11 +122,22 @@ function install(p, flags, out, deps = {}) {
96
122
  else { out(`server: no @kenazai/server-${process.platform}-${process.arch} package installed (npm install it, or pass --server-bin)`); rc = 1; }
97
123
  }
98
124
  if (!dry) {
125
+ if (srv) {
126
+ // Reinstall/upgrade: the kenaz-server this install manages may be running from p.bin. Windows
127
+ // locks a running exe (field report: "EPIPE ... being used by another process" on
128
+ // `npx kenaz@latest install`) and Linux refuses to overwrite one (ETXTBSY). Stop exactly that
129
+ // binary (exe path match, by PID); the service step below starts the new one.
130
+ const stopped = stopOwnServer(binPath, deps.dashboard || dash.realAdapter(p.home));
131
+ if (stopped.length) log(`server: stopped the running kenaz-server (PID ${stopped.join(', ')}) to replace it`);
132
+ }
99
133
  fs.rmSync(p.core, { recursive: true, force: true });
100
134
  fs.cpSync(srcCore, p.core, { recursive: true });
101
135
  if (srv) {
102
136
  fs.mkdirSync(p.bin, { recursive: true });
103
- fs.cpSync(srv.dir, p.bin, { recursive: true });
137
+ // Unix: unlink first. A running binary can be unlinked (its process keeps the old inode) but not
138
+ // overwritten (ETXTBSY), so this works even when the running server was not found above.
139
+ if (process.platform !== 'win32') fs.rmSync(binPath, { force: true });
140
+ retryBusy(() => fs.cpSync(srv.dir, p.bin, { recursive: true }));
104
141
  fs.chmodSync(binPath, 0o755);
105
142
  }
106
143
  fs.writeFileSync(p.meta, JSON.stringify({ version: PKG.version, server: srv ? srv.version : null, serverBin: binPath, installedAt: new Date().toISOString() }, null, 2));
@@ -163,9 +200,9 @@ function status(p, flags, out) {
163
200
  return 0;
164
201
  }
165
202
 
166
- function probe(port) {
203
+ function probe(port, urlPath = '/api/health') {
167
204
  return new Promise((resolve) => {
168
- const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: 2500 }, (res) => {
205
+ const req = http.get({ host: '127.0.0.1', port, path: urlPath, timeout: 2500 }, (res) => {
169
206
  let body = ''; res.on('data', (d) => { body += d; }); res.on('end', () => resolve({ ok: res.statusCode < 500, detail: `HTTP ${res.statusCode} on :${port}`, body }));
170
207
  });
171
208
  req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: `no answer on 127.0.0.1:${port}` }); });
@@ -195,7 +232,11 @@ async function doctor(p, flags, out, deps = {}) {
195
232
  const o = d.portOwner;
196
233
  add(`port ${d.port} owner`, o && d.ownerIsDashboard ? 'FAIL' : 'ok', o ? `PID ${o.pid} ${o.exe || o.name}${d.ownerIsDashboard ? ' (Dashboard)' : ''}` : 'nobody listening');
197
234
  }
198
- const upd = readJson(path.join(p.dir, 'update', 'status.json')); // TASK_2378, written by kenaz-server
235
+ // Ask the running server first: its answer carries its own version. The status file can still
236
+ // describe the previous binary right after an upgrade (field report: "[running 0.3.1]" on 0.3.3).
237
+ let upd = null;
238
+ if (r.ok) { const u = await probe(process.env.KENAZ_HTTP_PORT || '18080', '/api/update/status'); try { upd = JSON.parse(u.body); } catch (_) { /* older server */ } }
239
+ if (!upd || !upd.state) upd = readJson(path.join(p.dir, 'update', 'status.json')); // TASK_2378, written by kenaz-server
199
240
  if (!upd) add('self-update', 'skip', 'no status yet (kenaz-server has not run the updater)');
200
241
  else {
201
242
  const d = { disabled: `off: ${upd.reason}`, error: `last check failed: ${upd.last_error}`, staged: `${upd.staged_version} staged, applies when idle`,
@@ -248,4 +289,4 @@ function run(argv, out = console.log, deps = {}) {
248
289
  catch (e) { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; }
249
290
  }
250
291
 
251
- module.exports = { run, parseArgs, platformServer, paths };
292
+ module.exports = { run, parseArgs, platformServer, paths, stopOwnServer, retryBusy };
package/lib/cli.test.js CHANGED
@@ -57,3 +57,27 @@ test('kenaz ygg / init pass through to the core scripts with the remaining args'
57
57
  assert.match(calls[1][0].split(path.sep).join('/'), /plugins\/kenaz\/scripts\/kenaz-mode\.js$/);
58
58
  assert.deepStrictEqual(calls[1].slice(1), ['init']);
59
59
  });
60
+
61
+ test('reinstall stops only the kenaz-server running from the managed path (exact exe match, by PID)', () => {
62
+ const { stopOwnServer } = require('./cli');
63
+ const killed = [];
64
+ const bin = path.join(os.tmpdir(), 'kh', '.kenaz', 'bin', process.platform === 'win32' ? 'kenaz-server.exe' : 'kenaz-server');
65
+ const adapter = {
66
+ procs: () => [
67
+ { pid: 11, exe: bin },
68
+ { pid: 12, exe: path.join(os.tmpdir(), 'other', 'kenaz-server.exe') }, // same name, different path: untouched
69
+ { pid: 13, exe: '' },
70
+ ],
71
+ kill: (pid) => killed.push(pid),
72
+ };
73
+ assert.deepStrictEqual(stopOwnServer(bin, adapter), [11]);
74
+ assert.deepStrictEqual(killed, [11]);
75
+ });
76
+
77
+ test('retryBusy retries a locked copy and then succeeds; other errors are thrown at once', () => {
78
+ const { retryBusy } = require('./cli');
79
+ let n = 0;
80
+ assert.strictEqual(retryBusy(() => { if (++n < 3) { const e = new Error('busy'); e.code = 'EBUSY'; throw e; } return 'ok'; }, 5), 'ok');
81
+ assert.strictEqual(n, 3);
82
+ assert.throws(() => retryBusy(() => { const e = new Error('nope'); e.code = 'ENOENT'; throw e; }, 5), /nope/);
83
+ });
package/lib/dashboard.js CHANGED
@@ -29,6 +29,16 @@ function realAdapter(home) {
29
29
  if (r.status !== 0) return [];
30
30
  try { const v = JSON.parse(r.stdout); return (Array.isArray(v) ? v : [v]).map((x) => ({ pid: x.ProcessId, name: x.Name || '', exe: x.ExecutablePath || '' })); } catch (_) { return []; }
31
31
  }
32
+ if (process.platform === 'linux') {
33
+ // /proc is always there; `ps` is not (node:22-slim has no procps, so a reinstall could not
34
+ // find the running kenaz-server and failed with ETXTBSY).
35
+ const out = [];
36
+ for (const d of fs.readdirSync('/proc')) {
37
+ if (!/^\d+$/.test(d)) continue;
38
+ try { const exe = fs.readlinkSync('/proc/' + d + '/exe'); out.push({ pid: Number(d), name: path.basename(exe), exe }); } catch (_) { /* gone or not ours */ }
39
+ }
40
+ return out;
41
+ }
32
42
  const r = sh('ps', ['-eo', 'pid=,args=']);
33
43
  return String(r.stdout || '').split('\n').map((l) => /^\s*(\d+)\s+(.+)$/.exec(l)).filter(Boolean)
34
44
  .map((m) => ({ pid: Number(m[1]), name: path.basename(m[2].split(' ')[0]), exe: m[2].split(' ')[0] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
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,15 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.2",
24
- "@kenazai/server-darwin-x64": "0.3.2",
25
- "@kenazai/server-linux-arm64": "0.3.2",
26
- "@kenazai/server-linux-x64": "0.3.2",
27
- "@kenazai/server-win32-x64": "0.3.2"
23
+ "@kenazai/server-darwin-arm64": "0.3.5",
24
+ "@kenazai/server-darwin-x64": "0.3.5",
25
+ "@kenazai/server-linux-arm64": "0.3.5",
26
+ "@kenazai/server-linux-x64": "0.3.5",
27
+ "@kenazai/server-win32-x64": "0.3.5"
28
28
  },
29
- "license": "UNLICENSED"
29
+ "license": "UNLICENSED",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/xyz9516/KenazAI.git"
33
+ }
30
34
  }
@@ -111,7 +111,9 @@ function retireDashboardHooks(file, dry) {
111
111
  }
112
112
 
113
113
  function claudeVersion() {
114
- const r = spawnSync('claude', ['--version'], { encoding: 'utf8', shell: process.platform === 'win32', timeout: 15000 });
114
+ const r = process.platform === 'win32' // one string with shell (.cmd shim): args + shell:true is deprecated (DEP0190)
115
+ ? spawnSync('claude --version', { encoding: 'utf8', shell: true, timeout: 15000 })
116
+ : spawnSync('claude', ['--version'], { encoding: 'utf8', timeout: 15000 });
115
117
  return r.status === 0 ? parseVersion(r.stdout) : null;
116
118
  }
117
119
 
@@ -125,14 +127,21 @@ function winVbs(bin, core) { // hidden window, env for task tools; server binds
125
127
  return 'Set sh = WScript.CreateObject("WScript.Shell")\r\nsh.Environment("Process")("KENAZ_SOURCE_DIR") = "' + core + '"\r\n' +
126
128
  'sh.Run """' + bin + '""", 0, False\r\n';
127
129
  }
128
- function launchdPlist(bin, core, log) {
130
+ // launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
131
+ // nvm or /usr/local was not found and every MCP tool that runs a node script failed with
132
+ // "No such file or directory" (field report, macOS 0.3.1). Put the node running this install first.
133
+ function servicePath(nodeExe = process.execPath) {
134
+ return [path.dirname(nodeExe), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']
135
+ .filter((d, i, a) => a.indexOf(d) === i).join(':');
136
+ }
137
+ function launchdPlist(bin, core, log, nodeExe) {
129
138
  return '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n' +
130
139
  ' <key>Label</key><string>' + LABEL + '</string>\n <key>ProgramArguments</key><array><string>' + bin + '</string></array>\n' +
131
- ' <key>EnvironmentVariables</key><dict><key>KENAZ_SOURCE_DIR</key><string>' + core + '</string></dict>\n' +
140
+ ' <key>EnvironmentVariables</key><dict><key>KENAZ_SOURCE_DIR</key><string>' + core + '</string><key>PATH</key><string>' + servicePath(nodeExe) + '</string></dict>\n' +
132
141
  ' <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n <key>StandardOutPath</key><string>' + log + '</string>\n <key>StandardErrorPath</key><string>' + log + '</string>\n</dict></plist>\n';
133
142
  }
134
- function systemdUnit(bin, core) {
135
- return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
143
+ function systemdUnit(bin, core, nodeExe) {
144
+ return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nEnvironment=PATH=' + servicePath(nodeExe) + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
136
145
  }
137
146
 
138
147
  function servicePlan(home, bin, core, platform = process.platform) {
@@ -163,7 +172,9 @@ function serviceInstall(plan, dry, log) {
163
172
  fs.mkdirSync(path.dirname(plan.fallbackFile), { recursive: true });
164
173
  fs.copyFileSync(plan.files[0][0], plan.fallbackFile);
165
174
  sh('wscript.exe', [plan.files[0][0]]);
166
- log('schtasks refused (' + (r.stderr || '').trim() + '); installed Startup-folder launcher instead');
175
+ // schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
176
+ // missing admin rights for /SC ONLOGON. The Startup-folder launcher starts at logon the same way.
177
+ log('schtasks needs administrator rights here (exit ' + r.status + '); installed the Startup-folder launcher instead (also starts at logon)');
167
178
  }
168
179
  } else if (plan.kind === 'launchd') {
169
180
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
@@ -259,7 +270,7 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
259
270
  else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
260
271
  else serviceInstall(plan, dry, log);
261
272
  }
262
- if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz init; /Ansuz');
273
+ if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz:kenaz init; /kenaz:Ansuz (Codex: run /hooks once to trust the kenaz hooks, then $kenaz status)');
263
274
  if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
264
275
  return rc;
265
276
  }
@@ -1,103 +0,0 @@
1
- 'use strict';
2
- /**
3
- * Broadcast Layer
4
- *
5
- * Supabase/Yggdrasil broadcast helpers extracted from headless.js.
6
- * Lazy-loaded singletons — no hard dependency on supabase-client at require time.
7
- *
8
- * Exports: getSupabaseClient, getTaskSync, getCommitSync, syncCommit,
9
- * broadcastStatus, broadcastStatusSync
10
- */
11
-
12
- const path = require('path');
13
- const { ROOT } = require('./utils');
14
-
15
- // ─────────────────────────────────────────
16
- // Yggdrasil: kenaz_status 廣播 + commit-sync(lazy loaded, graceful degradation)
17
- // ─────────────────────────────────────────
18
- let _supabaseClient = null;
19
- let _taskSync = null;
20
- let _commitSync = null;
21
-
22
- function getSupabaseClient(projectPath) {
23
- if (_supabaseClient === undefined) return null;
24
- if (!_supabaseClient) try { _supabaseClient = require('./supabase-client'); } catch { _supabaseClient = undefined; return null; }
25
- return _supabaseClient.getClient(projectPath);
26
- }
27
-
28
- function getTaskSync() {
29
- if (_taskSync === undefined) return null;
30
- if (!_taskSync) try { _taskSync = require('./task-sync'); } catch { _taskSync = undefined; return null; }
31
- return _taskSync;
32
- }
33
-
34
- function getCommitSync() {
35
- if (_commitSync === undefined) return null;
36
- if (!_commitSync) try { _commitSync = require('./commit-sync'); } catch { _commitSync = undefined; return null; }
37
- return _commitSync;
38
- }
39
-
40
- /**
41
- * 同步 commit 到 kenaz_commits。
42
- * 回傳 Promise(await 時確保完成,或 fire-and-forget)。
43
- */
44
- function syncCommit(taskId, projectPath) {
45
- try {
46
- const cs = getCommitSync();
47
- if (!cs) return Promise.resolve();
48
- return cs.syncLatestCommit({ taskId, projectPath }).catch(() => {});
49
- } catch { return Promise.resolve(); }
50
- }
51
-
52
- /**
53
- * 廣播 kenaz_status — 讓 Agent Monitor 和其他機器看到 dispatch agent。
54
- * 回傳 Promise — caller 可 await 確保完成。
55
- */
56
- function broadcastStatus(projectPath, updates) {
57
- try {
58
- const sb = getSupabaseClient(projectPath);
59
- if (!sb) return Promise.resolve();
60
- const { client, config } = sb;
61
- const ts = getTaskSync();
62
- const project = ts?.resolveProjectName?.(projectPath || ROOT) || path.basename(ROOT);
63
- return client.from('kenaz_status').upsert({
64
- node_name: config.node_name,
65
- project,
66
- ...updates,
67
- }, { onConflict: 'node_name,project' }).then(() => {}).catch(() => {});
68
- } catch { return Promise.resolve(); }
69
- }
70
-
71
- /**
72
- * 同步版 broadcastStatus — 用於 process exit 前確保送達。
73
- * 使用 synchronous HTTP(spawnSync + curl)避免 async 在退出時丟失。
74
- */
75
- function broadcastStatusSync(projectPath, updates) {
76
- try {
77
- const sb = getSupabaseClient(projectPath);
78
- if (!sb) return;
79
- const { config } = sb;
80
- const ts = getTaskSync();
81
- const project = ts?.resolveProjectName?.(projectPath || ROOT) || path.basename(ROOT);
82
- const payload = { node_name: config.node_name, project, ...updates };
83
- const url = `${config.supabase_url}/rest/v1/kenaz_status`;
84
- const { spawnSync } = require('child_process');
85
- spawnSync('curl', [
86
- '-s', '-X', 'POST', url,
87
- '-H', `apikey: ${config.supabase_anon_key}`,
88
- '-H', `Authorization: Bearer ${config.supabase_anon_key}`,
89
- '-H', 'Content-Type: application/json',
90
- '-H', 'Prefer: resolution=merge-duplicates',
91
- '-d', JSON.stringify(payload),
92
- ], { timeout: 5000, stdio: 'ignore', windowsHide: true });
93
- } catch { /* best-effort */ }
94
- }
95
-
96
- module.exports = {
97
- getSupabaseClient,
98
- getTaskSync,
99
- getCommitSync,
100
- syncCommit,
101
- broadcastStatus,
102
- broadcastStatusSync,
103
- };
@@ -1,54 +0,0 @@
1
- // TASK_2274: formation-mode `--dry-run` must have NO side effects: no git init
2
- // of the target, no resource lock files, no claim adoption. Drives the real CLI
3
- // entry (dispatch.js -> dispatch()) against a temp project root.
4
- const test = require('node:test');
5
- const assert = require('node:assert/strict');
6
- const fs = require('fs');
7
- const os = require('os');
8
- const path = require('path');
9
- const { spawnSync } = require('child_process');
10
-
11
- const DISPATCH = path.join(__dirname, 'dispatch.js');
12
-
13
- function mkProject() {
14
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 't2274_'));
15
- fs.mkdirSync(path.join(root, '.kenaz', 'tasks', 'backlog'), { recursive: true });
16
- fs.mkdirSync(path.join(root, '.kenaz', 'tasks', 'active'), { recursive: true });
17
- fs.writeFileSync(path.join(root, '.kenaz', 'tasks', 'backlog', 'TASK_9001_dryrun.yaml'), [
18
- 'id: TASK_9001', 'title: dry run probe', 'description: probe', 'status: backlog',
19
- 'priority: P2', 'scope_paths:', ' - src/x.js', 'resources:', ' - dryrun_res', ''].join('\n'));
20
- return root;
21
- }
22
- function run(root, extra, envOver) {
23
- const env = { ...process.env, ...(envOver || {}) }; delete env.CLAUDE_PROJECT_DIR;
24
- return spawnSync(process.execPath, [DISPATCH, '--formation=unit', '--skill=do', '--task=TASK_9001',
25
- '--project-path', root, ...extra], { encoding: 'utf8', env, windowsHide: true, timeout: 90000 });
26
- }
27
- const lockFile = (root) => path.join(root, '.kenaz', 'locks', 'resource_dryrun_res.lock');
28
-
29
- test('formation-mode --dry-run: no git init, no resource lock, reports intent', () => {
30
- const root = mkProject();
31
- try {
32
- const r = run(root, ['--dry-run']);
33
- const out = (r.stdout || '') + (r.stderr || '');
34
- assert.equal(r.status, 0, out.slice(-500));
35
- assert.ok(!fs.existsSync(path.join(root, '.git')), 'dry-run must not git init');
36
- assert.ok(!fs.existsSync(lockFile(root)), 'dry-run must not acquire resource lock');
37
- assert.match(out, /\[dry-run\] resource locks NOT acquired: dryrun_res/);
38
- assert.match(out, /\[dry-run\] would git init/);
39
- } finally { fs.rmSync(root, { recursive: true, force: true }); }
40
- });
41
-
42
- test('control: non-dry-run formation dispatch DOES git init and acquire the lock', () => {
43
- const root = mkProject();
44
- try {
45
- // PATH = node + git dirs only and a fake HOME, so the real agent launch cannot
46
- // find a claude binary (it fails after the lock/git steps we are asserting).
47
- const gitDirs = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['git'], { encoding: 'utf8' })
48
- .stdout.split(/\r?\n/).filter(Boolean).map((p) => path.dirname(p));
49
- const PATH = [path.dirname(process.execPath), ...gitDirs].join(path.delimiter);
50
- run(root, [], { PATH, Path: PATH, USERPROFILE: root, HOME: root });
51
- assert.ok(fs.existsSync(path.join(root, '.git')), 'control: git init expected');
52
- assert.ok(fs.existsSync(lockFile(root)), 'control: lock expected');
53
- } finally { fs.rmSync(root, { recursive: true, force: true }); }
54
- });