@jmtrin/opencode-kevin 1.1.0 → 1.3.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/dist/plugin/capabilities.d.ts +2 -0
  2. package/dist/plugin/capabilities.js +3 -0
  3. package/dist/plugin/index.d.ts +5 -4
  4. package/dist/plugin/index.js +401 -108
  5. package/dist/plugin/native.d.ts +2 -2
  6. package/dist/plugin/native.js +2 -2
  7. package/package.json +56 -63
  8. package/LICENSE +0 -21
  9. package/README.md +0 -576
  10. package/dist/migrations/001_initial.sql +0 -92
  11. package/dist/migrations/002_indexes.sql +0 -14
  12. package/dist/migrations/003_v02_signal.sql +0 -58
  13. package/dist/migrations/004_v03_knowledge.sql +0 -138
  14. package/dist/migrations/005_v04_signal.sql +0 -57
  15. package/dist/migrations/006_v05_glassbox.sql +0 -118
  16. package/dist/migrations/007_v06_pull.sql +0 -145
  17. package/dist/migrations/008_v07_truth.sql +0 -124
  18. package/dist/migrations/009_v08_team.sql +0 -100
  19. package/dist/migrations/010_v09_native.sql +0 -78
  20. package/dist/migrations/011_v10_proven.sql +0 -69
  21. package/dist/migrations/012_v11_drift.sql +0 -24
  22. package/dist/plugin/Archiver.d.ts +0 -42
  23. package/dist/plugin/Archiver.js +0 -83
  24. package/dist/plugin/ArtifactWriter.d.ts +0 -93
  25. package/dist/plugin/ArtifactWriter.js +0 -291
  26. package/dist/plugin/CausalChain.d.ts +0 -22
  27. package/dist/plugin/CausalChain.js +0 -197
  28. package/dist/plugin/ConflictDetector.d.ts +0 -35
  29. package/dist/plugin/ConflictDetector.js +0 -260
  30. package/dist/plugin/ContextInjector.d.ts +0 -222
  31. package/dist/plugin/ContextInjector.js +0 -533
  32. package/dist/plugin/ConventionMiner.d.ts +0 -35
  33. package/dist/plugin/ConventionMiner.js +0 -242
  34. package/dist/plugin/Curator.d.ts +0 -116
  35. package/dist/plugin/Curator.js +0 -324
  36. package/dist/plugin/Feedback.d.ts +0 -67
  37. package/dist/plugin/Feedback.js +0 -120
  38. package/dist/plugin/HookLiveness.d.ts +0 -88
  39. package/dist/plugin/HookLiveness.js +0 -322
  40. package/dist/plugin/InjectionLedger.d.ts +0 -98
  41. package/dist/plugin/InjectionLedger.js +0 -295
  42. package/dist/plugin/LessonFixer.d.ts +0 -44
  43. package/dist/plugin/LessonFixer.js +0 -45
  44. package/dist/plugin/Materializer.d.ts +0 -83
  45. package/dist/plugin/Materializer.js +0 -181
  46. package/dist/plugin/MemoryService.d.ts +0 -360
  47. package/dist/plugin/MemoryService.js +0 -1242
  48. package/dist/plugin/Migrate.d.ts +0 -17
  49. package/dist/plugin/Migrate.js +0 -271
  50. package/dist/plugin/PatternMiner.d.ts +0 -49
  51. package/dist/plugin/PatternMiner.js +0 -132
  52. package/dist/plugin/QualityGate.d.ts +0 -124
  53. package/dist/plugin/QualityGate.js +0 -119
  54. package/dist/plugin/Reflector.d.ts +0 -91
  55. package/dist/plugin/Reflector.js +0 -307
  56. package/dist/plugin/RepoIdentity.d.ts +0 -124
  57. package/dist/plugin/RepoIdentity.js +0 -300
  58. package/dist/plugin/RepoTruth.d.ts +0 -80
  59. package/dist/plugin/RepoTruth.js +0 -599
  60. package/dist/plugin/Retrospective.d.ts +0 -17
  61. package/dist/plugin/Retrospective.js +0 -213
  62. package/dist/plugin/SharedLayer.d.ts +0 -159
  63. package/dist/plugin/SharedLayer.js +0 -462
  64. package/dist/plugin/Store.d.ts +0 -29
  65. package/dist/plugin/Store.js +0 -50
  66. package/dist/plugin/ToolCallObserver.d.ts +0 -32
  67. package/dist/plugin/ToolCallObserver.js +0 -201
  68. package/dist/plugin/columns.d.ts +0 -11
  69. package/dist/plugin/columns.js +0 -54
  70. package/dist/plugin/confidence.d.ts +0 -8
  71. package/dist/plugin/confidence.js +0 -34
  72. package/dist/plugin/contract.d.ts +0 -54
  73. package/dist/plugin/contract.js +0 -383
  74. package/dist/plugin/diff.d.ts +0 -8
  75. package/dist/plugin/diff.js +0 -182
  76. package/dist/plugin/escape.d.ts +0 -39
  77. package/dist/plugin/escape.js +0 -60
  78. package/dist/plugin/fingerprint.d.ts +0 -27
  79. package/dist/plugin/fingerprint.js +0 -73
  80. package/dist/plugin/inferability.d.ts +0 -32
  81. package/dist/plugin/inferability.js +0 -88
  82. package/dist/plugin/kevin_approve.d.ts +0 -34
  83. package/dist/plugin/kevin_approve.js +0 -52
  84. package/dist/plugin/kevin_audit.d.ts +0 -213
  85. package/dist/plugin/kevin_audit.js +0 -563
  86. package/dist/plugin/kevin_bench.d.ts +0 -10
  87. package/dist/plugin/kevin_bench.js +0 -82
  88. package/dist/plugin/kevin_conflicts.d.ts +0 -9
  89. package/dist/plugin/kevin_conflicts.js +0 -50
  90. package/dist/plugin/kevin_contract.d.ts +0 -8
  91. package/dist/plugin/kevin_contract.js +0 -59
  92. package/dist/plugin/kevin_doctor.d.ts +0 -62
  93. package/dist/plugin/kevin_doctor.js +0 -196
  94. package/dist/plugin/kevin_facts.d.ts +0 -42
  95. package/dist/plugin/kevin_facts.js +0 -36
  96. package/dist/plugin/kevin_forget.d.ts +0 -33
  97. package/dist/plugin/kevin_forget.js +0 -260
  98. package/dist/plugin/kevin_native.d.ts +0 -29
  99. package/dist/plugin/kevin_native.js +0 -79
  100. package/dist/plugin/kevin_propose.d.ts +0 -23
  101. package/dist/plugin/kevin_propose.js +0 -14
  102. package/dist/plugin/kevin_publish.d.ts +0 -38
  103. package/dist/plugin/kevin_publish.js +0 -18
  104. package/dist/plugin/kevin_why.d.ts +0 -23
  105. package/dist/plugin/kevin_why.js +0 -111
  106. package/dist/plugin/memory-format.d.ts +0 -20
  107. package/dist/plugin/memory-format.js +0 -59
  108. package/dist/plugin/metrics.d.ts +0 -97
  109. package/dist/plugin/metrics.js +0 -284
  110. package/dist/plugin/okf-export.d.ts +0 -3
  111. package/dist/plugin/okf-export.js +0 -134
  112. package/dist/plugin/okf-import.d.ts +0 -76
  113. package/dist/plugin/okf-import.js +0 -271
  114. package/dist/plugin/okf.d.ts +0 -107
  115. package/dist/plugin/okf.js +0 -303
  116. package/dist/plugin/perf.d.ts +0 -35
  117. package/dist/plugin/perf.js +0 -174
  118. package/dist/plugin/query-tokenizer.d.ts +0 -13
  119. package/dist/plugin/query-tokenizer.js +0 -133
  120. package/dist/plugin/redact.d.ts +0 -2
  121. package/dist/plugin/redact.js +0 -18
  122. package/dist/plugin/replay-types.d.ts +0 -69
  123. package/dist/plugin/replay-types.js +0 -156
  124. package/dist/plugin/replay.d.ts +0 -36
  125. package/dist/plugin/replay.js +0 -202
  126. package/dist/plugin/sqlite-adapter.d.ts +0 -12
  127. package/dist/plugin/sqlite-adapter.js +0 -81
  128. package/dist/plugin/time-ms.d.ts +0 -1
  129. package/dist/plugin/time-ms.js +0 -16
  130. package/dist/plugin/uuid.d.ts +0 -1
  131. package/dist/plugin/uuid.js +0 -50
