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.
Files changed (131) hide show
  1. package/core/.claude/skills/code_reviewer/SKILL.md +0 -1
  2. package/core/.claude/skills/code_reviewer/patterns.yaml +0 -8
  3. package/core/.claude/skills/dev_agent/SKILL.md +3 -6
  4. package/core/.claude/skills/heimdall/SKILL.md +5 -6
  5. package/core/.claude/skills/huginn/SKILL.md +11 -38
  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 +223 -230
  9. package/core/.collaboration/core/task-api.test.js +1 -138
  10. package/core/.collaboration/core/task-replica.js +189 -0
  11. package/core/.formation/.merge-lock +1 -0
  12. package/core/.formation/bifrost/codex-doctor.js +12 -2
  13. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  14. package/core/.formation/bifrost/launch.js +2 -2
  15. package/core/.formation/bifrost/launch.test.js +2 -2
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/bifrost/shared/ansuz-boot-policy.js +3 -3
  18. package/core/.formation/bifrost/shared/codex-injection.js +3 -17
  19. package/core/.formation/bifrost/shared/consolidate.js +1 -51
  20. package/core/.formation/bifrost/sync.js +4 -5
  21. package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +1 -1
  22. package/core/.formation/tools/dispatch.js +2 -2
  23. package/core/.formation/tools/kenaz-stats.js +1 -1
  24. package/core/.formation/tools/mcp-allowlist.test.js +5 -3
  25. package/core/.formation/tools/post-complete/bounded-http.js +1 -40
  26. package/core/.formation/tools/post-complete/bounded-http.test.js +2 -89
  27. package/core/.formation/tools/post-complete/cognitive.js +2 -119
  28. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  29. package/core/.formation/tools/post-complete/testing.js +3 -21
  30. package/core/.formation/tools/post-complete.js +131 -436
  31. package/core/.formation/tools/post-complete.test.js +0 -135
  32. package/core/.formation/tools/route-audit.test.js +8 -20
  33. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  34. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  35. package/core/.rule/INDEX.yaml +3 -3
  36. package/core/.rule/general/ddx-synapse-stack.yaml +11 -11
  37. package/core/.rule/general/specialist-identity-contract.yaml +5 -5
  38. package/core/.rule/general/synapse-code-intelligence.yaml +25 -111
  39. package/core/.rule/general/worktree-isolation.yaml +2 -2
  40. package/core/.rule/pipeline/task-execution.yaml +6 -9
  41. package/core/.specialist/Amelia/README.md +5 -5
  42. package/core/.specialist/Amelia/capability.yaml +10 -18
  43. package/core/.specialist/Amelia/knowledge.yaml +15 -66
  44. package/core/.specialist/Amelia/workflow.yaml +12 -21
  45. package/core/.specialist/Ansuz/capability.yaml +12 -87
  46. package/core/.specialist/Ansuz/concepts/autonomous_agenda.yaml +1 -1
  47. package/core/.specialist/Ansuz/concepts/autonomy_contract.yaml +2 -3
  48. package/core/.specialist/Ansuz/concepts/capability_pointers.yaml +1 -3
  49. package/core/.specialist/Ansuz/concepts/cognitive_fluency_contract.yaml +8 -21
  50. package/core/.specialist/Ansuz/concepts/cognitive_systems_overview.yaml +0 -4
  51. package/core/.specialist/Ansuz/concepts/cognitive_systems_turn_context.yaml +1 -1
  52. package/core/.specialist/Ansuz/concepts/completion_tracking.yaml +0 -1
  53. package/core/.specialist/Ansuz/concepts/deep_knowledge_pointers.yaml +0 -3
  54. package/core/.specialist/Ansuz/concepts/evolvable_guardrails.yaml +3 -3
  55. package/core/.specialist/Ansuz/concepts/fatigue_handoff.yaml +1 -7
  56. package/core/.specialist/Ansuz/concepts/guardrails_immutable.yaml +3 -4
  57. package/core/.specialist/Ansuz/concepts/identity.yaml +1 -2
  58. package/core/.specialist/Ansuz/concepts/phase9_verification.yaml +0 -1
  59. package/core/.specialist/Ansuz/concepts/phase_protocol.yaml +3 -4
  60. package/core/.specialist/Ansuz/core.yaml +3 -6
  61. package/core/.specialist/Ansuz/elixir.yaml +6 -6
  62. package/core/.specialist/Ansuz/exo_cortex_tools.yaml +5 -9
  63. package/core/.specialist/Ansuz/guards/blind_spot_guards.yaml +2 -2
  64. package/core/.specialist/Ansuz/guards/lesson_guards.yaml +6 -19
  65. package/core/.specialist/Ansuz/identity_contract.yaml +4 -4
  66. package/core/.specialist/Ansuz/knowledge.yaml +13 -40
  67. package/core/.specialist/Ansuz/modules/ddx.yaml +2 -2
  68. package/core/.specialist/Ansuz/modules/prophecy.yaml +19 -19
  69. package/core/.specialist/Ansuz/modules/pulse_empty.yaml +6 -17
  70. package/core/.specialist/Ansuz/tools/ansuz-handoff-snapshot.js +3 -102
  71. package/core/.specialist/Ansuz/tools/ansuz-sdk-runner.js +2 -44
  72. package/core/.specialist/Ansuz/workflow.yaml +32 -76
  73. package/core/.specialist/Forseti/core.yaml +2 -7
  74. package/core/.specialist/Forseti/rule.yaml +1 -1
  75. package/core/.specialist/Heimdall/SKILL.md +2 -3
  76. package/core/.specialist/Heimdall/capability.yaml +22 -45
  77. package/core/.specialist/Heimdall/core.yaml +24 -126
  78. package/core/.specialist/Heimdall/principles.yaml +8 -11
  79. package/core/.specialist/Heimdall/rule.yaml +2 -2
  80. package/core/.specialist/Huginn/capability.yaml +31 -52
  81. package/core/.specialist/Huginn/core.yaml +43 -123
  82. package/core/.specialist/Huginn/principles.yaml +5 -5
  83. package/core/.specialist/Huginn/rule.yaml +3 -9
  84. package/core/.specialist/REFLECTION_TO_CONTROL.md +3 -4
  85. package/core/.specialist/shared/guard_schema.json +1 -1
  86. package/core/.specialist/shared/principles.yaml +6 -6
  87. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  88. package/core/.specialist/tools/phase-gate.js +10 -230
  89. package/core/.specialist/tools/phase-gate.test.js +2 -2
  90. package/core/.specialist/tools/prompt-files.test.js +1 -1
  91. package/core/.specialist/tools/rea-detect.js +6 -2
  92. package/core/.specialist/tools/session.js +2 -89
  93. package/core/.synapse/memory-shared.json.sync +1 -0
  94. package/core/.system/memory/README.md +3 -0
  95. package/core/CLAUDE.md +6 -7
  96. package/core/KENAZ_CORE_VERSION +1 -1
  97. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -83
  98. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  99. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  100. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  101. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  102. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  103. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  104. package/core/plugins/kenaz/INVENTORY.md +1 -1
  105. package/core/plugins/kenaz/scripts/kenaz.test.js +70 -1
  106. package/core/plugins/kenaz/scripts/ygg.js +45 -4
  107. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +0 -1
  108. package/core/plugins/kenaz/skills/code_reviewer/patterns.yaml +0 -8
  109. package/core/plugins/kenaz/skills/dev_agent/SKILL.md +3 -6
  110. package/lib/cli.js +17 -9
  111. package/lib/cli.test.js +10 -0
  112. package/lib/dashboard.test.js +3 -3
  113. package/package.json +6 -6
  114. package/vendor/install-kenaz-cli.js +167 -16
  115. package/core/.claude/hooks/handoff-snapshot-on-decision.js +0 -188
  116. package/core/.claude/skills/synapse_maintain/SKILL.md +0 -84
  117. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
  118. package/core/.formation/tools/alaya-gate.js +0 -198
  119. package/core/.formation/tools/post-complete/health.js +0 -464
  120. package/core/.formation/tools/post-complete/task2038.test.js +0 -246
  121. package/core/.formation/tools/post-complete/task2341.test.js +0 -74
  122. package/core/.rule/general/cortex-tools.yaml +0 -181
  123. package/core/.rule/general/memory-language.yaml +0 -67
  124. package/core/.rule/general/memory-source-priority.yaml +0 -207
  125. package/core/.rule/general/synapse-cognitive-fusion.yaml +0 -301
  126. package/core/.specialist/Ansuz/concepts/cognitive_systems_hippocampus.yaml +0 -14
  127. package/core/.specialist/Ansuz/concepts/write_verification.yaml +0 -26
  128. package/core/.specialist/Ansuz/modules/recall.yaml +0 -45
  129. package/core/.specialist/tools/MEMORY_RECALL_TRIGGERS.md +0 -156
  130. package/core/.specialist/tools/context-injection.js +0 -395
  131. 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
