@jmtrin/opencode-kevin 0.7.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 (70) hide show
  1. package/README.md +254 -12
  2. package/dist/migrations/009_v08_team.sql +100 -0
  3. package/dist/migrations/010_v09_native.sql +78 -0
  4. package/dist/plugin/ArtifactWriter.d.ts +28 -0
  5. package/dist/plugin/ArtifactWriter.js +35 -2
  6. package/dist/plugin/ArtifactWriter.js.map +1 -1
  7. package/dist/plugin/ContextInjector.d.ts +9 -0
  8. package/dist/plugin/ContextInjector.js +14 -3
  9. package/dist/plugin/ContextInjector.js.map +1 -1
  10. package/dist/plugin/Curator.d.ts +22 -2
  11. package/dist/plugin/Curator.js +56 -14
  12. package/dist/plugin/Curator.js.map +1 -1
  13. package/dist/plugin/HookLiveness.d.ts +81 -0
  14. package/dist/plugin/HookLiveness.js +324 -0
  15. package/dist/plugin/HookLiveness.js.map +1 -0
  16. package/dist/plugin/InjectionLedger.d.ts +6 -0
  17. package/dist/plugin/InjectionLedger.js +6 -0
  18. package/dist/plugin/InjectionLedger.js.map +1 -1
  19. package/dist/plugin/Materializer.d.ts +29 -5
  20. package/dist/plugin/Materializer.js +48 -16
  21. package/dist/plugin/Materializer.js.map +1 -1
  22. package/dist/plugin/MemoryService.d.ts +34 -5
  23. package/dist/plugin/MemoryService.js +215 -7
  24. package/dist/plugin/MemoryService.js.map +1 -1
  25. package/dist/plugin/Migrate.d.ts +1 -0
  26. package/dist/plugin/Migrate.js +74 -3
  27. package/dist/plugin/Migrate.js.map +1 -1
  28. package/dist/plugin/RepoIdentity.d.ts +124 -0
  29. package/dist/plugin/RepoIdentity.js +301 -0
  30. package/dist/plugin/RepoIdentity.js.map +1 -0
  31. package/dist/plugin/Retrospective.js +18 -0
  32. package/dist/plugin/Retrospective.js.map +1 -1
  33. package/dist/plugin/SharedLayer.d.ts +159 -0
  34. package/dist/plugin/SharedLayer.js +463 -0
  35. package/dist/plugin/SharedLayer.js.map +1 -0
  36. package/dist/plugin/host.d.ts +70 -0
  37. package/dist/plugin/host.js +251 -0
  38. package/dist/plugin/host.js.map +1 -0
  39. package/dist/plugin/index.d.ts +30 -1
  40. package/dist/plugin/index.js +582 -12
  41. package/dist/plugin/index.js.map +1 -1
  42. package/dist/plugin/kevin_approve.js +7 -4
  43. package/dist/plugin/kevin_approve.js.map +1 -1
  44. package/dist/plugin/kevin_audit.d.ts +48 -1
  45. package/dist/plugin/kevin_audit.js +179 -3
  46. package/dist/plugin/kevin_audit.js.map +1 -1
  47. package/dist/plugin/kevin_doctor.d.ts +57 -0
  48. package/dist/plugin/kevin_doctor.js +168 -0
  49. package/dist/plugin/kevin_doctor.js.map +1 -0
  50. package/dist/plugin/kevin_native.d.ts +29 -0
  51. package/dist/plugin/kevin_native.js +80 -0
  52. package/dist/plugin/kevin_native.js.map +1 -0
  53. package/dist/plugin/metrics.d.ts +1 -1
  54. package/dist/plugin/metrics.js +9 -0
  55. package/dist/plugin/metrics.js.map +1 -1
  56. package/dist/plugin/native.d.ts +92 -0
  57. package/dist/plugin/native.js +191 -0
  58. package/dist/plugin/native.js.map +1 -0
  59. package/dist/plugin/okf-export.d.ts +2 -2
  60. package/dist/plugin/okf-export.js +15 -7
  61. package/dist/plugin/okf-export.js.map +1 -1
  62. package/dist/plugin/okf.d.ts +107 -0
  63. package/dist/plugin/okf.js +304 -0
  64. package/dist/plugin/okf.js.map +1 -0
  65. package/dist/plugin/replay-types.d.ts +29 -287
  66. package/dist/plugin/replay-types.js +145 -53
  67. package/dist/plugin/replay-types.js.map +1 -1
  68. package/migrations/009_v08_team.sql +100 -0
  69. package/migrations/010_v09_native.sql +78 -0
  70. package/package.json +2 -3
@@ -11,28 +11,36 @@ import { ContextInjector } from "./ContextInjector.js";
11
11
  import { ConventionMiner } from "./ConventionMiner.js";
12
12
  import { Curator } from "./Curator.js";
13
13
  import { Feedback } from "./Feedback.js";
14
+ import { HookLiveness } from "./HookLiveness.js";
14
15
  import { InjectionLedger } from "./InjectionLedger.js";
15
16
  import { Materializer, SKILL_TOPIC } from "./Materializer.js";
16
- import { MemoryService, } from "./MemoryService.js";
17
+ import { MemoryService, hasRepoIdColumn, } from "./MemoryService.js";
17
18
  import { Migrate } from "./Migrate.js";
18
19
  import { PatternMiner } from "./PatternMiner.js";
19
20
  import { ERROR_LINE_RE, Reflector, STRONG_ERROR_RE } from "./Reflector.js";
