token-pilot 0.19.2 → 0.22.2

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 (89) hide show
  1. package/.claude-plugin/hooks/hooks.json +21 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +172 -315
  5. package/dist/agents/tp-commit-writer.md +41 -0
  6. package/dist/agents/tp-dead-code-finder.md +43 -0
  7. package/dist/agents/tp-debugger.md +45 -0
  8. package/dist/agents/tp-impact-analyzer.md +44 -0
  9. package/dist/agents/tp-migration-scout.md +43 -0
  10. package/dist/agents/tp-onboard.md +40 -0
  11. package/dist/agents/tp-pr-reviewer.md +41 -0
  12. package/dist/agents/tp-refactor-planner.md +42 -0
  13. package/dist/agents/tp-run.md +48 -0
  14. package/dist/agents/tp-test-triage.md +40 -0
  15. package/dist/agents/tp-test-writer.md +46 -0
  16. package/dist/cli/agent-frontmatter.d.ts +48 -0
  17. package/dist/cli/agent-frontmatter.js +189 -0
  18. package/dist/cli/bless-agents.d.ts +65 -0
  19. package/dist/cli/bless-agents.js +307 -0
  20. package/dist/cli/claudeignore.d.ts +33 -0
  21. package/dist/cli/claudeignore.js +88 -0
  22. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  23. package/dist/cli/claudemd-hygiene.js +43 -0
  24. package/dist/cli/doctor-drift.d.ts +31 -0
  25. package/dist/cli/doctor-drift.js +130 -0
  26. package/dist/cli/doctor-env-check.d.ts +25 -0
  27. package/dist/cli/doctor-env-check.js +91 -0
  28. package/dist/cli/install-agents.d.ts +108 -0
  29. package/dist/cli/install-agents.js +402 -0
  30. package/dist/cli/save-doc.d.ts +42 -0
  31. package/dist/cli/save-doc.js +145 -0
  32. package/dist/cli/scan-agents.d.ts +46 -0
  33. package/dist/cli/scan-agents.js +227 -0
  34. package/dist/cli/stats.d.ts +36 -0
  35. package/dist/cli/stats.js +131 -0
  36. package/dist/cli/unbless-agents.d.ts +33 -0
  37. package/dist/cli/unbless-agents.js +85 -0
  38. package/dist/cli/uninstall-agents.d.ts +36 -0
  39. package/dist/cli/uninstall-agents.js +117 -0
  40. package/dist/config/defaults.d.ts +1 -1
  41. package/dist/config/defaults.js +14 -8
  42. package/dist/config/loader.d.ts +1 -1
  43. package/dist/config/loader.js +105 -11
  44. package/dist/core/context-registry.d.ts +16 -1
  45. package/dist/core/context-registry.js +60 -28
  46. package/dist/core/event-log.d.ts +79 -0
  47. package/dist/core/event-log.js +190 -0
  48. package/dist/core/session-registry.d.ts +43 -0
  49. package/dist/core/session-registry.js +113 -0
  50. package/dist/core/session-savings.d.ts +19 -0
  51. package/dist/core/session-savings.js +60 -0
  52. package/dist/handlers/session-budget.d.ts +32 -0
  53. package/dist/handlers/session-budget.js +61 -0
  54. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  55. package/dist/handlers/session-snapshot-persist.js +76 -0
  56. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  57. package/dist/hooks/adaptive-threshold.js +46 -0
  58. package/dist/hooks/format-deny-message.d.ts +21 -0
  59. package/dist/hooks/format-deny-message.js +147 -0
  60. package/dist/hooks/installer.js +121 -31
  61. package/dist/hooks/path-safety.d.ts +16 -0
  62. package/dist/hooks/path-safety.js +34 -0
  63. package/dist/hooks/post-bash.d.ts +46 -0
  64. package/dist/hooks/post-bash.js +77 -0
  65. package/dist/hooks/session-start.d.ts +45 -0
  66. package/dist/hooks/session-start.js +179 -0
  67. package/dist/hooks/summary-ast-index.d.ts +28 -0
  68. package/dist/hooks/summary-ast-index.js +122 -0
  69. package/dist/hooks/summary-head-tail.d.ts +15 -0
  70. package/dist/hooks/summary-head-tail.js +78 -0
  71. package/dist/hooks/summary-pipeline.d.ts +35 -0
  72. package/dist/hooks/summary-pipeline.js +63 -0
  73. package/dist/hooks/summary-regex.d.ts +14 -0
  74. package/dist/hooks/summary-regex.js +130 -0
  75. package/dist/hooks/summary-types.d.ts +29 -0
  76. package/dist/hooks/summary-types.js +9 -0
  77. package/dist/index.d.ts +15 -3
  78. package/dist/index.js +509 -149
  79. package/dist/integration/context-mode-detector.d.ts +7 -1
  80. package/dist/integration/context-mode-detector.js +51 -15
  81. package/dist/server/tool-definitions.d.ts +149 -0
  82. package/dist/server/tool-definitions.js +424 -202
  83. package/dist/server.d.ts +1 -1
  84. package/dist/server.js +456 -179
  85. package/dist/templates/agent-builder.d.ts +49 -0
  86. package/dist/templates/agent-builder.js +104 -0
  87. package/dist/types.d.ts +38 -4
  88. package/package.json +4 -2
  89. package/skills/stats/SKILL.md +13 -2
