@jmtrin/opencode-kevin 0.8.0 → 0.9.0

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 (36) hide show
  1. package/README.md +63 -10
  2. package/dist/migrations/010_v09_native.sql +78 -0
  3. package/dist/plugin/HookLiveness.d.ts +81 -0
  4. package/dist/plugin/HookLiveness.js +324 -0
  5. package/dist/plugin/HookLiveness.js.map +1 -0
  6. package/dist/plugin/Materializer.d.ts +25 -0
  7. package/dist/plugin/Materializer.js +39 -10
  8. package/dist/plugin/Materializer.js.map +1 -1
  9. package/dist/plugin/Migrate.d.ts +1 -0
  10. package/dist/plugin/Migrate.js +46 -5
  11. package/dist/plugin/Migrate.js.map +1 -1
  12. package/dist/plugin/RepoIdentity.d.ts +20 -9
  13. package/dist/plugin/RepoIdentity.js +44 -9
  14. package/dist/plugin/RepoIdentity.js.map +1 -1
  15. package/dist/plugin/Retrospective.js +9 -0
  16. package/dist/plugin/Retrospective.js.map +1 -1
  17. package/dist/plugin/host.d.ts +70 -0
  18. package/dist/plugin/host.js +251 -0
  19. package/dist/plugin/host.js.map +1 -0
  20. package/dist/plugin/index.d.ts +2 -2
  21. package/dist/plugin/index.js +129 -7
  22. package/dist/plugin/index.js.map +1 -1
  23. package/dist/plugin/kevin_audit.d.ts +29 -0
  24. package/dist/plugin/kevin_audit.js +84 -0
  25. package/dist/plugin/kevin_audit.js.map +1 -1
  26. package/dist/plugin/kevin_doctor.d.ts +57 -0
  27. package/dist/plugin/kevin_doctor.js +168 -0
  28. package/dist/plugin/kevin_doctor.js.map +1 -0
  29. package/dist/plugin/kevin_native.d.ts +29 -0
  30. package/dist/plugin/kevin_native.js +80 -0
  31. package/dist/plugin/kevin_native.js.map +1 -0
  32. package/dist/plugin/native.d.ts +92 -0
  33. package/dist/plugin/native.js +191 -0
  34. package/dist/plugin/native.js.map +1 -0
  35. package/migrations/010_v09_native.sql +78 -0
  36. package/package.json +2 -2
package/README.md CHANGED
@@ -51,7 +51,7 @@ For a **single project**, put the same `plugin` array in `./opencode.json` or `.
51
51
 
52
52
  ### 2. Restart OpenCode
53
53
 
54
- Config is loaded once at startup and is **not hot-reloaded** — quit and reopen OpenCode after editing. On start, Kevin exposes 21 tools, including `kevin_facts`, `kevin_conflicts`, `kevin_project`, `kevin_share` and `kevin_sync`.
54
+ Config is loaded once at startup and is **not hot-reloaded** — quit and reopen OpenCode after editing. On start, Kevin exposes 23 tools, including `kevin_facts`, `kevin_conflicts`, `kevin_project`, `kevin_share`, `kevin_sync`, `kevin_doctor` and `kevin_native`.
55
55
 
56
56
  Contradictions de-rank memories and surface conflicts. They never delete, stale, archive, or auto-resolve a memory.
57
57
 
@@ -156,7 +156,7 @@ Since v0.8.0, memories live in one of two layers: **local** (private to this ins
156
156
 
157
157
  ## Tools
158
158
 
159
- Kevin exposes 21 tools callable by the agent.
159
+ Kevin exposes 23 tools callable by the agent.
160
160
 
161
161
  ### `kevin_save`
162
162
 
@@ -210,8 +210,8 @@ Global counts and metrics: memory census, the precision block, the six blocked-g
210
210
  ```
211
211
  kevin_status({})
212
212
  // → { "memories": 42, "memories_reflector": 12, "memories_agent": 30,
213
- // "memories_pattern": 0, "memories_causal": 1, "tool_calls": 318,
214
- // "retrospectives": 7, "tool_count": 21,
213
+ // "memories_pattern": 0, "memories_causal": 1, "tool_calls": 318,
214
+ // "retrospectives": 7, "tool_count": 23,
215
215
  // "metrics": { "tokens_injected_pre_prompt": 51, "tokens_injected_compacting": 0,
216
216
  // "reflections_throttled": 3, "duplicate_suppressions": 2,