21
+ import * as RepoIdentity from "./RepoIdentity.js";
20
22
  import { RepoTruth } from "./RepoTruth.js";
21
23
  import { Retrospective } from "./Retrospective.js";
24
+ import { SharedLayer } from "./SharedLayer.js";
22
25
  import { Store } from "./Store.js";
23
26
  import { ToolCallObserver } from "./ToolCallObserver.js";
24
27
  import { probe } from "./capabilities.js";
25
- import { fingerprint } from "./fingerprint.js";
28
+ import { computeConfidence } from "./confidence.js";
29
+ import { probeHost, summarize } from "./host.js";
26
30
  import { kevinApprove } from "./kevin_approve.js";
27
31
  import { buildAudit } from "./kevin_audit.js";
28
32
  import { executeKevinConflicts } from "./kevin_conflicts.js";
33
+ import { buildDoctor } from "./kevin_doctor.js";
29
34
  import { buildKevinFacts } from "./kevin_facts.js";
35
+ import { handleNative } from "./kevin_native.js";
30
36
  import { kevinPropose } from "./kevin_propose.js";
31
37
  import { kevinPublish } from "./kevin_publish.js";
32
38
  import { kevinWhy } from "./kevin_why.js";
33
39
  import { Metrics } from "./metrics.js";
40
+ import { attachNative } from "./native.js";
34
41
  import { exportMarkdown, exportOkf } from "./okf-export.js";
35
42
  import { importOkf } from "./okf-import.js";
43
+ import { uuidv7 } from "./uuid.js";
36
44
  // v0.6.0 (K6-019) — process-global set of reference topics already
37
45
  // registered with the host this process. Keeps re-registration idempotent
38
46
  // across sessions within one process: a topic registered once is never
@@ -71,6 +79,28 @@ export const KEVIN_CONFIG_KEYS = [
71
79
  "convention_mining_enabled",
72
80
  "conflict_detection_enabled",
73
81
  "error_lesson_mode",
82
+ // v0.8.0 (K8-003 / plan §8.10) — the five keys seeded by migration 009
83
+ // section 5. Omitting these makes `kevin_config set` return
84
+ // { error: "unknown_key" } while `kevin_config list` still shows them.
85
+ // shared_layer_enabled must be compared with === "1" (it is TEXT, and
86
+ // '0' is truthy); shared_confidence_floor is a string read with
87
+ // Number.parseFloat and clamped to [0, 1] (conventions, §2).
88
+ "shared_layer_enabled",
89
+ "okf_path",
90
+ "share_requires_approval",
91
+ "author_identity_mode",
92
+ "shared_confidence_floor",
93
+ // v0.9.0 (K9-003 / plan §8.10) — the four keys seeded by migration 010
94
+ // section 5. Omitting these makes `kevin_config set` return
95
+ // { error: "unknown_key" } while `kevin_config list` still shows them.
96
+ // hook_liveness_enabled and the two registration/history flags must be
97
+ // compared with === "1" (they are TEXT, and '0' is truthy);
98
+ // dead_hook_report_threshold is a string read with Number.parseInt and
99
+ // clamped to [1, 1000], NaN defaulting to 3 (conventions, §2; D9-09).
100
+ "hook_liveness_enabled",
101
+ "native_registration_enabled",
102
+ "host_probe_history_enabled",
103
+ "dead_hook_report_threshold",
74
104
  ];
75
105
  // v0.7.0 (K7-003 / plan §5.6, D7-12) — the explicit VALUE domain for
76
106
  // `error_lesson_mode`. The setting is TEXT and must be compared with
