kenaz 0.3.11 → 0.3.12

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 (31) hide show
  1. package/core/.collaboration/core/task-2288-followups.test.js +2 -37
  2. package/core/.collaboration/core/task-2421-replica.test.js +229 -0
  3. package/core/.collaboration/core/task-api.js +221 -12
  4. package/core/.collaboration/core/task-replica.js +189 -0
  5. package/core/.formation/.merge-lock +1 -0
  6. package/core/.formation/bifrost/codex-doctor.js +12 -2
  7. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  8. package/core/.formation/bifrost/setup-codex.js +84 -16
  9. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  10. package/core/.formation/tools/post-complete.js +129 -8
  11. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  12. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  13. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  14. package/core/.specialist/tools/rea-detect.js +6 -2
  15. package/core/.synapse/memory-shared.json.sync +1 -0
  16. package/core/KENAZ_CORE_VERSION +1 -1
  17. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
  18. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  19. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  20. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  21. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  22. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  23. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  24. package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
  25. package/core/plugins/kenaz/scripts/ygg.js +43 -4
  26. package/lib/cli.js +17 -7
  27. package/lib/cli.test.js +10 -0
  28. package/lib/dashboard.test.js +3 -3
  29. package/package.json +6 -6
  30. package/vendor/install-kenaz-cli.js +167 -16
  31. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
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|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>]';
18
18
 
19
19
  function parseArgs(argv) {
20
20
  const o = { cmd: argv[0], flags: {} };
@@ -114,7 +114,7 @@ function retryBusy(fn, tries = 10) {
114
114
  }
115
115
  }
116
116
 
117
- function install(p, flags, out, deps = {}) {
117
+ async function install(p, flags, out, deps = {}) {
118
118
  const dry = !!flags['dry-run'];
119
119
  const tag = dry ? '[dry-run] ' : '';
120
120
  const log = (m) => out(tag + m);
@@ -167,7 +167,10 @@ function install(p, flags, out, deps = {}) {
167
167
  if (flags['no-service']) a.push('--no-service');
168
168
  if (dry) a.push('--dry-run');
169
169
  const ver = hasClaude ? inst.claudeVersion() : null;
170
- if (inst.main(a, out, () => ver) !== 0) rc = 1;
170
+ let plan = null;
171
+ if (inst.main(a, out, () => ver, { sh: deps.sh, platform: deps.platform, uid: deps.uid, onPlan: (x) => { plan = x; } }) !== 0) rc = 1;
172
+ // TASK_2418: the service was (re)started above; confirm the running server is the one just installed.
173
+ if (!dry && plan && srv && !flags['no-service']) await inst.verifyRuntime(plan, srv.version, out, deps.verify);
171
174
  }
172
175
  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
176
 
@@ -235,6 +238,10 @@ async function doctor(p, flags, out, deps = {}) {
235
238
  let ver = ''; try { ver = JSON.parse(r.body).version; } catch (_) { /* health body is optional */ }
236
239
  add('server reachable', r.ok ? 'ok' : 'FAIL', r.detail + (ver ? `, version ${ver}` : ''));
237
240
 
241
+ try { // TASK_2413: which mechanism restarts the server after a reboot (read-only; `deps.sh` / `deps.platform` for tests)
242
+ const sm = installer().detectServiceMode(p.home, p.core, deps.sh, deps.platform);
243
+ add('service mode', sm.mode === 'manual' ? 'skip' : 'ok', sm.mode + ' (' + sm.detail + ')');
244
+ } catch (e) { add('service mode', 'skip', 'unknown: ' + e.message); }
238
245
  const d = dash.detect(p.home, deps.dashboard);
239
246
  if (!d.installed) add('dashboard', 'skip', 'no Kenaz Dashboard on this machine');
240
247
  else {
@@ -284,13 +291,13 @@ async function doctor(p, flags, out, deps = {}) {
284
291
  if (rea) {
285
292
  const r = rea.detectRea({ home: p.home, projectPath: process.cwd() });
286
293
  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})`));
294
+ : `not installed; \`${r.setupCmd || rea.SETUP_CMD}\` lets Ansuz analyse reference products` + (r.nodeOk ? '' : ` (needs Node >=22.19, found ${process.versions.node})`));
288
295
  }
289
296
  for (const [l, s, d] of rows) out(`${s.padEnd(4)} ${l.padEnd(20)} ${d}`);
290
297
  return rows.some((r) => r[1] === 'FAIL') ? 1 : 0;
291
298
  }
292
299
 
293
- // `kenaz ygg join|status|leave ...` and `kenaz init`: short forms of the same core scripts the
300
+ // `kenaz ygg join|create|status|leave ...` and `kenaz init`: short forms of the same core scripts the
294
301
  // /kenaz (Claude) and $kenaz (Codex) commands run, from a normal terminal in the project folder.
295
302
  function passthrough(argv, deps) {
296
303
  const p = paths(os.homedir());
@@ -353,8 +360,11 @@ function run(argv, out = console.log, deps = {}) {
353
360
  const fn = { install, uninstall, status, doctor }[cmd];
354
361
  if (!fn) { out(USAGE); return 2; }
355
362
  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; }
363
+ const fail = (e) => { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; };
364
+ try {
365
+ const r = fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps);
366
+ return r && typeof r.then === 'function' ? r.catch(fail) : r;
367
+ } catch (e) { return fail(e); }
358
368
  }
