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
@@ -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';
@@ -85,6 +86,29 @@ function unregisterPlugin(file, dir, dry) {
85
86
  return 'removed';
86
87
  }
87
88
 
89
+ // Status line with the project's task counts (plugins/kenaz/scripts/statusline.js). Set only when the user has no
90
+ // statusLine; a user's own is never replaced. Ours is recognised by the script path, so a moved core is updated.
91
+ const STATUSLINE_TAIL = 'plugins/kenaz/scripts/statusline.js';
92
+ const isOurStatusline = (s) => !!(s && typeof s.command === 'string' && norm(s.command).includes(STATUSLINE_TAIL));
93
+ /** @returns 'added'|'already'|'replaced'|'kept-user' */
94
+ function registerStatusline(file, core, dry) {
95
+ const v = loadSettings(file);
96
+ const command = 'node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"';
97
+ if (v.statusLine && !isOurStatusline(v.statusLine)) return 'kept-user';
98
+ if (v.statusLine && norm(v.statusLine.command) === norm(command)) return 'already';
99
+ const r = v.statusLine ? 'replaced' : 'added';
100
+ if (!dry) { v.statusLine = { type: 'command', command }; saveSettings(file, v); }
101
+ return r;
102
+ }
103
+ /** @returns 'removed'|'absent' */
104
+ function unregisterStatusline(file, dry) {
105
+ if (!fs.existsSync(file)) return 'absent';
106
+ const v = loadSettings(file);
107
+ if (!isOurStatusline(v.statusLine)) return 'absent';
108
+ if (!dry) { delete v.statusLine; saveSettings(file, v); }
109
+ return 'removed';
110
+ }
111
+
88
112
  // TASK_2384: Dashboard-era user-level hooks (wrapped by .claude/hooks/_user-scope.js, direct .claude/hooks/*.js, or the
89
113
  // agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
90
114
  // once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
@@ -144,7 +168,30 @@ function systemdUnit(bin, core, nodeExe) {
144
168
  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
169
  }
146
170
 
147
- function servicePlan(home, bin, core, platform = process.platform) {
171
+ // TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
172
+ // silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
173
+ const CRON_MARK = '# kenaz-server (managed by kenaz install)';
174
+ const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
175
+ function cronStartCmd(bin, core, log, nodeExe) {
176
+ return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
177
+ }
178
+ function cronLine(bin, core, log, nodeExe) { // % is special in crontab
179
+ return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
180
+ }
181
+ // stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
182
+ // process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
183
+ function stopCmd(plan) {
184
+ const b = shq(plan.bin);
185
+ return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
186
+ '[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
187
+ }
188
+ // start now, detached from the installer (new session when setsid exists), output to the log
189
+ function detachedStartCmd(plan) {
190
+ return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
191
+ 'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
192
+ }
193
+
194
+ function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
148
195
  const L = launcherPaths(home);
149
196
  if (platform === 'win32') {
150
197
  const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
@@ -155,12 +202,101 @@ function servicePlan(home, bin, core, platform = process.platform) {
155
202
  const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
156
203
  return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
157
204
  }
205
+ if (caps.systemd === false) {
206
+ return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
207
+ }
158
208
  const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
159
209
  return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
160
210
  }
161
- const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
211
+ const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
212
+
213
+ // crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
214
+ function readCron(run) {
215
+ const r = run('crontab', ['-l']);
216
+ if (!r || r.error || r.status === 127) return null;
217
+ return r.status === 0 ? String(r.stdout || '') : '';
218
+ }
219
+ function probeLinux(run) {
220
+ const r = run('systemctl', ['--user', 'show-environment']);
221
+ const systemd = !!r && !r.error && r.status === 0;
222
+ return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
223
+ }
224
+ function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
225
+ const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
226
+ const next = line ? keep.concat(line) : keep;
227
+ const text = next.length ? next.join('\n') + '\n' : '';
228
+ if (text === current) return false;
229
+ if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
230
+ return true;
231
+ }
162
232
 
163
- function serviceInstall(plan, dry, log) {
233
+ // TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
234
+ // explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
235
+ // stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
236
+ // restart an active unit. restartHint is also the command printed when the version check fails.
237
+ const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
238
+ function restartHint(plan, uid = defaultUid()) {
239
+ if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
240
+ if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
241
+ if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
242
+ // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
243
+ 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');
244
+ }
245
+ function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
246
+ if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
247
+ log('start now (detached, log ' + plan.log + ')');
248
+ if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
249
+ return;
250
+ }
251
+ if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
252
+ log(restartHint(plan, uid));
253
+ if (dry) return;
254
+ if (plan.kind === 'launchd') {
255
+ const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
256
+ if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
257
+ log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
258
+ run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
259
+ }
260
+ } else run('systemctl', ['--user', 'restart', 'kenaz-server']);
261
+ }
262
+
263
+ /** One GET /api/health; resolves { ok, version }. */
264
+ function healthOnce(port, getFn = http.get) {
265
+ return new Promise((resolve) => {
266
+ const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
267
+ let b = ''; res.on('data', (d) => { b += d; });
268
+ res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
269
+ });
270
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
271
+ req.on('error', () => resolve({ ok: false, version: null }));
272
+ });
273
+ }
274
+ /**
275
+ * TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
276
+ * installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
277
+ * print a warning with the exact restart command and do not fail the install.
278
+ */
279
+ async function verifyRuntime(plan, expected, log, opts = {}) {
280
+ const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
281
+ const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
282
+ const probe = opts.health || ((p) => healthOnce(p));
283
+ let last = { ok: false, version: null };
284
+ for (let i = 0; i < tries; i++) {
285
+ last = await probe(port);
286
+ if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
287
+ if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
288
+ }
289
+ const hint = restartHint(plan, opts.uid);
290
+ if (last.ok && last.version) {
291
+ log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
292
+ return 'mismatch';
293
+ }
294
+ 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);
295
+ return 'unreachable';
296
+ }
297
+
298
+ function serviceInstall(plan, dry, log, run = sh, uid) {
299
+ const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
164
300
  for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
165
301
  if (plan.kind === 'windows') {
166
302
  const tr = 'wscript.exe ' + q(plan.files[0][0]);
@@ -178,29 +314,63 @@ function serviceInstall(plan, dry, log) {
178
314
  }
179
315
  } else if (plan.kind === 'launchd') {
180
316
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
317
+ serviceRestart(plan, dry, log, sh, uid);
318
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
319
+ if (plan.kind === 'cron') {
320
+ log('crontab: @reboot entry ' + plan.cronLine);
321
+ if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
322
+ } else {
323
+ 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));
324
+ }
325
+ serviceRestart(plan, dry, log, sh, uid);
181
326
  } else {
182
327
  log('systemctl --user enable --now kenaz-server');
183
328
  if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
329
+ serviceRestart(plan, dry, log, sh, uid);
184
330
  }
