@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
@@ -11,6 +11,7 @@ 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
17
  import { MemoryService, hasRepoIdColumn, } from "./MemoryService.js";
@@ -25,16 +26,24 @@ import { Store } from "./Store.js";
25
26
  import { ToolCallObserver } from "./ToolCallObserver.js";
26
27
  import { probe } from "./capabilities.js";
27
28
  import { computeConfidence } from "./confidence.js";
29
+ import { probeHost, summarize } from "./host.js";
28
30
  import { kevinApprove } from "./kevin_approve.js";
29
31
  import { buildAudit } from "./kevin_audit.js";
32
+ import { buildKevinBench } from "./kevin_bench.js";
30
33
  import { executeKevinConflicts } from "./kevin_conflicts.js";
34
+ import { buildKevinContract } from "./kevin_contract.js";
35
+ import { buildDoctor } from "./kevin_doctor.js";
31
36
  import { buildKevinFacts } from "./kevin_facts.js";
37
+ import { handleNative } from "./kevin_native.js";
32
38
  import { kevinPropose } from "./kevin_propose.js";
33
39
  import { kevinPublish } from "./kevin_publish.js";
34
40
  import { kevinWhy } from "./kevin_why.js";
35
41
  import { Metrics } from "./metrics.js";
42
+ import { attachNative } from "./native.js";
36
43
  import { exportMarkdown, exportOkf } from "./okf-export.js";
37
44
  import { importOkf } from "./okf-import.js";
45
+ import { Perf } from "./perf.js";
46
+ import { uuidv7 } from "./uuid.js";
38
47
  // v0.6.0 (K6-019) — process-global set of reference topics already
39
48
  // registered with the host this process. Keeps re-registration idempotent
40
49
  // across sessions within one process: a topic registered once is never
@@ -84,6 +93,24 @@ export const KEVIN_CONFIG_KEYS = [
84
93
  "share_requires_approval",
85
94
  "author_identity_mode",
86
95
  "shared_confidence_floor",
96
+ // v0.9.0 (K9-003 / plan §8.10) — the four keys seeded by migration 010
97
+ // section 5. Omitting these makes `kevin_config set` return
98
+ // { error: "unknown_key" } while `kevin_config list` still shows them.
99
+ // hook_liveness_enabled and the two registration/history flags must be
100
+ // compared with === "1" (they are TEXT, and '0' is truthy);
101
+ // dead_hook_report_threshold is a string read with Number.parseInt and
102
+ // clamped to [1, 1000], NaN defaulting to 3 (conventions, §2; D9-09).
103
+ "hook_liveness_enabled",
104
+ "native_registration_enabled",
105
+ "host_probe_history_enabled",
106
+ "dead_hook_report_threshold",
107
+ // v1.0.0 (K10-005 / plan §6) — the four keys seeded by migration 011
108
+ // section 4. Omitting these makes `kevin_config set` return
109
+ // { error: "unknown_key" } while `kevin_config list` still shows them.
110
+ "perf_enabled",
111
+ "perf_ring_capacity",
112
+ "perf_flush_on_idle",
113
+ "contract_report_enabled",
87
114
  ];
88
115
  // v0.7.0 (K7-003 / plan §5.6, D7-12) — the explicit VALUE domain for
89
116
  // `error_lesson_mode`. The setting is TEXT and must be compared with