@@ -26,6 +26,9 @@ export const DEFAULT_CONFIG = {
26
26
  interceptRead: true,
27
27
  autoInstall: true,
28
28
  denyThreshold: 300,
29
+ mode: "deny-enhanced",
30
+ adaptiveThreshold: false,
31
+ adaptiveBudgetTokens: 100_000,
29
32
  },
30
33
  context: {
31
34
  estimateTokens: true,
@@ -40,7 +43,7 @@ export const DEFAULT_CONFIG = {
40
43
  actionableHints: true,
41
44
  },
42
45
  contextMode: {
43
- enabled: 'auto',
46
+ enabled: "auto",
44
47
  adviseDelegation: true,
45
48
  largeNonCodeThreshold: 200,
46
49
  },
@@ -62,12 +65,15 @@ export const DEFAULT_CONFIG = {
62
65
  compactionCallThreshold: 15,
63
66
  compactionTokenThreshold: 8000,
64
67
  },
65
- ignore: [
66
- 'node_modules/**',
67
- 'dist/**',
68
- '.git/**',
69
- '*.min.js',
70
- '*.map',
71
- ],
68
+ sessionStart: {
69
+ enabled: true,
70
+ showStats: false,
71
+ maxReminderTokens: 250,
72
+ },
73
+ agents: {
74
+ scope: null,
75
+ reminder: true,
76
+ },
77
+ ignore: ["node_modules/**", "dist/**", ".git/**", "*.min.js", "*.map"],
72
78
  };
73
79
  //# sourceMappingURL=defaults.js.map
@@ -1,3 +1,3 @@
1
- import type { TokenPilotConfig } from '../types.js';
1
+ import type { TokenPilotConfig } from "../types.js";
2
2
  export declare function loadConfig(projectRoot: string): Promise<TokenPilotConfig>;
3
3
  //# sourceMappingURL=loader.d.ts.map