185
331
  }
186
- function serviceUninstall(plan, dry, log) {
332
+ function serviceUninstall(plan, dry, log, run = sh) {
187
333
  if (plan.kind === 'windows') {
188
334
  log('schtasks /End + /Delete /TN ' + TASK_NAME);
189
- if (!dry) { sh('schtasks', ['/End', '/TN', TASK_NAME]); sh('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
335
+ if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
190
336
  if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
191
337
  } else if (plan.kind === 'launchd') {
192
- log('launchctl unload ' + plan.unit); if (!dry) sh('launchctl', ['unload', '-w', plan.unit]);
338
+ log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
339
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
340
+ 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); } }
341
+ log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
193
342
  } else {
194
- log('systemctl --user disable --now kenaz-server'); if (!dry) sh('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
343
+ log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
195
344
  }
196
345
  for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
197
346
  }
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';
347
+ function serviceStatus(plan, run = sh) {
348
+ if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
349
+ if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
350
+ 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');
351
+ if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
201
352
  return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
202
353
  }
203
354
 
355
+ /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
356
+ function detectServiceMode(home, core, run = sh, platform = process.platform) {
357
+ if (platform === 'win32') {
358
+ const plan = servicePlan(home, '', core, 'win32');
359
+ if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
360
+ if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
361
+ return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
362
+ }
363
+ if (platform === 'darwin') {
364
+ const p = servicePlan(home, '', core, 'darwin').unit;
365
+ return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
366
+ }
367
+ const unit = servicePlan(home, '', core, 'linux').unit;
368
+ if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
369
+ const c = readCron(run);
370
+ if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
371
+ return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
372
+ }
373
+
204
374
  function parseArgs(argv) {
205
375
  const o = { cmd: argv[0], flags: {} };
206
376
  for (let i = 1; i < argv.length; i++) {
@@ -217,7 +387,7 @@ function findServerBin(core, home) {
217
387
  return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
218
388
  }
219
389
 
220
- function main(argv, out = console.log, getVersion = claudeVersion) { // getVersion: injectable for tests
390
+ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
221
391
  const { cmd, flags } = parseArgs(argv);
222
392
  if (!['install', 'uninstall', 'status'].includes(cmd)) {
223
393
  out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
@@ -242,6 +412,7 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
242
412
  out(name + ': ' + (list.some((e) => norm(e) === norm(pluginDir)) ? 'registered' : 'not registered') + ' (' + pluginDir + ')');
243
413
  } else if (cmd === 'uninstall') {
244
414
  log(name + ': ' + unregisterPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
415
+ if (name === 'kenaz') log('statusline: ' + unregisterStatusline(settings, dry));
245
416
  } else if (process.env.KENAZ_SKIP_CLAUDE_PROVISION === '1') {
246
417
  log(name + ': skipped (KENAZ_SKIP_CLAUDE_PROVISION=1)');
247
418
  } else if (!fs.existsSync(path.join(pluginDir, 'hooks', 'hooks.json'))) {
@@ -253,6 +424,8 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
253
424
  else {
254
425
  log(name + ': ' + registerPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
255
426
  if (name === 'kenaz') {
427
+ const sl = registerStatusline(settings, core, dry);
428
+ log('statusline: ' + (sl === 'kept-user' ? 'kept your own statusLine; to show Kenaz task counts, append the output of: node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"' : sl));
256
429
  const n = retireDashboardHooks(settings, dry);
257
430
  if (n) log('kenaz: retired ' + n + ' Dashboard-era user-level hook(s), now owned by the plugin (backup: ' + path.join(path.dirname(settings), BACKUP) + ')');
258
431
  }
@@ -264,11 +437,15 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
264
437
 
265
438
  if (!flags['no-service']) {
266
439
  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);
440
+ const run = deps.sh || sh, platform = deps.platform || process.platform;
441
+ // the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
442
+ const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
443
+ const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
444
+ if (deps.onPlan) deps.onPlan(plan);
445
+ if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
446
+ else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
270
447
  else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
271
- else serviceInstall(plan, dry, log);
448
+ else serviceInstall(plan, dry, log, run, deps.uid);
272
449
  }
273
450
  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
451
  if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
@@ -276,4 +453,4 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
276
453
  }
277
454
 
278
455
  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 };
456
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, 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
- })();