217
217
  // "tool_calls_deduped": 0, "patterns_mined": 0,
@@ -384,11 +384,45 @@ Ingests the OKF file (asserts → shared-layer projections; tombstones → archi
384
384
  ```
385
385
  kevin_sync({})
386
386
  // → { "path": ".kevin/knowledge.okf", "fileHash": "3d2f...", "parsed": 1,
387
- // "folded": 0, "rejected": 0, "imported": 1, "tombstoned": 0,
388
- // "skipped": false }
387
+ // "folded": 0, "rejected": 0, "imported": 1, "tombstoned": 0,
388
+ // "skipped": false }
389
389
  ```
390
390
 
391
- ---
391
+ ### `kevin_doctor`
392
+
393
+ Read-only health check — no writes, no probe re-run, no model call. Returns `host`, `hooks`, `dependencies`, `native`, `verdict` and `reason`; `hooks` is sorted dead first so the failure is the first thing on screen. Output contains no filesystem paths or session ids — paste it into an issue report.
394
+
395
+ ```
396
+ kevin_doctor({})
397
+ // → { "host": { "plugin_version": "1.18.18", "flavour": "v1+v2", "shell_available": true,
398
+ // "v2": { "skill": true, "reference": true } },
399
+ // "hooks": [{ "hook": "experimental.chat.system.transform", "state": "dead",
400
+ // "fire_count": 0, "expected_count": 3, "since": "2026-08-20T10:00:00Z" },
401
+ // { "hook": "tool.execute.after", "state": "live", "fire_count": 42, "expected_count": 42 }],
402
+ // "dependencies": { "declared": ["@opencode-ai/plugin"], "zod_copies": 1 },
403
+ // "native": { "enabled": true, "registered": { "skill": true, "reference": true },
404
+ // "verified": { "skill": true, "reference": true } },
405
+ // "verdict": "degraded", "reason": "experimental.chat.system.transform dead since 2026-08-20T10:00:00Z; 1 affected hook(s)", "partial": false }
406
+ ```
407
+
408
+ One dead hook is enough for `degraded`; every hook `live` is `healthy`; otherwise `unknown` — `unknown` is never rounded to `healthy` (D9-09). With no sessions yet, `verdict` is `unknown`.
409
+
410
+ ### `kevin_native`
411
+
412
+ Controls the additive v2 attachment (D9-01): `show` reports the setting, the frozen probe result and the latest `native_registrations` rows; `enable`/`disable` write `kevin_settings` only and never re-probe — a restart is required for the change to take effect. The value is TEXT `"1"`/`"0"`; `enable` on a host without the v2 subpath succeeds and reports `effective: false` (inert) rather than refusing.
413
+
414
+ ```
415
+ kevin_native({ action: "show" })
416
+ // → { "action": "show", "value": "0", "effective": true,
417
+ // "registrations": [{ "surface": "skill", "registered": true, "verified": true, "attached_at": "2026-08-20T10:00:00Z" }] }
418
+
419
+ kevin_native({ action: "enable" })
420
+ // → { "action": "enable", "value": "1", "effective": false,
421
+ // "reason": "v2 subpath absent from the resolved host package — registration would be inert",
422
+ // "note": "the probe is frozen for the process lifetime — restart the host for the change to take effect" }
423
+ ```
424
+
425
+ ---
392
426
 
393
427
  ## How Kevin measures itself
394
428
 
@@ -645,7 +679,7 @@ KevinPlugin(input, {
645
679
 
646
680
  ### Settings
647
681
 
648
- Read/write via `kevin_config({ action: "list" | "set", ... })`. All 23 values are TEXT; booleans compare against `"1"`.
682
+ Read/write via `kevin_config({ action: "list" | "set", ... })`. All 27 values are TEXT; booleans compare against `"1"`.
649
683
 
650
684
  | Setting | Default | Effect |
651
685
  |---|---|---|
@@ -672,8 +706,12 @@ Read/write via `kevin_config({ action: "list" | "set", ... })`. All 23 values ar
672
706
  | `share_requires_approval` | `"1"` | When `"1"`, `kevin_share` writes only with `confirm: true`; un-curated memories are refused with `not_curated` |
673
707
  | `author_identity_mode` | `"hashed"` | Author attribution in exports: `"hashed"` or `"none"` (**string** enum) |
674
708
  | `shared_confidence_floor` | `"0.7"` | Gate for `kevin_share` selection and export — a **string**, read with `Number.parseFloat` and clamped to `[0, 1]`. Deliberately stricter than `injection_confidence_floor` (`0.6`): sharing is a commitment, injecting is a suggestion. `parseInt` on this setting would yield `0` and share everything |
709
+ | `hook_liveness_enabled` | `"1"` | Master switch for the liveness instrument. When `"0"`, `HookLiveness.wrap()` returns hooks untouched and no counters are recorded — compare with `=== "1"` |
710
+ | `native_registration_enabled` | `"0"` | Opt-in for the additive v2 attachment (`skill.transform`/`reference.transform` via `define()`). Default `"0"` keeps the release byte-identical to v0.8.0; `kevin_native enable` sets `"1"` (TEXT) but the probe is frozen — restart required (D9-01) |
711
+ | `host_probe_history_enabled` | `"0"` | When `"1"`, one `host_probes` row per construction is appended (version, flavour, v2 flags, notes). Off by default because it is unbounded append-only |
712
+ | `dead_hook_report_threshold` | `"3"` | How many checkpointed sessions without a hook firing before it is `dead`. TEXT holding a number, read with `Number.parseInt(v, 10)`, clamped to `[1, 1000]`, `NaN` → `3` |
675
713
 
676
- All 23 settings are TEXT values; booleans and flags compare against `"1"`; the three v0.8 **string-valued** settings are `okf_path`, `author_identity_mode` and `shared_confidence_floor`.
714
+ All 27 settings are TEXT values; booleans and flags compare against `"1"`; the three v0.8 **string-valued** settings are `okf_path`, `author_identity_mode` and `shared_confidence_floor`, and `dead_hook_report_threshold` is a numeric string.
677
715
 
678
716
  ---
679
717
 
@@ -686,10 +724,25 @@ npm install
686
724
  npm run typecheck # tsc --noEmit (strict)
687
725
  npm run lint # biome check .
688
726
  npm test # vitest run (unit + integration + e2e + replay)
689
- npm run verify # post-install verification
727
+ npm run verify # post-install verification (also checks DB migrations)
690
728
  npm run replay # replay report over tests/replay/fixtures
691
729
  ```
692
730
 
731
+ ### Quick diagnostic
732
+
733
+ ```bash
734
+ # One read-only command that tells you whether Kevin is healthy:
735
+ # host surface, every hook's state, dependency check, and native registration.
736
+ kevin_doctor({})
737
+ # → { host: { flavour: "v1+v2", v2: { skill: true, reference: true } },
738
+ # hooks: [{ hook: "experimental.chat.system.transform", state: "dead", ... }],
739
+ # verdict: "degraded", reason: "experimental.chat.system.transform dead since ..." }
740
+
741
+ # Native registration is opt-in (default off). Enable, restart, check again:
742
+ kevin_native({ action: "enable" }) # writes native_registration_enabled="1" (TEXT), no re-probe
743
+ kevin_native({ action: "show" }) # → { effective: false, reason: "v2 subpath absent …" } on 1.17.x
744
+ ```
745
+
693
746
  ### Publishing (maintainer)
694
747
 
695
748
  ```bash
@@ -0,0 +1,78 @@
1
+ -- ============================================================
2
+ -- Kevin v0.9.0 "Native" — host surface and hook liveness
3
+ -- Migration 010. Additive only. No table rebuild.
4
+ -- ============================================================
5
+
6
+ -- 1. Hook liveness. Deliberately NOT project-scoped: a hook is a
7
+ -- property of the host binary, not of a checkout. One row per
8
+ -- hook name, updated in place. See D9-08.
9
+ CREATE TABLE IF NOT EXISTS hook_liveness (
10
+ hook TEXT PRIMARY KEY,
11
+ experimental INTEGER NOT NULL DEFAULT 0,
12
+ fire_count INTEGER NOT NULL DEFAULT 0,
13
+ error_count INTEGER NOT NULL DEFAULT 0,
14
+ expected_count INTEGER NOT NULL DEFAULT 0,
15
+ first_seen_at TEXT,
16
+ last_seen_at TEXT,
17
+ dead_since TEXT,
18
+ plugin_version TEXT
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_hook_liveness_dead
22
+ ON hook_liveness(dead_since);
23
+
24
+ -- 2. Host probe history. Append-only. Off by default; exists so a
25
+ -- user chasing an intermittent fault can turn it on and get a
26
+ -- timeline instead of a single current value.
27
+ CREATE TABLE IF NOT EXISTS host_probes (
28
+ id TEXT PRIMARY KEY,
29
+ probed_at TEXT NOT NULL DEFAULT (datetime('now')),
30
+ plugin_version TEXT,
31
+ flavour TEXT NOT NULL,
32
+ has_shell INTEGER NOT NULL DEFAULT 0,
33
+ v2_skill INTEGER NOT NULL DEFAULT 0,
34
+ v2_reference INTEGER NOT NULL DEFAULT 0,
35
+ notes TEXT
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_host_probes_at
39
+ ON host_probes(probed_at);
40
+
41
+ -- 3. Native registration outcomes. One row per attach attempt, so
42
+ -- "registered but unverified" is a queryable state rather than a
43
+ -- log line.
44
+ CREATE TABLE IF NOT EXISTS native_registrations (
45
+ id TEXT PRIMARY KEY,
46
+ attached_at TEXT NOT NULL DEFAULT (datetime('now')),
47
+ surface TEXT NOT NULL CHECK (surface IN ('skill', 'reference')),
48
+ registered INTEGER NOT NULL DEFAULT 0,
49
+ verified INTEGER NOT NULL DEFAULT 0,
50
+ note TEXT
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_native_registrations_surface
54
+ ON native_registrations(surface, attached_at);
55
+
56
+ -- 4. Metric seeds (39 -> 45).
57
+ INSERT OR IGNORE INTO kevin_metrics (key, value) VALUES
58
+ ('hook_fires_total', 0),
59
+ ('hook_errors_total', 0),
60
+ ('hooks_dead_total', 0),
61
+ ('injections_suppressed_dead_hook', 0),
62
+ ('native_registrations_total', 0),
63
+ ('native_registration_failures', 0);
64
+
65
+ -- 5. Setting seeds (23 -> 27).
66
+ -- hook_liveness_enabled defaults ON: it is a read-only instrument
67
+ -- on the success path, and an instrument nobody switches on is an
68
+ -- instrument nobody has.
69
+ -- native_registration_enabled defaults OFF: it changes where a
70
+ -- curated skill comes from, and that is a change a user opts into.
71
+ INSERT OR IGNORE INTO kevin_settings (key, value) VALUES
72
+ ('hook_liveness_enabled', '1'),
73
+ ('native_registration_enabled', '0'),
74
+ ('host_probe_history_enabled', '0'),
75
+ ('dead_hook_report_threshold', '3');
76
+
77
+ -- 6. Version marker.
78
+ INSERT OR IGNORE INTO schema_version (version) VALUES ('010');
@@ -0,0 +1,81 @@
1
+ import { HOOK_NAMES } from "./Migrate.js";
2
+ import type { Store } from "./Store.js";
3
+ export type HookName = (typeof HOOK_NAMES)[number];
4
+ export type LivenessState = "live" | "dead" | "unknown";
5
+ export interface HookReport {
6
+ hook: HookName;
7
+ experimental: boolean;
8
+ state: LivenessState;
9
+ firstSeenAt: string | null;
10
+ lastSeenAt: string | null;
11
+ fireCount: number;
12
+ expectedCount: number;
13
+ /** v0.9.0 (K9-012) — set once when the hook went dead; never cleared. */
14
+ deadSince: string | null;
15
+ }
16
+ /**
17
+ * v0.9.0 (K9-010 / plan §5.3, D9-09) — parse the TEXT setting
18
+ * `dead_hook_report_threshold` into the [1, 1000] clamp. NaN and empty
19
+ * fall back to 3 (never 0: a zero threshold would report every hook dead
20
+ * before a single session completes a checkpoint).
21
+ */
22
+ export declare function parseThreshold(text: string | null | undefined): number;
23
+ interface HookLivenessOptions {
24
+ /** `hook_liveness_enabled === "1"` (TEXT comparison, decided at construction). */
25
+ enabled: boolean;
26
+ /** Raw TEXT of `dead_hook_report_threshold`; parsed+clamped in report(). */
27
+ thresholdText: string | null | undefined;
28
+ /** `host.pluginVersion` at construction, stored on every flushed row. */
29
+ pluginVersion: string | null;
30
+ }
31
+ /**
32
+ * v0.9.0 (K9-009/K9-010 / plan §5.3) — wraps the plugin's hooks with a
33
+ * success-path recorder, dedups per-session checkpoints, and reports each
34
+ * hook's liveness state. Persistence is machine-scoped: the hook_liveness
35
+ * table carries no project_id or repo_id (D9-08).
36
+ */
37
+ export declare class HookLiveness {
38
+ private readonly store;
39
+ private readonly options;
40
+ private readonly counters;
41
+ private readonly seenSessions;
42
+ private readonly suppressedSessions;
43
+ private readonly threshold;
44
+ private readonly pluginVersion;
45
+ constructor(store: Store, options: HookLivenessOptions);
46
+ /**
47
+ * Returns a new object with the same keys; function values are replaced
48
+ * by delegating wrappers, non-function values (the tool map) pass by
49
+ * reference. When liveness is disabled, returns the argument unchanged —
50
+ * the same object, not a copy.
51
+ */
52
+ wrap<T>(hooks: T): T;
53
+ /**
54
+ * v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
55
+ * model turn (tool.execute.after fired), so the system prompt was
56
+ * assembled and `experimental.chat.system.transform` MUST have been
57
+ * offered. Deduped per session: 20 tool calls in one session count
58
+ * exactly one expectation.
59
+ */
60
+ expect(hook: HookName, sessionID: string): void;
61
+ /**
62
+ * Per-hook liveness verdicts in canonical HOOK_NAMES order.
63
+ * A hook that fired once is live forever; dead requires both zero fires
64
+ * and `expectedCount >= threshold`; everything else is unknown — and
65
+ * unknown is never rounded to healthy (D9-09).
66
+ */
67
+ report(): HookReport[];
68
+ /** Persists every counter row. Called on the metrics.flush() cadence. */
69
+ flush(): void;
70
+ private makeWrapper;
71
+ private recordSuccess;
72
+ private recordError;
73
+ private loadFromDb;
74
+ }
75
+ export type HostVerdict = "healthy" | "degraded" | "unknown";
76
+ export interface HostVerdictResult {
77
+ verdict: HostVerdict;
78
+ reason: string;
79
+ }
80
+ export declare function reduceVerdict(reports: readonly HookReport[]): HostVerdictResult;
81
+ export {};
@@ -0,0 +1,324 @@
1
+ import { HOOK_NAMES } from "./Migrate.js";
2
+ const DEFAULT_THRESHOLD = 3;
3
+ /**
4
+ * v0.9.0 (K9-010 / plan §5.3, D9-09) — parse the TEXT setting
5
+ * `dead_hook_report_threshold` into the [1, 1000] clamp. NaN and empty
6
+ * fall back to 3 (never 0: a zero threshold would report every hook dead
7
+ * before a single session completes a checkpoint).
8
+ */
9
+ export function parseThreshold(text) {
10
+ if (text === null || text === undefined || text === "")
11
+ return DEFAULT_THRESHOLD;
12
+ const n = Number.parseInt(text, 10);
13
+ // NaN → default; 0 is not a valid threshold (K9-010 AC: 'abc'/''/'0'
14
+ // all clamp to the default of three — never zero).
15
+ if (Number.isNaN(n) || n === 0)
16
+ return DEFAULT_THRESHOLD;
17
+ return Math.min(1000, Math.max(1, n));
18
+ }
19
+ /**
20
+ * v0.9.0 (K9-009/K9-010 / plan §5.3) — wraps the plugin's hooks with a
21
+ * success-path recorder, dedups per-session checkpoints, and reports each
22
+ * hook's liveness state. Persistence is machine-scoped: the hook_liveness
23
+ * table carries no project_id or repo_id (D9-08).
24
+ */
25
+ export class HookLiveness {
26
+ store;
27
+ options;
28
+ counters;
29
+ seenSessions;
30
+ suppressedSessions = new Set();
31
+ threshold;
32
+ pluginVersion;
33
+ constructor(store, options) {
34
+ this.store = store;
35
+ this.options = options;
36
+ this.threshold = parseThreshold(options.thresholdText);
37
+ this.pluginVersion = options.pluginVersion;
38
+ this.counters = new Map();
39
+ this.seenSessions = new Map();
40
+ for (const hook of HOOK_NAMES) {
41
+ this.counters.set(hook, {
42
+ fireCount: 0,
43
+ errorCount: 0,
44
+ expectedCount: 0,
45
+ firstSeenAt: null,
46
+ lastSeenAt: null,
47
+ deadSince: null,
48
+ });
49
+ this.seenSessions.set(hook, new Set());
50
+ }
51
+ this.loadFromDb();
52
+ }
53
+ /**
54
+ * Returns a new object with the same keys; function values are replaced
55
+ * by delegating wrappers, non-function values (the tool map) pass by
56
+ * reference. When liveness is disabled, returns the argument unchanged —
57
+ * the same object, not a copy.
58
+ */
59
+ wrap(hooks) {
60
+ if (!this.options.enabled)
61
+ return hooks;
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(hooks)) {
64
+ if (typeof value === "function") {
65
+ out[key] = this.makeWrapper(key, value);
66
+ }
67
+ else {
68
+ out[key] = value;
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
75
+ * model turn (tool.execute.after fired), so the system prompt was
76
+ * assembled and `experimental.chat.system.transform` MUST have been
77
+ * offered. Deduped per session: 20 tool calls in one session count
78
+ * exactly one expectation.
79
+ */
80
+ expect(hook, sessionID) {
81
+ const seen = this.seenSessions.get(hook);
82
+ if (!seen || seen.has(sessionID))
83
+ return;
84
+ seen.add(sessionID);
85
+ const counters = this.counters.get(hook);
86
+ if (!counters)
87
+ return;
88
+ counters.expectedCount += 1;
89
+ // The dead flag is materialized here (not only in report()) so that
90
+ // flush() persists dead_since even when no report() was ever called:
91
+ // kevin_doctor's PURE-SQL blocks read dead_since from the table.
92
+ if (counters.fireCount === 0 &&
93
+ counters.expectedCount >= this.threshold &&
94
+ counters.deadSince === null) {
95
+ counters.deadSince = counters.lastSeenAt ?? new Date().toISOString();
96
+ }
97
+ // v0.9.0 (K9-011 / plan §5.3) — when the injection hook is dead, the
98
+ // checkpointed session was suppressed: the host stopped offering the
99
+ // transform, so no injection could happen. Counted once per session
100
+ // (same dedup as expected_count) and persisted by flush(). This is
101
+ // the counter that turns "zero injections" from an ambiguous number
102
+ // into a diagnosis.
103
+ if (hook === "experimental.chat.system.transform" &&
104
+ counters.deadSince !== null &&
105
+ !this.suppressedSessions.has(sessionID)) {
106
+ this.suppressedSessions.add(sessionID);
107
+ }
108
+ }
109
+ /**
110
+ * Per-hook liveness verdicts in canonical HOOK_NAMES order.
111
+ * A hook that fired once is live forever; dead requires both zero fires
112
+ * and `expectedCount >= threshold`; everything else is unknown — and
113
+ * unknown is never rounded to healthy (D9-09).
114
+ */
115
+ report() {
116
+ const out = [];
117
+ for (const hook of HOOK_NAMES) {
118
+ const c = this.counters.get(hook);
119
+ if (!c)
120
+ continue;
121
+ let state;
122
+ if (c.fireCount > 0) {
123
+ state = "live";
124
+ }
125
+ else if (c.expectedCount >= this.threshold) {
126
+ // dead_since is set ONCE and never cleared: it documents the
127
+ // historical death even if the hook later recovers.
128
+ if (c.deadSince === null) {
129
+ c.deadSince = c.lastSeenAt ?? new Date().toISOString();
130
+ }
131
+ state = "dead";
132
+ }
133
+ else {
134
+ state = "unknown";
135
+ }
136
+ out.push({
137
+ hook,
138
+ experimental: hook.startsWith("experimental."),
139
+ state,
140
+ firstSeenAt: c.firstSeenAt,
141
+ lastSeenAt: c.lastSeenAt,
142
+ fireCount: c.fireCount,
143
+ expectedCount: c.expectedCount,
144
+ deadSince: c.deadSince,
145
+ });
146
+ }
147
+ return out;
148
+ }
149
+ /** Persists every counter row. Called on the metrics.flush() cadence. */
150
+ flush() {
151
+ const store = this.store;
152
+ store.transaction(() => {
153
+ // Created lazily on first flush, mirroring Metrics.flush(): the
154
+ // migration guarantees the table in production, but unit tests
155
+ // against pre-010 DBs must not explode at dispose.
156
+ store.exec(`CREATE TABLE IF NOT EXISTS hook_liveness (
157
+ hook TEXT PRIMARY KEY,
158
+ experimental INTEGER NOT NULL DEFAULT 0,
159
+ fire_count INTEGER NOT NULL DEFAULT 0,
160
+ error_count INTEGER NOT NULL DEFAULT 0,
161
+ expected_count INTEGER NOT NULL DEFAULT 0,
162
+ first_seen_at TEXT,
163
+ last_seen_at TEXT,
164
+ dead_since TEXT,
165
+ plugin_version TEXT
166
+ )`);
167
+ const upsert = store.prepare(`INSERT INTO hook_liveness (hook, experimental, fire_count, error_count,
168
+ expected_count, first_seen_at, last_seen_at, dead_since, plugin_version)
169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
170
+ ON CONFLICT(hook) DO UPDATE SET
171
+ experimental = excluded.experimental,
172
+ fire_count = excluded.fire_count,
173
+ error_count = excluded.error_count,
174
+ expected_count = excluded.expected_count,
175
+ first_seen_at = excluded.first_seen_at,
176
+ last_seen_at = excluded.last_seen_at,
177
+ dead_since = excluded.dead_since,
178
+ plugin_version = excluded.plugin_version`);
179
+ for (const [hook, c] of this.counters) {
180
+ upsert.run(hook, hook.startsWith("experimental.") ? 1 : 0, c.fireCount, c.errorCount, c.expectedCount, c.firstSeenAt, c.lastSeenAt, c.deadSince, this.pluginVersion);
181
+ }
182
+ // v0.9.0 (K9-011 / plan §5.3) — the four v0.9.0 counters are
183
+ // re-derived here, on the metrics cadence, exactly like the 009
184
+ // post-apply hook re-derives hooks_dead_total: SUM(fire_count),
185
+ // SUM(error_count), COUNT(dead_since) and the per-session
186
+ // suppression count. No Metrics dependency, no writes in the hot
187
+ // path — flush() is the single persistence point.
188
+ let fireSum = 0;
189
+ let errorSum = 0;
190
+ let deadCount = 0;
191
+ for (const c of this.counters.values()) {
192
+ fireSum += c.fireCount;
193
+ errorSum += c.errorCount;
194
+ if (c.deadSince !== null)
195
+ deadCount += 1;
196
+ }
197
+ store.exec(`CREATE TABLE IF NOT EXISTS kevin_metrics (
198
+ key TEXT PRIMARY KEY,
199
+ value INTEGER NOT NULL DEFAULT 0,
200
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
201
+ )`);
202
+ const derive = store.prepare(`INSERT INTO kevin_metrics (key, value, updated_at)
203
+ VALUES (?, ?, datetime('now'))
204
+ ON CONFLICT(key) DO UPDATE SET
205
+ value = excluded.value,
206
+ updated_at = datetime('now')`);
207
+ derive.run("hook_fires_total", fireSum);
208
+ derive.run("hook_errors_total", errorSum);
209
+ derive.run("hooks_dead_total", deadCount);
210
+ derive.run("injections_suppressed_dead_hook", this.suppressedSessions.size);
211
+ });
212
+ }
213
+ makeWrapper(key, delegate) {
214
+ const record = () => {
215
+ if (HOOK_NAMES.includes(key)) {
216
+ this.recordSuccess(key);
217
+ }
218
+ };
219
+ const recordError = (e) => {
220
+ if (HOOK_NAMES.includes(key)) {
221
+ this.recordError(key);
222
+ }
223
+ throw e;
224
+ };
225
+ const n = delegate.length;
226
+ if (n === 0) {
227
+ return async () => {
228
+ try {
229
+ const result = await delegate();
230
+ record();
231
+ return result;
232
+ }
233
+ catch (e) {
234
+ return recordError(e);
235
+ }
236
+ };
237
+ }
238
+ if (n === 1) {
239
+ return async (a) => {
240
+ try {
241
+ const result = await delegate(a);
242
+ record();
243
+ return result;
244
+ }
245
+ catch (e) {
246
+ return recordError(e);
247
+ }
248
+ };
249
+ }
250
+ return async (a, b) => {
251
+ try {
252
+ const result = await delegate(a, b);
253
+ record();
254
+ return result;
255
+ }
256
+ catch (e) {
257
+ return recordError(e);
258
+ }
259
+ };
260
+ }
261
+ recordSuccess(hook) {
262
+ const c = this.counters.get(hook);
263
+ if (!c)
264
+ return;
265
+ c.fireCount += 1;
266
+ const now = new Date().toISOString();
267
+ if (c.firstSeenAt === null)
268
+ c.firstSeenAt = now;
269
+ c.lastSeenAt = now;
270
+ }
271
+ recordError(hook) {
272
+ const c = this.counters.get(hook);
273
+ if (!c)
274
+ return;
275
+ c.errorCount += 1;
276
+ }
277
+ loadFromDb() {
278
+ let rows = [];
279
+ try {
280
+ rows = this.store
281
+ .prepare("SELECT hook, fire_count, error_count, expected_count, first_seen_at, last_seen_at, dead_since FROM hook_liveness")
282
+ .all();
283
+ }
284
+ catch {
285
+ // Graceful: pre-010 DBs have no hook_liveness table; the seeded
286
+ // zeros remain until the first flush creates rows.
287
+ rows = [];
288
+ }
289
+ for (const row of rows) {
290
+ if (!HOOK_NAMES.includes(row.hook))
291
+ continue;
292
+ const c = this.counters.get(row.hook);
293
+ if (!c)
294
+ continue;
295
+ c.fireCount = row.fire_count;
296
+ c.errorCount = row.error_count;
297
+ c.expectedCount = row.expected_count;
298
+ c.firstSeenAt = row.first_seen_at;
299
+ c.lastSeenAt = row.last_seen_at;
300
+ c.deadSince = row.dead_since;
301
+ }
302
+ }
303
+ }
304
+ export function reduceVerdict(reports) {
305
+ if (reports.length === 0) {
306
+ return { verdict: "unknown", reason: "no hook reports yet" };
307
+ }
308
+ const dead = reports.filter((r) => r.state === "dead");
309
+ if (dead.length > 0) {
310
+ return {
311
+ verdict: "degraded",
312
+ reason: `${dead.map((r) => r.hook).join(", ")} dead since ${dead[0].deadSince ?? "unknown"}; ${dead.length} affected hook(s)`,
313
+ };
314
+ }
315
+ if (reports.every((r) => r.state === "live")) {
316
+ return { verdict: "healthy", reason: "all hooks live" };
317
+ }
318
+ const pending = reports.filter((r) => r.state === "unknown").length;
319
+ return {
320
+ verdict: "unknown",
321
+ reason: `${pending} hook(s) without checkpoint`,
322
+ };
323
+ }
324
+ //# sourceMappingURL=HookLiveness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HookLiveness.js","sourceRoot":"","sources":["../../plugin/HookLiveness.ts"],"names":[],"mappings":"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"}
@@ -30,13 +30,32 @@ export interface BundleTarget {
30
30
  readonly path: string;
31
31
  }
32
32
  export declare const SKILL_TOPIC = "project-knowledge";
33
+ /**
34
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — the native registration surfaces.
35
+ * Registration replaces emission; they are never both active.
36
+ */
37
+ export type NativeSurface = "skill" | "reference";
33
38
  export declare class Materializer {
34
39
  private readonly store;
35
40
  /** `root` is injectable for tests; production defaults to ~/.opencode-kevin. */
36
41
  private readonly root;
42
+ /**
43
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — the mutual-exclusion guard.
44
+ * A surface `attachNative()` registered is skipped by `materialize()`
45
+ * for that surface only; the other surface keeps emitting.
46
+ */
47
+ private readonly nativeRegistered;
37
48
  constructor(store: Store, options?: {
38
49
  root?: string;
39
50
  });
51
+ /**
52
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — record whether the surface was
53
+ * registered natively. Called at attach time (index.ts wiring, K9-018)
54
+ * with each `NativeRegistration.registered` flag.
55
+ */
56
+ markNativeRegistered(surface: NativeSurface, registered: boolean): void;
57
+ /** v0.9.0 (K9-016 / plan §5.4, D9-10) — is the surface registered natively? */
58
+ hasNativeRegistration(surface: NativeSurface): boolean;
40
59
  /** The curated, active memories — the knowledge the pull channels publish. */
41
60
  private curatedRows;
42
61
  /** The rendered skill body over all curated memories; "" when empty. */
@@ -53,6 +72,12 @@ export declare class Materializer {
53
72
  * a call site of `write()` — the single write FUNCTION with two
54
73
  * constrained targets: `kevin_approve` reaches `agents_md_path`, this
55
74
  * module reaches `~/.opencode-kevin` only (D6-07; enforced by K6-020).
75
+ *
76
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — the guard: a surface registered
77
+ * natively by `attachNative()` is skipped here for that surface only.
78
+ * Both active would put the curated skill in front of the model twice
79
+ * from two sources that can disagree; neither active would silently
80
+ * remove the feature on hosts that gained the subpath.
56
81
  */
57
82
  materialize(writer: ArtifactWriter): MaterializedBundle[];
58
83
  }