- - 使用 `synapse_status` 了解記憶系統狀態
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 / Synapse 查詢失敗時
335
+ ### LSP 查詢失敗時
339
336
  1. 繼續執行任務(降級為 Grep/Glob)
340
337
  2. 確認 Dashboard 已啟動(依賴 Rust HTTP server on port 18080)
341
- 3. 使用 `synapse_status` 檢查記憶系統狀態
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
- if (inst.main(a, out, () => ver) !== 0) rc = 1;
170
+ let plan = null;
171
+ if (inst.main(a, out, () => ver, { sh: deps.sh, platform: deps.platform, uid: deps.uid, onPlan: (x) => { plan = x; } }) !== 0) rc = 1;
172
+ // TASK_2418: the service was (re)started above; confirm the running server is the one just installed.
173
+ if (!dry && plan && srv && !flags['no-service']) await inst.verifyRuntime(plan, srv.version, out, deps.verify);
171
174
  }
172
175
  if (dry && hasClaude) out(`[dry-run] note: plugin paths above are the packaged core; after install they live under ${path.join(p.core, 'plugins')}`);
173
176
 
@@ -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
- try { return fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps); }
359
- catch (e) { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; }
363
+ const fail = (e) => { out('kenaz ' + cmd + ': ERROR ' + e.message); return 1; };
364
+ try {
365
+ const r = fn(paths(path.resolve(flags.home || os.homedir())), flags, out, deps);
366
+ return r && typeof r.then === 'function' ? r.catch(fail) : r;
367
+ } catch (e) { return fail(e); }
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
+ });
@@ -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.10",
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.10",
24
- "@kenazai/server-darwin-x64": "0.3.10",
25
- "@kenazai/server-linux-arm64": "0.3.10",
26
- "@kenazai/server-linux-x64": "0.3.10",
27
- "@kenazai/server-win32-x64": "0.3.10"
23
+ "@kenazai/server-darwin-arm64": "0.3.12",
24
+ "@kenazai/server-darwin-x64": "0.3.12",
25
+ "@kenazai/server-linux-arm64": "0.3.12",
26
+ "@kenazai/server-linux-x64": "0.3.12",
27
+ "@kenazai/server-win32-x64": "0.3.12"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
+ const http = require('http');
16
17
  const { spawnSync } = require('child_process');
