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.
- package/core/.claude/commands/Ansuz.md +11 -1
- package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
- package/core/.claude/hooks/_run-user-prompt.js +2 -0
- package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
- package/core/.claude/hooks/correction-detect.js +99 -0
- package/core/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -0
- package/core/.formation/bifrost/exec.js +147 -0
- package/core/.formation/bifrost/provider-launch.js +57 -0
- package/core/.formation/bifrost/provider-launch.test.js +62 -0
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
- package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
- package/core/.formation/tools/dispatch-model.js +15 -10
- package/core/.formation/tools/dispatch.js +46 -10
- package/core/.formation/tools/kenaz-stats.js +57 -16
- package/core/.formation/tools/kenaz-stats.test.js +2 -2
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/Ansuz/RULES.md +7 -2
- package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
- package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
- package/core/.specialist/tools/handoff-doc.js +95 -0
- package/core/.specialist/tools/lessons-harvest.js +243 -0
- package/core/.specialist/tools/lessons-lifecycle.js +103 -0
- package/core/.specialist/tools/lessons.js +149 -24
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/scripts/correction-detect-eval.js +70 -0
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
- package/core/plugins/kenaz/commands/Ansuz.md +11 -1
- package/core/plugins/kenaz/commands/kenaz.md +5 -3
- package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/statusline.js +60 -0
- package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +27 -10
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +193 -16
- 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
|
-
|
|
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
|
-
|
|
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) {
|
|
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)
|
|
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)
|
|
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 === '
|
|
200
|
-
if (plan.kind === '
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
})();
|