@@ -79,10 +109,132 @@ export const KEVIN_CONFIG_KEYS = [
79
109
  // at the surface rather than silently changing every installation's
80
110
  // behaviour on the next reflection.
81
111
  export const ERROR_LESSON_MODE_VALUES = ["all", "triage_only"];
112
+ /** Plugin release version — stamped into generated files (K8-021/027). */
113
+ export const KEVIN_VERSION = "0.9.0";
82
114
  function resolveMigrationsDir() {
83
115
  const here = dirname(fileURLToPath(import.meta.url));
84
116
  return join(here, "..", "migrations");
85
117
  }
118
+ const REKEY_TABLES = ["memories", "shared_entries", "okf_imports"];
119
+ export function performRekey(store, toRepoId, opts) {
120
+ // The 009 migration carries the repo_id column AND the shared-layer
121
+ // tables; without it there is nothing to re-key.
122
+ if (!hasRepoIdColumn(store)) {
123
+ return {
124
+ action: "rekey",
125
+ ok: false,
126
+ reason: "la migracion 009 no se ha aplicado: no existe repo_id (ni shared_entries/okf_imports) sobre el que re-key",
127
+ };
128
+ }
129
+ // Rows that would move: every scoped row stored under a repo_id
130
+ // different from the target. NULL-repo_id rows are global by design
131
+ // and never move.
132
+ const groupRows = (table) => store
133
+ .prepare(`SELECT repo_id, COUNT(*) AS c FROM ${table}
134
+ WHERE repo_id IS NOT NULL AND repo_id != ? GROUP BY repo_id`)
135
+ .all(toRepoId);
136
+ const from = {};
137
+ const rows = {
138
+ memories: 0,
139
+ shared_entries: 0,
140
+ okf_imports: 0,
141
+ };
142
+ for (const table of REKEY_TABLES) {
143
+ for (const r of groupRows(table)) {
144
+ rows[table] += r.c;
145
+ from[r.repo_id] ??= {
146
+ memories: 0,
147
+ shared_entries: 0,
148
+ okf_imports: 0,
149
+ };
150
+ from[r.repo_id][table] = r.c;
151
+ }
152
+ }
153
+ const total = rows.memories + rows.shared_entries + rows.okf_imports;
154
+ if (total === 0) {
155
+ return {
156
+ action: "rekey",
157
+ ok: true,
158
+ rekeyed: false,
159
+ to_repo_id: toRepoId,
160
+ rows,
161
+ from,
162
+ };
163
+ }
164
+ // Monorepo collision (D8-03): rows already at the target repo_id
165
+ // belong to a different project_id set than the rows that would move.
166
+ // shared_entries and okf_imports carry no project_id, so memories is
167
+ // the only witness.
168
+ const pidSet = (sql, ...params) => {
169
+ const out = new Set();
170
+ for (const r of store.prepare(sql).all(...params)) {
171
+ if (r.project_id !== null)
172
+ out.add(r.project_id);
173
+ }
174
+ return out;
175
+ };
176
+ const targetPids = pidSet("SELECT DISTINCT project_id FROM memories WHERE repo_id = ?", toRepoId);
177
+ const movePids = pidSet("SELECT DISTINCT project_id FROM memories WHERE repo_id IS NOT NULL AND repo_id != ?", toRepoId);
178
+ const collision = targetPids.size > 0 &&
179
+ !(movePids.size === targetPids.size &&
180
+ [...movePids].every((p) => targetPids.has(p)));
181
+ if (!opts.confirm) {
182
+ return {
183
+ action: "rekey",
184
+ ok: true,
185
+ dry_run: true,
186
+ to_repo_id: toRepoId,
187
+ rows,
188
+ from,
189
+ collision,
190
+ };
191
+ }
192
+ if (collision && opts.force !== true) {
193
+ return {
194
+ action: "rekey",
195
+ ok: false,
196
+ reason: "monorepo collision: el repo_id destino ya contiene memorias de un conjunto de project_id distinto; pasa force: true solo si quieres fusionarlos",
197
+ to_repo_id: toRepoId,
198
+ rows,
199
+ collision: true,
200
+ };
201
+ }
202
+ // One transaction: the row moves and the rekey_events counter move
203
+ // together — a mid-way failure rolls both back and the database is
204
+ // completely unchanged.
205
+ try {
206
+ store.transaction(() => {
207
+ for (const table of REKEY_TABLES) {
208
+ store
209
+ .prepare(`UPDATE ${table} SET repo_id = ?
210
+ WHERE repo_id IS NOT NULL AND repo_id != ?`)
211
+ .run(toRepoId, toRepoId);
212
+ }
213
+ store
214
+ .prepare(`INSERT INTO kevin_metrics (key, value, updated_at)
215
+ VALUES ('rekey_events', 1, datetime('now'))
216
+ ON CONFLICT(key) DO UPDATE SET
217
+ value = value + 1,
218
+ updated_at = datetime('now')`)
219
+ .run();
220
+ });
221
+ }
222
+ catch (err) {
223
+ return {
224
+ action: "rekey",
225
+ ok: false,
226
+ reason: `rekey fallo y se revirtio completamente: ${err?.message ?? "unknown error"}`,
227
+ };
228
+ }
229
+ return {
230
+ action: "rekey",
231
+ ok: true,
232
+ rekeyed: true,
233
+ to_repo_id: toRepoId,
234
+ rows,
235
+ from,
236
+ };
237
+ }
86
238
  export const KevinPlugin = async (input, options) => {
87
239
  const opts = (options ?? {});
88
240
  const dbPath = opts.dbPath ?? join(homedir(), ".opencode-kevin", "kevin.db");
@@ -98,8 +250,48 @@ export const KevinPlugin = async (input, options) => {
98
250
  // v0.4.0 (K4-019): the plugin hooks expose no project field, so the
99
251
  // project id is derived once from the plugin host's working directory
100
252
  // (plan §5.7 fallback; D2-11 project scoping wired into the live path).
101
- const projectId = fingerprint(process.cwd());
102
- const memoryService = new MemoryService(store, metrics);
253
+ // v0.8.0 (K8-006 / plan §5.1): the repository identity resolves once at
254
+ // init, next to the project id, and never on a hot path.
255
+ // v0.9.0 (K9-004/K9-006 / plan §5.1-5.2, D9-12/D9-13): the host surface
256
+ // is probed once at construction — frozen, never re-probed — and feeds
257
+ // the identity chain as the third source, above `path` and below
258
+ // `declared`/`remote`.
259
+ const host = await probeHost(input);
260
+ const identity = RepoIdentity.resolve(process.cwd(), host);
261
+ const projectId = identity.projectId;
262
+ const repoId = identity.repoId;
263
+ const identitySource = identity.source;
264
+ const identityEvidence = identity.evidence;
265
+ // v0.8.0 (BUG-001/002) — the SESSION identity: what the shared layer
266
+ // bridge, MemoryService, Curator and the tools actually use. It is
267
+ // derived once at init and only moves when kevin_project rekey
268
+ // succeeds (the rows move with it). kevin_status and the audit
269
+ // rollups report THIS id, never a fresh per-call resolve — a
270
+ // mid-session `git remote add` changes what the identity WILL be,
271
+ // not what the session is scoped on.
272
+ let sessionIdentity = identity;
273
+ const memoryService = new MemoryService(store, metrics, repoId);
274
+ // v0.9.0 (K9-008 / plan §5.1, D9-08) — host probe history: one
275
+ // host_probes row per construction when host_probe_history_enabled
276
+ // is explicitly '1' (TEXT comparison — truthiness would fire on
277
+ // '0'). Append-only and unbounded by design: off by default, one
278
+ // row per process start, no retention policy.
279
+ if (memoryService.getSetting("host_probe_history_enabled", "0") === "1") {
280
+ store
281
+ .prepare(`INSERT INTO host_probes
282
+ (id, plugin_version, flavour, has_shell, v2_skill, v2_reference, notes)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
284
+ .run(uuidv7(), host.pluginVersion, host.flavour, host.hasShell ? 1 : 0, host.v2.skill ? 1 : 0, host.v2.reference ? 1 : 0, host.notes.join("\n"));
285
+ }
286
+ // v0.9.0 (K9-009 / plan §5.3, D9-07/D9-08) — hook liveness. Wraps every
287
+ // hook with a success-path recorder; counters persist on the
288
+ // metrics.flush() cadence. Explicit === "1" TEXT comparison
289
+ // (kevin_settings.value is TEXT).
290
+ const liveness = new HookLiveness(store, {
291
+ enabled: memoryService.getSetting("hook_liveness_enabled", "1") === "1",
292
+ thresholdText: memoryService.getSetting("dead_hook_report_threshold", "3"),
293
+ pluginVersion: host.pluginVersion,
294
+ });
103
295
  // v0.7.0 (K7-009 / plan §5.1, D7-13) — the repository truth scanner reads
104
296
  // the JSON project files. Runs once at init, gated by repo_truth_enabled.
105
297
  const projectRoot = opts.projectRoot ?? process.cwd();
@@ -151,7 +343,30 @@ export const KevinPlugin = async (input, options) => {
151
343
  // ArtifactWriter is constructed here and only `kevin_approve` may call
152
344
  // `apply()`. `kevin_propose` reaches the file only through `plan()`.
153
345
  const writer = new ArtifactWriter(store, projectId, metrics);
154
- const curator = new Curator(store, memoryService, projectId, metrics);
346
+ // v0.8.0 (K8-020/021 / plan §5.5) — the shared layer bridge, built
347
+ // once at init next to the writer. okf_path resolves against
348
+ // projectRoot at call time; the writer is the only write path
349
+ // (D8-08).
350
+ let sharedLayer = new SharedLayer({
351
+ store,
352
+ repoId,
353
+ projectId,
354
+ version: KEVIN_VERSION,
355
+ writer,
356
+ });
357
+ // v0.8.0 (K8-022 / plan §5.5) — the shared layer re-read. sync is
358
+ // deliberately narrow: re-read a file that is already on disk —
359
+ // one read plus one hash on an unchanged repository (the
360
+ // okf_imports hash skip), and nothing else. No fetch, no push, no
361
+ // remote, no poll (D8-01, Principle 30). Reachable only from the
362
+ // kevin_sync tool and session.idle, never from a hot-path hook.
363
+ function syncSharedLayer() {
364
+ const okfPath = join(projectRoot, memoryService.getSetting("okf_path", ".kevin/knowledge.okf"));
365
+ const report = sharedLayer.import(okfPath);
366
+ metrics.incr("shared_entries_imported", report.imported);
367
+ return report;
368
+ }
369
+ let curator = new Curator(store, memoryService, projectId, metrics, repoId);
155
370
  // v0.6.0 (K6-017/018 / plan §5.6-5.7, D6-13) — pull-channel bundles and
156
371
  // the v2 domain probe. `probe()` runs ONCE at init and the result is
157
372
  // held; probing per-event is a hot-path cost for a value that cannot
@@ -162,6 +377,24 @@ export const KevinPlugin = async (input, options) => {
162
377
  mkdirSync(join(materializerRoot, "refs"), { recursive: true });
163
378
  const capabilities = probe(input);
164
379
  const materializer = new Materializer(store, { root: materializerRoot });
380
+ // v0.9.0 (K9-016 / plan §5.4, D9-10) — native registration replaces
381
+ // file emission. When attachNative returns a registration for a
382
+ // surface, the corresponding *_emission_enabled path is skipped for
383
+ // that surface only. The guard lives in Materializer.
384
+ try {
385
+ const nativeReg = await attachNative(host, {
386
+ materializer,
387
+ settings: memoryService,
388
+ store,
389
+ });
390
+ if (nativeReg) {
391
+ materializer.markNativeRegistered("skill", nativeReg.registered.skill);
392
+ materializer.markNativeRegistered("reference", nativeReg.registered.reference);
393
+ }
394
+ }
395
+ catch {
396
+ // attachNative never throws (D9-12) — guard against unexpected rejection.
397
+ }
165
398
  // v0.6.0 (K6-018 / plan §8.14, D6-13) — skill emission at session
166
399
  // start, behind the capability probe AND `skill_emission_enabled`
167
400
  // (default '0'). Both no-op paths are silent: "unavailable" (v1 host)
@@ -273,7 +506,11 @@ export const KevinPlugin = async (input, options) => {
273
506
  projectId,
274
507
  }));
275
508
  }
276
- return {
509
+ // v0.9.0 (K9-009 / plan §5.3) — the hooks object is wrapped here at the
510
+ // point of return: transparent (identical keys/arity/returns/errors),
511
+ // recording fires on the success path only. With hook_liveness_enabled
512
+ // '0' the wrapper returns this same object untouched.
513
+ return liveness.wrap({
277
514
  tool: {
278
515
  kevin_save: tool({
279
516
  description: "Guarda una memoria en el conocimiento persistente de Kevin (error, pattern, decision o context).",
@@ -572,6 +809,35 @@ export const KevinPlugin = async (input, options) => {
572
809
  catch {
573
810
  v07 = undefined;
574
811
  }
812
+ // v0.8.0 (K8-025 / plan §5.8) — identity and shared-layer
813
+ // fields. repo_id is always a derived hash (never a raw
814
+ // remote URL — the "declared" source pins the .kevin/
815
+ // project.json id, "remote" hashes the normalized origin
816
+ // URL, "path" falls back to the project fingerprint).
817
+ // identity_source tells the user which of the three won.
818
+ // shared_layer_enabled is the config key read from
819
+ // kevin_settings (default "0"); shared_entries counts the
820
+ // active (asserted) entries for this repo — best-effort
821
+ // on pre-009 DBs, which have no shared layer at all.
822
+ // BUG-001: the SESSION identity (init-time, moved only by
823
+ // a successful rekey) — not a fresh per-call resolve, so
824
+ // the count always agrees with what kevin_share/kevin_sync
825
+ // actually read and write.
826
+ const identity = sessionIdentity;
827
+ let v08;
828
+ try {
829
+ v08 = {
830
+ repo_id: identity.repoId,
831
+ identity_source: identity.source,
832
+ shared_layer_enabled: memoryService.getSetting("shared_layer_enabled", "0"),
833
+ shared_entries: store
834
+ .prepare("SELECT COUNT(*) AS c FROM shared_entries WHERE repo_id = ? AND op = 'assert'")
835
+ .get(identity.repoId).c,
836
+ };
837
+ }
838
+ catch {
839
+ v08 = undefined;
840
+ }
575
841
  return {
576
842
  title: "Estado de Kevin",
577
843
  output: JSON.stringify({
@@ -580,8 +846,10 @@ export const KevinPlugin = async (input, options) => {
580
846
  // (13 v0.5 + kevin_propose + kevin_approve +
581
847
  // kevin_publish). The frozen ladder is verified
582
848
  // monotone across releases; K6-024 extends this
583
- // block with the remaining v0.6 fields.
584
- tool_count: 18,
849
+ // block with the remaining v0.6 fields. v0.8.0
850
+ // (K8-025) — 18 v0.7 + kevin_project +
851
+ // kevin_native = 23.
852
+ tool_count: 23,
585
853
  v07,
586
854
  memories_reflector: memoriesReflector,
587
855
  memories_agent: memoriesAgent,
@@ -614,6 +882,134 @@ export const KevinPlugin = async (input, options) => {
614
882
  },
615
883
  // v0.6.0 (K6-024) — omitted on pre-007 DBs.
616
884
  v06,
885
+ // v0.8.0 (K8-025) — omitted on pre-009 DBs.
886
+ v08,
887
+ // v0.9.0 (K9-008 / plan §5.1) — one-line host
888
+ // summary: version, flavour, shell, v2 flags;
889
+ // no paths, no ids (charset-safe for log lines).
890
+ host_summary: summarize(host),
891
+ }),
892
+ };
893
+ },
894
+ }),
895
+ kevin_project: tool({
896
+ description: "Identidad de repositorio (v0.8.0, K8-008/009 / plan §5.8): show reporta repoId, source, evidence, projectId, los conteos de memorias bajo cada scope y rekey_available (true cuando el corpus esta almacenado bajo un scope distinto del resuelto); init escribe .kevin/project.json fijando el id derivado (refusa si ya existe); rekey mueve el corpus entero al repo_id resuelto en UNA transaccion — sin confirm: true es un dry run que no muta nada, y la colision monorepo se rechaza salvo force: true.",
897
+ args: {
898
+ action: tool.schema.enum(["show", "init", "rekey"]),
899
+ confirm: tool.schema.boolean().optional(),
900
+ force: tool.schema.boolean().optional(),
901
+ },
902
+ async execute(args) {
903
+ // v0.8.0 (K8-008) — identity resolves against the boot
904
+ // project root (the directory the plugin was wired with),
905
+ // never against the host's cwd.
906
+ const identity = RepoIdentity.resolve(projectRoot);
907
+ if (args.action === "init") {
908
+ const res = RepoIdentity.initProjectFile(projectRoot, writer);
909
+ return {
910
+ title: "Kevin project.json",
911
+ output: JSON.stringify(res.ok
912
+ ? {
913
+ action: "init",
914
+ written: true,
915
+ path: res.path,
916
+ id: res.id,
917
+ created_at: res.createdAt,
918
+ generator: "opencode-kevin/0.9.0",
919
+ }
920
+ : {
921
+ action: "init",
922
+ written: false,
923
+ path: res.path,
924
+ reason: res.reason,
925
+ }),
926
+ };
927
+ }
928
+ if (args.action === "rekey") {
929
+ // v0.8.0 (K8-009 / plan §5.1, D8-03) — the only call
930
+ // site of performRekey in this codebase (asserted by
931
+ // source scan in the K8-009 acceptance).
932
+ const res = performRekey(store, identity.repoId, {
933
+ confirm: args.confirm === true,
934
+ force: args.force === true,
935
+ });
936
+ // v0.8.0 (BUG-002) — a successful rekey moves the
937
+ // rows to the freshly resolved id; the session must
938
+ // follow, or kevin_share/kevin_sync/retrieval stay
939
+ // scoped on the old id until a restart. The bridge,
940
+ // MemoryService and Curator are rebuilt on the new id
941
+ // in the same breath the rows move — there is never
942
+ // a window in which the session and the store
943
+ // disagree. A dry run or a refusal mutates nothing.
944
+ if (res.ok && !res.dry_run) {
945
+ const live = RepoIdentity.resolve(projectRoot);
946
+ sessionIdentity = live;
947
+ memoryService.setRepoId(live.repoId);
948
+ sharedLayer = new SharedLayer({
949
+ store,
950
+ repoId: live.repoId,
951
+ projectId: live.projectId,
952
+ version: KEVIN_VERSION,
953
+ writer,
954
+ });
955
+ curator = new Curator(store, memoryService, projectId, metrics, live.repoId);
956
+ // v0.8.0 (BUG-003) — heal the OKF file header.
957
+ // Rekey changes the scope the file is written
958
+ // under; a stale `#repo` first line would make
959
+ // every later planExport/planTombstone refuse
960
+ // with repo_mismatch forever. The heal lives in
961
+ // SharedLayer so the whole-file construction
962
+ // stays at its single allowed site (D8-08).
963
+ try {
964
+ sharedLayer.healHeader(join(projectRoot, memoryService.getSetting("okf_path", ".kevin/knowledge.okf")), live.repoId);
965
+ }
966
+ catch {
967
+ // Missing or unreadable file — nothing to heal.
968
+ }
969
+ }
970
+ return {
971
+ title: "Kevin project rekey",
972
+ output: JSON.stringify(res),
973
+ };
974
+ }
975
+ // show.
976
+ const withRepoColumn = hasRepoIdColumn(store);
977
+ const memoriesTotal = store.prepare("SELECT COUNT(*) AS c FROM memories").get().c;
978
+ const memoriesUnderRepo = withRepoColumn
979
+ ? store
980
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE repo_id = ?")
981
+ .get(identity.repoId).c
982
+ : 0;
983
+ const memoriesUnderProject = store
984
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE project_id = ?")
985
+ .get(identity.projectId).c;
986
+ // v0.8.0 (K8-008) — rekey_available: rows whose current
987
+ // scope differs from the resolved repo_id. On an
988
+ // unmigrated corpus the stored scope is project_id, so a
989
+ // git remote present (repoId != path fingerprint) reports
990
+ // true without the 009 column existing.
991
+ const differentlyScoped = withRepoColumn
992
+ ? store
993
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE repo_id IS NOT NULL AND repo_id != ?")
994
+ .get(identity.repoId).c
995
+ : store
996
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE project_id IS NOT NULL AND project_id != ?")
997
+ .get(identity.repoId).c;
998
+ return {
999
+ title: "Identidad de repositorio",
1000
+ output: JSON.stringify({
1001
+ action: "show",
1002
+ repo_id: identity.repoId,
1003
+ source: identity.source,
1004
+ evidence: identity.evidence,
1005
+ project_id: identity.projectId,
1006
+ memories_total: memoriesTotal,
1007
+ memories_repo_id: memoriesUnderRepo,
1008
+ memories_project_id: memoriesUnderProject,
1009
+ rekey_available: differentlyScoped > 0,
1010
+ project_json: existsSync(join(projectRoot, ".kevin", "project.json"))
1011
+ ? "present"
1012
+ : "absent",
617
1013
  }),
618
1014
  };
619
1015
  },
@@ -629,7 +1025,9 @@ export const KevinPlugin = async (input, options) => {
629
1025
  // three emission states ("unavailable" vs "off" vs "on").
630
1026
  // v0.7.0 (K7-019 / K7-006) — projectId scopes the `truth`
631
1027
  // block; on a pre-008 DB the block is omitted, partial:true.
632
- const report = buildAudit(store, metrics, capabilities, projectId);
1028
+ // v0.8.0 (K8-007 / plan §5.7) — the penalized-memory rollup
1029
+ // scopes on repoId once the 009 column exists.
1030
+ const report = buildAudit(store, metrics, capabilities, projectId, sessionIdentity.repoId);
633
1031
  const payload = args.verbose
634
1032
  ? report
635
1033
  : { ...report, settings: undefined };
@@ -639,6 +1037,48 @@ export const KevinPlugin = async (input, options) => {
639
1037
  };
640
1038
  },
641
1039
  }),