17
18
 
18
19
  const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
@@ -144,7 +145,30 @@ function systemdUnit(bin, core, nodeExe) {
144
145
  return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nEnvironment=PATH=' + servicePath(nodeExe) + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
145
146
  }
146
147
 
147
- function servicePlan(home, bin, core, platform = process.platform) {
148
+ // TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
149
+ // silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
150
+ const CRON_MARK = '# kenaz-server (managed by kenaz install)';
151
+ const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
152
+ function cronStartCmd(bin, core, log, nodeExe) {
153
+ return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
154
+ }
155
+ function cronLine(bin, core, log, nodeExe) { // % is special in crontab
156
+ return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
157
+ }
158
+ // stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
159
+ // process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
160
+ function stopCmd(plan) {
161
+ const b = shq(plan.bin);
162
+ return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
163
+ '[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
164
+ }
165
+ // start now, detached from the installer (new session when setsid exists), output to the log
166
+ function detachedStartCmd(plan) {
167
+ return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
168
+ 'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
169
+ }
170
+
171
+ function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
148
172
  const L = launcherPaths(home);
149
173
  if (platform === 'win32') {
150
174
  const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
@@ -155,12 +179,101 @@ function servicePlan(home, bin, core, platform = process.platform) {
155
179
  const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
156
180
  return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
157
181
  }
182
+ if (caps.systemd === false) {
183
+ return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
184
+ }
158
185
  const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
159
186
  return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
160
187
  }
161
- const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
188
+ const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
189
+
190
+ // crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
191
+ function readCron(run) {
192
+ const r = run('crontab', ['-l']);
193
+ if (!r || r.error || r.status === 127) return null;
194
+ return r.status === 0 ? String(r.stdout || '') : '';
195
+ }
196
+ function probeLinux(run) {
197
+ const r = run('systemctl', ['--user', 'show-environment']);
198
+ const systemd = !!r && !r.error && r.status === 0;
199
+ return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
200
+ }
201
+ function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
202
+ const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
203
+ const next = line ? keep.concat(line) : keep;
204
+ const text = next.length ? next.join('\n') + '\n' : '';
205
+ if (text === current) return false;
206
+ if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
207
+ return true;
208
+ }
162
209
 