@@ -1,67 +0,0 @@
1
- import type { Store } from "./Store.js";
2
- import type { Metrics } from "./metrics.js";
3
- /**
4
- * v0.5.0 Feedback (K5-009 / plan §5.3, D5-02).
5
- *
6
- * The human-judgement half of the glassbox: the agent can report whether a
7
- * memory was useful, wrong, outdated, or should be ignored. Verdicts are
8
- * stored in `memory_feedback` (migration 006_v05_glassbox.sql) and folded
9
- * into the memory's `feedback_positive` / `feedback_negative` counters —
10
- * kept SEPARATE from `evidence_count` / `recurrence_count` by design: human
11
- * judgement is evidence about the memory, causal counters are evidence
12
- * about the world (the confidence-poisoning defect closed in v0.4.0).
13
- *
14
- * D5-07 — the `ignore` verdict is a hard lifecycle action, not a soft
15
- * signal: the memory is stamped `ignored = 1`, which excludes it from
16
- * retrieval (K5-008) and from the quality gate (K5-007).
17
- *
18
- * Schema:
19
- * memory_feedback(id PK, memory_id, verdict, session_id, note, created_at)
20
- * memories(feedback_positive, feedback_negative, ignored)
21
- */
22
- export type FeedbackVerdict = "useful" | "wrong" | "outdated" | "ignore";
23
- export interface FeedbackRecordInput {
24
- memoryId: string;
25
- verdict: FeedbackVerdict;
26
- sessionId?: string | null;
27
- note?: string | null;
28
- }
29
- export interface FeedbackRow {
30
- id: string;
31
- memoryId: string;
32
- verdict: FeedbackVerdict;
33
- sessionId: string | null;
34
- note: string | null;
35
- createdAt: string;
36
- }
37
- export interface FeedbackCounts {
38
- positive: number;
39
- negative: number;
40
- }
41
- export declare class Feedback {
42
- private readonly store;
43
- private readonly metrics;
44
- private readonly now;
45
- constructor(store: Store, metrics?: Metrics | null, now?: () => Date);
46
- /**
47
- * Records one human verdict and folds it into the memory's counters.
48
- * Counters are RECOMPUTED from the table (never incremented) so rows
49
- * deleted in tests or by hand cannot drift them.
50
- *
51
- * The `ignore` verdict also stamps `memories.ignored = 1` (D5-07).
52
- * Returns the feedback row id.
53
- */
54
- record(input: FeedbackRecordInput): string;
55
- /** Human-judgement counters for one memory (from the row, not the table). */
56
- countsFor(memoryId: string): FeedbackCounts;
57
- /**
58
- * Raw verdict history, newest first. Used by `kevin_feedback` (K5-011)
59
- * and by the audit tool (K5-016).
60
- */
61
- list(memoryId?: string, limit?: number): FeedbackRow[];
62
- /**
63
- * Recompute a memory's positive/negative counters straight from the
64
- * verdict table. Kept private — `record` is the only mutation path.
65
- */
66
- private recomputeCounters;
67
- }
@@ -1,120 +0,0 @@
1
- import { hasFeedbackTable } from "./columns.js";
2
- import { uuidv7 } from "./uuid.js";
3
- const POSITIVE_VERDICTS = ["useful"];
4
- const NEGATIVE_VERDICTS = [
5
- "wrong",
6
- "outdated",
7
- "ignore",
8
- ];
9
- // v1.1.0 (K11-011) — table probe delegates to columns registry
10
- export class Feedback {
11
- store;
12
- metrics;
13
- now;
14
- constructor(store, metrics, now = () => new Date()) {
15
- this.store = store;
16
- this.metrics = metrics ?? null;
17
- this.now = now;
18
- }
19
- /**
20
- * Records one human verdict and folds it into the memory's counters.
21
- * Counters are RECOMPUTED from the table (never incremented) so rows
22
- * deleted in tests or by hand cannot drift them.
23
- *
24
- * The `ignore` verdict also stamps `memories.ignored = 1` (D5-07).
25
- * Returns the feedback row id.
26
- */
27
- record(input) {
28
- if (!hasFeedbackTable(this.store)) {
29
- throw new Error("kevin_feedback requires migration 006_v05_glassbox.sql (missing table memory_feedback)");
30
- }
31
- const id = uuidv7();
32
- this.store
33
- .prepare(`INSERT INTO memory_feedback
34
- (id, memory_id, verdict, session_id, note)
35
- VALUES (?, ?, ?, ?, ?)`)
36
- .run(id, input.memoryId, input.verdict, input.sessionId ?? null, input.note ?? null);
37
- this.recomputeCounters(input.memoryId);
38
- if (input.verdict === "ignore") {
39
- this.store
40
- .prepare("UPDATE memories SET ignored = 1 WHERE id = ?")
41
- .run(input.memoryId);
42
- }
43
- // v0.5.0 (K5-023 / plan §5.3, D5-06) — lifecycle action of a
44
- // negative verdict. `wrong` is an opinion and deserves a second
45
- // opinion: it demotes only at feedback_negative >= 2. `outdated`
46
- // is a self-verifying claim about the world and acts at once.
47
- // `useful` confirms the memory and refreshes its verification.
48
- if (input.verdict === "outdated") {
49
- this.store
50
- .prepare("UPDATE memories SET status = 'stale' WHERE id = ?")
51
- .run(input.memoryId);
52
- }
53
- else if (input.verdict === "wrong") {
54
- const counts = this.countsFor(input.memoryId);
55
- if (counts.negative >= 2) {
56
- this.store
57
- .prepare("UPDATE memories SET status = 'stale' WHERE id = ?")
58
- .run(input.memoryId);
59
- }
60
- }
61
- else if (input.verdict === "useful") {
62
- this.store
63
- .prepare("UPDATE memories SET last_verified_at = datetime('now') WHERE id = ?")
64
- .run(input.memoryId);
65
- }
66
- if (POSITIVE_VERDICTS.includes(input.verdict)) {
67
- this.metrics?.incr("feedback_positive_total", 1);
68
- }
69
- else if (NEGATIVE_VERDICTS.includes(input.verdict)) {
70
- this.metrics?.incr("feedback_negative_total", 1);
71
- }
72
- return id;
73
- }
74
- /** Human-judgement counters for one memory (from the row, not the table). */
75
- countsFor(memoryId) {
76
- const row = this.store
77
- .prepare("SELECT feedback_positive, feedback_negative FROM memories WHERE id = ?")
78
- .get(memoryId);
79
- if (!row)
80
- return { positive: 0, negative: 0 };
81
- return { positive: row.feedback_positive, negative: row.feedback_negative };
82
- }
83
- /**
84
- * Raw verdict history, newest first. Used by `kevin_feedback` (K5-011)
85
- * and by the audit tool (K5-016).
86
- */
87
- list(memoryId, limit = 50) {
88
- const rows = this.store
89
- .prepare(`SELECT id, memory_id, verdict, session_id, note, created_at
90
- FROM memory_feedback
91
- ${memoryId ? "WHERE memory_id = ?" : ""}
92
- ORDER BY created_at DESC, rowid DESC
93
- LIMIT ?`)
94
- .all(...(memoryId ? [memoryId, limit] : [limit]));
95
- return rows.map((r) => ({
96
- id: r.id,
97
- memoryId: r.memory_id,
98
- verdict: r.verdict,
99
- sessionId: r.session_id,
100
- note: r.note,
101
- createdAt: r.created_at,
102
- }));
103
- }
104
- /**
105
- * Recompute a memory's positive/negative counters straight from the
106
- * verdict table. Kept private — `record` is the only mutation path.
107
- */
108
- recomputeCounters(memoryId) {
109
- this.store
110
- .prepare(`UPDATE memories SET
111
- feedback_positive = (
112
- SELECT COUNT(*) FROM memory_feedback
113
- WHERE memory_id = ? AND verdict = 'useful'),
114
- feedback_negative = (
115
- SELECT COUNT(*) FROM memory_feedback
116
- WHERE memory_id = ? AND verdict IN ('wrong','outdated','ignore'))
117
- WHERE id = ?`)
118
- .run(memoryId, memoryId, memoryId);
119
- }
120
- }
@@ -1,88 +0,0 @@
1
- import { HOOK_NAMES } from "./Migrate.js";
2
- import type { Store } from "./Store.js";
3
- export type HookName = (typeof HOOK_NAMES)[number];
4
- export type LivenessState = "live" | "dead" | "unknown";
5
- export interface HookReport {
6
- hook: HookName;
7
- experimental: boolean;
8
- state: LivenessState;
9
- firstSeenAt: string | null;
10
- lastSeenAt: string | null;
11
- fireCount: number;
12
- expectedCount: number;
13
- /** v0.9.0 (K9-012) — set once when the hook went dead; never cleared. */
14
- deadSince: string | null;
15
- }
16
- /**
17
- * v0.9.0 (K9-010 / plan §5.3, D9-09) — parse the TEXT setting
18
- * `dead_hook_report_threshold` into the [1, 1000] clamp. NaN and empty
19
- * fall back to 3 (never 0: a zero threshold would report every hook dead
20
- * before a single session completes a checkpoint).
21
- */
22
- export declare function parseThreshold(text: string | null | undefined): number;
23
- interface HookLivenessOptions {
24
- /** `hook_liveness_enabled === "1"` (TEXT comparison, decided at construction). */
25
- enabled: boolean;
26
- /** Raw TEXT of `dead_hook_report_threshold`; parsed+clamped in report(). */
27
- thresholdText: string | null | undefined;
28
- /** `host.pluginVersion` at construction, stored on every flushed row. */
29
- pluginVersion: string | null;
30
- }
31
- /**
32
- * v0.9.0 (K9-009/K9-010 / plan §5.3) — wraps the plugin's hooks with a
33
- * success-path recorder, dedups per-session checkpoints, and reports each
34
- * hook's liveness state. Persistence is machine-scoped: the hook_liveness
35
- * table carries no project_id or repo_id (D9-08).
36
- */
37
- export declare class HookLiveness {
38
- private readonly store;
39
- private readonly options;
40
- excessArityCount: number;
41
- private readonly counters;
42
- private readonly seenSessions;
43
- private readonly suppressedSessions;
44
- private readonly threshold;
45
- private readonly pluginVersion;
46
- constructor(store: Store, options: HookLivenessOptions);
47
- /**
48
- * Returns a new object with the same keys; function values are replaced
49
- * by delegating wrappers, non-function values (the tool map) pass by
50
- * reference. When liveness is disabled, returns the argument unchanged —
51
- * the same object, not a copy.
52
- */
53
- wrap<T>(hooks: T): T;
54
- /**
55
- * v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
56
- * model turn (tool.execute.after fired), so the system prompt was
57
- * assembled and `experimental.chat.system.transform` MUST have been
58
- * offered. Deduped per session: 20 tool calls in one session count
59
- * exactly one expectation.
60
- */
61
- expect(hook: HookName, sessionID: string): void;
62
- /**
63
- * Per-hook liveness verdicts in canonical HOOK_NAMES order.
64
- * A hook that fired once is live forever; dead requires both zero fires
65
- * and `expectedCount >= threshold`; everything else is unknown — and
66
- * unknown is never rounded to healthy (D9-09).
67
- */
68
- report(): HookReport[];
69
- /** Persists every counter row. Called on the metrics.flush() cadence. */
70
- flush(): void;
71
- private makeWrapper;
72
- /**
73
- * v1.0.0 (K10-013 / plan §5.3) — records the `dispose` fire and
74
- * flushes immediately, from inside the dispose delegate: this is the
75
- * last write of the process, and nothing after it can persist.
76
- */
77
- recordDispose(): void;
78
- private recordSuccess;
79
- private recordError;
80
- private loadFromDb;
81
- }
82
- export type HostVerdict = "healthy" | "degraded" | "unknown";
83
- export interface HostVerdictResult {
84
- verdict: HostVerdict;
85
- reason: string;
86
- }
87
- export declare function reduceVerdict(reports: readonly HookReport[]): HostVerdictResult;
88
- export {};
@@ -1,322 +0,0 @@
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
- // v1.1.0 (K11-015) — debug counter for excess arity; never logged on hot path
29
- excessArityCount = 0;
30
- counters;
31
- seenSessions;
32
- suppressedSessions = new Set();
33
- threshold;
34
- pluginVersion;
35
- constructor(store, options) {
36
- this.store = store;
37
- this.options = options;
38
- this.threshold = parseThreshold(options.thresholdText);
39
- this.pluginVersion = options.pluginVersion;
40
- this.counters = new Map();
41
- this.seenSessions = new Map();
42
- for (const hook of HOOK_NAMES) {
43
- this.counters.set(hook, {
44
- fireCount: 0,
45
- errorCount: 0,
46
- expectedCount: 0,
47
- firstSeenAt: null,
48
- lastSeenAt: null,
49
- deadSince: null,
50
- });
51
- this.seenSessions.set(hook, new Set());
52
- }
53
- this.loadFromDb();
54
- }
55
- /**
56
- * Returns a new object with the same keys; function values are replaced
57
- * by delegating wrappers, non-function values (the tool map) pass by
58
- * reference. When liveness is disabled, returns the argument unchanged —
59
- * the same object, not a copy.
60
- */
61
- wrap(hooks) {
62
- if (!this.options.enabled)
63
- return hooks;
64
- const out = {};
65
- for (const [key, value] of Object.entries(hooks)) {
66
- if (typeof value === "function") {
67
- out[key] = this.makeWrapper(key, value);
68
- }
69
- else {
70
- out[key] = value;
71
- }
72
- }
73
- return out;
74
- }
75
- /**
76
- * v0.9.0 (K9-010 / plan §5.3) — a checkpoint: the session reached a
77
- * model turn (tool.execute.after fired), so the system prompt was
78
- * assembled and `experimental.chat.system.transform` MUST have been
79
- * offered. Deduped per session: 20 tool calls in one session count
80
- * exactly one expectation.
81
- */
82
- expect(hook, sessionID) {
83
- const seen = this.seenSessions.get(hook);
84
- if (!seen || seen.has(sessionID))
85
- return;
86
- seen.add(sessionID);
87
- const counters = this.counters.get(hook);
88
- if (!counters)
89
- return;
90
- counters.expectedCount += 1;
91
- // The dead flag is materialized here (not only in report()) so that
92
- // flush() persists dead_since even when no report() was ever called:
93
- // kevin_doctor's PURE-SQL blocks read dead_since from the table.
94
- if (counters.fireCount === 0 &&
95
- counters.expectedCount >= this.threshold &&
96
- counters.deadSince === null) {
97
- counters.deadSince = counters.lastSeenAt ?? new Date().toISOString();
98
- }
99
- // v0.9.0 (K9-011 / plan §5.3) — when the injection hook is dead, the
100
- // checkpointed session was suppressed: the host stopped offering the
101
- // transform, so no injection could happen. Counted once per session
102
- // (same dedup as expected_count) and persisted by flush(). This is
103
- // the counter that turns "zero injections" from an ambiguous number
104
- // into a diagnosis.
105
- if (hook === "experimental.chat.system.transform" &&
106
- counters.deadSince !== null &&
107
- !this.suppressedSessions.has(sessionID)) {
108
- this.suppressedSessions.add(sessionID);
109
- }
110
- }
111
- /**
112
- * Per-hook liveness verdicts in canonical HOOK_NAMES order.
113
- * A hook that fired once is live forever; dead requires both zero fires
114
- * and `expectedCount >= threshold`; everything else is unknown — and
115
- * unknown is never rounded to healthy (D9-09).
116
- */
117
- report() {
118
- const out = [];
119
- for (const hook of HOOK_NAMES) {
120
- const c = this.counters.get(hook);
121
- if (!c)
122
- continue;
123
- let state;
124
- if (c.fireCount > 0) {
125
- state = "live";
126
- }
127
- else if (c.expectedCount >= this.threshold) {
128
- // dead_since is set ONCE and never cleared: it documents the
129
- // historical death even if the hook later recovers.
130
- if (c.deadSince === null) {
131
- c.deadSince = c.lastSeenAt ?? new Date().toISOString();
132
- }
133
- state = "dead";
134
- }
135
- else {
136
- state = "unknown";
137
- }
138
- out.push({
139
- hook,
140
- experimental: hook.startsWith("experimental."),
141
- state,
142
- firstSeenAt: c.firstSeenAt,
143
- lastSeenAt: c.lastSeenAt,
144
- fireCount: c.fireCount,
145
- expectedCount: c.expectedCount,
146
- deadSince: c.deadSince,
147
- });
148
- }
149
- return out;
150
- }
151
- /** Persists every counter row. Called on the metrics.flush() cadence. */
152
- flush() {
153
- const store = this.store;
154
- store.transaction(() => {
155
- // Created lazily on first flush, mirroring Metrics.flush(): the
156
- // migration guarantees the table in production, but unit tests
157
- // against pre-010 DBs must not explode at dispose.
158
- store.exec(`CREATE TABLE IF NOT EXISTS hook_liveness (
159
- hook TEXT PRIMARY KEY,
160
- experimental INTEGER NOT NULL DEFAULT 0,
161
- fire_count INTEGER NOT NULL DEFAULT 0,
162
- error_count INTEGER NOT NULL DEFAULT 0,
163
- expected_count INTEGER NOT NULL DEFAULT 0,
164
- first_seen_at TEXT,
165
- last_seen_at TEXT,
166
- dead_since TEXT,
167
- plugin_version TEXT
168
- )`);
169
- const upsert = store.prepare(`INSERT INTO hook_liveness (hook, experimental, fire_count, error_count,
170
- expected_count, first_seen_at, last_seen_at, dead_since, plugin_version)
171
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
172
- ON CONFLICT(hook) DO UPDATE SET
173
- experimental = excluded.experimental,
174
- fire_count = excluded.fire_count,
175
- error_count = excluded.error_count,
176
- expected_count = excluded.expected_count,
177
- first_seen_at = excluded.first_seen_at,
178
- last_seen_at = excluded.last_seen_at,
179
- dead_since = excluded.dead_since,
180
- plugin_version = excluded.plugin_version`);
181
- for (const [hook, c] of this.counters) {
182
- upsert.run(hook, hook.startsWith("experimental.") ? 1 : 0, c.fireCount, c.errorCount, c.expectedCount, c.firstSeenAt, c.lastSeenAt, c.deadSince, this.pluginVersion);
183
- }
184
- // v0.9.0 (K9-011 / plan §5.3) — the four v0.9.0 counters are
185
- // re-derived here, on the metrics cadence, exactly like the 009
186
- // post-apply hook re-derives hooks_dead_total: SUM(fire_count),
187
- // SUM(error_count), COUNT(dead_since) and the per-session
188
- // suppression count. No Metrics dependency, no writes in the hot
189
- // path — flush() is the single persistence point.
190
- let fireSum = 0;
191
- let errorSum = 0;
192
- let deadCount = 0;
193
- for (const c of this.counters.values()) {
194
- fireSum += c.fireCount;
195
- errorSum += c.errorCount;
196
- if (c.deadSince !== null)
197
- deadCount += 1;
198
- }
199
- store.exec(`CREATE TABLE IF NOT EXISTS kevin_metrics (
200
- key TEXT PRIMARY KEY,
201
- value INTEGER NOT NULL DEFAULT 0,
202
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
203
- )`);
204
- const derive = store.prepare(`INSERT INTO kevin_metrics (key, value, updated_at)
205
- VALUES (?, ?, datetime('now'))
206
- ON CONFLICT(key) DO UPDATE SET
207
- value = excluded.value,
208
- updated_at = datetime('now')`);
209
- derive.run("hook_fires_total", fireSum);
210
- derive.run("hook_errors_total", errorSum);
211
- derive.run("hooks_dead_total", deadCount);
212
- derive.run("injections_suppressed_dead_hook", this.suppressedSessions.size);
213
- });
214
- }
215
- makeWrapper(key, delegate) {
216
- const record = () => {
217
- // v1.0.0 (K10-013) — dispose is recorded inside its own delegate
218
- // (via recordDispose()) rather than here: the wrapper's post-return
219
- // record would land after the store is closed and could never be
220
- // persisted — the event being detected is the process ending.
221
- if (key === "dispose")
222
- return;
223
- if (HOOK_NAMES.includes(key)) {
224
- this.recordSuccess(key);
225
- }
226
- };
227
- const recordError = (e) => {
228
- if (HOOK_NAMES.includes(key)) {
229
- this.recordError(key);
230
- }
231
- throw e;
232
- };
233
- // v1.1.0 (K11-015) — arity guard: maximum supported arity is 2 (plan §5.5).
234
- // Excess args are sliced and counted via excessArityCount; never logged.
235
- return async (...args) => {
236
- let callArgs = args;
237
- if (args.length > 2) {
238
- this.excessArityCount++;
239
- callArgs = args.slice(0, 2);
240
- }
241
- try {
242
- const result = await delegate(...callArgs);
243
- record();
244
- return result;
245
- }
246
- catch (e) {
247
- return recordError(e);
248
- }
249
- };
250
- }
251
- /**
252
- * v1.0.0 (K10-013 / plan §5.3) — records the `dispose` fire and
253
- * flushes immediately, from inside the dispose delegate: this is the
254
- * last write of the process, and nothing after it can persist.
255
- */
256
- recordDispose() {
257
- this.recordSuccess("dispose");
258
- this.flush();
259
- }
260
- recordSuccess(hook) {
261
- const c = this.counters.get(hook);
262
- if (!c)
263
- return;
264
- c.fireCount += 1;
265
- const now = new Date().toISOString();
266
- if (c.firstSeenAt === null)
267
- c.firstSeenAt = now;
268
- c.lastSeenAt = now;
269
- }
270
- recordError(hook) {
271
- const c = this.counters.get(hook);
272
- if (!c)
273
- return;
274
- c.errorCount += 1;
275
- }
276
- loadFromDb() {
277
- let rows = [];
278
- try {
279
- rows = this.store
280
- .prepare("SELECT hook, fire_count, error_count, expected_count, first_seen_at, last_seen_at, dead_since FROM hook_liveness")
281
- .all();
282
- }
283
- catch {
284
- // Graceful: pre-010 DBs have no hook_liveness table; the seeded
285
- // zeros remain until the first flush creates rows.
286
- rows = [];
287
- }
288
- for (const row of rows) {
289
- if (!HOOK_NAMES.includes(row.hook))
290
- continue;
291
- const c = this.counters.get(row.hook);
292
- if (!c)
293
- continue;
294
- c.fireCount = row.fire_count;
295
- c.errorCount = row.error_count;
296
- c.expectedCount = row.expected_count;
297
- c.firstSeenAt = row.first_seen_at;
298
- c.lastSeenAt = row.last_seen_at;
299
- c.deadSince = row.dead_since;
300
- }
301
- }
302
- }
303
- export function reduceVerdict(reports) {
304
- if (reports.length === 0) {
305
- return { verdict: "unknown", reason: "no hook reports yet" };
306
- }
307
- const dead = reports.filter((r) => r.state === "dead");
308
- if (dead.length > 0) {
309
- return {
310
- verdict: "degraded",
311
- reason: `${dead.map((r) => r.hook).join(", ")} dead since ${dead[0].deadSince ?? "unknown"}; ${dead.length} affected hook(s)`,
312
- };
313
- }
314
- if (reports.every((r) => r.state === "live")) {
315
- return { verdict: "healthy", reason: "all hooks live" };
316
- }
317
- const pending = reports.filter((r) => r.state === "unknown").length;
318
- return {
319
- verdict: "unknown",
320
- reason: `${pending} hook(s) without checkpoint`,
321
- };
322
- }