pi-harness-runtime 1.1.150 → 1.1.152

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 (158) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/harness/key-loader.ts +84 -0
  3. package/harness/langchain/agents.ts +65 -15
  4. package/harness/langchain/daemon.ts +58 -30
  5. package/harness/langchain/graph.ts +55 -7
  6. package/harness/langchain/jev-logger.ts +274 -0
  7. package/harness/langchain/run.ts +64 -161
  8. package/harness/langchain/surge.test.ts +42 -0
  9. package/harness/langchain/surge.ts +22 -1
  10. package/index.ts +24 -0
  11. package/package.json +1 -1
  12. package/packages/a2a-adapter/package.json +1 -1
  13. package/packages/agent-policy/dist/extension.d.ts +19 -0
  14. package/packages/agent-policy/dist/extension.d.ts.map +1 -0
  15. package/packages/agent-policy/dist/extension.js +322 -0
  16. package/packages/agent-policy/dist/extension.js.map +1 -0
  17. package/packages/agent-policy/dist/index.d.ts +25 -0
  18. package/packages/agent-policy/dist/index.d.ts.map +1 -0
  19. package/packages/agent-policy/dist/index.js +27 -0
  20. package/packages/agent-policy/dist/index.js.map +1 -0
  21. package/packages/agent-policy/dist/policy-loader.d.ts +90 -0
  22. package/packages/agent-policy/dist/policy-loader.d.ts.map +1 -0
  23. package/packages/agent-policy/dist/policy-loader.js +331 -0
  24. package/packages/agent-policy/dist/policy-loader.js.map +1 -0
  25. package/packages/agent-policy/dist/policy-renderer.d.ts +42 -0
  26. package/packages/agent-policy/dist/policy-renderer.d.ts.map +1 -0
  27. package/packages/agent-policy/dist/policy-renderer.js +295 -0
  28. package/packages/agent-policy/dist/policy-renderer.js.map +1 -0
  29. package/packages/agent-policy/dist/policy-store.d.ts +99 -0
  30. package/packages/agent-policy/dist/policy-store.d.ts.map +1 -0
  31. package/packages/agent-policy/dist/policy-store.js +244 -0
  32. package/packages/agent-policy/dist/policy-store.js.map +1 -0
  33. package/packages/agent-policy/dist/types.d.ts +237 -0
  34. package/packages/agent-policy/dist/types.d.ts.map +1 -0
  35. package/packages/agent-policy/dist/types.js +130 -0
  36. package/packages/agent-policy/dist/types.js.map +1 -0
  37. package/packages/agent-policy/package.json +45 -0
  38. package/packages/agent-policy/src/extension.ts +413 -0
  39. package/packages/agent-policy/src/index.ts +52 -0
  40. package/packages/agent-policy/src/policy-loader.ts +398 -0
  41. package/packages/agent-policy/src/policy-renderer.ts +362 -0
  42. package/packages/agent-policy/src/policy-store.ts +319 -0
  43. package/packages/agent-policy/src/types.ts +385 -0
  44. package/packages/agent-policy/test/policy.test.ts +383 -0
  45. package/packages/architecture-generator/package.json +1 -1
  46. package/packages/auth/package.json +1 -1
  47. package/packages/autonomous-refactor/package.json +1 -1
  48. package/packages/autonomous-runtime/package.json +1 -1
  49. package/packages/cache-strategy/package.json +1 -1
  50. package/packages/capability-registry/package.json +1 -1
  51. package/packages/checkpoint/package.json +1 -1
  52. package/packages/cli-plugin-sdk/package.json +1 -1
  53. package/packages/clipboard/package.json +1 -1
  54. package/packages/clipboard-plugin/package.json +1 -1
  55. package/packages/code-generation/package.json +1 -1
  56. package/packages/code-review/package.json +1 -1
  57. package/packages/codex-adapter/package.json +1 -1
  58. package/packages/codex-watcher/dist/src/parser.d.ts.map +1 -1
  59. package/packages/codex-watcher/dist/src/parser.js +35 -0
  60. package/packages/codex-watcher/dist/src/parser.js.map +1 -1
  61. package/packages/codex-watcher/package.json +1 -1
  62. package/packages/codex-watcher/src/parser.ts +41 -0
  63. package/packages/config-capture/package.json +1 -1
  64. package/packages/context-compiler/package.json +1 -1
  65. package/packages/context-discovery/package.json +1 -1
  66. package/packages/context-manager/package.json +1 -1
  67. package/packages/cookie-sanitizer/package.json +1 -1
  68. package/packages/cost-optimizer/package.json +1 -1
  69. package/packages/dependency-analyzer/package.json +1 -1
  70. package/packages/django-plugin/package.json +1 -1
  71. package/packages/doc-generator/package.json +1 -1
  72. package/packages/evaluation-engine/package.json +1 -1
  73. package/packages/evaluation-runner/package.json +1 -1
  74. package/packages/event-bus/package.json +1 -1
  75. package/packages/event-store/package.json +1 -1
  76. package/packages/experience-replay/package.json +1 -1
  77. package/packages/feedback-collector/package.json +1 -1
  78. package/packages/file-copy-helper/package.json +1 -1
  79. package/packages/framework-detector/package.json +1 -1
  80. package/packages/framework-plugin-sdk/package.json +1 -1
  81. package/packages/frappe-plugin/package.json +1 -1
  82. package/packages/generic-web-plugin/package.json +1 -1
  83. package/packages/health-monitor/package.json +1 -1
  84. package/packages/intent-analyzer/package.json +1 -1
  85. package/packages/jev-judge/package.json +1 -1
  86. package/packages/knowledge-graph/package.json +1 -1
  87. package/packages/knowledge-retrieval/package.json +1 -1
  88. package/packages/laravel-plugin/package.json +1 -1
  89. package/packages/learning-engine/package.json +1 -1
  90. package/packages/mcp-adapter/package.json +1 -1
  91. package/packages/memory-engine/package.json +1 -1
  92. package/packages/milestone-manager/package.json +1 -1
  93. package/packages/model-registry/package.json +1 -1
  94. package/packages/nextjs-plugin/package.json +1 -1
  95. package/packages/notification/dist/adapters/telegram-adapter.d.ts +5 -0
  96. package/packages/notification/dist/adapters/telegram-adapter.d.ts.map +1 -1
  97. package/packages/notification/dist/adapters/telegram-adapter.js +54 -0
  98. package/packages/notification/dist/adapters/telegram-adapter.js.map +1 -1
  99. package/packages/notification/dist/notification-center.d.ts +13 -0
  100. package/packages/notification/dist/notification-center.d.ts.map +1 -1
  101. package/packages/notification/dist/notification-center.js +36 -0
  102. package/packages/notification/dist/notification-center.js.map +1 -1
  103. package/packages/notification/package.json +1 -1
  104. package/packages/notification/src/adapters/telegram-adapter.ts +71 -0
  105. package/packages/notification/src/notification-center.ts +53 -0
  106. package/packages/observability/package.json +1 -1
  107. package/packages/okf-indexer/package.json +1 -1
  108. package/packages/paperclip-integration/package.json +1 -1
  109. package/packages/performance-optimizer/package.json +1 -1
  110. package/packages/privilege-broker/package.json +1 -1
  111. package/packages/project-analyzer/package.json +1 -1
  112. package/packages/project-bootstrap/package.json +1 -1
  113. package/packages/projection-engine/package.json +1 -1
  114. package/packages/prompt-compiler/package.json +1 -1
  115. package/packages/prompt-versioning/package.json +1 -1
  116. package/packages/provider-adapter-sdk/package.json +1 -1
  117. package/packages/provider-router/package.json +1 -1
  118. package/packages/provider-selector/package.json +1 -1
  119. package/packages/providers/package.json +1 -1
  120. package/packages/qdrant-skills/package.json +1 -1
  121. package/packages/quota-manager/package.json +1 -1
  122. package/packages/rate-limiter/package.json +1 -1
  123. package/packages/react-vite-plugin/package.json +1 -1
  124. package/packages/release-manager/package.json +1 -1
  125. package/packages/requirement-compiler/package.json +1 -1
  126. package/packages/runtime/package.json +1 -1
  127. package/packages/scheduler/package.json +1 -1
  128. package/packages/scheduler-adapter/package.json +1 -1
  129. package/packages/session/package.json +1 -1
  130. package/packages/session-api/package.json +1 -1
  131. package/packages/session-export/package.json +1 -1
  132. package/packages/shared-context/package.json +1 -1
  133. package/packages/skill-mcp-client/package.json +1 -1
  134. package/packages/skill-registry/package.json +1 -1
  135. package/packages/skills/package.json +1 -1
  136. package/packages/sprint-planner/package.json +1 -1
  137. package/packages/subscription-engine/package.json +1 -1
  138. package/packages/task-compiler/package.json +1 -1
  139. package/packages/tencentdb-memory/package.json +1 -1
  140. package/packages/tencentdb-sync/package.json +1 -1
  141. package/packages/test-data-generator/package.json +1 -1
  142. package/packages/test-generator/package.json +1 -1
  143. package/packages/todo-bd-sync/package.json +1 -1
  144. package/packages/token-estimation/package.json +1 -1
  145. package/packages/token-optimizer/package.json +1 -1
  146. package/packages/trajectory/package.json +1 -1
  147. package/packages/tui/package.json +1 -1
  148. package/packages/types/package.json +1 -1
  149. package/packages/workflow-events/package.json +1 -1
  150. package/packages/workspace-scanner/package.json +1 -1
  151. package/packages/worktree/package.json +1 -1
  152. package/packages/write-review/package.json +1 -1
  153. package/proactive-compact.ts +2 -2
  154. package/scripts/debug-codex-integration.ts +203 -0
  155. package/scripts/sync-keys-from-pi.ts +188 -0
  156. package/scripts/test-codex-session.ts +23 -0
  157. package/scripts/test-openai.ts +14 -0
  158. package/scripts/test-telegram.ts +34 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * PolicyRenderer - Renders compact system contract for agent injection.