163
- function serviceInstall(plan, dry, log) {
210
+ // TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
211
+ // explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
212
+ // stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
213
+ // restart an active unit. restartHint is also the command printed when the version check fails.
214
+ const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
215
+ function restartHint(plan, uid = defaultUid()) {
216
+ if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
217
+ if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
218
+ if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
219
+ // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
220
+ return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
221
+ }
222
+ function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
223
+ if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
224
+ log('start now (detached, log ' + plan.log + ')');
225
+ if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
226
+ return;
227
+ }
228
+ if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
229
+ log(restartHint(plan, uid));
230
+ if (dry) return;
231
+ if (plan.kind === 'launchd') {
232
+ const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
233
+ if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
234
+ log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
235
+ run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
236
+ }
237
+ } else run('systemctl', ['--user', 'restart', 'kenaz-server']);
238
+ }
239
+
240
+ /** One GET /api/health; resolves { ok, version }. */
241
+ function healthOnce(port, getFn = http.get) {
242
+ return new Promise((resolve) => {
243
+ const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
244
+ let b = ''; res.on('data', (d) => { b += d; });
245
+ res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
246
+ });
247
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
248
+ req.on('error', () => resolve({ ok: false, version: null }));
249
+ });
250
+ }
251
+ /**
252
+ * TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
253
+ * installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
254
+ * print a warning with the exact restart command and do not fail the install.
255
+ */
256
+ async function verifyRuntime(plan, expected, log, opts = {}) {
257
+ const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
258
+ const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
259
+ const probe = opts.health || ((p) => healthOnce(p));
260
+ let last = { ok: false, version: null };
261
+ for (let i = 0; i < tries; i++) {
262
+ last = await probe(port);
263
+ if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
264
+ if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
265
+ }
266
+ const hint = restartHint(plan, opts.uid);
267
+ if (last.ok && last.version) {
268
+ log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
269
+ return 'mismatch';
270
+ }
271
+ log('WARNING: could not confirm the new server (' + expected + ') on 127.0.0.1:' + port + (last.ok ? ' (no version in /api/health)' : ' (no answer)') + '. If it is not running: ' + hint);
272
+ return 'unreachable';
273
+ }
274
+
275
+ function serviceInstall(plan, dry, log, run = sh, uid) {
276
+ const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
164
277
  for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
165
278
  if (plan.kind === 'windows') {
166
279
  const tr = 'wscript.exe ' + q(plan.files[0][0]);
@@ -178,29 +291,63 @@ function serviceInstall(plan, dry, log) {
178
291
  }
179
292
  } else if (plan.kind === 'launchd') {
180
293
  log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
294
+ serviceRestart(plan, dry, log, sh, uid);
295
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
296
+ if (plan.kind === 'cron') {
297
+ log('crontab: @reboot entry ' + plan.cronLine);
298
+ if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
299
+ } else {
300
+ log('WARNING: no systemd user session and no crontab here. Add this to your own init/boot script to start the server at boot: ' + cronStartCmd(plan.bin, plan.core, plan.log));
301
+ }
302
+ serviceRestart(plan, dry, log, sh, uid);
181
303
  } else {
182
304
  log('systemctl --user enable --now kenaz-server');
183
305
  if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
306
+ serviceRestart(plan, dry, log, sh, uid);
184
307
  }
185
308
  }