359
369
 
360
370
  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.12",
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.12",
24
+ "@kenazai/server-darwin-x64": "0.3.12",
25
+ "@kenazai/server-linux-arm64": "0.3.12",
26
+ "@kenazai/server-linux-x64": "0.3.12",
27
+ "@kenazai/server-win32-x64": "0.3.12"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
+ const http = require('http');
16
17
  const { spawnSync } = require('child_process');
17
18
 
18
19
  const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
@@ -144,7 +145,30 @@ function systemdUnit(bin, core, nodeExe) {
144
145
  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';
145
146
  }
146
147
 
147
- function servicePlan(home, bin, core, platform = process.platform) {
148
+ // TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
149
+ // silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
150
+ const CRON_MARK = '# kenaz-server (managed by kenaz install)';
151
+ const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
152
+ function cronStartCmd(bin, core, log, nodeExe) {
153
+ return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
154
+ }
155
+ function cronLine(bin, core, log, nodeExe) { // % is special in crontab
156
+ return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
157
+ }
158
+ // stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
159
+ // process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
160
+ function stopCmd(plan) {
161
+ const b = shq(plan.bin);
162
+ return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
163
+ '[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
164
+ }
165
+ // start now, detached from the installer (new session when setsid exists), output to the log
166
+ function detachedStartCmd(plan) {
167
+ return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
168
+ 'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
169
+ }
170
+
171
+ function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
148
172
  const L = launcherPaths(home);
149
173
  if (platform === 'win32') {
150
174
  const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
@@ -155,12 +179,101 @@ function servicePlan(home, bin, core, platform = process.platform) {
155
179
  const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
156
180
  return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
157
181
  }
182
+ if (caps.systemd === false) {
183
+ return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
184
+ }
158
185
  const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
159
186
  return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
160
187
  }
161
- const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
188
+ const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
189
+
190
+ // crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
191
+ function readCron(run) {
192
+ const r = run('crontab', ['-l']);
193
+ if (!r || r.error || r.status === 127) return null;
194
+ return r.status === 0 ? String(r.stdout || '') : '';
195
+ }
196
+ function probeLinux(run) {
197
+ const r = run('systemctl', ['--user', 'show-environment']);
198
+ const systemd = !!r && !r.error && r.status === 0;
199
+ return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
200
+ }
201
+ function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
202
+ const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
203
+ const next = line ? keep.concat(line) : keep;
204
+ const text = next.length ? next.join('\n') + '\n' : '';
205
+ if (text === current) return false;
206
+ if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
207
+ return true;
208
+ }
162
209
 