@@ -93,7 +120,7 @@ export const KEVIN_CONFIG_KEYS = [
93
120
  // behaviour on the next reflection.
94
121
  export const ERROR_LESSON_MODE_VALUES = ["all", "triage_only"];
95
122
  /** Plugin release version — stamped into generated files (K8-021/027). */
96
- export const KEVIN_VERSION = "0.8.0";
123
+ export const KEVIN_VERSION = "1.0.0";
97
124
  function resolveMigrationsDir() {
98
125
  const here = dirname(fileURLToPath(import.meta.url));
99
126
  return join(here, "..", "migrations");
@@ -235,7 +262,12 @@ export const KevinPlugin = async (input, options) => {
235
262
  // (plan §5.7 fallback; D2-11 project scoping wired into the live path).
236
263
  // v0.8.0 (K8-006 / plan §5.1): the repository identity resolves once at
237
264
  // init, next to the project id, and never on a hot path.
238
- const identity = RepoIdentity.resolve(process.cwd());
265
+ // v0.9.0 (K9-004/K9-006 / plan §5.1-5.2, D9-12/D9-13): the host surface
266
+ // is probed once at construction — frozen, never re-probed — and feeds
267
+ // the identity chain as the third source, above `path` and below
268
+ // `declared`/`remote`.
269
+ const host = await probeHost(input);
270
+ const identity = RepoIdentity.resolve(process.cwd(), host);
239
271
  const projectId = identity.projectId;
240
272
  const repoId = identity.repoId;
241
273
  const identitySource = identity.source;
@@ -249,6 +281,36 @@ export const KevinPlugin = async (input, options) => {
249
281
  // not what the session is scoped on.
250
282
  let sessionIdentity = identity;
251
283
  const memoryService = new MemoryService(store, metrics, repoId);
284
+ // v0.9.0 (K9-008 / plan §5.1, D9-08) — host probe history: one
285
+ // host_probes row per construction when host_probe_history_enabled
286
+ // is explicitly '1' (TEXT comparison — truthiness would fire on
287
+ // '0'). Append-only and unbounded by design: off by default, one
288
+ // row per process start, no retention policy.
289
+ if (memoryService.getSetting("host_probe_history_enabled", "0") === "1") {
290
+ store
291
+ .prepare(`INSERT INTO host_probes
292
+ (id, plugin_version, flavour, has_shell, v2_skill, v2_reference, notes)
293
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
294
+ .run(uuidv7(), host.pluginVersion, host.flavour, host.hasShell ? 1 : 0, host.v2.skill ? 1 : 0, host.v2.reference ? 1 : 0, host.notes.join("\n"));
295
+ }
296
+ // v0.9.0 (K9-009 / plan §5.3, D9-07/D9-08) — hook liveness. Wraps every
297
+ // hook with a success-path recorder; counters persist on the
298
+ // metrics.flush() cadence. Explicit === "1" TEXT comparison
299
+ // (kevin_settings.value is TEXT).
300
+ const liveness = new HookLiveness(store, {
301
+ enabled: memoryService.getSetting("hook_liveness_enabled", "1") === "1",
302
+ thresholdText: memoryService.getSetting("dead_hook_report_threshold", "3"),
303
+ pluginVersion: host.pluginVersion,
304
+ });
305
+ // v1.0.0 (K10-012 / plan §5.2) — the performance instrument. Measures
306
+ // how long each hook holds the host into per-scope ring buffers that
307
+ // only reach the store at session.idle (D10-11: no perf_samples write
308
+ // anywhere else). perf_enabled uses the explicit === "1" TEXT
309
+ // comparison; the capacity string goes through the parse guard/clamp.
310
+ const perf = new Perf({
311
+ enabled: memoryService.getSetting("perf_enabled", "1") === "1",
312
+ capacity: memoryService.getSetting("perf_ring_capacity", "") ?? "",
313
+ });
252
314
  // v0.7.0 (K7-009 / plan §5.1, D7-13) — the repository truth scanner reads
253
315
  // the JSON project files. Runs once at init, gated by repo_truth_enabled.
254
316
  const projectRoot = opts.projectRoot ?? process.cwd();
@@ -334,6 +396,24 @@ export const KevinPlugin = async (input, options) => {
334
396
  mkdirSync(join(materializerRoot, "refs"), { recursive: true });
335
397
  const capabilities = probe(input);
336
398
  const materializer = new Materializer(store, { root: materializerRoot });
399
+ // v0.9.0 (K9-016 / plan §5.4, D9-10) — native registration replaces
400
+ // file emission. When attachNative returns a registration for a
401
+ // surface, the corresponding *_emission_enabled path is skipped for
402
+ // that surface only. The guard lives in Materializer.
403
+ try {
404
+ const nativeReg = await attachNative(host, {
405
+ materializer,
406
+ settings: memoryService,
407
+ store,
408
+ });
409
+ if (nativeReg) {
410
+ materializer.markNativeRegistered("skill", nativeReg.registered.skill);
411
+ materializer.markNativeRegistered("reference", nativeReg.registered.reference);
412
+ }
413
+ }
414
+ catch {
415
+ // attachNative never throws (D9-12) — guard against unexpected rejection.
416
+ }
337
417
  // v0.6.0 (K6-018 / plan §8.14, D6-13) — skill emission at session
338
418
  // start, behind the capability probe AND `skill_emission_enabled`
339
419
  // (default '0'). Both no-op paths are silent: "unavailable" (v1 host)
@@ -390,6 +470,9 @@ export const KevinPlugin = async (input, options) => {
390
470
  }
391
471
  }
392
472
  let currentSessionId = null;
473
+ // v1.0.0 (K10-013 / D10-08) — set on the first tool completion of a
474
+ // session; consumed at idle to arm the deferred-dispose marker.
475
+ let sessionRecordedWork = false;
393
476
  // BUG-011 — process-global last derived query. Cleared on
394
477
  // `session.idle`; the per-session map below is the preferred source.
395
478
  let lastUserQuery = null;
@@ -445,7 +528,11 @@ export const KevinPlugin = async (input, options) => {
445
528
  projectId,
446
529
  }));
447
530
  }
448
- return {
531
+ // v0.9.0 (K9-009 / plan §5.3) — the hooks object is wrapped here at the
532
+ // point of return: transparent (identical keys/arity/returns/errors),
533
+ // recording fires on the success path only. With hook_liveness_enabled
534
+ // '0' the wrapper returns this same object untouched.
535
+ return liveness.wrap({
449
536
  tool: {
450
537
  kevin_save: tool({
451
538
  description: "Guarda una memoria en el conocimiento persistente de Kevin (error, pattern, decision o context).",
@@ -783,8 +870,8 @@ export const KevinPlugin = async (input, options) => {
783
870
  // monotone across releases; K6-024 extends this
784
871
  // block with the remaining v0.6 fields. v0.8.0
785
872
  // (K8-025) — 18 v0.7 + kevin_project +
786
- // kevin_share + kevin_sync = 21.
787
- tool_count: 21,
873
+ // kevin_native = 23.
874
+ tool_count: 23,
788
875
  v07,
789
876
  memories_reflector: memoriesReflector,
790
877
  memories_agent: memoriesAgent,
@@ -819,6 +906,10 @@ export const KevinPlugin = async (input, options) => {
819
906
  v06,
820
907
  // v0.8.0 (K8-025) — omitted on pre-009 DBs.
821
908
  v08,
909
+ // v0.9.0 (K9-008 / plan §5.1) — one-line host
910
+ // summary: version, flavour, shell, v2 flags;
911
+ // no paths, no ids (charset-safe for log lines).
912
+ host_summary: summarize(host),
822
913
  }),
823
914
  };
824
915
  },
@@ -846,7 +937,7 @@ export const KevinPlugin = async (input, options) => {
846
937
  path: res.path,
847
938
  id: res.id,
848
939
  created_at: res.createdAt,
849
- generator: "opencode-kevin/0.8.0",
940
+ generator: "opencode-kevin/0.9.0",
850
941
  }
851
942
  : {
852
943
  action: "init",
@@ -968,6 +1059,91 @@ export const KevinPlugin = async (input, options) => {
968
1059
  };
969
1060
  },
970
1061
  }),
1062
+ // v0.9.0 (K9-018 / plan §5.5, D9-09) — kevin_doctor: pure
1063
+ // reads, no writes, no probe re-run, no model call. The host
1064
+ // block comes from the frozen init-time probe, hooks from the
1065
+ // persisted hook_liveness table, native from the last
1066
+ // registration per surface, verdict from reduceVerdict.
1067
+ kevin_doctor: tool({
1068
+ 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.",
1069
+ args: {},
1070
+ async execute() {
1071
+ const report = buildDoctor(store, host, memoryService);
1072
+ return {
1073
+ title: "Doctor de Kevin",
1074
+ output: JSON.stringify(report),
1075
+ };
1076
+ },
1077
+ }),
1078
+ // v0.9.0 (K9-019 / plan §5.5, D9-12) — kevin_native: inspect
1079
+ // and toggle native_registration_enabled. enable/disable write
1080
+ // kevin_settings only and never re-attach — the probe is
1081
+ // frozen for the process lifetime, so a restart is the
1082
+ // requirement for the change to take effect. `enable` on a
1083
+ // host without the v2 subpath succeeds and reports the
1084
+ // registration as inert: the setting is a statement of intent.
1085
+ kevin_native: tool({
1086
+ 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.",
1087
+ args: {
1088
+ action: tool.schema
1089
+ .enum(["show", "enable", "disable"])
1090
+ .default("show"),
1091
+ },
1092
+ async execute(args) {
1093
+ const report = handleNative(args.action, {
1094
+ host,
1095
+ store,
1096
+ settings: memoryService,
1097
+ });
1098
+ return {
1099
+ title: "Kevin native",
1100
+ output: JSON.stringify(report),
1101
+ };
1102
+ },
1103
+ }),
1104
+ // v1.0.0 (K10-018 / plan §5.6) — kevin_contract: the frozen
1105
+ // surface, inspectable at runtime rather than only at test
1106
+ // time. Read-only; summary by default, one clause's full value
1107
+ // with clause+format:'full'. Unknown clause ids are a
1108
+ // structured error, never a throw.
1109
+ kevin_contract: tool({
1110
+ description: "Contrato publico de Kevin (v1.0.0): version, digest y por clausula id/titulo/estabilidad/since/deprecacion. clause:'C-0N' con format:'full' retorna el valor completo de esa clausula (marcadores, tool names, settings keys, metric keys, entry points, schema, invariantes). Solo lectura, sin LLM ni red.",
1111
+ args: {
1112
+ clause: tool.schema
1113
+ .string()
1114
+ .optional()
1115
+ .describe("Id de clausula (ej. 'C-01'). Sin clause: resumen."),
1116
+ format: tool.schema
1117
+ .enum(["summary", "full"])
1118
+ .optional()
1119
+ .describe("summary (default): una linea por clausula. full: valores completos (requiere clause para una clausula; solo devuelve resumen enriquecido si se omite)."),
1120
+ },
1121
+ async execute(args) {
1122
+ const result = buildKevinContract({ packageVersion: KEVIN_VERSION }, args);
1123
+ return {
1124
+ title: "Contrato de Kevin",
1125
+ output: JSON.stringify(result),
1126
+ };
1127
+ },
1128
+ }),
1129
+ // v1.0.0 (K10-019 / plan §5.6) — kevin_bench: reports what
1130
+ // `npm run bench` recorded in bench_runs. It NEVER runs the
1131
+ // benchmark from inside a live session.
1132
+ kevin_bench: tool({
1133
+ description: "Resultados del benchmark de retrieval (v1.0.0). status: si hay corridas, digest del corpus mas reciente y si coincide con el corpus en disco. last: los cuatro brazos (none/recent-k/random-k/kevin) con precision@5, recall@5 y MRR de la ultima corrida. NUNCA ejecuta el benchmark — corre 'npm run bench' fuera de la sesion.",
1134
+ args: {
1135
+ action: tool.schema
1136
+ .enum(["status", "last"])
1137
+ .describe("status | last"),
1138
+ },
1139
+ async execute(args) {
1140
+ const result = buildKevinBench({ store }, args);
1141
+ return {
1142
+ title: "Benchmark de Kevin",
1143
+ output: JSON.stringify(result),
1144
+ };
1145
+ },
1146
+ }),
971
1147
  kevin_retrospective: tool({
972
1148
  description: "Genera un retrospective markdown para una sesion (resume tools que fallaron y lecciones aprendidas).",
973
1149
  args: {
@@ -1408,256 +1584,400 @@ export const KevinPlugin = async (input, options) => {
1408
1584
  }),
1409
1585
  },
1410
1586
  "tool.execute.before": async (hookInput, output) => {
1411
- rememberToolCall(hookInput.callID, hookInput.tool, output.args);
1412
- observer.onBefore({
1413
- tool: hookInput.tool,
1414
- args: output.args,
1415
- sessionId: hookInput.sessionID,
1416
- callID: hookInput.callID,
1417
- projectId,
1418
- }, {});
1587
+ // v1.0.0 (K10-012) — perf measures the synchronous hold time;
1588
+ // HookLiveness wraps the outside of this handler (compose,
1589
+ // never merge — D10-09).
1590
+ perf.measure("tool.execute.before", () => {
1591
+ rememberToolCall(hookInput.callID, hookInput.tool, output.args);
1592
+ observer.onBefore({
1593
+ tool: hookInput.tool,
1594
+ args: output.args,
1595
+ sessionId: hookInput.sessionID,
1596
+ callID: hookInput.callID,
1597
+ projectId,
1598
+ }, {});
1599
+ });
1419
1600
  },
1420
1601
  "tool.execute.after": async (hookInput, output) => {
1421
- const meta = (output.metadata ?? {});
1422
- const outputText = String(output.output ?? "");
1423
- const stderr = String(meta.stderr ?? "");
1424
- const stdout = String(meta.stdout ?? outputText);
1425
- const exitCode = pickExitCode(meta);
1426
- let success;
1427
- if (meta.success === false) {
1428
- success = false;
1429
- }
1430
- else if (exitCode !== undefined) {
1431
- success = exitCode === 0;
1432
- }
1433
- else if (stderr.length > 0 && ERROR_LINE_RE.test(stderr)) {
1434
- success = false;
1435
- }
1436
- else {
1437
- const stream = stdout.length > 0 ? stdout : outputText;
1438
- success = !(stream.length > 0 && STRONG_ERROR_RE.test(stream));
1439
- }
1440
- observer.onAfter({
1441
- tool: hookInput.tool,
1442
- args: hookInput.args,
1443
- sessionId: hookInput.sessionID,
1444
- callID: hookInput.callID,
1445
- projectId,
1446
- }, { success, stdout, stderr, exitCode });
1447
- if (!success) {
1448
- const errorType = observer.inferErrorType(stderr, stdout, exitCode);
1449
- fireAndForget(reflector.invoke({
1450
- toolName: hookInput.tool,
1451
- argsSummary: observer.summarizeArgs(hookInput.args),
1452
- stderr,
1453
- stdout,
1454
- exitCode,
1455
- errorType,
1602
+ perf.measure("tool.execute.after", () => {
1603
+ sessionRecordedWork = true;
1604
+ // v0.9.0 (K9-010 / plan §5.3) checkpoint: the session reached a
1605
+ // model turn, so the system prompt was assembled and
1606
+ // `experimental.chat.system.transform` MUST have been offered.
1607
+ // Deduped per session inside HookLiveness.expect.
1608
+ if (hookInput.sessionID) {
1609
+ liveness.expect("experimental.chat.system.transform", hookInput.sessionID);
1610
+ }
1611
+ const meta = (output.metadata ?? {});
1612
+ const outputText = String(output.output ?? "");
1613
+ const stderr = String(meta.stderr ?? "");
1614
+ const stdout = String(meta.stdout ?? outputText);
1615
+ const exitCode = pickExitCode(meta);
1616
+ let success;
1617
+ if (meta.success === false) {
1618
+ success = false;
1619
+ }
1620
+ else if (exitCode !== undefined) {
1621
+ success = exitCode === 0;
1622
+ }
1623
+ else if (stderr.length > 0 && ERROR_LINE_RE.test(stderr)) {
1624
+ success = false;
1625
+ }
1626
+ else {
1627
+ const stream = stdout.length > 0 ? stdout : outputText;
1628
+ success = !(stream.length > 0 && STRONG_ERROR_RE.test(stream));
1629
+ }
1630
+ observer.onAfter({
1631
+ tool: hookInput.tool,
1632
+ args: hookInput.args,
1456
1633
  sessionId: hookInput.sessionID,
1457
1634
  callID: hookInput.callID,
1458
1635
  projectId,
1459
- }));
1460
- }
1461
- else {
1462
- causalChain.onSuccess(hookInput.tool, hookInput.args, projectId, hookInput.sessionID);
1463
- }
1636
+ }, { success, stdout, stderr, exitCode });
1637
+ if (!success) {
1638
+ const errorType = observer.inferErrorType(stderr, stdout, exitCode);
1639
+ fireAndForget(reflector.invoke({
1640
+ toolName: hookInput.tool,
1641
+ argsSummary: observer.summarizeArgs(hookInput.args),
1642
+ stderr,
1643
+ stdout,
1644
+ exitCode,
1645
+ errorType,
1646
+ sessionId: hookInput.sessionID,
1647
+ callID: hookInput.callID,
1648
+ projectId,
1649
+ }));
1650
+ }
1651
+ else {
1652
+ causalChain.onSuccess(hookInput.tool, hookInput.args, projectId, hookInput.sessionID);
1653
+ }
1654
+ });
1464
1655
  },
1465
1656
  "chat.message": async (hookInput, output) => {
1466
- const text = output.parts
1467
- .map((p) => p)
1468
- .filter((p) => p.type === "text")
1469
- .map((p) => p.text ?? "")
1470
- .join(" ");
1471
- if (text.trim()) {
1472
- const derived = injector.deriveQuery([{ role: "user", content: text }]);
1473
- lastUserQuery = derived.length > 0 ? derived : null;
1474
- if (hookInput.sessionID && lastUserQuery) {
1475
- lastUserQueryBySession.set(hookInput.sessionID, lastUserQuery);
1657
+ perf.measure("chat.message", () => {
1658
+ const text = output.parts
1659
+ .map((p) => p)
1660
+ .filter((p) => p.type === "text")
1661
+ .map((p) => p.text ?? "")
1662
+ .join(" ");
1663
+ if (text.trim()) {
1664
+ const derived = injector.deriveQuery([
1665
+ { role: "user", content: text },
1666
+ ]);
1667
+ lastUserQuery = derived.length > 0 ? derived : null;
1668
+ if (hookInput.sessionID && lastUserQuery) {
1669
+ lastUserQueryBySession.set(hookInput.sessionID, lastUserQuery);
1670
+ }
1476
1671
  }
1477
- }
1672
+ });
1478
1673
  },
1479
1674
  "experimental.chat.system.transform": async (hookInput, output) => {
1480
- // BUG-011 prefer the per-session query so a new session whose
1481
- // first transform fires before any chat.message cannot reuse the
1482
- // previous session's query (the global is cleared on idle).
1483
- const query = lastUserQueryBySession.get(hookInput.sessionID ?? "") ?? lastUserQuery;
1484
- if (!query)
1485
- return;
1486
- const suggestion = injector.generateSuggestion();
1487
- if (suggestion)
1488
- output.system.push(suggestion);
1489
- injector.onSystemTransform({
1490
- sessionID: hookInput.sessionID ?? undefined,
1491
- messages: [{ role: "user", content: query }],
1492
- }, output);
1675
+ perf.measure("chat.system.transform", () => {
1676
+ // BUG-011 prefer the per-session query so a new session whose
1677
+ // first transform fires before any chat.message cannot reuse the
1678
+ // previous session's query (the global is cleared on idle).
1679
+ const query = lastUserQueryBySession.get(hookInput.sessionID ?? "") ??
1680
+ lastUserQuery;
1681
+ if (!query)
1682
+ return;
1683
+ const suggestion = injector.generateSuggestion();
1684
+ if (suggestion)
1685
+ output.system.push(suggestion);
1686
+ injector.onSystemTransform({
1687
+ sessionID: hookInput.sessionID ?? undefined,
1688
+ messages: [{ role: "user", content: query }],
1689
+ }, output);
1690
+ });
1493
1691
  },
1494
1692
  "experimental.session.compacting": async (hookInput, output) => {
1495
- // v0.4.0 (K4-018) plan §5.6: compaction often fires with no
1496
- // recent chat.message (auto-compact after a long tool turn,
1497
- // resumed sessions). Resolve a query per session first, then
1498
- // the global fallback, then any messages the runtime may
1499
- // provide (defensive the current SDK contract only exposes
1500
- // sessionID).
1501
- // BUG-012 — the HITL suggestion fires AT MOST ONCE per session:
1502
- // whichever hook (transform or compacting) runs first consumes
1503
- // the pending recurrence signal (generateSuggestion resets it).
1504
- const suggestion = injector.generateSuggestion();
1505
- if (suggestion)
1506
- output.context.push(suggestion);
1507
- const sid = hookInput.sessionID;
1508
- const sessionQuery = lastUserQueryBySession.get(sid) ?? lastUserQuery;
1509
- const runtimeMessages = hookInput
1510
- .messages;
1511
- const messages = sessionQuery != null
1512
- ? [{ role: "user", content: sessionQuery }]
1513
- : (runtimeMessages ?? []);
1514
- injector.onCompacting({
1515
- sessionID: sid,
1516
- messages,
1517
- }, output);
1693
+ perf.measure("session.compacting", () => {
1694
+ // v0.4.0 (K4-018) plan §5.6: compaction often fires with no
1695
+ // recent chat.message (auto-compact after a long tool turn,
1696
+ // resumed sessions). Resolve a query per session first, then
1697
+ // the global fallback, then any messages the runtime may
1698
+ // provide (defensive — the current SDK contract only exposes
1699
+ // sessionID).
1700
+ // BUG-012 the HITL suggestion fires AT MOST ONCE per session:
1701
+ // whichever hook (transform or compacting) runs first consumes
1702
+ // the pending recurrence signal (generateSuggestion resets it).
1703
+ const suggestion = injector.generateSuggestion();
1704
+ if (suggestion)
1705
+ output.context.push(suggestion);
1706
+ const sid = hookInput.sessionID;
1707
+ const sessionQuery = lastUserQueryBySession.get(sid) ?? lastUserQuery;
1708
+ const runtimeMessages = hookInput
1709
+ .messages;
1710
+ const messages = sessionQuery != null
1711
+ ? [{ role: "user", content: sessionQuery }]
1712
+ : (runtimeMessages ?? []);
1713
+ injector.onCompacting({
1714
+ sessionID: sid,
1715
+ messages,
1716
+ }, output);
1717
+ });
1518
1718
  },
1519
1719
  event: async ({ event }) => {
1520
1720
  const type = event.type;
1521
1721
  const props = event.properties ?? {};
1522
- if (type === "session.created") {
1523
- const info = props.info;
1524
- if (info?.id) {
1525
- currentSessionId = info.id;
1526
- // BUG-011 a fresh session must not inherit the
1527
- // previous session's derived query (the global may
1528
- // still hold it if no idle fired).
1529
- lastUserQueryBySession.delete(info.id);
1530
- // v0.4.0 (K4-017) — plan §5.1 rule 3: the per-session
1531
- // seen-set resets when a session is created.
1532
- injector.onSessionCreated(info.id);
1533
- }
1534
- }
1535
- else if (type === "session.idle") {
1722
+ if (type === "session.idle") {
1723
+ // v1.0.0 (K10-012) — the idle branch measures under
1724
+ // "session.idle" (150/600 ms budget); every other branch
1725
+ // measures under "event" (5/25 ms). Recording idle's ~150 ms
1726
+ // under "event" would make that budget permanently breached
1727
+ // and therefore ignored.
1536
1728
  const sid = props.sessionID;
1537
- if (sid) {
1538
- toolCache.clear();
1539
- // BUG-011 — the session is done: drop the global query
1540
- // so the next session cannot reuse it.
1541
- lastUserQuery = null;
1542
- // v0.4.0 (K4-024) — plan §5.2: settle the session's
1543
- // unmeasured injections (effective/ineffective +
1544
- // recurrence_count charges) at idle. Best-effort: a
1545
- // legacy DB without migration 005 has no ledger table.
1546
- try {
1547
- ledger.settle(sid);
1548
- }
1549
- catch {
1550
- // best-effort: a legacy DB without the ledger
1551
- // table must not break the idle path
1552
- }
1553
- // v0.5.0 (K5-012 / plan §5.4) — retire stale lessons at
1554
- // idle; pre-006 DBs degrade to a no-op.
1555
- try {
1556
- archiver.run();
1557
- }
1558
- catch {
1559
- // best-effort, same pattern as ledger.settle
1560
- }
1561
- fireAndForget(retrospective.generate(sid));
1562
- memoryService.boostPositiveReflectors(sid);
1563
- const recurred = memoryService.penalizeRecurringReflectors(sid);
1564
- injector.setRecurrences(recurred, sid);
1565
- patternMiner.mine(projectId);
1566
- // v0.7.0 (K7-012 / plan §5.4, D7-10) — convention mining is
1567
- // session.idle-only, default-off, and isolated from the rest of
1568
- // the idle chain. Mined rules still enter the ordinary Curator
1569
- // approval path; this step never writes to the repository.
1570
- try {
1571
- if (memoryService.getSetting("convention_mining_enabled", "0") === "1") {
1572
- const conventions = conventionMiner.mine();
1573
- conventionMiner.emit(conventions);
1729
+ await perf.measureAsync("session.idle", async () => {
1730
+ if (sid) {
1731
+ toolCache.clear();
1732
+ // BUG-011 the session is done: drop the global query
1733
+ // so the next session cannot reuse it.
1734
+ lastUserQuery = null;
1735
+ // v0.4.0 (K4-024) — plan §5.2: settle the session's
1736
+ // unmeasured injections (effective/ineffective +
1737
+ // recurrence_count charges) at idle. Best-effort: a
1738
+ // legacy DB without migration 005 has no ledger table.
1739
+ try {
1740
+ ledger.settle(sid);
1741
+ }
1742
+ catch {
1743
+ // best-effort: a legacy DB without the ledger
1744
+ // table must not break the idle path
1745
+ }
1746
+ // v0.5.0 (K5-012 / plan §5.4) retire stale lessons at
1747
+ // idle; pre-006 DBs degrade to a no-op.
1748
+ try {
1749
+ archiver.run();
1750
+ }
1751
+ catch {
1752
+ // best-effort, same pattern as ledger.settle
1753
+ }
1754
+ fireAndForget(retrospective.generate(sid));
1755
+ memoryService.boostPositiveReflectors(sid);
1756
+ const recurred = memoryService.penalizeRecurringReflectors(sid);
1757
+ injector.setRecurrences(recurred, sid);
1758
+ patternMiner.mine(projectId);
1759
+ // v0.7.0 (K7-012 / plan §5.4, D7-10) convention mining is
1760
+ // session.idle-only, default-off, and isolated from the rest of
1761
+ // the idle chain. Mined rules still enter the ordinary Curator
1762
+ // approval path; this step never writes to the repository.
1763
+ try {
1764
+ if (memoryService.getSetting("convention_mining_enabled", "0") ===
1765
+ "1") {
1766
+ const conventions = conventionMiner.mine();
1767
+ conventionMiner.emit(conventions);
1768
+ }
1769
+ }
1770
+ catch {
1771
+ // A throwing miner must not reject or truncate the idle chain.
1772
+ }
1773
+ // v0.7.0 (K7-016 / plan §5.5, D7-06) — conflict detection is
1774
+ // surfacing-only on idle. It may create/open conflict rows, but
1775
+ // no idle path can acknowledge or resolve one.
1776
+ try {
1777
+ if (memoryService.getSetting("conflict_detection_enabled", "0") ===
1778
+ "1") {
1779
+ conflictDetector.detect();
1780
+ }
1781
+ }
1782
+ catch {
1783
+ // Conflict surfacing is best-effort and must not reject idle.
1784
+ }
1785
+ fireAndForget(Promise.resolve()
1786
+ .then(() => causalChain.onSessionIdle(sid))
1787
+ // non-blocking — promote is a best-effort pass
1788
+ // (legacy DBs pre-005 lack the recurrence_count
1789
+ // column)
1790
+ .catch(() => { }));
1791
+ // v0.6.0 (K6-015 / plan §8.14) — session-idle curation
1792
+ // generation. Dry-run only: `propose()` calls `plan()`
1793
+ // and never `apply()`, so nothing here can touch disk
1794
+ // (D6-01). Guarded by curation_enabled (TEXT compare)
1795
+ // and a 1-hour throttle persisted in kevin_settings.
1796
+ try {
1797
+ if (memoryService.getSetting("curation_enabled", "1") === "1") {
1798
+ const CURATION_THROTTLE_MS = 3_600_000;
1799
+ const last = memoryService.getSetting("last_curation_at", "");
1800
+ if (last === "" ||
1801
+ Date.now() - Date.parse(last) > CURATION_THROTTLE_MS) {
1802
+ curator.propose("agents_md", writer);
1803
+ store
1804
+ .prepare(`INSERT INTO kevin_settings (key, value) VALUES (?, ?)
1805
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
1806
+ .run("last_curation_at", new Date().toISOString());
1807
+ }
1808
+ }
1809
+ }
1810
+ catch {
1811
+ // best-effort, same pattern as ledger.settle — a
1812
+ // curation failure must not break the idle path
1813
+ }
1814
+ // v0.8.0 (K8-022 / plan §5.5) — the shared layer
1815
+ // re-read at idle. Gated by shared_layer_enabled ===
1816
+ // "1", a TEXT comparison: '0' is a truthy string, so a
1817
+ // truthiness check would turn the release on for every
1818
+ // installation that upgrades. Never wired into
1819
+ // tool.execute.*, chat.message, system.transform or
1820
+ // session.compacting — the hot-path rule is absolute;
1821
+ // the file-hash skip is what makes the idle cost one
1822
+ // read plus one hash on an unchanged repository.
1823
+ try {
1824
+ if (memoryService.getSetting("shared_layer_enabled", "0") === "1") {
1825
+ syncSharedLayer();
1826
+ }
1827
+ }
1828
+ catch {
1829
+ // best-effort, same pattern as ledger.settle — a
1830
+ // sync failure must not break the idle path
1831
+ }
1832
+ // v1.0.0 (K10-013 / D10-08) — the session recorded work:
1833
+ // arm the deferred dispose settlement. The ISO timestamp
1834
+ // lets the next session.start compare it against
1835
+ // hook_liveness.last_seen_at for dispose.
1836
+ if (sessionRecordedWork) {
1837
+ try {
1838
+ store
1839
+ .prepare("INSERT INTO kevin_settings (key, value) VALUES ('last_session_recorded_work', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
1840
+ .run(new Date().toISOString());
1841
+ }
1842
+ catch {
1843
+ // best-effort: arming must not break idle
1844
+ }
1845
+ sessionRecordedWork = false;
1574
1846
  }
1575
1847
  }
1576
- catch {
1577
- // A throwing miner must not reject or truncate the idle chain.
1578
- }
1579
- // v0.7.0 (K7-016 / plan §5.5, D7-06) conflict detection is
1580
- // surfacing-only on idle. It may create/open conflict rows, but
1581
- // no idle path can acknowledge or resolve one.
1848
+ });
1849
+ metrics.flush();
1850
+ // v1.0.0 (K10-012 / plan §5.2, D10-11) — the idle perf_samples
1851
+ // write (the dispose hook holds the other one), gated by
1852
+ // perf_flush_on_idle (TEXT compare; the fallback matches the
1853
+ // migration-seeded default '1').
1854
+ if (memoryService.getSetting("perf_flush_on_idle", "1") === "1") {
1582
1855
  try {
1583
- if (memoryService.getSetting("conflict_detection_enabled", "0") ===
1584
- "1") {
1585
- conflictDetector.detect();
1586
- }
1856
+ perf.flush(store);
1587
1857
  }
1588
1858
  catch {
1589
- // Conflict surfacing is best-effort and must not reject idle.
1859
+ // best-effort: pre-011 DBs have no perf_samples table
1590
1860
  }
1591
- fireAndForget(Promise.resolve()
1592
- .then(() => causalChain.onSessionIdle(sid))
1593
- // non-blocking — promote is a best-effort pass
1594
- // (legacy DBs pre-005 lack the recurrence_count
1595
- // column)
1596
- .catch(() => { }));
1597
- // v0.6.0 (K6-015 / plan §8.14) — session-idle curation
1598
- // generation. Dry-run only: `propose()` calls `plan()`
1599
- // and never `apply()`, so nothing here can touch disk
1600
- // (D6-01). Guarded by curation_enabled (TEXT compare)
1601
- // and a 1-hour throttle persisted in kevin_settings.
1602
- try {
1603
- if (memoryService.getSetting("curation_enabled", "1") === "1") {
1604
- const CURATION_THROTTLE_MS = 3_600_000;
1605
- const last = memoryService.getSetting("last_curation_at", "");
1606
- if (last === "" ||
1607
- Date.now() - Date.parse(last) > CURATION_THROTTLE_MS) {
1608
- curator.propose("agents_md", writer);
1861
+ }
1862
+ liveness.flush();
1863
+ return;
1864
+ }
1865
+ perf.measure("event", () => {
1866
+ if (type === "session.created") {
1867
+ const info = props.info;
1868
+ if (info?.id) {
1869
+ currentSessionId = info.id;
1870
+ // BUG-011 a fresh session must not inherit the
1871
+ // previous session's derived query (the global may
1872
+ // still hold it if no idle fired).
1873
+ lastUserQueryBySession.delete(info.id);
1874
+ // v0.4.0 (K4-017) — plan §5.1 rule 3: the per-session
1875
+ // seen-set resets when a session is created.
1876
+ injector.onSessionCreated(info.id);
1877
+ // v1.0.0 (K10-013 / plan §5.3, D10-08) deferred
1878
+ // dispose settlement. The previous session recorded
1879
+ // work but the process never came back through
1880
+ // `dispose` (crash or hard kill): settle it HERE,
1881
+ // at the start of the next session, because dispose
1882
+ // cannot be settled within the session that observes
1883
+ // it. One settlement per work marker; a first-ever
1884
+ // run has no marker and never reports dead. The
1885
+ // threshold semantics stay with expect(): unknown
1886
+ // until expected_count crosses the threshold.
1887
+ try {
1888
+ const marker = memoryService.getSetting("last_session_recorded_work", "");
1889
+ if (marker !== "") {
1890
+ let fired = true;
1891
+ try {
1892
+ const row = store
1893
+ .prepare("SELECT last_seen_at FROM hook_liveness WHERE hook = 'dispose'")
1894
+ .get();
1895
+ const last = row?.last_seen_at ?? null;
1896
+ fired = last !== null && last > marker;
1897
+ }
1898
+ catch {
1899
+ // pre-010 DB without hook_liveness: treat as
1900
+ // no evidence of a fire, same as a crash.
1901
+ fired = false;
1902
+ }
1903
+ if (!fired) {
1904
+ liveness.expect("dispose", `miss:${marker}`);
1905
+ store
1906
+ .prepare("UPDATE kevin_metrics SET value = value + 1 WHERE key = 'dispose_misses_total'")
1907
+ .run();
1908
+ }
1609
1909
  store
1610
- .prepare(`INSERT INTO kevin_settings (key, value) VALUES (?, ?)
1611
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
1612
- .run("last_curation_at", new Date().toISOString());
1910
+ .prepare("UPDATE kevin_settings SET value = '' WHERE key = 'last_session_recorded_work'")
1911
+ .run();
1613
1912
  }
1614
1913
  }
1914
+ catch {
1915
+ // best-effort: settlement must not break session start
1916
+ }
1615
1917
  }
1616
- catch {
1617
- // best-effort, same pattern as ledger.settle — a
1618
- // curation failure must not break the idle path
1918
+ }
1919
+ else if (type === "session.next.tool.failed") {
1920
+ const callID = props.callID;
1921
+ const sessionID = props.sessionID;
1922
+ const error = props.error;
1923
+ if (callID && sessionID && error?.message) {
1924
+ handleToolFailed(callID, sessionID, error.message);
1619
1925
  }
1620
- // v0.8.0 (K8-022 / plan §5.5) — the shared layer
1621
- // re-read at idle. Gated by shared_layer_enabled ===
1622
- // "1", a TEXT comparison: '0' is a truthy string, so a
1623
- // truthiness check would turn the release on for every
1624
- // installation that upgrades. Never wired into
1625
- // tool.execute.*, chat.message, system.transform or
1626
- // session.compacting — the hot-path rule is absolute;
1627
- // the file-hash skip is what makes the idle cost one
1628
- // read plus one hash on an unchanged repository.
1926
+ }
1927
+ else if (type === "session.next.tool.success") {
1928
+ const callID = props.callID;
1929
+ if (callID)
1930
+ toolCache.delete(callID);
1931
+ }
1932
+ });
1933
+ },
1934
+ dispose: async () => {
1935
+ // v1.0.0 (K10-013 / plan §5.3) — the seventh instrumented hook.
1936
+ // HookLiveness.wrap (outside) records the fire only if the
1937
+ // delegate completes; Perf.measureAsync (inside) times it even
1938
+ // on throw.
1939
+ try {
1940
+ await perf.measureAsync("dispose", async () => {
1629
1941
  try {
1630
- if (memoryService.getSetting("shared_layer_enabled", "0") === "1") {
1631
- syncSharedLayer();
1632
- }
1942
+ await Promise.allSettled([...pending]);
1633
1943
  }
1634
- catch {
1635
- // best-effort, same pattern as ledger.settle a
1636
- // sync failure must not break the idle path
1944
+ finally {
1945
+ // v1.0.0 (K10-013) record the fire and persist it now:
1946
+ // this is the last write of the process.
1947
+ liveness.recordDispose();
1948
+ // v1.0.0 (K10-013) — record the successful fire and
1949
+ // disarm any pending deferred-settlement marker: a
1950
+ // clean dispose is exactly what the next session's
1951
+ // settlement check looks for.
1952
+ store
1953
+ .prepare("UPDATE kevin_metrics SET value = value + 1 WHERE key = 'dispose_fires_total'")
1954
+ .run();
1955
+ try {
1956
+ store
1957
+ .prepare("UPDATE kevin_settings SET value = '' WHERE key = 'last_session_recorded_work'")
1958
+ .run();
1959
+ }
1960
+ catch {
1961
+ // best-effort: legacy DBs without kevin_settings
1962
+ }
1637
1963
  }
1638
- }
1639
- metrics.flush();
1964
+ });
1640
1965
  }
1641
- else if (type === "session.next.tool.failed") {
1642
- const callID = props.callID;
1643
- const sessionID = props.sessionID;
1644
- const error = props.error;
1645
- if (callID && sessionID && error?.message) {
1646
- handleToolFailed(callID, sessionID, error.message);
1966
+ finally {
1967
+ // v1.0.0 review fix — persist the final period (including this
1968
+ // dispose sample, recorded by measureAsync above) BEFORE the
1969
+ // closes; otherwise the dispose budget could never be verified
1970
+ // by bench:check because its samples died with the ring.
1971
+ try {
1972
+ perf.flush(store);
1647
1973
  }
1648
- }
1649
- else if (type === "session.next.tool.success") {
1650
- const callID = props.callID;
1651
- if (callID)
1652
- toolCache.delete(callID);
1974
+ catch {
1975
+ // best-effort: pre-011 DBs have no perf_samples table
1976
+ }
1977
+ metrics.close();
1978
+ store.close();
1653
1979
  }
1654
1980
  },
1655
- dispose: async () => {
1656
- await Promise.allSettled([...pending]);
1657
- metrics.close();
1658
- store.close();
1659
- },
1660
- };
1981
+ });
1661
1982
  };
1662
1983
  export default KevinPlugin;
1663
- //# sourceMappingURL=index.js.map