186
- function serviceUninstall(plan, dry, log) {
309
+ function serviceUninstall(plan, dry, log, run = sh) {
187
310
  if (plan.kind === 'windows') {
188
311
  log('schtasks /End + /Delete /TN ' + TASK_NAME);
189
- if (!dry) { sh('schtasks', ['/End', '/TN', TASK_NAME]); sh('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
312
+ if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
190
313
  if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
191
314
  } else if (plan.kind === 'launchd') {
192
- log('launchctl unload ' + plan.unit); if (!dry) sh('launchctl', ['unload', '-w', plan.unit]);
315
+ log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
316
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
317
+ if (plan.kind === 'cron') { log('crontab: remove the kenaz-server @reboot entry'); if (!dry) { const cur = readCron(run); if (cur !== null) writeCron(run, cur, null); } }
318
+ log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
193
319
  } else {
194
- log('systemctl --user disable --now kenaz-server'); if (!dry) sh('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
320
+ log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
195
321
  }
196
322
  for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
197
323
  }
198
- function serviceStatus(plan) {
199
- if (plan.kind === 'windows') return sh('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
200
- if (plan.kind === 'systemd') return sh('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
324
+ function serviceStatus(plan, run = sh) {
325
+ if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
326
+ if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
327
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
328
+ if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
201
329
  return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
202
330
  }
203
331
 
332
+ /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
333
+ function detectServiceMode(home, core, run = sh, platform = process.platform) {
334
+ if (platform === 'win32') {
335
+ const plan = servicePlan(home, '', core, 'win32');
336
+ if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
337
+ if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
338
+ return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
339
+ }
340
+ if (platform === 'darwin') {
341
+ const p = servicePlan(home, '', core, 'darwin').unit;
342
+ return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
343
+ }
344
+ const unit = servicePlan(home, '', core, 'linux').unit;
345
+ if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
346
+ const c = readCron(run);
347
+ if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
348
+ return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
349
+ }
350
+
204
351
  function parseArgs(argv) {
205
352
  const o = { cmd: argv[0], flags: {} };
206
353
  for (let i = 1; i < argv.length; i++) {
@@ -217,7 +364,7 @@ function findServerBin(core, home) {
217
364
  return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
218
365
  }
219
366
 
220
- function main(argv, out = console.log, getVersion = claudeVersion) { // getVersion: injectable for tests
367
+ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
221
368
  const { cmd, flags } = parseArgs(argv);
222
369
  if (!['install', 'uninstall', 'status'].includes(cmd)) {
223
370
  out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
@@ -264,11 +411,15 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
264
411
 
265
412
  if (!flags['no-service']) {
266
413
  const bin = flags['server-bin'] ? path.resolve(flags['server-bin']) : findServerBin(core, home);
267
- const plan = servicePlan(home, bin || '<kenaz-server not found>', core);
268
- if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan)));
269
- else if (cmd === 'uninstall') serviceUninstall(plan, dry, log);
414
+ const run = deps.sh || sh, platform = deps.platform || process.platform;
415
+ // the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
416
+ const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
417
+ const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
418
+ if (deps.onPlan) deps.onPlan(plan);
419
+ if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
420
+ else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
270
421
  else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
271
- else serviceInstall(plan, dry, log);
422
+ else serviceInstall(plan, dry, log, run, deps.uid);
272
423
  }
273
424
  if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz:kenaz init; /kenaz:Ansuz (Codex: run /hooks once to trust the kenaz hooks, then $kenaz status)');
274
425
  if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
@@ -276,4 +427,4 @@ function main(argv, out = console.log, getVersion = claudeVersion) { // getVersi
276
427
  }
277
428
 
278
429
  if (require.main === module) process.exit(main(process.argv.slice(2)));
279
- module.exports = { main, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
430
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
@@ -1,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();