163
- function serviceInstall(plan, dry, log) {
210
+ // TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
211
+ // explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
212
+ // stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
213
+ // restart an active unit. restartHint is also the command printed when the version check fails.
214
+ const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
215
+ function restartHint(plan, uid = defaultUid()) {
216
+ if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
217
+ if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
218
+ if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
219
+ // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
220
+ return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
221
+ }
222
+ function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
223
+ if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
224
+ log('start now (detached, log ' + plan.log + ')');
225
+ if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
226
+ return;
227
+ }
228
+ if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
229
+ log(restartHint(plan, uid));
230
+ if (dry) return;
231
+ if (plan.kind === 'launchd') {
232
+ const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
233
+ if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
234
+ log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
235
+ run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
236
+ }
237
+ } else run('systemctl', ['--user', 'restart', 'kenaz-server']);
238
+ }
239
+
240
+ /** One GET /api/health; resolves { ok, version }. */
241
+ function healthOnce(port, getFn = http.get) {
242
+ return new Promise((resolve) => {
243
+ const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
244
+ let b = ''; res.on('data', (d) => { b += d; });
245
+ res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
246
+ });
247
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
248
+ req.on('error', () => resolve({ ok: false, version: null }));
249
+ });
250
+ }
251
+ /**
252
+ * TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
253
+ * installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
254
+ * print a warning with the exact restart command and do not fail the install.
255
+ */
256
+ async function verifyRuntime(plan, expected, log, opts = {}) {
257
+ const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
258
+ const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
259
+ const probe = opts.health || ((p) => healthOnce(p));
260
+ let last = { ok: false, version: null };
261
+ for (let i = 0; i < tries; i++) {
262
+ last = await probe(port);
263
+ if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
264
+ if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
265
+ }
266
+ const hint = restartHint(plan, opts.uid);
267
+ if (last.ok && last.version) {
268
+ log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
269
+ return 'mismatch';
270
+ }
271
+ log('WARNING: could not confirm the new server (' + expected + ') on 127.0.0.1:' + port + (last.ok ? ' (no version in /api/health)' : ' (no answer)') + '. If it is not running: ' + hint);
272
+ return 'unreachable';
273
+ }
274
+
275
+ function serviceInstall(plan, dry, log, run = sh, uid) {
276
+ const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
164
277
  for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
165
278
  if (plan.kind === 'windows') {
166
279
  const tr = 'wscript.exe ' + q(plan.files[0][0]);
@@ -178,29 +291,63 @@ function serviceInstall(plan, dry, log) {
178
291
  }
179
292
  } else if (plan.kind === 'launchd') {
180
293
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
294
+ serviceRestart(plan, dry, log, sh, uid);
295
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
296
+ if (plan.kind === 'cron') {
297
+ log('crontab: @reboot entry ' + plan.cronLine);
298
+ if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
299
+ } else {
300
+ log('WARNING: no systemd user session and no crontab here. Add this to your own init/boot script to start the server at boot: ' + cronStartCmd(plan.bin, plan.core, plan.log));
301
+ }
302
+ serviceRestart(plan, dry, log, sh, uid);
181
303
  } else {
182
304
  log('systemctl --user enable --now kenaz-server');
183
305
  if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
306
+ serviceRestart(plan, dry, log, sh, uid);
184
307
  }
185
308
  }
