kenaz 0.3.11 → 0.3.13

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 +11 -1
  2. package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
  3. package/core/.claude/hooks/_run-user-prompt.js +2 -0
  4. package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
  5. package/core/.claude/hooks/correction-detect.js +99 -0
  6. package/core/.collaboration/core/task-2288-followups.test.js +2 -37
  7. package/core/.collaboration/core/task-2421-replica.test.js +229 -0
  8. package/core/.collaboration/core/task-api.js +221 -12
  9. package/core/.collaboration/core/task-replica.js +189 -0
  10. package/core/.formation/.merge-lock +1 -0
  11. package/core/.formation/bifrost/codex-doctor.js +12 -2
  12. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  13. package/core/.formation/bifrost/exec.js +147 -0
  14. package/core/.formation/bifrost/provider-launch.js +57 -0
  15. package/core/.formation/bifrost/provider-launch.test.js +62 -0
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
  18. package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
  19. package/core/.formation/tools/dispatch-model.js +15 -10
  20. package/core/.formation/tools/dispatch.js +46 -10
  21. package/core/.formation/tools/kenaz-stats.js +57 -16
  22. package/core/.formation/tools/kenaz-stats.test.js +2 -2
  23. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  24. package/core/.formation/tools/post-complete.js +129 -8
  25. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  26. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  27. package/core/.specialist/Ansuz/RULES.md +7 -2
  28. package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
  29. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  30. package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
  31. package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
  32. package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
  33. package/core/.specialist/tools/handoff-doc.js +95 -0
  34. package/core/.specialist/tools/lessons-harvest.js +243 -0
  35. package/core/.specialist/tools/lessons-lifecycle.js +103 -0
  36. package/core/.specialist/tools/lessons.js +149 -24
  37. package/core/.specialist/tools/rea-detect.js +6 -2
  38. package/core/.synapse/memory-shared.json.sync +1 -0
  39. package/core/KENAZ_CORE_VERSION +1 -1
  40. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
  41. package/core/dev/scripts/correction-detect-eval.js +70 -0
  42. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  43. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  44. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  45. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  46. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  47. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  48. package/core/plugins/kenaz/commands/Ansuz.md +11 -1
  49. package/core/plugins/kenaz/commands/kenaz.md +5 -3
  50. package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
  51. package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
  52. package/core/plugins/kenaz/scripts/statusline.js +60 -0
  53. package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
  54. package/core/plugins/kenaz/scripts/ygg.js +43 -4
  55. package/lib/cli.js +27 -10
  56. package/lib/cli.test.js +10 -0
  57. package/lib/dashboard.test.js +3 -3
  58. package/package.json +6 -6
  59. package/vendor/install-kenaz-cli.js +193 -16
  60. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
@@ -122,3 +122,71 @@ test('ygg join activates the room in collaboration_projects; status explains how
122
122
  const { buildStatus, renderStatus } = require('./ygg.js');
123
123
  assert.match(renderStatus(buildStatus(cfg, { connected: false, nodes: [] }, null)), /not active in this project - run "ygg join <key>"/);
124
124
  });