1040
+ // v0.9.0 (K9-018 / plan §5.5, D9-09) — kevin_doctor: pure
1041
+ // reads, no writes, no probe re-run, no model call. The host
1042
+ // block comes from the frozen init-time probe, hooks from the
1043
+ // persisted hook_liveness table, native from the last
1044
+ // registration per surface, verdict from reduceVerdict.
1045
+ kevin_doctor: tool({
1046
+ description: "Doctor de Kevin (v0.9.0): salud del host, hooks, dependencias y registros nativos. Solo lectura, sin LLM ni writes, invocable en cualquier momento. Devuelve host (version, flavour, shell, v2), hooks ordenados dead primero, dependencies (zod_copies), native (enabled, registered/verified por surface), verdict (healthy|degraded|unknown) y reason. Output pensado para pegar en un issue: sin filesystem paths ni session ids.",
1047
+ args: {},
1048
+ async execute() {
1049
+ const report = buildDoctor(store, host, memoryService);
1050
+ return {
1051
+ title: "Doctor de Kevin",
1052
+ output: JSON.stringify(report),
1053
+ };
1054
+ },
1055
+ }),
1056
+ // v0.9.0 (K9-019 / plan §5.5, D9-12) — kevin_native: inspect
1057
+ // and toggle native_registration_enabled. enable/disable write
1058
+ // kevin_settings only and never re-attach — the probe is
1059
+ // frozen for the process lifetime, so a restart is the
1060
+ // requirement for the change to take effect. `enable` on a
1061
+ // host without the v2 subpath succeeds and reports the
1062
+ // registration as inert: the setting is a statement of intent.
1063
+ kevin_native: tool({
1064
+ description: "Registros nativos v2 (v0.9.0): show reporta el setting native_registration_enabled, si el host resuelto expone el subpath v2 (effective) y las ultimas filas de native_registrations por surface; enable/disable escriben el setting ('1'/'0' TEXT) y NO re-adjuntan — el probe es frozen para la vida del proceso, un restart del host es el requisito para que el cambio tenga efecto; enable en un host sin subpath v2 exito con registration inert.",
1065
+ args: {
1066
+ action: tool.schema
1067
+ .enum(["show", "enable", "disable"])
1068
+ .default("show"),
1069
+ },
1070
+ async execute(args) {
1071
+ const report = handleNative(args.action, {
1072
+ host,
1073
+ store,
1074
+ settings: memoryService,
1075
+ });
1076
+ return {
1077
+ title: "Kevin native",
1078
+ output: JSON.stringify(report),
1079
+ };
1080
+ },
1081
+ }),
642
1082
  kevin_retrospective: tool({
643
1083
  description: "Genera un retrospective markdown para una sesion (resume tools que fallaron y lecciones aprendidas).",
644
1084
  args: {
@@ -782,9 +1222,13 @@ export const KevinPlugin = async (input, options) => {
782
1222
  .describe("okf | markdown"),
783
1223
  },
784
1224
  async execute(args) {
1225
+ // v0.8.0 (K8-027) — the v1 export is scoped to this
1226
+ // plugin's project: the global database holds several
1227
+ // projects' memories, and a committed export must not
1228
+ // leak another project's knowledge.
785
1229
  const output = args.format === "markdown"
786
- ? exportMarkdown(store)
787
- : exportOkf(store);
1230
+ ? exportMarkdown(store, projectId)
1231
+ : exportOkf(store, projectId);
788
1232
  return {
789
1233
  title: "Export completado",
790
1234
  output,
@@ -976,6 +1420,103 @@ export const KevinPlugin = async (input, options) => {
976
1420
  };
977
1421
  },
978
1422
  }),
1423
+ // v0.8.0 (K8-021 / plan §5.5) — kevin_share. The dry run is the
1424
+ // default: nothing is written until the diff is shown. The
1425
+ // write itself goes through the single funnel (applyExport),
1426
+ // and shared_entries_exported counts only entries actually
1427
+ // added to the file.
1428
+ kevin_share: tool({
1429
+ description: "Comparte memorias locales al archivo OKF compartido (v0.8.0, K8-021 / plan 5.5): dry-run por defecto - sin memory_ids selecciona toda memoria curated=1 en o sobre shared_confidence_floor no ya compartida, y devuelve el plan con su diff sin escribir nada. confirm: true escribe via el unico write path (ArtifactWriter, D8-08); con share_requires_approval='1' (default) ademas se requiere confirm para escribir y las memorias no curadas se rechazan con not_curated; las que no alcanzan el floor se rechazan con below_floor; compartir la misma memoria dos veces es un noop en el segundo intento. Nunca escribe otro fichero que no sea okf_path.",
1430
+ args: {
1431
+ memory_ids: tool.schema.array(tool.schema.string()).optional(),
1432
+ dry_run: tool.schema.boolean().optional(),
1433
+ confirm: tool.schema.boolean().optional(),
1434
+ },
1435
+ async execute(args) {
1436
+ const okfPath = join(projectRoot, memoryService.getSetting("okf_path", ".kevin/knowledge.okf"));
1437
+ const requiresApproval = memoryService.getSetting("share_requires_approval", "1") === "1";
1438
+ const dryRun = args.dry_run ?? true;
1439
+ const confirm = args.confirm ?? false;
1440
+ const floor = Number.parseFloat(memoryService.getSetting("shared_confidence_floor", "0.7"));
1441
+ const clampedFloor = Number.isNaN(floor)
1442
+ ? 0.7
1443
+ : Math.min(1, Math.max(0, floor));
1444
+ const ids = args.memory_ids !== undefined && args.memory_ids.length > 0
1445
+ ? args.memory_ids
1446
+ : store
1447
+ .prepare(`SELECT id, evidence_count, recurrence_count
1448
+ FROM memories
1449
+ WHERE layer = 'local' AND curated = 1
1450
+ AND shared_entry_id IS NULL AND repo_id = ?`)
1451
+ .all(sessionIdentity.repoId)
1452
+ .filter((row) => computeConfidence(row.evidence_count ?? 0, row.recurrence_count ?? 0) >= clampedFloor)
1453
+ .map((row) => row.id);
1454
+ const plan = sharedLayer.planExport(ids, okfPath);
1455
+ if (plan.write.outcome === "refused") {
1456
+ return {
1457
+ title: "Exportacion rechazada",
1458
+ output: JSON.stringify({
1459
+ refused: plan.write.reason,
1460
+ okf_path: okfPath,
1461
+ }),
1462
+ };
1463
+ }
1464
+ const base = {
1465
+ memory_ids: ids,
1466
+ entries_added: plan.entriesAdded,
1467
+ okf_path: okfPath,
1468
+ diff: plan.write.diff,
1469
+ };
1470
+ if (dryRun) {
1471
+ return {
1472
+ title: "Plan de exportacion (dry-run)",
1473
+ output: JSON.stringify({ ...base, dry_run: true }),
1474
+ };
1475
+ }
1476
+ if (requiresApproval && !confirm) {
1477
+ return {
1478
+ title: "Confirmacion requerida",
1479
+ output: JSON.stringify({
1480
+ ...base,
1481
+ dry_run: true,
1482
+ confirm_required: true,
1483
+ }),
1484
+ };
1485
+ }
1486
+ // The writer's atomic temp file lives next to the target,
1487
+ // so the .kevin directory must exist before the write
1488
+ // (same prep as RepoIdentity.initProjectFile).
1489
+ mkdirSync(dirname(okfPath), { recursive: true });
1490
+ const applied = sharedLayer.applyExport(plan);
1491
+ if (applied.applied === "written") {
1492
+ metrics.incr("shared_entries_exported", plan.entriesAdded);
1493
+ }
1494
+ return {
1495
+ title: applied.applied === "noop"
1496
+ ? "Nada nuevo que compartir"
1497
+ : "Exportacion aplicada",
1498
+ output: JSON.stringify({
1499
+ ...base,
1500
+ outcome: applied.applied,
1501
+ }),
1502
+ };
1503
+ },
1504
+ }),
1505
+ // v0.8.0 (K8-022 / plan §5.5) — kevin_sync. The name and the
1506
+ // scope are deliberately narrow: "sync" means re-reading a
1507
+ // file that is already on disk. Manual invocation works
1508
+ // regardless of shared_layer_enabled; the automatic idle
1509
+ // re-read below is the one gated by the flag.
1510
+ kevin_sync: tool({
1511
+ description: "Re-lee el archivo OKF compartido ya en disco (v0.8.0, K8-022 / plan 5.5): importa las entradas del archivo okf_path al store compartido y reporta el ImportReport (parsed/folded/rejected/imported/tombstoned/skipped). No hay fetch, push, remoto ni polling (D8-01, Principle 30). Funciona con shared_layer_enabled en '0'; la sincronizacion automatica en session.idle esta gated por shared_layer_enabled === '1' y nunca corre en hot paths.",
1512
+ args: {},
1513
+ async execute() {
1514
+ return {
1515
+ title: "Sincronizacion compartida",
1516
+ output: JSON.stringify(syncSharedLayer()),
1517
+ };
1518
+ },
1519
+ }),
979
1520
  },
980
1521
  "tool.execute.before": async (hookInput, output) => {
981
1522
  rememberToolCall(hookInput.callID, hookInput.tool, output.args);
@@ -988,6 +1529,13 @@ export const KevinPlugin = async (input, options) => {
988
1529
  }, {});
989
1530
  },
