kenaz 0.3.10 → 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.
- package/core/.claude/skills/code_reviewer/SKILL.md +0 -1
- package/core/.claude/skills/code_reviewer/patterns.yaml +0 -8
- package/core/.claude/skills/dev_agent/SKILL.md +3 -6
- package/core/.claude/skills/heimdall/SKILL.md +5 -6
- package/core/.claude/skills/huginn/SKILL.md +11 -38
- 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 +223 -230
- package/core/.collaboration/core/task-api.test.js +1 -138
- 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/launch.js +2 -2
- package/core/.formation/bifrost/launch.test.js +2 -2
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/bifrost/shared/ansuz-boot-policy.js +3 -3
- package/core/.formation/bifrost/shared/codex-injection.js +3 -17
- package/core/.formation/bifrost/shared/consolidate.js +1 -51
- package/core/.formation/bifrost/sync.js +4 -5
- package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +1 -1
- package/core/.formation/tools/dispatch.js +2 -2
- package/core/.formation/tools/kenaz-stats.js +1 -1
- package/core/.formation/tools/mcp-allowlist.test.js +5 -3
- package/core/.formation/tools/post-complete/bounded-http.js +1 -40
- package/core/.formation/tools/post-complete/bounded-http.test.js +2 -89
- package/core/.formation/tools/post-complete/cognitive.js +2 -119
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete/testing.js +3 -21
- package/core/.formation/tools/post-complete.js +131 -436
- package/core/.formation/tools/post-complete.test.js +0 -135
- package/core/.formation/tools/route-audit.test.js +8 -20
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.rule/INDEX.yaml +3 -3
- package/core/.rule/general/ddx-synapse-stack.yaml +11 -11
- package/core/.rule/general/specialist-identity-contract.yaml +5 -5
- package/core/.rule/general/synapse-code-intelligence.yaml +25 -111
- package/core/.rule/general/worktree-isolation.yaml +2 -2
- package/core/.rule/pipeline/task-execution.yaml +6 -9
- package/core/.specialist/Amelia/README.md +5 -5
- package/core/.specialist/Amelia/capability.yaml +10 -18
- package/core/.specialist/Amelia/knowledge.yaml +15 -66
- package/core/.specialist/Amelia/workflow.yaml +12 -21
- package/core/.specialist/Ansuz/capability.yaml +12 -87
- package/core/.specialist/Ansuz/concepts/autonomous_agenda.yaml +1 -1
- package/core/.specialist/Ansuz/concepts/autonomy_contract.yaml +2 -3
- package/core/.specialist/Ansuz/concepts/capability_pointers.yaml +1 -3
- package/core/.specialist/Ansuz/concepts/cognitive_fluency_contract.yaml +8 -21
- package/core/.specialist/Ansuz/concepts/cognitive_systems_overview.yaml +0 -4
- package/core/.specialist/Ansuz/concepts/cognitive_systems_turn_context.yaml +1 -1
- package/core/.specialist/Ansuz/concepts/completion_tracking.yaml +0 -1
- package/core/.specialist/Ansuz/concepts/deep_knowledge_pointers.yaml +0 -3
- package/core/.specialist/Ansuz/concepts/evolvable_guardrails.yaml +3 -3
- package/core/.specialist/Ansuz/concepts/fatigue_handoff.yaml +1 -7
- package/core/.specialist/Ansuz/concepts/guardrails_immutable.yaml +3 -4
- package/core/.specialist/Ansuz/concepts/identity.yaml +1 -2
- package/core/.specialist/Ansuz/concepts/phase9_verification.yaml +0 -1
- package/core/.specialist/Ansuz/concepts/phase_protocol.yaml +3 -4
- package/core/.specialist/Ansuz/core.yaml +3 -6
- package/core/.specialist/Ansuz/elixir.yaml +6 -6
- package/core/.specialist/Ansuz/exo_cortex_tools.yaml +5 -9
- package/core/.specialist/Ansuz/guards/blind_spot_guards.yaml +2 -2
- package/core/.specialist/Ansuz/guards/lesson_guards.yaml +6 -19
- package/core/.specialist/Ansuz/identity_contract.yaml +4 -4
- package/core/.specialist/Ansuz/knowledge.yaml +13 -40
- package/core/.specialist/Ansuz/modules/ddx.yaml +2 -2
- package/core/.specialist/Ansuz/modules/prophecy.yaml +19 -19
- package/core/.specialist/Ansuz/modules/pulse_empty.yaml +6 -17
- package/core/.specialist/Ansuz/tools/ansuz-handoff-snapshot.js +3 -102
- package/core/.specialist/Ansuz/tools/ansuz-sdk-runner.js +2 -44
- package/core/.specialist/Ansuz/workflow.yaml +32 -76
- package/core/.specialist/Forseti/core.yaml +2 -7
- package/core/.specialist/Forseti/rule.yaml +1 -1
- package/core/.specialist/Heimdall/SKILL.md +2 -3
- package/core/.specialist/Heimdall/capability.yaml +22 -45
- package/core/.specialist/Heimdall/core.yaml +24 -126
- package/core/.specialist/Heimdall/principles.yaml +8 -11
- package/core/.specialist/Heimdall/rule.yaml +2 -2
- package/core/.specialist/Huginn/capability.yaml +31 -52
- package/core/.specialist/Huginn/core.yaml +43 -123
- package/core/.specialist/Huginn/principles.yaml +5 -5
- package/core/.specialist/Huginn/rule.yaml +3 -9
- package/core/.specialist/REFLECTION_TO_CONTROL.md +3 -4
- package/core/.specialist/shared/guard_schema.json +1 -1
- package/core/.specialist/shared/principles.yaml +6 -6
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/phase-gate.js +10 -230
- package/core/.specialist/tools/phase-gate.test.js +2 -2
- package/core/.specialist/tools/prompt-files.test.js +1 -1
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.specialist/tools/session.js +2 -89
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/.system/memory/README.md +3 -0
- package/core/CLAUDE.md +6 -7
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -83
- 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/INVENTORY.md +1 -1
- package/core/plugins/kenaz/scripts/kenaz.test.js +70 -1
- package/core/plugins/kenaz/scripts/ygg.js +45 -4
- package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +0 -1
- package/core/plugins/kenaz/skills/code_reviewer/patterns.yaml +0 -8
- package/core/plugins/kenaz/skills/dev_agent/SKILL.md +3 -6
- package/lib/cli.js +17 -9
- 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 +167 -16
- package/core/.claude/hooks/handoff-snapshot-on-decision.js +0 -188
- package/core/.claude/skills/synapse_maintain/SKILL.md +0 -84
- package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
- package/core/.formation/tools/alaya-gate.js +0 -198
- package/core/.formation/tools/post-complete/health.js +0 -464
- package/core/.formation/tools/post-complete/task2038.test.js +0 -246
- package/core/.formation/tools/post-complete/task2341.test.js +0 -74
- package/core/.rule/general/cortex-tools.yaml +0 -181
- package/core/.rule/general/memory-language.yaml +0 -67
- package/core/.rule/general/memory-source-priority.yaml +0 -207
- package/core/.rule/general/synapse-cognitive-fusion.yaml +0 -301
- package/core/.specialist/Ansuz/concepts/cognitive_systems_hippocampus.yaml +0 -14
- package/core/.specialist/Ansuz/concepts/write_verification.yaml +0 -26
- package/core/.specialist/Ansuz/modules/recall.yaml +0 -45
- package/core/.specialist/tools/MEMORY_RECALL_TRIGGERS.md +0 -156
- package/core/.specialist/tools/context-injection.js +0 -395
- package/core/dev/scripts/probe/hippocampus-concurrency.js +0 -247
|
@@ -17,14 +17,6 @@ mutation_counterpart:
|
|
|
17
17
|
# orphan = logical state / lifecycle not terminated
|
|
18
18
|
|
|
19
19
|
pairs:
|
|
20
|
-
- pair: hippocampus_episode
|
|
21
|
-
mutation: 'hippocampus_episode_start'
|
|
22
|
-
counterpart: 'hippocampus_episode_close'
|
|
23
|
-
severity: leak
|
|
24
|
-
rationale: 'episode never closed -> consolidation never fires, unarchived memory accumulates'
|
|
25
|
-
grep_mutation: 'hippocampus_episode_start'
|
|
26
|
-
grep_counterpart: 'hippocampus_episode_close'
|
|
27
|
-
|
|
28
20
|
- pair: task_lifecycle
|
|
29
21
|
mutation: 'task-api.js claim'
|
|
30
22
|
counterpart: 'task-api.js complete | task-api.js move'
|
|
@@ -256,8 +256,6 @@ description: 開發協調、Pipeline管理、任務執行、進度追蹤、Agent
|
|
|
256
256
|
| `kenaz_lsp_references` | 所有引用 / 修改影響範圍 | 修改前評估 blast radius |
|
|
257
257
|
| `kenaz_lsp_definition` | 定義位置 | 跳到宣告處 |
|
|
258
258
|
| `kenaz_lsp_symbols` | 檔案內 symbol 列表 | 了解檔案結構 |
|
|
259
|
-
| `synapse_recall` | 歷史 rune / 已知風險 | 修改前召回 |
|
|
260
|
-
| `synapse_status` | 記憶系統狀態 | 任務開始前檢查 |
|
|
261
259
|
|
|
262
260
|
文字與概念搜尋用 Grep / Glob。
|
|
263
261
|
|
|
@@ -281,8 +279,7 @@ description: 開發協調、Pipeline管理、任務執行、進度追蹤、Agent
|
|
|
281
279
|
**在開始任何開發任務前,建議執行:**
|
|
282
280
|
|
|
283
281
|
1. **獲取專案 Context**:
|
|
284
|
-
-
|
|
285
|
-
- 使用 `synapse_recall` 召回相關已知風險與決策
|
|
282
|
+
- 讀取任務卡與 `kenaz learn list` 的相關教訓
|
|
286
283
|
|
|
287
284
|
2. **搜索相關代碼**:
|
|
288
285
|
- 使用 Grep / Glob 找到相關實現
|
|
@@ -335,10 +332,10 @@ description: 開發協調、Pipeline管理、任務執行、進度追蹤、Agent
|
|
|
335
332
|
2. 重新讀取 Skill 文件
|
|
336
333
|
3. 如果問題持續,直接應用該 Agent 的核心職責邏輯
|
|
337
334
|
|
|
338
|
-
### LSP
|
|
335
|
+
### LSP 查詢失敗時
|
|
339
336
|
1. 繼續執行任務(降級為 Grep/Glob)
|
|
340
337
|
2. 確認 Dashboard 已啟動(依賴 Rust HTTP server on port 18080)
|
|
341
|
-
3. 使用 `
|
|
338
|
+
3. 使用 `kenaz_ping` 檢查服務狀態
|
|
342
339
|
|
|
343
340
|
---
|
|
344
341
|
|
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
|
-
|
|
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
|
|
|
@@ -234,9 +237,11 @@ async function doctor(p, flags, out, deps = {}) {
|
|
|
234
237
|
const r = await probe(process.env.KENAZ_HTTP_PORT || '18080');
|
|
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
|
-
// TASK_2390: memory is frozen in the CLI build; 'disabled' is the expected state, never a failure.
|
|
238
|
-
if (r.ok) { let emb = ''; try { emb = JSON.parse(r.body).embedding || ''; } catch (_) { /* optional */ } add('memory', 'ok', emb === 'disabled' || !emb ? 'disabled (set KENAZ_MEMORY=on to enable)' : `on, embedding ${emb}`); }
|
|
239
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); }
|
|
240
245
|
const d = dash.detect(p.home, deps.dashboard);
|
|
241
246
|
if (!d.installed) add('dashboard', 'skip', 'no Kenaz Dashboard on this machine');
|
|
242
247
|
else {
|
|
@@ -286,13 +291,13 @@ async function doctor(p, flags, out, deps = {}) {
|
|
|
286
291
|
if (rea) {
|
|
287
292
|
const r = rea.detectRea({ home: p.home, projectPath: process.cwd() });
|
|
288
293
|
add('rea (optional)', r.installed ? 'ok' : 'skip', r.installed ? 'MCP registered in ' + [r.claude && 'claude', r.codex && 'codex'].filter(Boolean).join(', ')
|
|
289
|
-
: `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})`));
|
|
290
295
|
}
|
|
291
296
|
for (const [l, s, d] of rows) out(`${s.padEnd(4)} ${l.padEnd(20)} ${d}`);
|
|
292
297
|
return rows.some((r) => r[1] === 'FAIL') ? 1 : 0;
|
|
293
298
|
}
|
|
294
299
|
|
|
295
|
-
// `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
|
|
296
301
|
// /kenaz (Claude) and $kenaz (Codex) commands run, from a normal terminal in the project folder.
|
|
297
302
|
function passthrough(argv, deps) {
|
|
298
303
|
const p = paths(os.homedir());
|
|
@@ -355,8 +360,11 @@ function run(argv, out = console.log, deps = {}) {
|
|
|
355
360
|
const fn = { install, uninstall, status, doctor }[cmd];
|
|
356
361
|
if (!fn) { out(USAGE); return 2; }
|
|
357
362
|
if (!nodeOk()) { out(`Node.js >= ${MIN_NODE} is required (found ${process.versions.node}). Install it from https://nodejs.org and rerun.`); return 1; }
|
|
358
|
-
|
|
359
|
-
|
|
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); }
|
|
360
368
|
}
|
|
361
369
|
|
|
362
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
|
+
});
|
package/lib/dashboard.test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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)
|
|
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)
|
|
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 === '
|
|
200
|
-
if (plan.kind === '
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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,188 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* handoff-snapshot-on-decision.js — PostToolUse hook for synapse_inscribe (TASK_1400)
|
|
4
|
-
*
|
|
5
|
-
* Fires after mcp__kenaz-dashboard__synapse_inscribe when the inscribed rune type
|
|
6
|
-
* is one of: decision, risk, ash. Insight/pattern/fix/summary inscribes do NOT
|
|
7
|
-
* fire (too noisy). Includes rune_id in the nudge.
|
|
8
|
-
* 5-minute dedup keyed on rune_id (or normalized content hash if rune_id absent).
|
|
9
|
-
*
|
|
10
|
-
* Matcher: mcp__kenaz-dashboard__synapse_inscribe
|
|
11
|
-
*/
|
|
12
|
-
'use strict';
|
|
13
|
-
try { require('./_dedupe-guard').guard(typeof __filename === 'string' ? __filename : null); } catch (_) { /* never block a hook on guard load failure (e.g. vm sandbox without __filename) */ }
|
|
14
|
-
const kenazProjectRoot = require('./_project-guard').guard();
|
|
15
|
-
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
const path = require('path');
|
|
18
|
-
const { fenceSignal } = require('./_signal-fence');
|
|
19
|
-
|
|
20
|
-
const PROJECT_DIR = kenazProjectRoot;
|
|
21
|
-
const TELEMETRY_PATH = path.join(
|
|
22
|
-
PROJECT_DIR,
|
|
23
|
-
'.kenaz', 'specialist', 'handoff_nudges.jsonl'
|
|
24
|
-
);
|
|
25
|
-
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
26
|
-
const DEDUP_KEY_PREFIX = 'inscribe:';
|
|
27
|
-
|
|
28
|
-
// Only these rune types trigger the nudge
|
|
29
|
-
const TRIGGER_TYPES = new Set(['decision', 'risk', 'ash']);
|
|
30
|
-
|
|
31
|
-
function readStdin() {
|
|
32
|
-
try {
|
|
33
|
-
return JSON.parse(fs.readFileSync(0, 'utf8').trim() || '{}');
|
|
34
|
-
} catch {
|
|
35
|
-
return {};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function extractInscribeType(payload) {
|
|
40
|
-
// Type can be in tool_input or inferred from response
|
|
41
|
-
return (
|
|
42
|
-
payload?.tool_input?.type ||
|
|
43
|
-
payload?.toolInput?.type ||
|
|
44
|
-
null
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function extractRuneId(payload) {
|
|
49
|
-
// rune_id appears in the tool_response from synapse_inscribe
|
|
50
|
-
try {
|
|
51
|
-
const resp =
|
|
52
|
-
payload?.tool_response ||
|
|
53
|
-
payload?.tool_result;
|
|
54
|
-
if (!resp) return null;
|
|
55
|
-
|
|
56
|
-
// Attempt to parse text content as JSON
|
|
57
|
-
let text = '';
|
|
58
|
-
if (typeof resp === 'string') {
|
|
59
|
-
text = resp;
|
|
60
|
-
} else if (resp?.content && Array.isArray(resp.content)) {
|
|
61
|
-
text = resp.content.map(c => c?.text || '').join('');
|
|
62
|
-
} else if (typeof resp === 'object') {
|
|
63
|
-
text = JSON.stringify(resp);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Try JSON parse first
|
|
67
|
-
try {
|
|
68
|
-
const parsed = JSON.parse(text);
|
|
69
|
-
if (parsed?.rune_id) return parsed.rune_id;
|
|
70
|
-
if (parsed?.id) return parsed.id;
|
|
71
|
-
} catch { /* not JSON */ }
|
|
72
|
-
|
|
73
|
-
// Fallback: regex extract
|
|
74
|
-
const m = text.match(/"rune_id"\s*:\s*"([^"]+)"/);
|
|
75
|
-
if (m) return m[1];
|
|
76
|
-
const m2 = text.match(/"id"\s*:\s*"([^"]+)"/);
|
|
77
|
-
if (m2) return m2[1];
|
|
78
|
-
return null;
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function normalizeKey(runeId, type, content) {
|
|
85
|
-
if (runeId) return runeId;
|
|
86
|
-
// Fallback: hash of type+content snippet
|
|
87
|
-
const snippet = `${type}:${(content || '').substring(0, 40)}`;
|
|
88
|
-
return snippet.replace(/\s+/g, '_');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function isDeduped(key) {
|
|
92
|
-
try {
|
|
93
|
-
if (!fs.existsSync(TELEMETRY_PATH)) return false;
|
|
94
|
-
const lines = fs.readFileSync(TELEMETRY_PATH, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
95
|
-
const cutoff = Date.now() - DEDUP_WINDOW_MS;
|
|
96
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
97
|
-
let entry;
|
|
98
|
-
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
99
|
-
const ts = Number(entry.timestamp_ms || 0);
|
|
100
|
-
if (ts && ts < cutoff) break;
|
|
101
|
-
if (entry.dedup_key === key && !entry.suppressed) return true;
|
|
102
|
-
}
|
|
103
|
-
return false;
|
|
104
|
-
} catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function appendTelemetry(record) {
|
|
110
|
-
try {
|
|
111
|
-
fs.mkdirSync(path.dirname(TELEMETRY_PATH), { recursive: true });
|
|
112
|
-
fs.appendFileSync(TELEMETRY_PATH, JSON.stringify(record) + '\n', 'utf8');
|
|
113
|
-
} catch { /* best-effort */ }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Auto-invoke the snapshot CLI as a detached process so the hook returns fast
|
|
117
|
-
// and the snapshot write does not depend on the agent remembering. Errors are
|
|
118
|
-
// swallowed — never block the user turn.
|
|
119
|
-
function invokeHandoffSnapshot(trigger, milestone, sessionId, transcriptPath) {
|
|
120
|
-
try {
|
|
121
|
-
const { spawn } = require('child_process');
|
|
122
|
-
const cli = path.join(PROJECT_DIR, '.specialist', 'Ansuz', 'tools', 'ansuz-handoff-snapshot.js');
|
|
123
|
-
if (!fs.existsSync(cli)) return;
|
|
124
|
-
const args = [cli, `--trigger=${trigger}`];
|
|
125
|
-
if (milestone) args.push(`--last-milestone=${milestone}`);
|
|
126
|
-
if (sessionId) args.push(`--session-id=${sessionId}`);
|
|
127
|
-
// Scopes pending/unfinished to tasks this conversation touched.
|
|
128
|
-
if (transcriptPath) args.push(`--transcript-path=${transcriptPath}`);
|
|
129
|
-
const child = spawn('node', args, {
|
|
130
|
-
detached: true,
|
|
131
|
-
stdio: 'ignore',
|
|
132
|
-
cwd: PROJECT_DIR,
|
|
133
|
-
windowsHide: true,
|
|
134
|
-
});
|
|
135
|
-
child.unref();
|
|
136
|
-
} catch { /* best-effort */ }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function main() {
|
|
140
|
-
const payload = readStdin();
|
|
141
|
-
const inscribeType = extractInscribeType(payload);
|
|
142
|
-
|
|
143
|
-
// Only fire for decision/risk/ash types
|
|
144
|
-
if (!inscribeType || !TRIGGER_TYPES.has(inscribeType)) {
|
|
145
|
-
process.exit(0);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Ansuz-only: see handoff-snapshot-on-commit.js.
|
|
149
|
-
if (!require('./_ansuz-conversation').isAnsuzConversation(payload.transcript_path, PROJECT_DIR)) {
|
|
150
|
-
process.exit(0);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const runeId = extractRuneId(payload);
|
|
154
|
-
const content = payload?.tool_input?.content || payload?.toolInput?.content || '';
|
|
155
|
-
const dedupKey = DEDUP_KEY_PREFIX + normalizeKey(runeId, inscribeType, content);
|
|
156
|
-
const suppressed = isDeduped(dedupKey);
|
|
157
|
-
|
|
158
|
-
appendTelemetry({
|
|
159
|
-
timestamp: new Date().toISOString(),
|
|
160
|
-
timestamp_ms: Date.now(),
|
|
161
|
-
hook: 'handoff-snapshot-on-decision',
|
|
162
|
-
dedup_key: dedupKey,
|
|
163
|
-
inscribe_type: inscribeType,
|
|
164
|
-
rune_id: runeId,
|
|
165
|
-
suppressed,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
if (suppressed) {
|
|
169
|
-
process.exit(0);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const runeRef = runeId ? ` (rune_id: ${runeId})` : '';
|
|
173
|
-
|
|
174
|
-
// Auto-write the handoff (system-level enforcement, instance-independent).
|
|
175
|
-
// TASK_1837: pass NO --last-milestone. The old value ('<type> rune inscribed')
|
|
176
|
-
// carried zero information and became the slot's current_topic, clobbering the
|
|
177
|
-
// real work topic (measured 2026-07-31: 5 of 8 orphan slots had it as topic —
|
|
178
|
-
// garbage inherited by the next session). With no milestone the snapshot CLI
|
|
179
|
-
// derives the topic from the latest commit subject: actual work content. The
|
|
180
|
-
// rune reference stays in the stdout nudge below, where it is useful.
|
|
181
|
-
invokeHandoffSnapshot('live_decision_point', null, payload.session_id, payload.transcript_path);
|
|
182
|
-
|
|
183
|
-
process.stdout.write(
|
|
184
|
-
'\n' + fenceSignal('handoff', `🔁 ${inscribeType} rune inscribed${runeRef}. Handoff auto-snapshot dispatched (live_decision_point).`) + '\n'
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
main();
|