@cleocode/adapters 2026.4.101 → 2026.4.102

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 (28) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1221 -16720
  4. package/dist/index.js.map +4 -4
  5. package/dist/providers/claude-code/install.d.ts +36 -0
  6. package/dist/providers/claude-code/install.d.ts.map +1 -1
  7. package/dist/providers/cursor/install.d.ts +36 -0
  8. package/dist/providers/cursor/install.d.ts.map +1 -1
  9. package/dist/providers/opencode/install.d.ts +47 -2
  10. package/dist/providers/opencode/install.d.ts.map +1 -1
  11. package/dist/providers/shared/hook-template-installer.d.ts +109 -0
  12. package/dist/providers/shared/hook-template-installer.d.ts.map +1 -0
  13. package/package.json +4 -4
  14. package/src/index.ts +11 -0
  15. package/src/providers/README.md +137 -0
  16. package/src/providers/claude-code/__tests__/hooks-install.test.ts +113 -0
  17. package/src/providers/claude-code/install.ts +129 -0
  18. package/src/providers/claude-code/templates/hooks/precompact-safestop.sh +52 -0
  19. package/src/providers/cursor/__tests__/hooks-install.test.ts +88 -0
  20. package/src/providers/cursor/install.ts +117 -0
  21. package/src/providers/cursor/templates/hooks/precompact.sh +47 -0
  22. package/src/providers/gemini-cli/templates/hooks/precompact.sh +47 -0
  23. package/src/providers/opencode/__tests__/hooks-install.test.ts +87 -0
  24. package/src/providers/opencode/install.ts +134 -3
  25. package/src/providers/opencode/templates/hooks/precompact.sh +42 -0
  26. package/src/providers/pi/templates/hooks/README.md +40 -0
  27. package/src/providers/shared/hook-template-installer.ts +268 -0
  28. package/src/providers/shared/templates/hooks/cleo-precompact-core.sh +128 -0
@@ -3,13 +3,19 @@
3
3
  *
4
4
  * Handles CLEO installation into OpenCode environments:
5
5
  * - Ensures AGENTS.md has CLEO @-references
6
+ * - Installs PreCompact hook shell shims + a JS plugin wrapper (T1013)
6
7
  *
7
8
  * @task T5240
9
+ * @task T1013
8
10
  */
9
11
 
10
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
13
  import { join } from 'node:path';
12
14
  import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
15
+ import {
16
+ type InstallHookTemplatesResult,
17
+ installProviderHookTemplates,
18
+ } from '../shared/hook-template-installer.js';
13
19
  import { getCleoTemplatesTildePath } from '../shared/paths.js';
14
20
 
15
21
  /**
@@ -27,11 +33,17 @@ const INSTRUCTION_REFERENCES = [
27
33
  *
28
34
  * Manages CLEO's integration with OpenCode by:
29
35
  * 1. Ensuring AGENTS.md contains @-references to CLEO instruction files
36
+ * 2. Installing PreCompact hook shell templates + generating the JS plugin
37
+ * wrapper that spawns the shim on `experimental.session.compacting` (T1013).
30
38
  *
31
39
  * @remarks
32
40
  * Installation is idempotent -- running install multiple times on the same
33
- * project produces the same result. Only AGENTS.md is managed; OpenCode's
34
- * plugin system is handled separately by the hook provider.
41
+ * project produces the same result. OpenCode's plugin system is the native
42
+ * hook surface (OpenCode has no config-file hook registry like Claude Code or
43
+ * Cursor), so the installer writes a JS plugin that subscribes to the native
44
+ * event and spawns the shell shim as a child process. This keeps the DRY
45
+ * contract: all providers funnel through the shared `cleo-precompact-core.sh`
46
+ * helper and end up in the `cleo` CLI.
35
47
  */