3
+ *
4
+ * Responsibilities:
5
+ * - Render a compact, deterministic contract for before_agent_start injection
6
+ * - Render full rule text for the harness_rules tool
7
+ * - Render policy status for display
8
+ */
9
+
10
+ import { readFileSync, existsSync } from "node:fs";
11
+ import type { PolicyManifest, PolicySection, HarnessRulesOutput, MinimaxFindingEnvelope } from "./types.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // System Contract Rendering
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Render the compact mandatory contract for system prompt injection.
19
+ */
20
+ export function renderSystemContract(manifest: PolicyManifest): string {
21
+ const lines: string[] = [];
22
+
23
+ // Header
24
+ lines.push(`PI-HARNESS POLICY revision=${manifest.revision} coverage=${manifest.coverage}`);
25
+ lines.push("");
26
+
27
+ // Mandatory rules
28
+ lines.push("Mandatory:");
29
+ lines.push("- Read and follow the active harness and scoped project rules.");
30
+ lines.push("- Before mutation, call harness_rules when the current revision is not loaded.");
31
+ lines.push("- Treat [BLOCKING] code findings as completion gates.");
32
+ lines.push("- Treat [DEGRADED] as incomplete evidence, never as clean.");
33
+ lines.push("- Re-read a file after the harness reports an automatic mutation.");
34
+ lines.push("");
35
+
36
+ // Tool reference
37
+ lines.push("Use harness_rules for full text and source attribution.");
38
+ lines.push("");
39
+
40
+ // Trust indicator
41
+ if (manifest.trust === "untrusted") {
42
+ lines.push("WARNING: Project is not trusted. Project rules are advisory only.");
43
+ lines.push("");
44
+ }
45
+
46
+ // Coverage warning
47
+ if (manifest.coverage !== "complete") {
48
+ lines.push(`WARNING: Policy coverage is ${manifest.coverage}. Some rules may be missing.`);
49
+ lines.push("");
50
+ }
51
+
52
+ return lines.join("\n");
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Rule Section Parsing
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Parse markdown content into sections.
61
+ */
62
+ export function parseSections(content: string, source: string, scope: string, priority: "mandatory" | "advisory"): PolicySection[] {
63
+ const sections: PolicySection[] = [];
64
+ const lines = content.split("\n");
65
+
66
+ let currentSection: {
67
+ id: string;
68
+ title: string;
69
+ content: string[];
70
+ offset: number;
71
+ } | null = null;
72
+
73
+ let sectionIndex = 0;
74
+
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const line = lines[i];
77
+ const lineOffset = content.split("\n").slice(0, i).join("\n").length;
78
+
79
+ // Check for markdown heading
80
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
81
+ if (headingMatch) {
82
+ // Save previous section
83
+ if (currentSection) {
84
+ sections.push({
85
+ id: currentSection.id,
86
+ title: currentSection.title,
87
+ content: currentSection.content.join("\n").trim(),
88
+ source,
89
+ scope,
90
+ priority,
91
+ offset: currentSection.offset,
92
+ truncated: false,
93
+ });
94
+ }
95
+
96
+ // Start new section
97
+ const level = headingMatch[1].length;
98
+ const title = headingMatch[2].trim();
99
+ currentSection = {
100
+ id: `section-${sectionIndex++}`,
101
+ title,
102
+ content: [],
103
+ offset: lineOffset,
104
+ };
105
+ } else if (currentSection) {
106
+ currentSection.content.push(line);
107
+ }
108
+ }
109
+
110
+ // Save last section
111
+ if (currentSection) {
112
+ sections.push({
113
+ id: currentSection.id,
114
+ title: currentSection.title,
115
+ content: currentSection.content.join("\n").trim(),
116
+ source,
117
+ scope,
118
+ priority,
119
+ offset: currentSection.offset,
120
+ truncated: false,
121
+ });
122
+ }
123
+
124
+ // If no sections found, create a single section with the whole content
125
+ if (sections.length === 0) {
126
+ sections.push({
127
+ id: "section-0",
128
+ title: "Full Content",
129
+ content: content.trim(),
130
+ source,
131
+ scope,
132
+ priority,
133
+ offset: 0,
134
+ truncated: false,
135
+ });
136
+ }
137
+
138
+ return sections;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Full Rules Rendering
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Render full rules for the harness_rules tool.
147
+ */
148
+ export function renderFullRules(
149
+ manifest: PolicyManifest,
150
+ options: {
151
+ source?: string;
152
+ section?: string;
153
+ maxBytes?: number;
154
+ } = {}
155
+ ): HarnessRulesOutput {
156
+ const sections: PolicySection[] = [];
157
+ const maxBytes = options.maxBytes ?? 50 * 1024; // Default 50KB cap
158
+
159
+ // Filter sources
160
+ let sources = manifest.sources;
161
+ if (options.source) {
162
+ sources = sources.filter(s => s.path.includes(options.source!));
163
+ }
164
+
165
+ // Parse sections from each source
166
+ for (const source of sources) {
167
+ const content = readSourceContent(source.path);
168
+ if (!content) continue;
169
+
170
+ const sourceSections = parseSections(content, source.path, source.scope, source.priority);
171
+
172
+ // Filter by section if requested
173
+ const filteredSections = options.section
174
+ ? sourceSections.filter(s =>
175
+ s.title.toLowerCase().includes(options.section!.toLowerCase()) ||
176
+ s.id === options.section
177
+ )
178
+ : sourceSections;
179
+
180
+ sections.push(...filteredSections);
181
+ }
182
+
183
+ // Truncate if needed
184
+ let totalBytes = 0;
185
+ let truncated = false;
186
+ const truncatedSections: PolicySection[] = [];
187
+
188
+ for (const section of sections) {
189
+ const sectionBytes = Buffer.byteLength(section.content, "utf8");
190
+
191
+ if (totalBytes + sectionBytes > maxBytes) {
192
+ // Check if we can fit a partial section
193
+ const remainingBytes = maxBytes - totalBytes;
194
+ if (remainingBytes > 100) {
195
+ // Fit as much as possible
196
+ truncatedSections.push({
197
+ ...section,
198
+ content: section.content.slice(0, remainingBytes),
199
+ truncated: true,
200
+ });
201
+ }
202
+ truncated = true;
203
+ break;
204
+ }
205
+
206
+ truncatedSections.push(section);
207
+ totalBytes += sectionBytes;
208
+ }
209
+
210
+ return {
211
+ sections: truncatedSections,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Render a manifest summary.
217
+ */
218
+ export function renderManifestSummary(manifest: PolicyManifest): string {
219
+ const lines: string[] = [];
220
+
221
+ lines.push(`Policy Manifest`);
222
+ lines.push(`================`);
223
+ lines.push(`Revision: ${manifest.revision}`);
224
+ lines.push(`Project: ${manifest.projectRoot}`);
225
+ lines.push(`Trust: ${manifest.trust}`);
226
+ lines.push(`Coverage: ${manifest.coverage}`);
227
+ lines.push(`Loaded: ${new Date(manifest.loadedAt).toISOString()}`);
228
+ lines.push("");
229
+ lines.push("Sources:");
230
+
231
+ for (const source of manifest.sources) {
232
+ const scope = source.scope || "(root)";
233
+ const priority = source.priority;
234
+ const owner = source.harnessOwned ? "[harness]" : "[project]";
235
+ lines.push(` ${owner} ${scope} ${priority} ${source.path}`);
236
+ }
237
+
238
+ if (manifest.loadError) {
239
+ lines.push("");
240
+ lines.push(`ERROR: ${manifest.loadError}`);
241
+ }
242
+
243
+ return lines.join("\n");
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Finding Envelope Rendering
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Render a finding envelope for display.
252
+ */
253
+ export function renderFindingEnvelope(envelope: MinimaxFindingEnvelope): string {
254
+ const lines: string[] = [];
255
+
256
+ // Header with verdict
257
+ const verdictPrefix = {
258
+ blocking: "[BLOCKING]",
259
+ advisory: "[ADVISORY]",
260
+ clean: "[CLEAN]",
261
+ degraded: "[DEGRADED]",
262
+ }[envelope.verdict];
263
+
264
+ lines.push(`${verdictPrefix} Code Analysis (${envelope.coverage} coverage, ${envelope.freshness})`);
265
+ lines.push("");
266
+
267
+ // Summary
268
+ if (envelope.summary) {
269
+ lines.push(envelope.summary);
270
+ lines.push("");
271
+ }
272
+
273
+ // Individual findings
274
+ if (envelope.findings.length > 0) {
275
+ for (const finding of envelope.findings) {
276
+ const severity = {
277
+ error: "ERROR",
278
+ warning: "WARN",
279
+ information: "INFO",
280
+ hint: "HINT",
281
+ }[finding.severity];
282
+
283
+ const location = finding.line ? `${finding.path}:${finding.line}` : finding.path;
284
+ const blocking = finding.blocking ? " [BLOCKING]" : "";
285
+
286
+ lines.push(`${severity} ${location}: ${finding.message}${blocking}`);
287
+
288
+ if (finding.fix) {
289
+ lines.push(` Fix: ${finding.fix}`);
290
+ }
291
+ }
292
+ lines.push("");
293
+ }
294
+
295
+ // Next action
296
+ if (envelope.nextAction) {
297
+ const actionText = {
298
+ fix: "Required: fix these issues before reporting completion.",
299
+ reread: "Required: re-read affected files to get updated analysis.",
300
+ run_full: "Required: do not report the project clean; run a full analysis.",
301
+ continue: "No blocking issues found.",
302
+ }[envelope.nextAction];
303
+
304
+ lines.push(actionText);
305
+ }
306
+
307
+ return lines.join("\n");
308
+ }
309
+
310
+ /**
311
+ * Create a clean finding envelope.
312
+ */
313
+ export function createCleanEnvelope(coverage: "delta" | "targeted" | "full" = "full"): MinimaxFindingEnvelope {
314
+ return {
315
+ verdict: "clean",
316
+ coverage,
317
+ freshness: "fresh",
318
+ findings: [],
319
+ nextAction: "continue",
320
+ summary: "No blocking issues found in code analysis.",
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Create a degraded finding envelope.
326
+ */
327
+ export function createDegradedEnvelope(
328
+ reason: string,
329
+ coverage: "delta" | "targeted" | "full" = "delta"
330
+ ): MinimaxFindingEnvelope {
331
+ return {
332
+ verdict: "degraded",
333
+ coverage,
334
+ freshness: "indeterminate",
335
+ findings: [],
336
+ nextAction: "run_full",
337
+ summary: `Analysis degraded: ${reason}. Do not claim the project is clean.`,
338
+ };
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Helpers
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Read source content from a path.
347
+ */
348
+ function readSourceContent(path: string): string | null {
349
+ // Handle file:// URLs
350
+ const normalizedPath = path.startsWith("file://")
351
+ ? path.slice(7)
352
+ : path;
353
+
354
+ if (existsSync(normalizedPath)) {
355
+ try {
356
+ return readFileSync(normalizedPath, "utf8");
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+ return null;
362
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * PolicyStore - Session receipt tracking and validation.
3
+ *
4
+ * Responsibilities:
5
+ * - Record when a policy revision was delivered to a session
6
+ * - Validate receipts against current manifest
7
+ * - Invalidate receipts when rules change
8
+ * - Track delivery method (system_prompt vs tool)
9
+ */
10
+
11
+ import type { PolicyManifest, PolicyReceipt } from "./types.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // PolicyStore
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Policy store configuration.
19
+ */
20
+ export interface PolicyStoreConfig {
21
+ /** Maximum receipts to keep per session (prevents memory leaks) */
22
+ maxReceiptsPerSession?: number;
23
+ /** Receipt TTL in milliseconds (default: 24 hours) */
24
+ receiptTtlMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Stores policy receipts and validates delivery.
29
+ */
30
+ export class PolicyStore {
31
+ private receipts: Map<string, PolicyReceipt> = new Map();
32
+ private sessionScopes: Map<string, Set<string>> = new Map();
33
+ private lastKnownManifests: Map<string, PolicyManifest> = new Map();
34
+ private config: Required<PolicyStoreConfig>;
35
+
36
+ constructor(config: PolicyStoreConfig = {}) {
37
+ this.config = {
38
+ maxReceiptsPerSession: config.maxReceiptsPerSession ?? 100,
39
+ receiptTtlMs: config.receiptTtlMs ?? 24 * 60 * 60 * 1000, // 24 hours
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Record a policy delivery for a session.
45
+ */
46
+ noteDelivered(
47
+ sessionId: string,
48
+ manifest: PolicyManifest,
49
+ deliveryMethod: "system_prompt" | "tool" = "system_prompt",
50
+ sectionsDelivered: string[] = ["*"]
51
+ ): PolicyReceipt {
52
+ // Invalidate any existing receipt for this session+scope
53
+ const existingKey = this.getReceiptKey(sessionId, manifest.projectRoot, "");
54
+ this.receipts.delete(existingKey);
55
+
56
+ const receipt: PolicyReceipt = {
57
+ sessionId,
58
+ projectRoot: manifest.projectRoot,
59
+ revision: manifest.revision,
60
+ scope: this.getScopeFromManifest(manifest),
61
+ deliveredAt: Date.now(),
62
+ sectionsDelivered,
63
+ deliveryMethod,
64
+ };
65
+
66
+ this.receipts.set(existingKey, receipt);
67
+
68
+ // Track scope for this session
69
+ if (!this.sessionScopes.has(sessionId)) {
70
+ this.sessionScopes.set(sessionId, new Set());
71
+ }
72
+ this.sessionScopes.get(sessionId)!.add(manifest.projectRoot);
73
+
74
+ // Store manifest for later validation
75
+ this.lastKnownManifests.set(existingKey, manifest);
76
+
77
+ // Cleanup old receipts if needed
78
+ this.cleanupOldReceipts();
79
+
80
+ return receipt;
81
+ }
82
+
83
+ /**
84
+ * Get receipt for a session, if it exists.
85
+ */
86
+ getReceipt(sessionId: string, projectRoot?: string, scope?: string): PolicyReceipt | null {
87
+ const key = this.getReceiptKey(sessionId, projectRoot ?? "", scope ?? "");
88
+ const receipt = this.receipts.get(key);
89
+
90
+ if (!receipt) return null;
91
+
92
+ // Check TTL
93
+ if (Date.now() - receipt.deliveredAt > this.config.receiptTtlMs) {
94
+ this.receipts.delete(key);
95
+ return null;
96
+ }
97
+
98
+ return receipt;
99
+ }
100
+
101
+ /**
102
+ * Check if a session has a valid receipt for the given manifest.
103
+ */
104
+ hasValidReceipt(sessionId: string, manifest: PolicyManifest): boolean {
105
+ const receipt = this.getReceipt(sessionId, manifest.projectRoot);
106
+ if (!receipt) return false;
107
+
108
+ // Check revision match
109
+ if (receipt.revision !== manifest.revision) {
110
+ return false;
111
+ }
112
+
113
+ // Check scope coverage
114
+ const receiptScope = receipt.scope;
115
+ const manifestScope = this.getScopeFromManifest(manifest);
116
+
117
+ if (receiptScope && manifestScope && !receiptScope.startsWith(manifestScope)) {
118
+ return false;
119
+ }
120
+
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Validate a receipt against the current manifest.
126
+ */
127
+ validateReceipt(sessionId: string, manifest: PolicyManifest): {
128
+ valid: boolean;
129
+ reason?: string;
130
+ } {
131
+ const receipt = this.getReceipt(sessionId, manifest.projectRoot);
132
+
133
+ if (!receipt) {
134
+ return { valid: false, reason: "No receipt found for this session" };
135
+ }
136
+
137
+ if (receipt.revision !== manifest.revision) {
138
+ return {
139
+ valid: false,
140
+ reason: `Receipt revision ${receipt.revision} does not match current ${manifest.revision}`,
141
+ };
142
+ }
143
+
144
+ if (Date.now() - receipt.deliveredAt > this.config.receiptTtlMs) {
145
+ return { valid: false, reason: "Receipt has expired" };
146
+ }
147
+
148
+ return { valid: true };
149
+ }
150
+
151
+ /**
152
+ * Invalidate receipts when a rule file changes.
153
+ */
154
+ invalidateOnChange(changedPath: string): string[] {
155
+ const invalidatedSessions: string[] = [];
156
+
157
+ for (const [key, manifest] of this.lastKnownManifests) {
158
+ // Check if the changed path affects any of our sources
159
+ const affectedSource = manifest.sources.find(s =>
160
+ s.path === changedPath || s.path.includes(changedPath)
161
+ );
162
+
163
+ if (affectedSource) {
164
+ // Extract session ID from key
165
+ const sessionId = key.split("|")[0];
166
+ this.receipts.delete(key);
167
+ invalidatedSessions.push(sessionId);
168
+ }
169
+ }
170
+
171
+ return invalidatedSessions;
172
+ }
173
+
174
+ /**
175
+ * Invalidate all receipts for a project root.
176
+ */
177
+ invalidateProject(projectRoot: string): void {
178
+ for (const [key, receipt] of this.receipts) {
179
+ if (receipt.projectRoot === projectRoot) {
180
+ this.receipts.delete(key);
181
+ }
182
+ }
183
+
184
+ // Also clean up last known manifests for this project
185
+ for (const [key, manifest] of this.lastKnownManifests) {
186
+ if (manifest.projectRoot === projectRoot) {
187
+ this.lastKnownManifests.delete(key);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Invalidate all receipts (e.g., after compaction).
194
+ */
195
+ invalidateAll(): void {
196
+ this.receipts.clear();
197
+ this.sessionScopes.clear();
198
+ }
199
+
200
+ /**
201
+ * Clear receipts for a specific session.
202
+ */
203
+ clearSession(sessionId: string): void {
204
+ for (const [key, receipt] of this.receipts) {
205
+ if (receipt.sessionId === sessionId) {
206
+ this.receipts.delete(key);
207
+ }
208
+ }
209
+ this.sessionScopes.delete(sessionId);
210
+ }
211
+
212
+ /**
213
+ * Get all receipts for a session.
214
+ */
215
+ getSessionReceipts(sessionId: string): PolicyReceipt[] {
216
+ const receipts: PolicyReceipt[] = [];
217
+ for (const receipt of this.receipts.values()) {
218
+ if (receipt.sessionId === sessionId) {
219
+ receipts.push(receipt);
220
+ }
221
+ }
222
+ return receipts;
223
+ }
224
+
225
+ /**
226
+ * Get statistics about stored receipts.
227
+ */
228
+ getStats(): {
229
+ totalReceipts: number;
230
+ sessionsTracked: number;
231
+ projectsTracked: number;
232
+ oldestReceipt: number | null;
233
+ newestReceipt: number | null;
234
+ } {
235
+ let oldest: number | null = null;
236
+ let newest: number | null = null;
237
+ const projects = new Set<string>();
238
+
239
+ for (const receipt of this.receipts.values()) {
240
+ if (oldest === null || receipt.deliveredAt < oldest) {
241
+ oldest = receipt.deliveredAt;
242
+ }
243
+ if (newest === null || receipt.deliveredAt > newest) {
244
+ newest = receipt.deliveredAt;
245
+ }
246
+ projects.add(receipt.projectRoot);
247
+ }
248
+
249
+ return {
250
+ totalReceipts: this.receipts.size,
251
+ sessionsTracked: this.sessionScopes.size,
252
+ projectsTracked: projects.size,
253
+ oldestReceipt: oldest,
254
+ newestReceipt: newest,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Generate receipt key for storage.
260
+ */
261
+ private getReceiptKey(sessionId: string, projectRoot: string, scope: string): string {
262
+ return `${sessionId}|${projectRoot}|${scope}`;
263
+ }
264
+
265
+ /**
266
+ * Extract scope from manifest.
267
+ */
268
+ private getScopeFromManifest(manifest: PolicyManifest): string {
269
+ const mandatorySources = manifest.sources.filter(s => s.priority === "mandatory");
270
+ if (mandatorySources.length === 0) return "";
271
+
272
+ // Get the most specific (longest) scope
273
+ mandatorySources.sort((a, b) => b.scope.length - a.scope.length);
274
+ return mandatorySources[0]?.scope ?? "";
275
+ }
276
+
277
+ /**
278
+ * Cleanup old receipts to prevent memory leaks.
279
+ */
280
+ private cleanupOldReceipts(): void {
281
+ if (this.receipts.size <= this.config.maxReceiptsPerSession) {
282
+ return;
283
+ }
284
+
285
+ // Sort by delivery time, oldest first
286
+ const sorted = [...this.receipts.entries()].sort(
287
+ (a, b) => a[1].deliveredAt - b[1].deliveredAt
288
+ );
289
+
290
+ // Remove oldest entries until under limit
291
+ const toRemove = sorted.slice(0, sorted.length - this.config.maxReceiptsPerSession);
292
+ for (const [key] of toRemove) {
293
+ this.receipts.delete(key);
294
+ }
295
+ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Singleton instance (for use in extension)
300
+ // ---------------------------------------------------------------------------
301
+
302
+ let globalPolicyStore: PolicyStore | null = null;
303
+
304
+ /**
305
+ * Get the global policy store instance.
306
+ */
307
+ export function getPolicyStore(): PolicyStore {
308
+ if (!globalPolicyStore) {
309
+ globalPolicyStore = new PolicyStore();
310
+ }
311
+ return globalPolicyStore;
312
+ }
313
+
314
+ /**
315
+ * Reset the global policy store (for testing).
316
+ */
317
+ export function resetPolicyStore(): void {
318
+ globalPolicyStore = null;
319
+ }