@@ -1,30 +1,124 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
- import { DEFAULT_CONFIG } from './defaults.js';
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { DEFAULT_CONFIG } from "./defaults.js";
4
+ const VALID_HOOK_MODES = new Set([
5
+ "off",
6
+ "advisory",
7
+ "deny-enhanced",
8
+ ]);
4
9
  export async function loadConfig(projectRoot) {
5
- const configPath = resolve(projectRoot, '.token-pilot.json');
10
+ const configPath = resolve(projectRoot, ".token-pilot.json");
11
+ let userConfig = null;
6
12
  try {
7
- const raw = await readFile(configPath, 'utf-8');
8
- const userConfig = JSON.parse(raw);
9
- return deepMerge(structuredClone(DEFAULT_CONFIG), userConfig);
13
+ const raw = await readFile(configPath, "utf-8");
14
+ userConfig = JSON.parse(raw);
10
15
  }
11
16
  catch (err) {
12
- if (err?.code !== 'ENOENT') {
17
+ if (err?.code !== "ENOENT") {
13
18
  console.error(`[token-pilot] Invalid config at ${configPath}: ${err?.message ?? err}. Using defaults.`);
14
19
  }
15
20
  return structuredClone(DEFAULT_CONFIG);
16
21
  }
22
+ // Phase 6 subtask 6.4 — rewrite legacy `mode:"deny"` to `"advisory"`
23
+ // before merge so downstream code (incl. applyHookModeMigration's
24
+ // unknown-mode warning) sees the migrated value.
25
+ await applyLegacyDenyMigration(configPath, userConfig ?? {});
26
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), userConfig ?? {});
27
+ applyHookModeMigration(merged, userConfig ?? {});
28
+ applyEnvOverrides(merged);
29
+ return merged;
30
+ }
31
+ /**
32
+ * Env-var overrides that the user can set without editing the config
33
+ * file. Per TP-816 §7.3. Only integer-valued, positive numbers are
34
+ * accepted; malformed values are ignored silently.
35
+ */
36
+ function applyEnvOverrides(merged) {
37
+ const raw = process.env.TOKEN_PILOT_DENY_THRESHOLD;
38
+ if (raw !== undefined) {
39
+ const n = Number.parseInt(raw, 10);
40
+ if (Number.isFinite(n) && n > 0) {
41
+ merged.hooks.denyThreshold = n;
42
+ }
43
+ }
44
+ const adaptive = process.env.TOKEN_PILOT_ADAPTIVE_THRESHOLD;
45
+ if (adaptive !== undefined) {
46
+ merged.hooks.adaptiveThreshold = /^(1|true|yes|on)$/i.test(adaptive.trim());
47
+ }
48
+ const budget = process.env.TOKEN_PILOT_ADAPTIVE_BUDGET;
49
+ if (budget !== undefined) {
50
+ const n = Number.parseInt(budget, 10);
51
+ if (Number.isFinite(n) && n > 0) {
52
+ merged.hooks.adaptiveBudgetTokens = n;
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * When a user's config still has `hooks.mode: "deny"` (the removed
58
+ * v0.19 legacy value), rewrite it on disk to `"advisory"` and stamp
59
+ * `hooks.migratedFrom: "deny"` so the stderr notice fires exactly once.
60
+ *
61
+ * Mutates `userConfig` in place so the caller's subsequent merge picks
62
+ * up the new mode. Failures are swallowed — a broken rewrite must not
63
+ * prevent the session from starting.
64
+ */
65
+ async function applyLegacyDenyMigration(configPath, userConfig) {
66
+ const hooks = (userConfig.hooks ?? {});
67
+ if (hooks.mode !== "deny")
68
+ return;
69
+ if (hooks.migratedFrom === "deny") {
70
+ // Already migrated at some point; user reverted mode manually.
71
+ // Leave their choice alone — downstream unknown-mode path will
72
+ // handle it (falls back to default with a warning).
73
+ return;
74
+ }
75
+ hooks.mode = "advisory";
76
+ hooks.migratedFrom = "deny";
77
+ userConfig.hooks = hooks;
78
+ console.error(`[token-pilot] Config migrated: hooks.mode "deny" is no longer valid in v0.20. ` +
79
+ `Rewriting to "advisory" (strict superset of old behaviour is "deny-enhanced"; ` +
80
+ `switch there manually when ready). Stamped hooks.migratedFrom:"deny" to silence this notice.`);
81
+ try {
82
+ await writeFile(configPath, JSON.stringify(userConfig, null, 2) + "\n");
83
+ }
84
+ catch {
85
+ /* ignore — migration is best-effort */
86
+ }
87
+ }
88
+ /**
89
+ * Reconcile the new hooks.mode field with the legacy hooks.enabled boolean.
90
+ * - Explicit user-provided mode wins (after validation).
91
+ * - If user omitted mode but set enabled:false → migrate to mode:"off" with a
92
+ * deprecation notice (preserves v0.19 behaviour for users who actively
93
+ * turned the hook off).
94
+ * - Unknown mode values fall back to the default with a warning.
95
+ */
96
+ function applyHookModeMigration(merged, userConfig) {
97
+ const userHooks = (userConfig.hooks ?? {});
98
+ const userProvidedMode = typeof userHooks.mode === "string";
99
+ const userSetEnabledFalse = userHooks.enabled === false;
100
+ if (userProvidedMode && !VALID_HOOK_MODES.has(merged.hooks.mode)) {
101
+ console.error(`[token-pilot] Unknown hooks.mode "${merged.hooks.mode}". ` +
102
+ `Valid values: off, advisory, deny-enhanced. Falling back to default "${DEFAULT_CONFIG.hooks.mode}".`);
103
+ merged.hooks.mode = DEFAULT_CONFIG.hooks.mode;
104
+ return;
105
+ }
106
+ if (!userProvidedMode && userSetEnabledFalse) {
107
+ console.error(`[token-pilot] hooks.enabled:false is deprecated — migrated to hooks.mode:"off". ` +
108
+ `Update your .token-pilot.json to use hooks.mode explicitly.`);
109
+ merged.hooks.mode = "off";
110
+ }
17
111
  }
18
112
  function deepMerge(target, source) {
19
113
  const result = { ...target };
20
114
  for (const key of Object.keys(source)) {
21
- if (key === '__proto__' || key === 'constructor' || key === 'prototype')
115
+ if (key === "__proto__" || key === "constructor" || key === "prototype")
22
116
  continue;
23
117
  if (source[key] &&
24
- typeof source[key] === 'object' &&
118
+ typeof source[key] === "object" &&
25
119
  !Array.isArray(source[key]) &&
26
120
  target[key] &&
27
- typeof target[key] === 'object') {
121
+ typeof target[key] === "object") {
28
122
  result[key] = deepMerge(target[key], source[key]);
29
123
  }
30
124
  else {
@@ -1,4 +1,4 @@
1
- import type { ContextEntry, LoadedRegion, SymbolInfo } from '../types.js';
1
+ import type { ContextEntry, LoadedRegion, SymbolInfo } from "../types.js";
2
2
  /**
3
3
  * Advisory Context Registry.
4
4
  * Tracks what was sent to the LLM but never blocks re-sends.
@@ -45,5 +45,20 @@ export declare class ContextRegistry {
45
45
  /** Get the timestamp when a file was last loaded into context. */
46
46
  getLoadedAt(path: string): number | undefined;
47
47
  invalidateByGitDiff(changedFiles: string[]): void;
48
+ /**
49
+ * Serialize registry state to a plain object (TP-69m persistence).
50
+ * Sets inside `entries[*].loaded[*]` lack `Set` fields; only `contentHash`
51
+ * etc. are simple scalars, so JSON round-trip works.
52
+ */
53
+ toSnapshot(): {
54
+ sessionStart: number;
55
+ entries: ContextEntry[];
56
+ };
57
+ /**
58
+ * Rehydrate registry state previously produced by `toSnapshot()`. Silent
59
+ * on malformed input — a broken snapshot file should degrade to an empty
60
+ * registry, not crash the MCP server.
61
+ */
62
+ loadSnapshot(snap: unknown): void;
48
63
  }
49
64
  //# sourceMappingURL=context-registry.d.ts.map
@@ -1,4 +1,4 @@
1
- import { formatDuration } from './format-duration.js';
1
+ import { formatDuration } from "./format-duration.js";
2
2
  /**
3
3
  * Advisory Context Registry.
4
4
  * Tracks what was sent to the LLM but never blocks re-sends.
@@ -11,7 +11,7 @@ export class ContextRegistry {
11
11
  const existing = this.entries.get(path);
12
12
  if (existing) {
13
13
  // Replace region of same type/symbol, add new ones
14
- const idx = existing.loaded.findIndex(r => r.type === region.type && r.symbolName === region.symbolName);
14
+ const idx = existing.loaded.findIndex((r) => r.type === region.type && r.symbolName === region.symbolName);
15
15
  if (idx >= 0) {
16
16
  existing.loaded[idx] = region;
17
17
  }
@@ -25,7 +25,7 @@ export class ContextRegistry {
25
25
  this.entries.set(path, {
26
26
  path,
27
27
  loaded: [region],
28
- contentHash: '',
28
+ contentHash: "",
29
29
  tokenEstimate: region.tokens,
30
30
  loadedAt: Date.now(),
31
31
  });
@@ -45,7 +45,7 @@ export class ContextRegistry {
45
45
  const entry = this.entries.get(path);
46
46
  if (!entry)
47
47
  return false;
48
- return entry.loaded.some(r => r.symbolName === symbolName);
48
+ return entry.loaded.some((r) => r.symbolName === symbolName);
49
49
  }
50
50
  /** Check if any region of a file has been loaded into context. */
51
51
  hasAnyLoaded(path) {
@@ -68,14 +68,14 @@ export class ContextRegistry {
68
68
  compactReminder(path, symbols) {
69
69
  const entry = this.entries.get(path);
70
70
  if (!entry)
71
- return '';
71
+ return "";
72
72
  const elapsed = formatDuration(Date.now() - entry.loadedAt);
73
73
  const lines = [
74
74
  `REMINDER: ${path} (previously loaded ${elapsed} ago, unchanged)`,
75
- '',
75
+ "",
76
76
  ];
77
77
  for (const region of entry.loaded) {
78
- if (region.type === 'structure') {
78
+ if (region.type === "structure") {
79
79
  lines.push(` Structure loaded (${region.tokens} tokens)`);
80
80
  // Add brief symbol list
81
81
  for (const sym of symbols.slice(0, 5)) {
@@ -85,23 +85,23 @@ export class ContextRegistry {
85
85
  lines.push(` ... (${symbols.length - 5} more symbols)`);
86
86
  }
87
87
  }
88
- else if (region.type === 'symbol' && region.symbolName) {
88
+ else if (region.type === "symbol" && region.symbolName) {
89
89
  lines.push(` ${region.symbolName} [L${region.startLine}-${region.endLine}] (${region.tokens} tokens)`);
90
90
  }
91
- else if (region.type === 'full') {
91
+ else if (region.type === "full") {
92
92
  lines.push(` Full file loaded (${region.tokens} tokens)`);
93
93
  }
94
94
  }
95
- lines.push('');
96
- lines.push('HINT: File unchanged since last read. Use read_symbol() to reload specific parts, or read_diff() to see changes.');
97
- return lines.join('\n');
95
+ lines.push("");
96
+ lines.push("HINT: File unchanged since last read. Use read_symbol() to reload specific parts, or read_diff() to see changes.");
97
+ return lines.join("\n");
98
98
  }
99
99
  /** Check if file was loaded in full (type='full' region exists). */
100
100
  isFullyLoaded(path) {
101
101
  const entry = this.entries.get(path);
102
102
  if (!entry)
103
103
  return false;
104
- return entry.loaded.some(r => r.type === 'full');
104
+ return entry.loaded.some((r) => r.type === "full");
105
105
  }
106
106
  /**
107
107
  * Generate a compact dedup reminder for read_symbol.
@@ -110,24 +110,26 @@ export class ContextRegistry {
110
110
  symbolReminder(path, symbolName) {
111
111
  const entry = this.entries.get(path);
112
112
  if (!entry)
113
- return '';
113
+ return "";
114
114
  const elapsed = formatDuration(Date.now() - entry.loadedAt);
115
- const symbolRegion = entry.loaded.find(r => r.symbolName === symbolName);
116
- const fullRegion = entry.loaded.find(r => r.type === 'full');
115
+ const symbolRegion = entry.loaded.find((r) => r.symbolName === symbolName);
116
+ const fullRegion = entry.loaded.find((r) => r.type === "full");
117
117
  if (fullRegion) {
118
- const loc = symbolRegion ? ` Symbol at [L${symbolRegion.startLine}-${symbolRegion.endLine}].` : '';
118
+ const loc = symbolRegion
119
+ ? ` Symbol at [L${symbolRegion.startLine}-${symbolRegion.endLine}].`
120
+ : "";
119
121
  return [
120
122
  `DEDUP: "${symbolName}" in ${path} — full file already in context (loaded ${elapsed} ago, ${fullRegion.tokens} tokens, unchanged).${loc}`,
121
- 'HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.',
122
- ].join('\n');
123
+ "HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.",
124
+ ].join("\n");
123
125
  }
124
126
  if (symbolRegion) {
125
127
  return [
126
128
  `DEDUP: "${symbolName}" in ${path} — already loaded ${elapsed} ago [L${symbolRegion.startLine}-${symbolRegion.endLine}] (${symbolRegion.tokens} tokens, unchanged).`,
127
- 'HINT: Symbol unchanged since last read. No need to re-read.',
128
- ].join('\n');
129
+ "HINT: Symbol unchanged since last read. No need to re-read.",
130
+ ].join("\n");
129
131
  }
130
- return '';
132
+ return "";
131
133
  }
132
134
  /**
133
135
  * Generate a compact dedup reminder for read_range.
@@ -136,21 +138,21 @@ export class ContextRegistry {
136
138
  rangeReminder(path, startLine, endLine) {
137
139
  const entry = this.entries.get(path);
138
140
  if (!entry)
139
- return '';
140
- const fullRegion = entry.loaded.find(r => r.type === 'full');
141
+ return "";
142
+ const fullRegion = entry.loaded.find((r) => r.type === "full");
141
143
  if (!fullRegion)
142
- return '';
144
+ return "";
143
145
  const elapsed = formatDuration(Date.now() - entry.loadedAt);
144
146
  return [
145
147
  `DEDUP: ${path} [L${startLine}-${endLine}] — full file already in context (loaded ${elapsed} ago, ${fullRegion.tokens} tokens, unchanged).`,
146
- 'HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.',
147
- ].join('\n');
148
+ "HINT: File unchanged. No need to re-read. Use read_for_edit() if you need exact code for editing.",
149
+ ].join("\n");
148
150
  }
149
151
  forget(path, symbolName) {
150
152
  if (symbolName) {
151
153
  const entry = this.entries.get(path);
152
154
  if (entry) {
153
- entry.loaded = entry.loaded.filter(r => r.symbolName !== symbolName);
155
+ entry.loaded = entry.loaded.filter((r) => r.symbolName !== symbolName);
154
156
  if (entry.loaded.length === 0) {
155
157
  this.entries.delete(path);
156
158
  }
@@ -200,5 +202,35 @@ export class ContextRegistry {
200
202
  this.entries.delete(file);
201
203
  }
202
204
  }
205
+ /**
206
+ * Serialize registry state to a plain object (TP-69m persistence).
207
+ * Sets inside `entries[*].loaded[*]` lack `Set` fields; only `contentHash`
208
+ * etc. are simple scalars, so JSON round-trip works.
209
+ */
210
+ toSnapshot() {
211
+ return {
212
+ sessionStart: this.sessionStart,
213
+ entries: Array.from(this.entries.values()),
214
+ };
215
+ }
216
+ /**
217
+ * Rehydrate registry state previously produced by `toSnapshot()`. Silent
218
+ * on malformed input — a broken snapshot file should degrade to an empty
219
+ * registry, not crash the MCP server.
220
+ */
221
+ loadSnapshot(snap) {
222
+ if (!snap || typeof snap !== "object")
223
+ return;
224
+ const s = snap;
225
+ if (typeof s.sessionStart === "number")
226
+ this.sessionStart = s.sessionStart;
227
+ if (Array.isArray(s.entries)) {
228
+ for (const e of s.entries) {
229
+ if (e && typeof e.path === "string" && Array.isArray(e.loaded)) {
230
+ this.entries.set(e.path, e);
231
+ }
232
+ }
233
+ }
234
+ }
203
235
  }
204
236
  //# sourceMappingURL=context-registry.js.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Phase 6 subtasks 6.1 + 6.2 — hook-events JSONL log.
3
+ *
4
+ * Writes to `<projectRoot>/.token-pilot/hook-events.jsonl` with the
5
+ * schema specified in TP-c2a acceptance:
6
+ *
7
+ * { ts, session_id, agent_type, agent_id, event, file, lines,
8
+ * estTokens, summaryTokens, savedTokens }
9
+ *
10
+ * Rotation: when the current file grows past `ROTATION_THRESHOLD_BYTES`
11
+ * (10 MB), it is renamed to `hook-events.<unix-ms>.jsonl` and a new
12
+ * empty file begins.
13
+ *
14
+ * Retention: `applyRetention` deletes rotated files older than
15
+ * `RETENTION_MAX_AGE_DAYS` (30 days) and trims the directory down to
16
+ * `RETENTION_MAX_TOTAL_BYTES` (100 MB) by removing the oldest archives
17
+ * first. The current file is never deleted.
18
+ *
19
+ * Legacy coexistence: the old `.token-pilot/hook-denied.jsonl` is left
20
+ * in place — this module writes only to the new file.
21
+ */
22
+ export declare const ROTATION_THRESHOLD_BYTES = 10000000;
23
+ export declare const RETENTION_MAX_AGE_DAYS = 30;
24
+ export declare const RETENTION_MAX_TOTAL_BYTES = 100000000;
25
+ export interface HookEvent {
26
+ ts: number;
27
+ session_id: string;
28
+ /** null for top-level session; agent_type string inside a subagent. */
29
+ agent_type: string | null;
30
+ agent_id: string | null;
31
+ event: "denied" | "allowed" | "bypass" | "pass-through" | string;
32
+ file: string;
33
+ lines: number;
34
+ estTokens: number;
35
+ /** Tokens delivered back to the agent as the summary; 0 for allow/bypass. */
36
+ summaryTokens: number;
37
+ /** estTokens - summaryTokens; 0 for allow/bypass. */
38
+ savedTokens: number;
39
+ }
40
+ export declare function eventLogDir(projectRoot: string): string;
41
+ export declare function currentLogPath(projectRoot: string): string;
42
+ /**
43
+ * Decide whether the current log file has grown past the rotation
44
+ * threshold and should be archived before the next append.
45
+ */
46
+ export declare function shouldRotate(stat: {
47
+ size: number;
48
+ }, thresholdBytes?: number): boolean;
49
+ /**
50
+ * Given the full list of archive files with their mtime + size, return
51
+ * the subset whose paths should be deleted to satisfy:
52
+ * (a) maxAgeDays — delete anything older
53
+ * (b) maxTotalBytes — delete oldest first until total fits
54
+ *
55
+ * `now` is passed in to keep the function deterministic for tests.
56
+ */
57
+ export declare function retentionDeletions(files: Array<{
58
+ path: string;
59
+ mtime: Date;
60
+ size: number;
61
+ }>, now: Date, maxAgeDays?: number, maxTotalBytes?: number): string[];
62
+ /**
63
+ * Append one event to the current log file. Rotates first if the
64
+ * current file has reached the threshold. Never throws — a failure
65
+ * here must not break hook dispatch.
66
+ */
67
+ export declare function appendEvent(projectRoot: string, event: HookEvent): Promise<void>;
68
+ /**
69
+ * Read all events from the current log file. Malformed JSONL lines are
70
+ * skipped silently (a corrupted line should not poison the whole
71
+ * dataset). Returns [] if the file is missing.
72
+ */
73
+ export declare function loadEvents(projectRoot: string): Promise<HookEvent[]>;
74
+ /**
75
+ * Apply age + size retention. Safe to call on startup; no-op when the
76
+ * directory does not exist.
77
+ */
78
+ export declare function applyRetention(projectRoot: string, now?: Date): Promise<void>;
79
+ //# sourceMappingURL=event-log.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Phase 6 subtasks 6.1 + 6.2 — hook-events JSONL log.
3
+ *
4
+ * Writes to `<projectRoot>/.token-pilot/hook-events.jsonl` with the
5
+ * schema specified in TP-c2a acceptance:
6
+ *
7
+ * { ts, session_id, agent_type, agent_id, event, file, lines,
8
+ * estTokens, summaryTokens, savedTokens }
9
+ *
10
+ * Rotation: when the current file grows past `ROTATION_THRESHOLD_BYTES`
11
+ * (10 MB), it is renamed to `hook-events.<unix-ms>.jsonl` and a new
12
+ * empty file begins.
13
+ *
14
+ * Retention: `applyRetention` deletes rotated files older than
15
+ * `RETENTION_MAX_AGE_DAYS` (30 days) and trims the directory down to
16
+ * `RETENTION_MAX_TOTAL_BYTES` (100 MB) by removing the oldest archives
17
+ * first. The current file is never deleted.
18
+ *
19
+ * Legacy coexistence: the old `.token-pilot/hook-denied.jsonl` is left
20
+ * in place — this module writes only to the new file.
21
+ */
22
+ import { promises as fs } from "node:fs";
23
+ import { join } from "node:path";
24
+ export const ROTATION_THRESHOLD_BYTES = 10_000_000;
25
+ export const RETENTION_MAX_AGE_DAYS = 30;
26
+ export const RETENTION_MAX_TOTAL_BYTES = 100_000_000;
27
+ const CURRENT_FILE = "hook-events.jsonl";
28
+ const ARCHIVE_RE = /^hook-events\.\d+\.jsonl$/;
29
+ export function eventLogDir(projectRoot) {
30
+ return join(projectRoot, ".token-pilot");
31
+ }
32
+ export function currentLogPath(projectRoot) {
33
+ return join(eventLogDir(projectRoot), CURRENT_FILE);
34
+ }
35
+ // ─── pure: rotation predicate ───────────────────────────────────────────────
36
+ /**
37
+ * Decide whether the current log file has grown past the rotation
38
+ * threshold and should be archived before the next append.
39
+ */
40
+ export function shouldRotate(stat, thresholdBytes = ROTATION_THRESHOLD_BYTES) {
41
+ return stat.size >= thresholdBytes;
42
+ }
43
+ // ─── pure: retention policy ─────────────────────────────────────────────────
44
+ /**
45
+ * Given the full list of archive files with their mtime + size, return
46
+ * the subset whose paths should be deleted to satisfy:
47
+ * (a) maxAgeDays — delete anything older
48
+ * (b) maxTotalBytes — delete oldest first until total fits
49
+ *
50
+ * `now` is passed in to keep the function deterministic for tests.
51
+ */
52
+ export function retentionDeletions(files, now, maxAgeDays = RETENTION_MAX_AGE_DAYS, maxTotalBytes = RETENTION_MAX_TOTAL_BYTES) {
53
+ const toDelete = new Set();
54
+ const maxAgeMs = maxAgeDays * 86_400_000;
55
+ // (a) age-based
56
+ const survivors = [];
57
+ for (const f of files) {
58
+ if (now.getTime() - f.mtime.getTime() > maxAgeMs) {
59
+ toDelete.add(f.path);
60
+ }
61
+ else {
62
+ survivors.push(f);
63
+ }
64
+ }
65
+ // (b) size-based — delete oldest first from survivors until cap is met
66
+ const totalSize = survivors.reduce((sum, f) => sum + f.size, 0);
67
+ if (totalSize > maxTotalBytes) {
68
+ const byOldest = [...survivors].sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
69
+ let trimmed = totalSize;
70
+ for (const f of byOldest) {
71
+ if (trimmed <= maxTotalBytes)
72
+ break;
73
+ toDelete.add(f.path);
74
+ trimmed -= f.size;
75
+ }
76
+ }
77
+ return [...toDelete];
78
+ }
79
+ // ─── FS wrappers ────────────────────────────────────────────────────────────
80
+ async function ensureLogDir(projectRoot) {
81
+ await fs.mkdir(eventLogDir(projectRoot), { recursive: true });
82
+ }
83
+ async function rotateIfNeeded(projectRoot, thresholdBytes = ROTATION_THRESHOLD_BYTES) {
84
+ const current = currentLogPath(projectRoot);
85
+ let stat;
86
+ try {
87
+ const s = await fs.stat(current);
88
+ stat = { size: s.size };
89
+ }
90
+ catch {
91
+ return; // no current file → nothing to rotate
92
+ }
93
+ if (!shouldRotate(stat, thresholdBytes))
94
+ return;
95
+ const archivePath = join(eventLogDir(projectRoot), `hook-events.${Date.now()}.jsonl`);
96
+ try {
97
+ await fs.rename(current, archivePath);
98
+ }
99
+ catch {
100
+ // Rename raced with another process; caller will just append onto
101
+ // whichever file now exists.
102
+ }
103
+ }
104
+ /**
105
+ * Append one event to the current log file. Rotates first if the
106
+ * current file has reached the threshold. Never throws — a failure
107
+ * here must not break hook dispatch.
108
+ */
109
+ export async function appendEvent(projectRoot, event) {
110
+ try {
111
+ await ensureLogDir(projectRoot);
112
+ await rotateIfNeeded(projectRoot);
113
+ const line = JSON.stringify(event) + "\n";
114
+ await fs.appendFile(currentLogPath(projectRoot), line);
115
+ }
116
+ catch {
117
+ /* silent — telemetry is best-effort */
118
+ }
119
+ }
120
+ /**
121
+ * Read all events from the current log file. Malformed JSONL lines are
122
+ * skipped silently (a corrupted line should not poison the whole
123
+ * dataset). Returns [] if the file is missing.
124
+ */
125
+ export async function loadEvents(projectRoot) {
126
+ let raw;
127
+ try {
128
+ raw = await fs.readFile(currentLogPath(projectRoot), "utf-8");
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const out = [];
134
+ for (const line of raw.split("\n")) {
135
+ if (!line.trim())
136
+ continue;
137
+ try {
138
+ out.push(JSON.parse(line));
139
+ }
140
+ catch {
141
+ // skip malformed
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Enumerate all archive files (`hook-events.<ts>.jsonl`) with metadata
148
+ * needed by `retentionDeletions`.
149
+ */
150
+ async function listArchives(projectRoot) {
151
+ const dir = eventLogDir(projectRoot);
152
+ let entries;
153
+ try {
154
+ entries = await fs.readdir(dir);
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ const out = [];
160
+ for (const name of entries) {
161
+ if (!ARCHIVE_RE.test(name))
162
+ continue;
163
+ const full = join(dir, name);
164
+ try {
165
+ const s = await fs.stat(full);
166
+ out.push({ path: full, mtime: s.mtime, size: s.size });
167
+ }
168
+ catch {
169
+ /* skip unreadable */
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ /**
175
+ * Apply age + size retention. Safe to call on startup; no-op when the
176
+ * directory does not exist.
177
+ */
178
+ export async function applyRetention(projectRoot, now = new Date()) {
179
+ const archives = await listArchives(projectRoot);
180
+ const victims = retentionDeletions(archives, now);
181
+ for (const p of victims) {
182
+ try {
183
+ await fs.unlink(p);
184
+ }
185
+ catch {
186
+ /* ignore */
187
+ }
188
+ }
189
+ }
190
+ //# sourceMappingURL=event-log.js.map