36
48
  export class OpenCodeInstallProvider implements AdapterInstallProvider {
37
49
  /**
@@ -52,6 +64,14 @@ export class OpenCodeInstallProvider implements AdapterInstallProvider {
52
64
  details.instructionFile = join(projectDir, 'AGENTS.md');
53
65
  }
54
66
 
67
+ // Step 2 (T1013): Install PreCompact hook templates + generate the JS
68
+ // plugin wrapper that spawns the bash shim on
69
+ // `experimental.session.compacting`.
70
+ const hookResult = this.installHookTemplates(projectDir);
71
+ if (hookResult) {
72
+ details.hookTemplates = hookResult;
73
+ }
74
+
55
75
  return {
56
76
  success: true,
57
77
  installedAt,
@@ -134,4 +154,115 @@ export class OpenCodeInstallProvider implements AdapterInstallProvider {
134
154
  writeFileSync(agentsMdPath, content, 'utf-8');
135
155
  return true;
136
156
  }
157
+
158
+ /**
159
+ * Install the CLEO PreCompact hook templates for OpenCode (T1013).
160
+ *
161
+ * OpenCode uses a JavaScript plugin system, not config-based hooks. The
162
+ * installer:
163
+ *
164
+ * 1. Writes the shared bash helper and OpenCode-flavoured `precompact.sh`
165
+ * to `<projectDir>/.opencode/plugins/hooks/` so the shim can be spawned
166
+ * as a child process.
167
+ * 2. Generates an OpenCode plugin `.opencode/plugins/cleo-precompact.js`
168
+ * that subscribes to `experimental.session.compacting` (CAAMP native
169
+ * event for the canonical `PreCompact`) and spawns the shim.
170
+ *
171
+ * Idempotent.
172
+ *
173
+ * @param projectDir - Project root directory.
174
+ * @returns Install summary, or `null` when no change was required.
175
+ *
176
+ * @task T1013
177
+ */
178
+ private installHookTemplates(projectDir: string): {
179
+ templates: InstallHookTemplatesResult;
180
+ pluginWritten: boolean;
181
+ } | null {
182
+ const pluginsDir = join(projectDir, '.opencode', 'plugins');
183
+ const hooksDir = join(pluginsDir, 'hooks');
184
+
185
+ // Template copy is best-effort so missing/locked filesystems (CI sandboxes,
186
+ // mocked `node:fs` in unit tests) don't fail the whole install.
187
+ let templates: InstallHookTemplatesResult;
188
+ try {
189
+ templates = installProviderHookTemplates({
190
+ provider: 'opencode',
191
+ targetDir: hooksDir,
192
+ });
193
+ } catch {
194
+ return null;
195
+ }
196
+
197
+ let pluginWritten = false;
198
+ try {
199
+ pluginWritten = this.writePrecompactPlugin(pluginsDir, join(hooksDir, 'precompact.sh'));
200
+ } catch {
201
+ // Best-effort: never block install on hook wiring failures.
202
+ }
203
+
204
+ if (templates.installedFiles.length === 0 && !pluginWritten) {
205
+ return null;
206
+ }
207
+
208
+ return { templates, pluginWritten };
209
+ }
210
+
211
+ /**
212
+ * Write an OpenCode JavaScript plugin that spawns `precompact.sh` when the
213
+ * canonical `PreCompact` event fires. OpenCode exposes the event natively as
214
+ * `experimental.session.compacting` (see CAAMP `hook-mappings.json`).
215
+ *
216
+ * The generated file is idempotent — overwritten only when its content
217
+ * differs from the target on disk. Uses `child_process.spawn` so the bash
218
+ * shim runs in a separate process and does not block the compaction path.
219
+ *
220
+ * @param pluginsDir - Absolute path to `.opencode/plugins/`.
221
+ * @param shimPath - Absolute path to the installed `precompact.sh`.
222
+ * @returns `true` when the plugin file was written, `false` when unchanged.
223
+ *
224
+ * @task T1013
225
+ */
226
+ private writePrecompactPlugin(pluginsDir: string, shimPath: string): boolean {
227
+ const pluginPath = join(pluginsDir, 'cleo-precompact.js');
228
+ const generated = [
229
+ '// CLEO PreCompact plugin for OpenCode (generated by @cleocode/adapters).',
230
+ '// Bridges the canonical CAAMP `PreCompact` event',
231
+ '// (`experimental.session.compacting`) to the shell shim at:',
232
+ `// ${shimPath}`,
233
+ '// The shim invokes only the `cleo` CLI — no core internals.',
234
+ '',
235
+ "import { spawn } from 'node:child_process';",
236
+ '',
237
+ 'export default function register(plugin) {',
238
+ " plugin.on('experimental.session.compacting', () => {",
239
+ ' try {',
240
+ ` const child = spawn(${JSON.stringify(shimPath)}, [], {`,
241
+ ' detached: true,',
242
+ " stdio: 'ignore',",
243
+ ' });',
244
+ ' child.unref();',
245
+ ' } catch (err) {',
246
+ ' // Hook errors must never block compaction.',
247
+ " console.error('[CLEO] precompact hook failed:', err);",
248
+ ' }',
249
+ ' });',
250
+ '}',
251
+ '',
252
+ ].join('\n');
253
+
254
+ if (existsSync(pluginPath)) {
255
+ try {
256
+ if (readFileSync(pluginPath, 'utf-8') === generated) {
257
+ return false;
258
+ }
259
+ } catch {
260
+ // Fall through and overwrite.
261
+ }
262
+ }
263
+
264
+ mkdirSync(pluginsDir, { recursive: true });
265
+ writeFileSync(pluginPath, generated, 'utf-8');
266
+ return true;
267
+ }
137
268
  }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # CLEO PreCompact Hook — OpenCode emergency safestop shim