125
+
126
+ // TASK_2416: `ygg create` posts to the relay (config relay_url), then joins with the returned key.
127
+ // One local stub plays both the Kenaz backend and the relay; nothing touches ~/.kenaz or ~/.claude.
128
+ async function withStub(relayReply, fn) {
129
+ const http = require('http');
130
+ const seen = { relay: [], config: [] };
131
+ const cfg = { enabled: false, effective_project_key: 'tmp-room', node_name: 'n', publishable_key: '', collaboration_projects: [], relay_url: '' };
132
+ const srv = http.createServer((req, res) => {
133
+ let b = ''; req.on('data', (d) => { b += d; });
134
+ req.on('end', () => {
135
+ res.setHeader('content-type', 'application/json');
136
+ if (req.url === '/api/v1/teams') {
137
+ seen.relay.push({ method: req.method, body: b });
138
+ res.statusCode = relayReply.status; return res.end(JSON.stringify(relayReply.body));
139
+ }
140
+ if (req.url.startsWith('/api/nodes')) return res.end('[]');
141
+ if (req.url.startsWith('/api/yggdrasil/config')) {
142
+ if (req.method === 'POST') { seen.config.push(JSON.parse(b)); return res.end(JSON.stringify({ ok: true })); }
143
+ return res.end(JSON.stringify(cfg));
144
+ }
145
+ res.end(JSON.stringify({ success: true, created: false }));
146
+ });
147
+ });
148
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
149
+ cfg.relay_url = 'ws://127.0.0.1:' + srv.address().port;
150
+ const prev = process.env.KENAZ_HTTP_PORT;
151
+ process.env.KENAZ_HTTP_PORT = String(srv.address().port);
152
+ try { return await fn(seen, cfg); } finally {
153
+ if (prev === undefined) delete process.env.KENAZ_HTTP_PORT; else process.env.KENAZ_HTTP_PORT = prev;
154
+ srv.close();
155
+ }
156
+ }
157
+
158
+ test('ygg create posts the name to the relay, joins with the new key, prints it once (TASK_2416)', async () => {
159
+ const KEY = 'ygk_' + '2'.repeat(32);
160
+ await withStub({ status: 201, body: { ok: true, team_id: 't1', team_name: 'Acme', key: KEY, max_nodes: 10 } }, async (seen) => {
161
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg-create-'));
162
+ const { createTeam, renderStatus } = require('./ygg.js');
163
+ const s = await createTeam(dir, 'Acme', 'project');
164
+ assert.deepStrictEqual(JSON.parse(seen.relay[0].body), { name: 'Acme' });
165
+ assert.strictEqual(seen.config[0].publishable_key, KEY);
166
+ assert.strictEqual(seen.config[0].enabled, true);
167
+ assert.deepStrictEqual(seen.config[0].collaboration_projects, ['tmp-room']);
168
+ const out = renderStatus(s);
169
+ assert.match(out, new RegExp('share with teammates: kenaz ygg join ' + KEY));
170
+ assert.ok(!out.includes('Key: ' + KEY), 'status line keeps the key masked');
171
+ });
172
+ });
173
+
174
+ test('ygg create explains 429, 503, missing relay_url and unreachable relay (TASK_2416)', async () => {
175
+ const { createTeam } = require('./ygg.js');
176
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg-create-err-'));
177
+ await withStub({ status: 429, body: { ok: false, error: 'Too many team creations (limit 3 per hour)' } }, async (seen) => {
178
+ await assert.rejects(createTeam(dir, '', 'project'), /rate limited by the relay: Too many team creations/);
179
+ assert.strictEqual(seen.config.length, 0, 'no join after a failed create');
180
+ });
181
+ await withStub({ status: 503, body: { ok: false, error: 'REGISTRY_SERVICE_KEY not configured' } }, async () => {
182
+ await assert.rejects(createTeam(dir, '', 'project'), /does not offer self-serve keys: REGISTRY_SERVICE_KEY/);
183
+ });
184
+ await withStub({ status: 201, body: {} }, async (seen, cfg) => {
185
+ cfg.relay_url = '';
186
+ await assert.rejects(createTeam(dir, '', 'project'), /no relay_url configured/);
187
+ });
188
+ await withStub({ status: 201, body: {} }, async (seen, cfg) => {
189
+ cfg.relay_url = 'ws://127.0.0.1:1'; // nothing listens
190
+ await assert.rejects(createTeam(dir, '', 'project'), /cannot reach the relay at http:\/\/127\.0\.0\.1:1/);
191
+ });
192
+ });
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Claude Code status line: task counts of the Kenaz project around the session's cwd.
4
+ // ᚨ KenazAI · ▶ 1 進行中 · ☐ 12 待辦 · ⛔ 2 卡住 · ◎ 3 池 (zero counts left out)
5
+ // ᚨ KenazAI · 無待辦任務
6
+ // Prints nothing outside a Kenaz project. Reads files only (no HTTP): Claude Code runs it on every refresh.
7
+ // Registered in ~/.claude/settings.json `statusLine` by the installer only when the user has none.
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const STATES = [['active', '▶', '進行中'], ['backlog', '☐', '待辦'], ['blocked', '⛔', '卡住'], ['pool', '◎', '池']];
12
+
13
+ function projectRoot(start) {
14
+ let d = path.resolve(start);
15
+ for (;;) {
16
+ if (fs.existsSync(path.join(d, '.kenaz', 'project.yaml'))) return d;
17
+ const up = path.dirname(d);
18
+ if (up === d) return null;
19
+ d = up;
20
+ }
21
+ }
22
+
23
+ function tasksDir(root) {
24
+ const k = path.join(root, '.kenaz', 'tasks');
25
+ return fs.existsSync(k) ? k : path.join(root, '.collaboration', 'tasks');
26
+ }
27
+
28
+ function count(dir) {
29
+ try { return fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).length; } catch { return 0; }
30
+ }
31
+
32
+ function projectName(root) {
33
+ try {
34
+ const m = fs.readFileSync(path.join(root, '.kenaz', 'project.yaml'), 'utf8').match(/^name:\s*(.+)$/m);
35
+ if (m) { const n = m[1].trim().replace(/^(['"])(.*)\1$/, '$2'); if (n) return n; }
36
+ } catch { /* fall back to the folder name */ }
37
+ return path.basename(root);
38
+ }
39
+
40
+ function render(cwd) {
41
+ const root = projectRoot(cwd);
42
+ if (!root) return '';
43
+ const dir = tasksDir(root);
44
+ const parts = STATES.map(([s, icon, label]) => [count(path.join(dir, s)), icon, label]).filter(([n]) => n > 0)
45
+ .map(([n, icon, label]) => icon + ' ' + n + ' ' + label);
46
+ return 'ᚨ ' + projectName(root) + ' · ' + (parts.length ? parts.join(' · ') : '無待辦任務');
47
+ }
48
+
49
+ function cwdFromInput(raw) {
50
+ try { const j = JSON.parse(raw); return (j.workspace && j.workspace.current_dir) || j.cwd || null; } catch { return null; }
51
+ }
52
+
53
+ if (require.main === module) {
54
+ let raw = '';
55
+ const done = () => { try { process.stdout.write(render(cwdFromInput(raw) || process.cwd())); } catch { /* never break the status bar */ } };
56
+ if (process.stdin.isTTY) done();
57
+ else { process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { raw += c; }); process.stdin.on('end', done); }
58
+ }
59
+
60
+ module.exports = { render, projectRoot, cwdFromInput };
@@ -0,0 +1,41 @@
1
+ // Status line task counts (statusline.js). Temp project dirs only.
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+ const { render, cwdFromInput } = require('./statusline');
9
+
10
+ function project(counts, name) {
11
+ const p = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-sl-'));
12
+ fs.mkdirSync(path.join(p, '.kenaz'), { recursive: true });
13
+ fs.writeFileSync(path.join(p, '.kenaz', 'project.yaml'), name ? 'name: "' + name + '"\n' : 'id: x\n');
14
+ for (const [s, n] of Object.entries(counts)) {
15
+ fs.mkdirSync(path.join(p, '.kenaz', 'tasks', s), { recursive: true });
16
+ for (let i = 0; i < n; i++) fs.writeFileSync(path.join(p, '.kenaz', 'tasks', s, 'TASK_' + i + '.yaml'), 'id: TASK_' + i + '\n');
17
+ }
18
+ return p;
19
+ }
20
+
21
+ test('counts per state, zero states left out, from a subfolder of the project', () => {
22
+ const p = project({ active: 1, backlog: 12, blocked: 0, pool: 3, done: 40 }, 'Demo');
23
+ fs.mkdirSync(path.join(p, 'src', 'deep'), { recursive: true });
24
+ assert.strictEqual(render(path.join(p, 'src', 'deep')), 'ᚨ Demo · ▶ 1 進行中 · ☐ 12 待辦 · ◎ 3 池');
25
+ });
26
+
27
+ test('no open tasks, folder name when project.yaml has no name', () => {
28
+ const p = project({ done: 5 });
29
+ assert.strictEqual(render(p), 'ᚨ ' + path.basename(p) + ' · 無待辦任務');
30
+ });
31
+
32
+ test('control: outside a Kenaz project prints nothing', () => {
33
+ assert.strictEqual(render(fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-sl-none-'))), '');
34
+ });
35
+
36
+ test('reads the cwd from the Claude Code stdin JSON (real process)', () => {
37
+ const p = project({ blocked: 2 }, 'Piped');
38
+ assert.strictEqual(cwdFromInput(JSON.stringify({ workspace: { current_dir: p } })), p);
39
+ const out = execFileSync(process.execPath, [path.join(__dirname, 'statusline.js')], { input: JSON.stringify({ workspace: { current_dir: p }, cwd: os.tmpdir() }), encoding: 'utf8' });
40
+ assert.strictEqual(out, 'ᚨ Piped · ⛔ 2 卡住');
41
+ });
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // node ygg.js <join <key> [--project|--global] | status | leave> [--project-path <dir>] [--json]
3
+ // node ygg.js <join <key> [--project|--global] | create [--name N] [--project|--global] | status | leave> [--project-path <dir>] [--json]
4
4
  // Yggdrasil setup without the Dashboard (TASK_2366). Thin client over the Rust backend's
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.
@@ -98,6 +98,10 @@ function renderStatus(s) {
98
98
  'Delivery: ' + s.delivery_mode + (s.sessions.length ? '' : ' (no live sessions)'),
99
99
  ];
100
100
  for (const x of s.sessions) L.push(' - ' + x.agent + ' ' + String(x.session_id).slice(0, 8) + ': ' + x.delivery_mode);
101
+ if (s.created) {
102
+ L.push('', 'New team key (shown once, save it): ' + s.created.key + (s.created.team_name ? ' [' + s.created.team_name + ']' : ''));
103
+ L.push('share with teammates: kenaz ygg join ' + s.created.key);
104
+ }
101
105
  if (s.room_warning) L.push('WARNING: ' + s.room_warning);
102
106
  if (s.delivery_note) L.push('Note: ' + s.delivery_note);
103
107
  return L.join('\n');
@@ -143,6 +147,38 @@ async function join(dir, key, scope) {
143
147
  return status(dir);
144
148
  }
145
149
 
150
+ // TASK_2416: self-serve room key. POST <relay>/api/v1/teams (anonymous, per-IP limited), then join
151
+ // with the new key so this node is in the room immediately. The full key is returned once.
152
+ async function createTeam(dir, name, scope) {
153
+ const cfg = await getConfig(dir);
154
+ if (cfg.error) throw new Error(cfg.error);
155
+ const base = relayHttpBase(cfg.relay_url);
156
+ if (!base) throw new Error('no relay_url configured - cannot reach the relay to create a key (run "npx kenaz@latest install" or set relay_url in .kenaz/yggdrasil.yaml)');
157
+ let res;
158
+ try {
159
+ res = await fetch(base + '/api/v1/teams', {
160
+ method: 'POST', headers: { 'content-type': 'application/json' },
161
+ body: JSON.stringify(name ? { name } : {}), signal: AbortSignal.timeout(15000),
162
+ });
163
+ } catch (e) {
164
+ throw new Error('cannot reach the relay at ' + base + ' (' + (e.cause && e.cause.code || e.message) + '); check your network and try again');
165
+ }
166
+ let data = null;
167
+ try { data = await res.json(); } catch (e) { /* non-JSON body */ }
168
+ if (!res.ok || !data || !data.key) {
169
+ const why = data && data.error ? data.error : 'HTTP ' + res.status;
170
+ if (res.status === 429) throw new Error('rate limited by the relay: ' + why);
171
+ if (res.status === 503) throw new Error('this relay does not offer self-serve keys: ' + why);
172
+ throw new Error('relay refused to create a key (HTTP ' + res.status + '): ' + why);
173
+ }
174
+ let s;
175
+ try { s = await join(dir, data.key, scope); } catch (e) {
176
+ // The key exists on the relay now; never lose it because the local join failed.
177
+ throw new Error('key created (' + data.key + ') but joining failed: ' + e.message + ' - retry with: kenaz ygg join ' + data.key);
178
+ }
179
+ return Object.assign(s, { created: { key: data.key, team_name: data.team_name || name || '' } });
180
+ }
181
+
146
182
  async function leave(dir) {
147
183
  const cfg = await getConfig(dir);
148
184
  await postConfig(dir, postBody(cfg, { key_scope: 'clear_project', collaboration_projects: withCollab(cfg, false) }));
@@ -150,7 +186,7 @@ async function leave(dir) {
150
186
  }
151
187
 
152
188
  async function main(argv) {
153
- const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false;
189
+ const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false; let name = '';
154
190
  for (let i = 0; i < argv.length; i++) {
155
191
  const a = argv[i];
156
192
  if (a === '--project-path' && argv[i + 1]) dir = argv[++i];
@@ -158,6 +194,8 @@ async function main(argv) {
158
194
  else if (a === '--global') scope = 'global';
159
195
  else if (a === '--project') scope = 'project';
160
196
  else if (a === '--json') json = true;
197
+ else if (a === '--name' && argv[i + 1]) name = argv[++i];
198
+ else if (a.startsWith('--name=')) name = a.slice(7);
161
199
  else rest.push(a);
162
200
  }
163
201
  dir = path.resolve(dir);
@@ -165,9 +203,10 @@ async function main(argv) {
165
203
  let s;
166
204
  try {
167
205
  if (sub === 'join') s = await join(dir, rest[1], scope);
206
+ else if (sub === 'create') s = await createTeam(dir, name, scope);
168
207
  else if (sub === 'leave') s = await leave(dir);
169
208
  else if (sub === 'status') s = await status(dir);
170
- else throw new Error('unknown ygg subcommand: ' + sub + ' (join <key> | status | leave)');
209
+ else throw new Error('unknown ygg subcommand: ' + sub + ' (join <key> | create [--name N] | status | leave)');
171
210
  } catch (e) {
172
211
  console.error('ygg: ' + e.message + (/fetch failed|timeout|abort/i.test(e.message) ? ' (is the Kenaz backend running on ' + BASE() + '?)' : ''));
173
212
  process.exit(1);
@@ -175,5 +214,5 @@ async function main(argv) {
175
214
  console.log(json ? JSON.stringify(s, null, 2) : renderStatus(s));
176
215
  }
177
216
 
178
- module.exports = { nodeIsOnline, maskKey, buildStatus, renderStatus, join };
217
+ module.exports = { nodeIsOnline, maskKey, buildStatus, renderStatus, join, createTeam };
179
218
  if (require.main === module) main(process.argv.slice(2));
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|alaya status|alaya register|model [set <id>|unset] [--rigor r]|stats [--days N] [--json]|learn <add|list|rm>> [--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 create [--name N]|ygg status|ygg leave|alaya status|alaya register|subagent [set <id>|unset] [--rigor r]|launch --provider deepseek [--model id]|stats [--days N] [--json]|learn <add|list|rm|review|merge|graduate>> [--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: {} };
@@ -43,6 +43,7 @@ function coreModule(p, ...rel) {
43
43
  const reaDetect = (p) => coreModule(p, '.specialist', 'tools', 'rea-detect.js');
44
44
  const lessons = (p) => coreModule(p, '.specialist', 'tools', 'lessons.js');
45
45
  const dispatchModel = (p) => coreModule(p, '.formation', 'tools', 'dispatch-model.js');
46
+ const providerLaunch = (p) => coreModule(p, '.formation', 'bifrost', 'provider-launch.js');
46
47
  const kenazStats = (p) => coreModule(p, '.formation', 'tools', 'kenaz-stats.js');
47
48
 
48
49
  function paths(home) {
@@ -114,7 +115,7 @@ function retryBusy(fn, tries = 10) {
114
115
  }
115
116
  }
116
117
 
117
- function install(p, flags, out, deps = {}) {
118
+ async function install(p, flags, out, deps = {}) {
118
119
  const dry = !!flags['dry-run'];
119
120
  const tag = dry ? '[dry-run] ' : '';
120
121
  const log = (m) => out(tag + m);
@@ -167,7 +168,10 @@ function install(p, flags, out, deps = {}) {
167
168
  if (flags['no-service']) a.push('--no-service');
168
169
  if (dry) a.push('--dry-run');
169
170
  const ver = hasClaude ? inst.claudeVersion() : null;
170
- if (inst.main(a, out, () => ver) !== 0) rc = 1;
171
+ let plan = null;
172
+ if (inst.main(a, out, () => ver, { sh: deps.sh, platform: deps.platform, uid: deps.uid, onPlan: (x) => { plan = x; } }) !== 0) rc = 1;
173
+ // TASK_2418: the service was (re)started above; confirm the running server is the one just installed.
174
+ if (!dry && plan && srv && !flags['no-service']) await inst.verifyRuntime(plan, srv.version, out, deps.verify);
171
175
  }
172
176
  if (dry && hasClaude) out(`[dry-run] note: plugin paths above are the packaged core; after install they live under ${path.join(p.core, 'plugins')}`);
173
177
 
@@ -235,6 +239,10 @@ async function doctor(p, flags, out, deps = {}) {
235
239
  let ver = ''; try { ver = JSON.parse(r.body).version; } catch (_) { /* health body is optional */ }
236
240
  add('server reachable', r.ok ? 'ok' : 'FAIL', r.detail + (ver ? `, version ${ver}` : ''));
237
241
 
242
+ try { // TASK_2413: which mechanism restarts the server after a reboot (read-only; `deps.sh` / `deps.platform` for tests)
243
+ const sm = installer().detectServiceMode(p.home, p.core, deps.sh, deps.platform);
244
+ add('service mode', sm.mode === 'manual' ? 'skip' : 'ok', sm.mode + ' (' + sm.detail + ')');
245
+ } catch (e) { add('service mode', 'skip', 'unknown: ' + e.message); }
238
246
  const d = dash.detect(p.home, deps.dashboard);
239
247
  if (!d.installed) add('dashboard', 'skip', 'no Kenaz Dashboard on this machine');
240
248
  else {
@@ -277,20 +285,20 @@ async function doctor(p, flags, out, deps = {}) {
277
285
  if (dm) {
278
286
  const d = dm.describe(process.cwd());
279
287
  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>`');
288
+ 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 subagent set <id>`');
281
289
  }
282
290
  // Optional and user-installed: never a failure.
283
291
  const rea = reaDetect(p);
284
292
  if (rea) {
285
293
  const r = rea.detectRea({ home: p.home, projectPath: process.cwd() });
286
294
  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})`));
295
+ : `not installed; \`${r.setupCmd || rea.SETUP_CMD}\` lets Ansuz analyse reference products` + (r.nodeOk ? '' : ` (needs Node >=22.19, found ${process.versions.node})`));
288
296
  }
289
297
  for (const [l, s, d] of rows) out(`${s.padEnd(4)} ${l.padEnd(20)} ${d}`);
290
298
  return rows.some((r) => r[1] === 'FAIL') ? 1 : 0;
291
299
  }
292
300
 
293
- // `kenaz ygg join|status|leave ...` and `kenaz init`: short forms of the same core scripts the
301
+ // `kenaz ygg join|create|status|leave ...` and `kenaz init`: short forms of the same core scripts the
294
302
  // /kenaz (Claude) and $kenaz (Codex) commands run, from a normal terminal in the project folder.
295
303
  function passthrough(argv, deps) {
296
304
  const p = paths(os.homedir());
@@ -333,18 +341,24 @@ async function alaya(argv, out = console.log) {
333
341
 
334
342
  function run(argv, out = console.log, deps = {}) {
335
343
  if (argv[0] === 'alaya') return alaya(argv, out);
336
- if (argv[0] === 'model') {
344
+ if (argv[0] === 'subagent' || argv[0] === 'model') { // model: pre-0.3.13 name
337
345
  const dm = dispatchModel(paths(os.homedir()));
338
346
  if (!dm) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
339
347
  return dm.main(argv.slice(1), out);
340
348
  }
349
+ if (argv[0] === 'launch') {
350
+ const pl = providerLaunch(paths(os.homedir()));
351
+ if (!pl) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
352
+ return pl.main(argv.slice(1), out, deps);
353
+ }
341
354
  if (argv[0] === 'stats') {
342
355
  const ks = kenazStats(paths(os.homedir()));
343
356
  if (!ks) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
344
357
  return ks.main(argv.slice(1), out);
345
358
  }
346
359
  if (argv[0] === 'learn') {
347
- const lm = lessons(paths(os.homedir()));
360
+ const hi = argv.indexOf('--home');
361
+ const lm = lessons(paths(hi >= 0 && argv[hi + 1] ? path.resolve(argv[hi + 1]) : os.homedir()));
348
362
  if (!lm) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
349
363
  return lm.main(argv.slice(1), out);
350
364
  }
@@ -353,8 +367,11 @@ function run(argv, out = console.log, deps = {}) {
353
367
  const fn = { install, uninstall, status, doctor }[cmd];
354
368
  if (!fn) { out(USAGE); return 2; }
355
369
  if (!nodeOk()) { out(`Node.js >= ${MIN_NODE} is required (found ${process.versions.node}). Install it from https://nodejs.org and rerun.`); return 1; }
356
- try { return fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps); }
357
- catch (e) { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; }
370
+ const fail = (e) => { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; };
371
+ try {
372
+ const r = fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps);
373
+ return r && typeof r.then === 'function' ? r.catch(fail) : r;
374
+ } catch (e) { return fail(e); }
358
375
  }
359
376
 
360
377
  module.exports = { run, parseArgs, platformServer, paths, stopOwnServer, retryBusy };
package/lib/cli.test.js CHANGED
@@ -107,3 +107,13 @@ test('retryBusy retries a locked copy and then succeeds; other errors are thrown
107
107
  assert.strictEqual(n, 3);
108
108
  assert.throws(() => retryBusy(() => { const e = new Error('nope'); e.code = 'ENOENT'; throw e; }, 5), /nope/);
109
109
  });
110
+
111
+ // TASK_2413: doctor names the active service mode. Stubbed `sh` + forced platform: no real system command runs.
112
+ test('doctor reports the service mode (cron on a Linux box without systemd, manual when nothing is installed)', async () => {
113
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-doc-'));
114
+ const dashboard = { candidateDirs: () => [], procs: () => [], portPids: () => [] };
115
+ const go = async (sh) => { const l = []; await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (x) => l.push(x), { dashboard, sh, platform: 'linux' }); return l.find((x) => /service mode/.test(x)); };
116
+ const cron = await go((c) => (c === 'crontab' ? { status: 0, stdout: '@reboot x # kenaz-server (managed by kenaz install)\n' } : { status: 1, stdout: '' }));
117
+ assert.match(cron, /^ok\s+service mode\s+cron/);
118
+ assert.match(await go(() => ({ status: 1, stdout: '' })), /^skip\s+service mode\s+manual/);
119
+ });
@@ -12,9 +12,9 @@ const tmpHome = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-dash-'));
12
12
  const fake = (o = {}) => ({ candidateDirs: () => o.dirs || [], procs: () => o.procs || [], portPids: () => o.owners || [] });
13
13
  const DASH_PROC = { pid: 4242, name: 'kenaz-dashboard.exe', exe: 'C:/Users/u/AppData/Local/BLOCK ACTION/kenaz-dashboard.exe' };
14
14
 
15
- test('install with a running Dashboard owning the port: Chinese steps, rc 1, nothing written', () => {
15
+ test('install with a running Dashboard owning the port: Chinese steps, rc 1, nothing written', async () => {
16
16
  const home = tmpHome(), lines = [];
17
- const rc = run(['install', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ procs: [DASH_PROC], owners: [4242] }) });
17
+ const rc = await run(['install', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ procs: [DASH_PROC], owners: [4242] }) });
18
18
  assert.strictEqual(rc, 1);
19
19
  assert.ok(lines.join('\n').includes('npx kenaz install') && lines.join('\n').includes('開機時啟動'));
20
20
  assert.ok(!fs.existsSync(path.join(home, '.kenaz')), 'no files, no service');
@@ -43,7 +43,7 @@ test('a foreign process on the port is not treated as the Dashboard', () => {
43
43
 
44
44
  test('doctor flags a running Dashboard, its port and Dashboard plugin dirs', async () => {
45
45
  const home = tmpHome(), lines = [];
46
- const rc = await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ dirs: [os.tmpdir()], procs: [DASH_PROC], owners: [4242] }) });
46
+ const rc = await run(['doctor', '--home', home, '--no-claude', '--no-codex'], (m) => lines.push(m), { dashboard: fake({ dirs: [os.tmpdir()], procs: [DASH_PROC], owners: [4242] }), sh: () => ({ status: 1, stdout: '' }) });
47
47
  assert.strictEqual(rc, 1);
48
48
  assert.ok(lines.some((l) => /^FAIL\s+dashboard running/.test(l)) && lines.some((l) => /^FAIL\s+port \d+ owner/.test(l)));
49
49
  const d = dash.detect(home, fake({ dirs: [os.tmpdir()] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
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.11",
24
- "@kenazai/server-darwin-x64": "0.3.11",
25
- "@kenazai/server-linux-arm64": "0.3.11",
26
- "@kenazai/server-linux-x64": "0.3.11",
27
- "@kenazai/server-win32-x64": "0.3.11"
23
+ "@kenazai/server-darwin-arm64": "0.3.13",
24
+ "@kenazai/server-darwin-x64": "0.3.13",
25
+ "@kenazai/server-linux-arm64": "0.3.13",
26
+ "@kenazai/server-linux-x64": "0.3.13",
27
+ "@kenazai/server-win32-x64": "0.3.13"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {