@jmtrin/opencode-kevin 0.8.0 → 1.0.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 (131) hide show
  1. package/README.md +559 -771
  2. package/dist/migrations/010_v09_native.sql +78 -0
  3. package/dist/migrations/011_v10_proven.sql +69 -0
  4. package/dist/plugin/Archiver.js +0 -1
  5. package/dist/plugin/ArtifactWriter.d.ts +25 -10
  6. package/dist/plugin/ArtifactWriter.js +43 -25
  7. package/dist/plugin/CausalChain.js +0 -1
  8. package/dist/plugin/ConflictDetector.js +0 -1
  9. package/dist/plugin/ContextInjector.js +0 -1
  10. package/dist/plugin/ConventionMiner.js +0 -1
  11. package/dist/plugin/Curator.js +0 -1
  12. package/dist/plugin/Feedback.js +0 -1
  13. package/dist/plugin/HookLiveness.d.ts +87 -0
  14. package/dist/plugin/HookLiveness.js +338 -0
  15. package/dist/plugin/InjectionLedger.js +0 -1
  16. package/dist/plugin/LessonFixer.js +0 -1
  17. package/dist/plugin/Materializer.d.ts +25 -0
  18. package/dist/plugin/Materializer.js +39 -11
  19. package/dist/plugin/MemoryService.js +0 -1
  20. package/dist/plugin/Migrate.d.ts +1 -0
  21. package/dist/plugin/Migrate.js +69 -6
  22. package/dist/plugin/PatternMiner.js +0 -1
  23. package/dist/plugin/QualityGate.js +0 -1
  24. package/dist/plugin/Reflector.js +0 -1
  25. package/dist/plugin/RepoIdentity.d.ts +20 -9
  26. package/dist/plugin/RepoIdentity.js +44 -10
  27. package/dist/plugin/RepoTruth.js +0 -1
  28. package/dist/plugin/Retrospective.js +16 -1
  29. package/dist/plugin/SharedLayer.js +0 -1
  30. package/dist/plugin/Store.js +0 -1
  31. package/dist/plugin/ToolCallObserver.js +0 -1
  32. package/dist/plugin/capabilities.js +0 -1
  33. package/dist/plugin/confidence.js +0 -1
  34. package/dist/plugin/contract.d.ts +46 -0
  35. package/dist/plugin/contract.js +368 -0
  36. package/dist/plugin/diff.js +0 -1
  37. package/dist/plugin/escape.d.ts +39 -0
  38. package/dist/plugin/escape.js +60 -0
  39. package/dist/plugin/fingerprint.js +0 -1
  40. package/dist/plugin/host.d.ts +70 -0
  41. package/dist/plugin/host.js +250 -0
  42. package/dist/plugin/index.d.ts +2 -2
  43. package/dist/plugin/index.js +542 -222
  44. package/dist/plugin/inferability.js +0 -1
  45. package/dist/plugin/kevin_approve.js +0 -1
  46. package/dist/plugin/kevin_audit.d.ts +60 -0
  47. package/dist/plugin/kevin_audit.js +144 -1
  48. package/dist/plugin/kevin_bench.d.ts +10 -0
  49. package/dist/plugin/kevin_bench.js +82 -0
  50. package/dist/plugin/kevin_conflicts.js +0 -1
  51. package/dist/plugin/kevin_contract.d.ts +8 -0
  52. package/dist/plugin/kevin_contract.js +59 -0
  53. package/dist/plugin/kevin_doctor.d.ts +62 -0
  54. package/dist/plugin/kevin_doctor.js +196 -0
  55. package/dist/plugin/kevin_facts.js +0 -1
  56. package/dist/plugin/kevin_native.d.ts +29 -0
  57. package/dist/plugin/kevin_native.js +79 -0
  58. package/dist/plugin/kevin_propose.js +0 -1
  59. package/dist/plugin/kevin_publish.js +0 -1
  60. package/dist/plugin/kevin_why.js +0 -1
  61. package/dist/plugin/memory-format.js +0 -1
  62. package/dist/plugin/metrics.js +0 -1
  63. package/dist/plugin/native.d.ts +92 -0
  64. package/dist/plugin/native.js +190 -0
  65. package/dist/plugin/okf-export.js +0 -1
  66. package/dist/plugin/okf-import.js +0 -1
  67. package/dist/plugin/okf.js +0 -1
  68. package/dist/plugin/perf.d.ts +35 -0
  69. package/dist/plugin/perf.js +174 -0
  70. package/dist/plugin/query-tokenizer.js +0 -1
  71. package/dist/plugin/redact.js +0 -1
  72. package/dist/plugin/replay-types.js +0 -1
  73. package/dist/plugin/replay.js +0 -1
  74. package/dist/plugin/sqlite-adapter.js +0 -1
  75. package/dist/plugin/uuid.js +0 -1
  76. package/package.json +22 -7
  77. package/dist/plugin/Archiver.js.map +0 -1
  78. package/dist/plugin/ArtifactWriter.js.map +0 -1
  79. package/dist/plugin/CausalChain.js.map +0 -1
  80. package/dist/plugin/ConflictDetector.js.map +0 -1
  81. package/dist/plugin/ContextInjector.js.map +0 -1
  82. package/dist/plugin/ConventionMiner.js.map +0 -1
  83. package/dist/plugin/Curator.js.map +0 -1
  84. package/dist/plugin/Feedback.js.map +0 -1
  85. package/dist/plugin/InjectionLedger.js.map +0 -1
  86. package/dist/plugin/LessonFixer.js.map +0 -1
  87. package/dist/plugin/Materializer.js.map +0 -1
  88. package/dist/plugin/MemoryService.js.map +0 -1
  89. package/dist/plugin/Migrate.js.map +0 -1
  90. package/dist/plugin/PatternMiner.js.map +0 -1
  91. package/dist/plugin/QualityGate.js.map +0 -1
  92. package/dist/plugin/Reflector.js.map +0 -1
  93. package/dist/plugin/RepoIdentity.js.map +0 -1
  94. package/dist/plugin/RepoTruth.js.map +0 -1
  95. package/dist/plugin/Retrospective.js.map +0 -1
  96. package/dist/plugin/SharedLayer.js.map +0 -1
  97. package/dist/plugin/Store.js.map +0 -1
  98. package/dist/plugin/ToolCallObserver.js.map +0 -1
  99. package/dist/plugin/capabilities.js.map +0 -1
  100. package/dist/plugin/confidence.js.map +0 -1
  101. package/dist/plugin/diff.js.map +0 -1
  102. package/dist/plugin/fingerprint.js.map +0 -1
  103. package/dist/plugin/index.js.map +0 -1
  104. package/dist/plugin/inferability.js.map +0 -1
  105. package/dist/plugin/kevin_approve.js.map +0 -1
  106. package/dist/plugin/kevin_audit.js.map +0 -1
  107. package/dist/plugin/kevin_conflicts.js.map +0 -1
  108. package/dist/plugin/kevin_facts.js.map +0 -1
  109. package/dist/plugin/kevin_propose.js.map +0 -1
  110. package/dist/plugin/kevin_publish.js.map +0 -1
  111. package/dist/plugin/kevin_why.js.map +0 -1
  112. package/dist/plugin/memory-format.js.map +0 -1
  113. package/dist/plugin/metrics.js.map +0 -1
  114. package/dist/plugin/okf-export.js.map +0 -1
  115. package/dist/plugin/okf-import.js.map +0 -1
  116. package/dist/plugin/okf.js.map +0 -1
  117. package/dist/plugin/query-tokenizer.js.map +0 -1
  118. package/dist/plugin/redact.js.map +0 -1
  119. package/dist/plugin/replay-types.js.map +0 -1
  120. package/dist/plugin/replay.js.map +0 -1
  121. package/dist/plugin/sqlite-adapter.js.map +0 -1
  122. package/dist/plugin/uuid.js.map +0 -1
  123. package/migrations/001_initial.sql +0 -92
  124. package/migrations/002_indexes.sql +0 -14
  125. package/migrations/003_v02_signal.sql +0 -58
  126. package/migrations/004_v03_knowledge.sql +0 -138
  127. package/migrations/005_v04_signal.sql +0 -57
  128. package/migrations/006_v05_glassbox.sql +0 -118
  129. package/migrations/007_v06_pull.sql +0 -145
  130. package/migrations/008_v07_truth.sql +0 -124
  131. package/migrations/009_v08_team.sql +0 -100
@@ -0,0 +1,338 @@
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
+ // v1.0.0 (K10-013) — dispose is recorded inside its own delegate
216
+ // (via recordDispose()) rather than here: the wrapper's post-return
217
+ // record would land after the store is closed and could never be
218
+ // persisted — the event being detected is the process ending.
219
+ if (key === "dispose")
220
+ return;
221
+ if (HOOK_NAMES.includes(key)) {
222
+ this.recordSuccess(key);
223
+ }
224
+ };
225
+ const recordError = (e) => {
226
+ if (HOOK_NAMES.includes(key)) {
227
+ this.recordError(key);
228
+ }
229
+ throw e;
230
+ };
231
+ const n = delegate.length;
232
+ if (n === 0) {
233
+ return async () => {
234
+ try {
235
+ const result = await delegate();
236
+ record();
237
+ return result;
238
+ }
239
+ catch (e) {
240
+ return recordError(e);
241
+ }
242
+ };
243
+ }
244
+ if (n === 1) {
245
+ return async (a) => {
246
+ try {
247
+ const result = await delegate(a);
248
+ record();
249
+ return result;
250
+ }
251
+ catch (e) {
252
+ return recordError(e);
253
+ }
254
+ };
255
+ }
256
+ return async (a, b) => {
257
+ try {
258
+ const result = await delegate(a, b);
259
+ record();
260
+ return result;
261
+ }
262
+ catch (e) {
263
+ return recordError(e);
264
+ }
265
+ };
266
+ }
267
+ /**
268
+ * v1.0.0 (K10-013 / plan §5.3) — records the `dispose` fire and
269
+ * flushes immediately, from inside the dispose delegate: this is the
270
+ * last write of the process, and nothing after it can persist.
271
+ */
272
+ recordDispose() {
273
+ this.recordSuccess("dispose");
274
+ this.flush();
275
+ }
276
+ recordSuccess(hook) {
277
+ const c = this.counters.get(hook);
278
+ if (!c)
279
+ return;
280
+ c.fireCount += 1;
281
+ const now = new Date().toISOString();
282
+ if (c.firstSeenAt === null)
283
+ c.firstSeenAt = now;
284
+ c.lastSeenAt = now;
285
+ }
286
+ recordError(hook) {
287
+ const c = this.counters.get(hook);
288
+ if (!c)
289
+ return;
290
+ c.errorCount += 1;
291
+ }
292
+ loadFromDb() {
293
+ let rows = [];
294
+ try {
295
+ rows = this.store
296
+ .prepare("SELECT hook, fire_count, error_count, expected_count, first_seen_at, last_seen_at, dead_since FROM hook_liveness")
297
+ .all();
298
+ }
299
+ catch {
300
+ // Graceful: pre-010 DBs have no hook_liveness table; the seeded
301
+ // zeros remain until the first flush creates rows.
302
+ rows = [];
303
+ }
304
+ for (const row of rows) {
305
+ if (!HOOK_NAMES.includes(row.hook))
306
+ continue;
307
+ const c = this.counters.get(row.hook);
308
+ if (!c)
309
+ continue;
310
+ c.fireCount = row.fire_count;
311
+ c.errorCount = row.error_count;
312
+ c.expectedCount = row.expected_count;
313
+ c.firstSeenAt = row.first_seen_at;
314
+ c.lastSeenAt = row.last_seen_at;
315
+ c.deadSince = row.dead_since;
316
+ }
317
+ }
318
+ }
319
+ export function reduceVerdict(reports) {
320
+ if (reports.length === 0) {
321
+ return { verdict: "unknown", reason: "no hook reports yet" };
322
+ }
323
+ const dead = reports.filter((r) => r.state === "dead");
324
+ if (dead.length > 0) {
325
+ return {
326
+ verdict: "degraded",
327
+ reason: `${dead.map((r) => r.hook).join(", ")} dead since ${dead[0].deadSince ?? "unknown"}; ${dead.length} affected hook(s)`,
328
+ };
329
+ }
330
+ if (reports.every((r) => r.state === "live")) {
331
+ return { verdict: "healthy", reason: "all hooks live" };
332
+ }
333
+ const pending = reports.filter((r) => r.state === "unknown").length;
334
+ return {
335
+ verdict: "unknown",
336
+ reason: `${pending} hook(s) without checkpoint`,
337
+ };
338
+ }
@@ -246,4 +246,3 @@ function readOriginCallId(store, memoryId) {
246
246
  return null;
247
247
  }
248
248
  }
249
- //# sourceMappingURL=InjectionLedger.js.map
@@ -43,4 +43,3 @@ export async function enrichAtPromotion(pattern, enrichFn) {
43
43
  }
44
44
  return deterministicFixLine(pattern);
45
45
  }
46
- //# sourceMappingURL=LessonFixer.js.map
@@ -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
  }
@@ -141,10 +141,31 @@ export class Materializer {
141
141
  store;
142
142
  /** `root` is injectable for tests; production defaults to ~/.opencode-kevin. */
143
143
  root;
144
+ /**
145
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — the mutual-exclusion guard.
146
+ * A surface `attachNative()` registered is skipped by `materialize()`
147
+ * for that surface only; the other surface keeps emitting.
148
+ */
149
+ nativeRegistered = new Set();
144
150
  constructor(store, options = {}) {
145
151
  this.store = store;
146
152
  this.root = options.root ?? join(homedir(), ".opencode-kevin");
147
153
  }
154
+ /**
155
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — record whether the surface was
156
+ * registered natively. Called at attach time (index.ts wiring, K9-018)
157
+ * with each `NativeRegistration.registered` flag.
158
+ */
159
+ markNativeRegistered(surface, registered) {
160
+ if (registered)
161
+ this.nativeRegistered.add(surface);
162
+ else
163
+ this.nativeRegistered.delete(surface);
164
+ }
165
+ /** v0.9.0 (K9-016 / plan §5.4, D9-10) — is the surface registered natively? */
166
+ hasNativeRegistration(surface) {
167
+ return this.nativeRegistered.has(surface);
168
+ }
148
169
  /** The curated, active memories — the knowledge the pull channels publish. */
149
170
  curatedRows() {
150
171
  return this.store
@@ -204,27 +225,35 @@ export class Materializer {
204
225
  * a call site of `write()` — the single write FUNCTION with two
205
226
  * constrained targets: `kevin_approve` reaches `agents_md_path`, this
206
227
  * module reaches `~/.opencode-kevin` only (D6-07; enforced by K6-020).
228
+ *
229
+ * v0.9.0 (K9-016 / plan §5.4, D9-10) — the guard: a surface registered
230
+ * natively by `attachNative()` is skipped here for that surface only.
231
+ * Both active would put the curated skill in front of the model twice
232
+ * from two sources that can disagree; neither active would silently
233
+ * remove the feature on hosts that gained the subpath.
207
234
  */
208
235
  materialize(writer) {
209
236
  const pending = [];
210
237
  const rows = this.curatedRows();
211
238
  const skillBody = renderRows(rows);
212
- if (skillBody !== "") {
239
+ if (skillBody !== "" && !this.nativeRegistered.has("skill")) {
213
240
  pending.push({
214
241
  topic: SKILL_TOPIC,
215
242
  path: join(this.root, "skills", "project-knowledge.md"),
216
243
  body: skillBody,
217
244
  });
218
245
  }
219
- for (const group of this.groupByTopic(rows)) {
220
- const body = renderRows(group.rows);
221
- if (body === "")
222
- continue;
223
- pending.push({
224
- topic: group.topic,
225
- path: join(this.root, "refs", `${group.topic}.md`),
226
- body,
227
- });
246
+ if (!this.nativeRegistered.has("reference")) {
247
+ for (const group of this.groupByTopic(rows)) {
248
+ const body = renderRows(group.rows);
249
+ if (body === "")
250
+ continue;
251
+ pending.push({
252
+ topic: group.topic,
253
+ path: join(this.root, "refs", `${group.topic}.md`),
254
+ body,
255
+ });
256
+ }
228
257
  }
229
258
  return pending.map((bundle) => ({
230
259
  topic: bundle.topic,
@@ -237,4 +266,3 @@ export class Materializer {
237
266
  }));
238
267
  }
239
268
  }
240
- //# sourceMappingURL=Materializer.js.map
@@ -1333,4 +1333,3 @@ function rankCompare(a, b, nowMs, deterministic) {
1333
1333
  }
1334
1334
  return sqliteUtcToMs(b.createdAt) - sqliteUtcToMs(a.createdAt); // newer first
1335
1335
  }
1336
- //# sourceMappingURL=MemoryService.js.map
@@ -5,6 +5,7 @@ export interface MigrateResult {
5
5
  applied: string[];
6
6
  }
7
7
  export type PostApplyHook = (store: Store) => void;
8
+ export declare const HOOK_NAMES: readonly ["tool.execute.before", "tool.execute.after", "chat.message", "experimental.chat.system.transform", "experimental.session.compacting", "event", "dispose"];
8
9
  export declare class Migrate {
9
10
  private store;
10
11
  private migrationsDir;
@@ -1,5 +1,20 @@
1
1
  import { readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ // v0.9.0 (K9-001 / plan §5.3, D9-08) — the six hooks Kevin registers, in
4
+ // hook-object order, plus `dispose` (v1.0.0 K10-013): the seventh
5
+ // instrumented hook. Canonical list: the "010" post-apply hook seeds
6
+ // hook_liveness from it, and HookLiveness (K9-009) wraps exactly these
7
+ // names, so a future hook added here is visible in the table on the next
8
+ // migration run.
9
+ export const HOOK_NAMES = [
10
+ "tool.execute.before",
11
+ "tool.execute.after",
12
+ "chat.message",
13
+ "experimental.chat.system.transform",
14
+ "experimental.session.compacting",
15
+ "event",
16
+ "dispose",
17
+ ];
3
18
  // Built-in post-apply hooks, keyed by migration version. Each hook runs inside
4
19
  // the same transaction as the migration's DDL, so a hook failure rolls back the
5
20
  // whole migration. Hooks run when their version is applied. Migration 008's
@@ -125,6 +140,49 @@ const DEFAULT_POST_APPLY_HOOKS = {
125
140
  .prepare("UPDATE kevin_metrics SET value = (SELECT COUNT(*) FROM shared_entries) WHERE key = 'shared_entries_total'")
126
141
  .run();
127
142
  },
143
+ // v0.9.0 (K9-001 / plan §6.1, D9-08) — Native: three operations, all
144
+ // idempotent by design.
145
+ // 1. Seed one hook_liveness row per name in HOOK_NAMES, with the
146
+ // experimental flag derived from the hook's own `experimental.`
147
+ // prefix and every counter at zero. Seeding eagerly (INSERT OR
148
+ // IGNORE) makes a hook that has never fired a visible row with
149
+ // fire_count = 0, not an absent row indistinguishable from a hook
150
+ // Kevin does not register.
151
+ // 2. Re-derive hooks_dead_total from hook_liveness state rather than
152
+ // trusting an incremented value (same discipline as "006"-"009").
153
+ // 3. Normalize any experimental flag that disagrees with its own
154
+ // hook column's prefix — cheap, and it repairs a row hand-edited
155
+ // during debugging.
156
+ "010": (store) => {
157
+ const seed = store.prepare("INSERT OR IGNORE INTO hook_liveness (hook, experimental) VALUES (?, ?)");
158
+ for (const name of HOOK_NAMES) {
159
+ seed.run(name, name.startsWith("experimental.") ? 1 : 0);
160
+ }
161
+ store
162
+ .prepare("UPDATE kevin_metrics SET value = (SELECT COUNT(*) FROM hook_liveness WHERE dead_since IS NOT NULL) WHERE key = 'hooks_dead_total'")
163
+ .run();
164
+ store
165
+ .prepare("UPDATE hook_liveness SET experimental = CASE WHEN hook LIKE 'experimental.%' THEN 1 ELSE 0 END")
166
+ .run();
167
+ },
168
+ // v1.0.0 (K10-005 / plan §6.1) — Proven: four operations, all idempotent.
169
+ // 1. Seed dispose row defensively.
170
+ // 2-3. Re-derive perf_budget_breaches and bench_runs_total.
171
+ // 4. Normalise NULL within_budget to 1.
172
+ "011": (store) => {
173
+ store
174
+ .prepare("INSERT OR IGNORE INTO hook_liveness (hook) VALUES ('dispose')")
175
+ .run();
176
+ store
177
+ .prepare("UPDATE kevin_metrics SET value = (SELECT COUNT(*) FROM perf_samples WHERE within_budget = 0) WHERE key = 'perf_budget_breaches'")
178
+ .run();
179
+ store
180
+ .prepare("UPDATE kevin_metrics SET value = (SELECT COUNT(*) FROM bench_runs) WHERE key = 'bench_runs_total'")
181
+ .run();
182
+ store
183
+ .prepare("UPDATE perf_samples SET within_budget = 1 WHERE within_budget IS NULL")
184
+ .run();
185
+ },
128
186
  };
129
187
  export class Migrate {
130
188
  store;
@@ -155,11 +213,17 @@ export class Migrate {
155
213
  // v0.7.0 (K7-002) — heal drift in the 008 counters on a no-op
156
214
  // startup while preserving `applied: []` idempotency. v0.8.0
157
215
  // (K8-002 / plan §6.1) extends the same repair to the 009
158
- // back-fill and the shared_entries_total re-derivation: both
159
- // hooks are idempotent by guarded updates and re-derivation,
160
- // so a no-op startup can heal a crash that landed between the
161
- // ALTER and the hook without re-applying DDL.
162
- if (from === "008" || from === "009") {
216
+ // back-fill and the shared_entries_total re-derivation, and
217
+ // v0.9.0 (K9-001 / plan §6.1) to the 010 seeding,
218
+ // hooks_dead_total re-derivation and experimental
219
+ // normalization: all three hooks are idempotent by guarded
220
+ // updates and re-derivation, so a no-op startup can heal a
221
+ // crash that landed between the DDL and the hook without
222
+ // re-applying DDL.
223
+ if (from === "008" ||
224
+ from === "009" ||
225
+ from === "010" ||
226
+ from === "011") {
163
227
  const repairHook = this.postApplyHooks.get(from);
164
228
  if (repairHook)
165
229
  this.store.transaction(() => repairHook(this.store));
@@ -200,4 +264,3 @@ export class Migrate {
200
264
  .filter((m) => m !== null && m.version > current);
201
265
  }
202
266
  }
203
- //# sourceMappingURL=Migrate.js.map
@@ -130,4 +130,3 @@ export class PatternMiner {
130
130
  return Array.from(map.values());
131
131
  }
132
132
  }
133
- //# sourceMappingURL=PatternMiner.js.map
@@ -117,4 +117,3 @@ export const QualityGate = {
117
117
  return { allowed: true, reason: "ok" };
118
118
  },
119
119
  };
120
- //# sourceMappingURL=QualityGate.js.map
@@ -305,4 +305,3 @@ export class Reflector {
305
305
  return "";
306
306
  }
307
307
  }
308
- //# sourceMappingURL=Reflector.js.map
@@ -1,8 +1,9 @@
1
1
  import type { ArtifactWriter } from "./ArtifactWriter.js";
2
+ import type { HostSurface } from "./host.js";
2
3
  /**
3
- * The three sources `resolve()` tries, in order (plan §5.1).
4
+ * The four sources `resolve()` tries, in order (plan §5.2, D9-13).
4
5
  */
5
- export type IdentitySource = "declared" | "remote" | "path";
6
+ export type IdentitySource = "declared" | "remote" | "host" | "path";
6
7
  /**
7
8
  * The outcome of resolving a repository identity.
8
9
  */
@@ -58,8 +59,8 @@ export declare function normalizeRemote(url: string): string | null;
58
59
  */
59
60
  export declare function computeRepoId(normalized: string): string;
60
61
  /**
61
- * Resolve the repository identity for `cwd`, trying the three
62
- * sources in order (plan §5.1):
62
+ * Resolve the repository identity for `cwd`, trying the four
63
+ * sources in order (plan §5.2, D9-13):
63
64
  *
64
65
  * 1. `declared` — `.kevin/project.json` → `id` (validated as
65
66
  * exactly 16 lowercase hex characters; anything else is ignored
@@ -68,14 +69,24 @@ export declare function computeRepoId(normalized: string): string;
68
69
  * onto a typo);
69
70
  * 2. `remote` — `.git/config` → `[remote "origin"] url`,
70
71
  * normalized and hashed;
71
- * 3. `path` — `fingerprint(cwd)`, the v0.7.0 behaviour.
72
+ * 3. `host` — the value the host resolved for this directory,
73
+ * `host.project.worktree` first, `host.project.directory` as
74
+ * fallback; both empty or absent falls through to `path`.
75
+ * This is strictly better than `process.cwd()` — the host's own
76
+ * ToolContext documents "prefer this over `process.cwd()`" —
77
+ * but it sits below the explicit sources, because monorepos and
78
+ * D8-03's confirmed re-keying depend on `.kevin/project.json`
79
+ * winning;
80
+ * 4. `path` — `fingerprint(cwd)`, the v0.7.0 behaviour, preserved
81
+ * exactly when `host` is absent.
72
82
  *
73
83
  * Never throws: a directory that is not a git repository, an
74
- * unreadable `.git/config`, and a malformed `project.json` all fall
75
- * through to source 3. `projectId` is always returned alongside
76
- * `repoId`, regardless of which source won.
84
+ * unreadable `.git/config`, a malformed `project.json`, and a host
85
+ * with no usable project fields all fall through to source 4.
86
+ * `projectId` is always returned alongside `repoId`, regardless of
87
+ * which source won.
77
88
  */
78
- export declare function resolve(cwd: string): ResolvedIdentity;
89
+ export declare function resolve(cwd: string, host?: HostSurface): ResolvedIdentity;
79
90
  /**
80
91
  * The outcome of `initProjectFile` (K8-008).
81
92
  */