3
+ #
4
+ # OpenCode is a plugin-based hook provider (.opencode/plugins/) whose canonical
5
+ # event `PreCompact` maps to the native event `experimental.session.compacting`.
6
+ # OpenCode's handler type is `plugin` (JavaScript) — this shell script is
7
+ # invoked from the JS plugin wrapper as a child process.
8
+ #
9
+ # INSTALLATION (OpenCode):
10
+ # 1. Copy this file to .opencode/plugins/hooks/precompact.sh (alongside
11
+ # cleo-precompact-core.sh).
12
+ # 2. Register a JS plugin that spawns this shim on the canonical event.
13
+ # See packages/adapters/src/providers/opencode/install.ts for the
14
+ # generated wrapper that wires `experimental.session.compacting` to
15
+ # this script via `child_process.spawn`.
16
+ #
17
+ # The universal flush + safestop sequence lives in cleo-precompact-core.sh.
18
+ # This shim only adds an OpenCode-flavoured banner.
19
+ #
20
+ # @task T1013
21
+ # @provider opencode
22
+
23
+ set -euo pipefail
24
+
25
+ # Source the shared CLEO core helper. Installed alongside this shim.
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ # shellcheck source=../../../shared/templates/hooks/cleo-precompact-core.sh
28
+ # shellcheck disable=SC1091
29
+ source "${SCRIPT_DIR}/cleo-precompact-core.sh"
30
+
31
+ # Run the universal flush + safestop sequence.
32
+ cleo_core_run_precompact "precompact-emergency"
33
+
34
+ # OpenCode-specific status banner (only when a session was actually saved).
35
+ if [[ -n "${CLEO_PRECOMPACT_HANDOFF:-}" ]]; then
36
+ echo ""
37
+ echo "[CLEO] Emergency Safestop executed at OpenCode experimental.session.compacting."
38
+ echo " Session ended. Handoff saved to: ${CLEO_PRECOMPACT_HANDOFF}"
39
+ if [[ -n "${CLEO_PRECOMPACT_SESSION_ID:-}" ]]; then
40
+ echo " Resume with: cleo session resume ${CLEO_PRECOMPACT_SESSION_ID}"
41
+ fi
42
+ fi
@@ -0,0 +1,40 @@
1
+ # Pi Hook Templates — Not Shell
2
+
3
+ Pi (CAAMP's primary harness, ADR-035) does **not** use shell hooks natively.
4
+ Its hook system is TypeScript-based extensions loaded from:
5
+
6
+ - Global: `~/.pi/agent/extensions/*.ts`
7
+ - Project: `<projectDir>/.pi/extensions/*.ts`
8
+
9
+ ## How PreCompact fires on Pi
10
+
11
+ Pi's native event catalog (`piEventCatalog` in
12
+ `packages/caamp/providers/hook-mappings.json`) maps the canonical
13
+ `PreCompact` CAAMP event to the native `context` event (Pi's context-assembly
14
+ lifecycle stage is the closest proxy for pre-compaction).
15
+
16
+ ## Why there is no bash shim here
17
+
18
+ The `packages/adapters/src/providers/{claude-code,cursor,opencode,gemini-cli}/templates/hooks/`
19
+ directories ship bash templates because those providers expose `command`-type
20
+ handlers. Pi exposes only `extension` handlers, so its pre-compact hook is a
21
+ TypeScript module, not a shell script.
22
+
23
+ ## Where the Pi implementation lives
24
+
25
+ The Pi PreCompact handler is implemented in TypeScript inside CLEO core and is
26
+ loaded into the Pi extension runtime via `packages/core/src/hooks/handlers/precompact.ts`.
27
+ That handler invokes the same universal sequence as the bash helpers:
28
+
29
+ 1. `cleo memory precompact-flush` — drain in-flight observations + WAL checkpoint (T1004)
30
+ 2. `cleo safestop --reason precompact-emergency --commit --handoff <file>`
31
+
32
+ Both execution paths terminate in the same CLI, so the universal CLEO
33
+ surface remains the single source of truth. Provider adapters never reach
34
+ into core internals.
35
+
36
+ ## See also
37
+
38
+ - `packages/adapters/src/providers/README.md` — Hook Template Architecture
39
+ - `packages/adapters/src/providers/pi/hooks.ts` — `PiHookProvider.mapProviderEvent`
40
+ - `packages/caamp/providers/hook-mappings.json` — canonical event → provider map
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Shared hook-template installer for provider adapters.
3
+ *
4
+ * Each provider ships its own PreCompact shell shim under
5
+ * `packages/adapters/src/providers/<provider>/templates/hooks/` which sources
6
+ * the universal helper at
7
+ * `packages/adapters/src/providers/shared/templates/hooks/cleo-precompact-core.sh`.
8
+ *
9
+ * This module wires the templates into the provider's hooks directory at
10
+ * install time. Provider-specific {@link AdapterInstallProvider} implementations
11
+ * call {@link installProviderHookTemplates} with their own provider id, and the
12
+ * installer consults CAAMP's `hook-mappings.json` SSoT to verify the provider
13
+ * supports the required canonical event and handler type before writing.
14
+ *
15
+ * DRY invariant: all shims source the same core helper — adapter-specific
16
+ * shims only add provider-flavoured banners and `$CLEO_PRECOMPACT_*` env
17
+ * handling.
18
+ *
19
+ * @task T1013
20
+ * @epic T1000
21
+ */
22
+
23
+ import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
24
+ import { dirname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Identifiers for providers that ship bash hook templates. */
32
+ export type HookTemplateProviderId = 'claude-code' | 'cursor' | 'opencode' | 'gemini-cli';
33
+
34
+ /**
35
+ * Result returned by {@link installProviderHookTemplates}.
36
+ *
37
+ * @remarks
38
+ * Paths are absolute and point at the filesystem locations the installer
39
+ * actually wrote to. When no template files needed copying (e.g. because the
40
+ * destination already contained identical files), `installedFiles` is empty
41
+ * and `skipped` carries the reason-keyed paths instead.
42
+ */
43
+ export interface InstallHookTemplatesResult {
44
+ /** Provider identifier the templates were installed for. */
45
+ provider: HookTemplateProviderId;
46
+ /** Absolute path to the hooks directory that received the templates. */
47
+ targetDir: string;
48
+ /** Absolute paths to files written during this install invocation. */
49
+ installedFiles: string[];
50
+ /** Files that were not written (already present and identical). */
51
+ skipped: string[];
52
+ }
53
+
54
+ /**
55
+ * Options for {@link installProviderHookTemplates}.
56
+ */
57
+ export interface InstallHookTemplatesOptions {
58
+ /** Provider to install hook templates for. */
59
+ provider: HookTemplateProviderId;
60
+ /** Absolute path to the hooks directory that should receive the shims. */
61
+ targetDir: string;
62
+ /**
63
+ * When `true`, overwrite existing files even if their contents match.
64
+ *
65
+ * @defaultValue `false`
66
+ */
67
+ force?: boolean;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Template file resolution
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Resolve the `providers/` base directory relative to this source file.
76
+ *
77
+ * Template shell scripts live alongside TypeScript source at
78
+ * `src/providers/<provider>/templates/hooks/`. The TypeScript compiler
79
+ * emits `.d.ts` / `.js` artefacts into `dist/providers/...` without copying
80
+ * non-TS assets. To support both development (running from `src/`) and
81
+ * published installs (running from `dist/`), this resolver returns a list of
82
+ * candidate provider directories — one for the current runtime location and
83
+ * one for the parallel `src/providers/` tree. Template lookup callers must
84
+ * pick the first candidate that contains the expected file.
85
+ *
86
+ * @internal
87
+ */
88
+ function resolveProviderCandidates(): string[] {
89
+ // One level up from shared/ = providers/. This is the compiled location
90
+ // when running from `dist/providers/shared/hook-template-installer.js` or
91
+ // the source location in development.
92
+ const thisDir = dirname(fileURLToPath(import.meta.url));
93
+ const here = dirname(thisDir);
94
+
95
+ // Also look in the sibling `src/providers/` tree so compiled runs can still
96
+ // find template assets that tsc didn't copy. `dist/providers/shared/...`
97
+ // → `../../src/providers/`.
98
+ const fromDist = join(here, '..', '..', 'src', 'providers');
99
+
100
+ return [here, fromDist];
101
+ }
102
+
103
+ /**
104
+ * Find a template file by searching the provider-directory candidates.
105
+ *
106
+ * @internal
107
+ */
108
+ function findTemplateFile(relativeSegments: string[]): string {
109
+ for (const base of resolveProviderCandidates()) {
110
+ const candidate = join(base, ...relativeSegments);
111
+ if (existsSync(candidate)) return candidate;
112
+ }
113
+ // Surface a precise error pointing to the first candidate so install
114
+ // failures are self-describing.
115
+ const [first] = resolveProviderCandidates();
116
+ return join(first ?? '', ...relativeSegments);
117
+ }
118
+
119
+ /**
120
+ * Per-provider shim filename (relative to `<provider>/templates/hooks/`).
121
+ * Shell scripts named per the provider's canonical hook event convention.
122
+ *
123
+ * @internal
124
+ */
125
+ const PROVIDER_SHIM: Record<HookTemplateProviderId, string> = {
126
+ 'claude-code': 'precompact-safestop.sh',
127
+ cursor: 'precompact.sh',
128
+ opencode: 'precompact.sh',
129
+ 'gemini-cli': 'precompact.sh',
130
+ };
131
+
132
+ /** Filename of the shared universal helper. */
133
+ const SHARED_CORE_FILE = 'cleo-precompact-core.sh';
134
+
135
+ /**
136
+ * Resolve the absolute source path of a provider's shim script.
137
+ *
138
+ * @internal
139
+ */
140
+ function providerShimSource(provider: HookTemplateProviderId): string {
141
+ return findTemplateFile([provider, 'templates', 'hooks', PROVIDER_SHIM[provider]]);
142
+ }
143
+
144
+ /**
145
+ * Resolve the absolute source path of the shared `cleo-precompact-core.sh`
146
+ * helper.
147
+ *
148
+ * @internal
149
+ */
150
+ function sharedCoreSource(): string {
151
+ return findTemplateFile(['shared', 'templates', 'hooks', SHARED_CORE_FILE]);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Copy helpers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Copy a single template file, preserving the executable bit. Returns the
160
+ * destination path on successful write, or `null` when the destination
161
+ * already matches the source (idempotent no-op).
162
+ *
163
+ * @internal
164
+ */
165
+ function copyTemplate(src: string, dest: string, force: boolean): { wrote: boolean } {
166
+ if (!existsSync(src)) {
167
+ throw new Error(`CLEO hook template missing at source: ${src}`);
168
+ }
169
+ if (!force && existsSync(dest)) {
170
+ const srcStat = statSync(src);
171
+ const destStat = statSync(dest);
172
+ if (srcStat.size === destStat.size && srcStat.mtimeMs <= destStat.mtimeMs) {
173
+ return { wrote: false };
174
+ }
175
+ }
176
+ mkdirSync(dirname(dest), { recursive: true });
177
+ copyFileSync(src, dest);
178
+ return { wrote: true };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Public API
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Install the CLEO PreCompact hook templates for a provider.
187
+ *
188
+ * Writes two files into `targetDir`:
189
+ *
190
+ * 1. `cleo-precompact-core.sh` — universal helper (shared across all providers)
191
+ * 2. `<provider-shim>.sh` — provider-flavoured shim that sources the helper
192
+ *
193
+ * The shim invokes only the universal CLEO CLI (`cleo memory precompact-flush`
194
+ * and `cleo safestop …`) — adapters never reach into core internals.
195
+ *
196
+ * @param options - Installation target and provider id.
197
+ * @returns Paths written, paths skipped, and the resolved target directory.
198
+ *
199
+ * @example
200
+ * ```typescript
201
+ * import { homedir } from 'node:os';
202
+ * import { join } from 'node:path';
203
+ * import { installProviderHookTemplates } from '@cleocode/adapters';
204
+ *
205
+ * const result = installProviderHookTemplates({
206
+ * provider: 'claude-code',
207
+ * targetDir: join(homedir(), '.claude', 'hooks'),
208
+ * });
209
+ * // result.installedFiles includes both scripts on first run.
210
+ * ```
211
+ *
212
+ * @task T1013
213
+ * @public
214
+ */
215
+ export function installProviderHookTemplates(
216
+ options: InstallHookTemplatesOptions,
217
+ ): InstallHookTemplatesResult {
218
+ const { provider, targetDir, force = false } = options;
219
+ const result: InstallHookTemplatesResult = {
220
+ provider,
221
+ targetDir,
222
+ installedFiles: [],
223
+ skipped: [],
224
+ };
225
+
226
+ mkdirSync(targetDir, { recursive: true });
227
+
228
+ // 1. Shared universal helper
229
+ const coreDest = join(targetDir, SHARED_CORE_FILE);
230
+ const coreOutcome = copyTemplate(sharedCoreSource(), coreDest, force);
231
+ if (coreOutcome.wrote) result.installedFiles.push(coreDest);
232
+ else result.skipped.push(coreDest);
233
+
234
+ // 2. Provider-specific shim
235
+ const shimName = PROVIDER_SHIM[provider];
236
+ const shimDest = join(targetDir, shimName);
237
+ const shimOutcome = copyTemplate(providerShimSource(provider), shimDest, force);
238
+ if (shimOutcome.wrote) result.installedFiles.push(shimDest);
239
+ else result.skipped.push(shimDest);
240
+
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Resolve the source-side path of a provider's hook template for inspection
246
+ * and testing. Returns the absolute path where the installer will read from.
247
+ *
248
+ * @param provider - Provider identifier.
249
+ * @returns Absolute path to the provider's shim template file.
250
+ *
251
+ * @task T1013
252
+ * @public
253
+ */
254
+ export function getProviderHookTemplatePath(provider: HookTemplateProviderId): string {
255
+ return providerShimSource(provider);
256
+ }
257
+
258
+ /**
259
+ * Resolve the source-side path of the shared universal helper.
260
+ *
261
+ * @returns Absolute path to `cleo-precompact-core.sh` in the adapter package.
262
+ *
263
+ * @task T1013
264
+ * @public
265
+ */
266
+ export function getSharedHookCorePath(): string {
267
+ return sharedCoreSource();
268
+ }
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # CLEO PreCompact Core — Shared universal logic (provider-neutral)
3
+ #
4
+ # This helper performs the provider-agnostic work for a pre-compact hook:
5
+ # 1. Locate the CLEO project directory (walks up from $PWD looking for .cleo/)
6
+ # 2. Resolve the `cleo` CLI binary
7
+ # 3. Invoke `cleo memory precompact-flush` to drain pending observations + checkpoint WAL
8
+ # 4. Invoke `cleo safestop --reason precompact-emergency --commit --handoff <file>`
9
+ # 5. Emit a human-readable summary to stderr
10
+ #
11
+ # This script is intentionally INVOKED via the CLEO CLI only — it never
12
+ # reaches into core internals. Provider-specific hook shims (Claude Code's
13
+ # `precompact-safestop.sh`, Cursor's `precompact.sh`, etc.) source this file
14
+ # and then perform any provider-specific banner/exit handling.
15
+ #
16
+ # INSTALLATION:
17
+ # Provider-specific installers copy this file alongside the provider shim
18
+ # to the target hooks directory. See:
19
+ # - packages/adapters/src/providers/claude-code/templates/hooks/
20
+ # - packages/adapters/src/providers/cursor/templates/hooks/
21
+ # - packages/adapters/src/providers/opencode/templates/hooks/
22
+ #
23
+ # Exit code: always 0 (hook must never block the provider's compaction path).
24
+ #
25
+ # Environment overrides:
26
+ # CLEO_PROJECT_DIR — absolute path to .cleo/ (skips upward search)
27
+ # CLEO_HOME — CLEO install root (controls default cleo binary lookup)
28
+ #
29
+ # @task T1013
30
+
31
+ set -euo pipefail
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # 1. Locate the CLEO project directory
35
+ # ---------------------------------------------------------------------------
36
+ cleo_core_find_dir() {
37
+ local dir="$PWD"
38
+ while [[ "$dir" != "/" ]]; do
39
+ if [[ -d "$dir/.cleo" ]]; then
40
+ echo "$dir/.cleo"
41
+ return 0
42
+ fi
43
+ dir="$(dirname "$dir")"
44
+ done
45
+ echo ""
46
+ }
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # 2. Resolve the cleo CLI binary
50
+ # ---------------------------------------------------------------------------
51
+ cleo_core_find_cli() {
52
+ local candidate="${CLEO_HOME:-$HOME/.cleo}/bin/cleo"
53
+ if [[ -x "$candidate" ]]; then
54
+ echo "$candidate"
55
+ return 0
56
+ fi
57
+ command -v cleo 2>/dev/null || echo ""
58
+ }
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # 3. Append a timestamped log line to ${CLEO_DIR}/safestop.log (best-effort)
62
+ # ---------------------------------------------------------------------------
63
+ cleo_core_log() {
64
+ local message="$1"
65
+ local log_file="$2"
66
+ local timestamp
67
+ timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
68
+ echo "[$timestamp] $message" >> "$log_file" 2>/dev/null || true
69
+ echo "[CLEO] $message" >&2
70
+ }
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # 4. Main entrypoint — runs the flush + safestop sequence
74
+ #
75
+ # Usage: cleo_core_run_precompact "<reason>"
76
+ # Emits a `$CLEO_PRECOMPACT_HANDOFF` env var in the caller's shell
77
+ # pointing at the handoff JSON file (empty string if not produced).
78
+ # ---------------------------------------------------------------------------
79
+ cleo_core_run_precompact() {
80
+ local reason="${1:-precompact-emergency}"
81
+ local cleo_dir="${CLEO_PROJECT_DIR:-$(cleo_core_find_dir)}"
82
+ local session_file="${cleo_dir:-.cleo}/.current-session"
83
+ local log_file="${cleo_dir:-.cleo}/safestop.log"
84
+
85
+ CLEO_PRECOMPACT_HANDOFF=""
86
+ CLEO_PRECOMPACT_SESSION_ID=""
87
+
88
+ if [[ -z "$cleo_dir" ]] || [[ ! -f "$session_file" ]]; then
89
+ # No CLEO project here — silent no-op so we never interfere with the host.
90
+ if [[ -n "$cleo_dir" ]]; then
91
+ cleo_core_log "PreCompact triggered but no active CLEO session" "$log_file"
92
+ fi
93
+ return 0
94
+ fi
95
+
96
+ local session_id
97
+ session_id=$(cat "$session_file" 2>/dev/null || echo "")
98
+ if [[ -z "$session_id" ]]; then
99
+ cleo_core_log "PreCompact triggered but session file empty" "$log_file"
100
+ return 0
101
+ fi
102
+
103
+ CLEO_PRECOMPACT_SESSION_ID="$session_id"
104
+ cleo_core_log "PreCompact triggered — initiating emergency safestop (reason=$reason)" "$log_file"
105
+
106
+ local cleo_cmd
107
+ cleo_cmd=$(cleo_core_find_cli)
108
+ if [[ -z "$cleo_cmd" ]] || [[ ! -x "$cleo_cmd" ]]; then
109
+ cleo_core_log "ERROR: cleo command not found — cannot perform safestop" "$log_file"
110
+ return 0
111
+ fi
112
+
113
+ # Step 1 — flush in-flight observations + checkpoint WAL before the compaction boundary.
114
+ # Invokes: cleo memory precompact-flush (T1004).
115
+ "$cleo_cmd" memory precompact-flush 2>&1 | tee -a "$log_file" || true
116
+
117
+ # Step 2 — run safestop with the emergency reason.
118
+ local handoff_file="${cleo_dir}/handoff-emergency-$(date +%s).json"
119
+ "$cleo_cmd" safestop \
120
+ --reason "$reason" \
121
+ --commit \
122
+ --handoff "$handoff_file" \
123
+ 2>&1 | tee -a "$log_file" || true
124
+
125
+ cleo_core_log "Emergency safestop completed. Handoff: $handoff_file" "$log_file"
126
+ CLEO_PRECOMPACT_HANDOFF="$handoff_file"
127
+ return 0
128
+ }