186
- function serviceUninstall(plan, dry, log) {
309
+ function serviceUninstall(plan, dry, log, run = sh) {
187
310
  if (plan.kind === 'windows') {
188
311
  log('schtasks /End + /Delete /TN ' + TASK_NAME);
189
- if (!dry) { sh('schtasks', ['/End', '/TN', TASK_NAME]); sh('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
312
+ if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
190
313
  if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
191
314
  } else if (plan.kind === 'launchd') {
192
- log('launchctl unload ' + plan.unit); if (!dry) sh('launchctl', ['unload', '-w', plan.unit]);
315
+ log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
316
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
317
+ if (plan.kind === 'cron') { log('crontab: remove the kenaz-server @reboot entry'); if (!dry) { const cur = readCron(run); if (cur !== null) writeCron(run, cur, null); } }
318
+ log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
193
319
  } else {
194
- log('systemctl --user disable --now kenaz-server'); if (!dry) sh('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
320
+ log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
195
321
  }
196
322
  for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
197
323
  }
198
- function serviceStatus(plan) {
199
- if (plan.kind === 'windows') return sh('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
200
- if (plan.kind === 'systemd') return sh('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
324
+ function serviceStatus(plan, run = sh) {
325
+ if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
326
+ if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
327
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
328
+ if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
201
329
  return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
202
330
  }
203
331
 
332
+ /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
333
+ function detectServiceMode(home, core, run = sh, platform = process.platform) {
334
+ if (platform === 'win32') {
335
+ const plan = servicePlan(home, '', core, 'win32');
336
+ if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
337
+ if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
338
+ return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
339
+ }
340
+ if (platform === 'darwin') {
341
+ const p = servicePlan(home, '', core, 'darwin').unit;
342
+ return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
343
+ }
344
+ const unit = servicePlan(home, '', core, 'linux').unit;
345
+ if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
346
+ const c = readCron(run);
347
+ if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
348
+ return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
349
+ }
350
+
204
351
  function parseArgs(argv) {
205
352
  const o = { cmd: argv[0], flags: {} };
206
353
  for (let i = 1; i < argv.length; i++) {
@@ -217,7 +364,7 @@ function findServerBin(core, home) {
217
364
  return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
218
365
  }
219
366
 
220
- function main(argv, out = console.log, getVersion = claudeVersion) { // getVersion: injectable for tests
367
+ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
221
368
  const { cmd, flags } = parseArgs(argv);
222
369
  if (!['install', 'uninstall', 'status'].includes(cmd)) {
223
370
  out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
@@ -264,11 +411,15 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
264
411
 
265
412
  if (!flags['no-service']) {
266
413
  const bin = flags['server-bin'] ? path.resolve(flags['server-bin']) : findServerBin(core, home);
267
- const plan = servicePlan(home, bin || '<kenaz-server not found>', core);
268
- if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan)));
269
- else if (cmd === 'uninstall') serviceUninstall(plan, dry, log);
414
+ const run = deps.sh || sh, platform = deps.platform || process.platform;
415
+ // the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
416
+ const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
417
+ const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
418
+ if (deps.onPlan) deps.onPlan(plan);
419
+ if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
420
+ else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
270
421
  else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
271
- else serviceInstall(plan, dry, log);
422
+ else serviceInstall(plan, dry, log, run, deps.uid);
272
423
  }
273
424
  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)');
274
425
  if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
@@ -276,4 +427,4 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
276
427
  }
277
428
 
278
429
  if (require.main === module) process.exit(main(process.argv.slice(2)));
279
- module.exports = { main, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
430
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
@@ -1,61 +0,0 @@
1
- // TASK_2264 — completeTask must schedule exactly ONE 'after' health capture.
2
- // moveTask used to schedule a detached 'after' capture on every ->done move even when
3
- // completeTask's own postComplete was about to measure the same snapshot.
4
- // Counting seam: child_process.spawn is patched (task-api requires it at call time) and
5
- // calls whose inline script carries phase "after" are counted. Isolated temp project.
6
- // Usage: node .collaboration/core/task-2264-single-after-capture.test.js
7
- const fs = require('fs');
8
- const path = require('path');
9
- const os = require('os');
10
- const cp = require('child_process');
11
- const { TaskAPI, toYAML } = require('./task-api.js');
12
-
13
- const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2264_'));
14
- const tasksDir = path.join(SB, '.kenaz', 'tasks');
15
- for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel', 'archived']) fs.mkdirSync(path.join(tasksDir, d), { recursive: true });
16
- delete process.env.FORMATION_SCHEDULER; delete process.env.FORMATION_HEADLESS;
17
-
18
- let pass = 0, fail = 0;
19
- const check = (n, c, d) => { console.log((c ? 'PASS' : 'FAIL') + ' | ' + n + (c ? '' : ' | ' + d)); c ? pass++ : fail++; };
20
-
21
- let phases = [];
22
- const realSpawn = cp.spawn;
23
- cp.spawn = function (cmd, argv, opts) {
24
- const script = Array.isArray(argv) ? argv.find(a => typeof a === 'string' && a.includes('const CFG')) : null;
25
- if (script) {
26
- const m = script.match(/"phase":"(before|after)"/);
27
- phases.push(m ? m[1] : '?');
28
- return { unref() {}, on() {} };
29
- }
30
- return realSpawn.apply(this, arguments);
31
- };
32
-
33
- const mkActive = (id) => fs.writeFileSync(path.join(tasksDir, 'active', id + '_t.yaml'),
34
- toYAML({ id, title: 't2264 ' + id, priority: 'P2', status: 'in_progress' }));
35
-
36
- (async () => {
37
- const api = new TaskAPI({ tasksDir, projectPath: SB });
38
- try {
39
- // A: complete with post-complete running in-process (dry run, no git/archive) -> post-complete owns 'after'
40
- mkActive('TASK_9001'); phases = [];
41
- await api.completeTask('TASK_9001', { dryRun: true, skipGit: true, skipArchive: true });
42
- check('A complete w/ post-complete: moveTask schedules NO after capture', phases.filter(p => p === 'after').length === 0, JSON.stringify(phases));
43
-
44
- // B control: post-complete skipped -> nobody else measures, moveTask must still capture exactly once
45
- mkActive('TASK_9002'); phases = [];
46
- await api.completeTask('TASK_9002', { skipPostComplete: true });
47
- check('B control: complete w/ skipPostComplete schedules exactly 1 after capture', phases.filter(p => p === 'after').length === 1, JSON.stringify(phases));
48
-
49
- // C control: bare move done (no post-complete) keeps its single capture
50
- mkActive('TASK_9003'); phases = [];
51
- await api.moveTask('TASK_9003', 'done');
52
- check('C control: bare move done schedules exactly 1 after capture', phases.filter(p => p === 'after').length === 1, JSON.stringify(phases));
53
- } catch (e) {
54
- check('no exception', false, e && e.stack);
55
- } finally {
56
- cp.spawn = realSpawn;
57
- try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
58
- }
59
- console.log(`\n${pass} passed, ${fail} failed`);
60
- process.exit(fail ? 1 : 0);
61
- })();