990
1531
  "tool.execute.after": async (hookInput, output) => {
1532
+ // v0.9.0 (K9-010 / plan §5.3) — checkpoint: the session reached a
1533
+ // model turn, so the system prompt was assembled and
1534
+ // `experimental.chat.system.transform` MUST have been offered.
1535
+ // Deduped per session inside HookLiveness.expect.
1536
+ if (hookInput.sessionID) {
1537
+ liveness.expect("experimental.chat.system.transform", hookInput.sessionID);
1538
+ }
991
1539
  const meta = (output.metadata ?? {});
992
1540
  const outputText = String(output.output ?? "");
993
1541
  const stderr = String(meta.stderr ?? "");
@@ -1187,8 +1735,29 @@ export const KevinPlugin = async (input, options) => {
1187
1735
  // best-effort, same pattern as ledger.settle — a
1188
1736
  // curation failure must not break the idle path
1189
1737
  }
1738
+ // v0.8.0 (K8-022 / plan §5.5) — the shared layer
1739
+ // re-read at idle. Gated by shared_layer_enabled ===
1740
+ // "1", a TEXT comparison: '0' is a truthy string, so a
1741
+ // truthiness check would turn the release on for every
1742
+ // installation that upgrades. Never wired into
1743
+ // tool.execute.*, chat.message, system.transform or
1744
+ // session.compacting — the hot-path rule is absolute;
1745
+ // the file-hash skip is what makes the idle cost one
1746
+ // read plus one hash on an unchanged repository.
1747
+ try {
1748
+ if (memoryService.getSetting("shared_layer_enabled", "0") === "1") {
1749
+ syncSharedLayer();
1750
+ }
1751
+ }
1752
+ catch {
1753
+ // best-effort, same pattern as ledger.settle — a
1754
+ // sync failure must not break the idle path
1755
+ }
1190
1756
  }
1191
1757
  metrics.flush();
1758
+ // v0.9.0 (K9-009 / plan §5.3) — liveness counters persist on
1759
+ // the same cadence as the metrics.
1760
+ liveness.flush();
1192
1761
  }
1193
1762
  else if (type === "session.next.tool.failed") {
1194
1763
  const callID = props.callID;
@@ -1206,10 +1775,11 @@ export const KevinPlugin = async (input, options) => {
1206
1775
  },
1207
1776
  dispose: async () => {
1208
1777
  await Promise.allSettled([...pending]);
1778
+ liveness.flush();
1209
1779
  metrics.close();
1210
1780
  store.close();
1211
1781
  },
1212
- };
1782
+ });
1213
1783
  };
1214
1784
  export default KevinPlugin;
1215
1785
  //# sourceMappingURL=index.js.map