foundry-mcp 0.3.3__py3-none-any.whl → 0.8.10__py3-none-any.whl

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 (85) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/__init__.py +0 -13
  3. foundry_mcp/cli/commands/plan.py +10 -3
  4. foundry_mcp/cli/commands/review.py +19 -4
  5. foundry_mcp/cli/commands/session.py +1 -8
  6. foundry_mcp/cli/commands/specs.py +38 -208
  7. foundry_mcp/cli/context.py +39 -0
  8. foundry_mcp/cli/output.py +3 -3
  9. foundry_mcp/config.py +615 -11
  10. foundry_mcp/core/ai_consultation.py +146 -9
  11. foundry_mcp/core/batch_operations.py +1196 -0
  12. foundry_mcp/core/discovery.py +7 -7
  13. foundry_mcp/core/error_store.py +2 -2
  14. foundry_mcp/core/intake.py +933 -0
  15. foundry_mcp/core/llm_config.py +28 -2
  16. foundry_mcp/core/metrics_store.py +2 -2
  17. foundry_mcp/core/naming.py +25 -2
  18. foundry_mcp/core/progress.py +70 -0
  19. foundry_mcp/core/prometheus.py +0 -13
  20. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  21. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  22. foundry_mcp/core/prompts/plan_review.py +5 -1
  23. foundry_mcp/core/providers/__init__.py +12 -0
  24. foundry_mcp/core/providers/base.py +39 -0
  25. foundry_mcp/core/providers/claude.py +51 -48
  26. foundry_mcp/core/providers/codex.py +70 -60
  27. foundry_mcp/core/providers/cursor_agent.py +25 -47
  28. foundry_mcp/core/providers/detectors.py +34 -7
  29. foundry_mcp/core/providers/gemini.py +69 -58
  30. foundry_mcp/core/providers/opencode.py +101 -47
  31. foundry_mcp/core/providers/package-lock.json +4 -4
  32. foundry_mcp/core/providers/package.json +1 -1
  33. foundry_mcp/core/providers/validation.py +128 -0
  34. foundry_mcp/core/research/__init__.py +68 -0
  35. foundry_mcp/core/research/memory.py +528 -0
  36. foundry_mcp/core/research/models.py +1220 -0
  37. foundry_mcp/core/research/providers/__init__.py +40 -0
  38. foundry_mcp/core/research/providers/base.py +242 -0
  39. foundry_mcp/core/research/providers/google.py +507 -0
  40. foundry_mcp/core/research/providers/perplexity.py +442 -0
  41. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  42. foundry_mcp/core/research/providers/tavily.py +383 -0
  43. foundry_mcp/core/research/workflows/__init__.py +25 -0
  44. foundry_mcp/core/research/workflows/base.py +298 -0
  45. foundry_mcp/core/research/workflows/chat.py +271 -0
  46. foundry_mcp/core/research/workflows/consensus.py +539 -0
  47. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  48. foundry_mcp/core/research/workflows/ideate.py +682 -0
  49. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  50. foundry_mcp/core/responses.py +690 -0
  51. foundry_mcp/core/spec.py +2439 -236
  52. foundry_mcp/core/task.py +1205 -31
  53. foundry_mcp/core/testing.py +512 -123
  54. foundry_mcp/core/validation.py +319 -43
  55. foundry_mcp/dashboard/components/charts.py +0 -57
  56. foundry_mcp/dashboard/launcher.py +11 -0
  57. foundry_mcp/dashboard/views/metrics.py +25 -35
  58. foundry_mcp/dashboard/views/overview.py +1 -65
  59. foundry_mcp/resources/specs.py +25 -25
  60. foundry_mcp/schemas/intake-schema.json +89 -0
  61. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  62. foundry_mcp/server.py +0 -14
  63. foundry_mcp/tools/unified/__init__.py +39 -18
  64. foundry_mcp/tools/unified/authoring.py +2371 -248
  65. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  66. foundry_mcp/tools/unified/environment.py +434 -32
  67. foundry_mcp/tools/unified/error.py +18 -1
  68. foundry_mcp/tools/unified/lifecycle.py +8 -0
  69. foundry_mcp/tools/unified/plan.py +133 -2
  70. foundry_mcp/tools/unified/provider.py +0 -40
  71. foundry_mcp/tools/unified/research.py +1283 -0
  72. foundry_mcp/tools/unified/review.py +374 -17
  73. foundry_mcp/tools/unified/review_helpers.py +16 -1
  74. foundry_mcp/tools/unified/server.py +9 -24
  75. foundry_mcp/tools/unified/spec.py +367 -0
  76. foundry_mcp/tools/unified/task.py +1664 -30
  77. foundry_mcp/tools/unified/test.py +69 -8
  78. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
  79. foundry_mcp-0.8.10.dist-info/RECORD +153 -0
  80. foundry_mcp/cli/flags.py +0 -266
  81. foundry_mcp/core/feature_flags.py +0 -592
  82. foundry_mcp-0.3.3.dist-info/RECORD +0 -135
  83. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  84. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  85. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,933 @@
1
+ """
2
+ Intake storage backend for the bikelane fast-capture system.
3
+
4
+ Provides thread-safe JSONL-based storage for intake items with file locking
5
+ for concurrent access. Items are stored in specs/.bikelane/intake.jsonl.
6
+
7
+ Storage Structure:
8
+ specs/.bikelane/
9
+ intake.jsonl - Append-only intake log
10
+ .intake.lock - Lock file for cross-process synchronization
11
+ intake.YYYY-MM.jsonl - Archived intake files (after rotation)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import fcntl
18
+ import json
19
+ import logging
20
+ import os
21
+ import re
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+ from foundry_mcp.core.security import is_prompt_injection
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Security constants
35
+ LOG_DESCRIPTION_MAX_LENGTH = 100 # Max chars for descriptions in logs
36
+
37
+ # Constants
38
+ LOCK_TIMEOUT_SECONDS = 5.0
39
+ ROTATION_ITEM_THRESHOLD = 1000
40
+ ROTATION_SIZE_THRESHOLD = 1024 * 1024 # 1MB
41
+ IDEMPOTENCY_SCAN_LIMIT = 100
42
+ DEFAULT_PAGE_SIZE = 50
43
+ MAX_PAGE_SIZE = 200
44
+
45
+ # Validation patterns
46
+ INTAKE_ID_PATTERN = re.compile(r"^intake-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$")
47
+ TAG_PATTERN = re.compile(r"^[a-z0-9_-]+$")
48
+
49
+
50
+ @dataclass
51
+ class IntakeItem:
52
+ """
53
+ Represents a single intake item in the bikelane queue.
54
+
55
+ Attributes:
56
+ schema_version: Fixed value 'intake-v1'
57
+ id: Unique identifier in format 'intake-<uuid4>'
58
+ title: Brief title (1-140 chars)
59
+ status: 'new' or 'dismissed'
60
+ created_at: ISO 8601 UTC timestamp
61
+ updated_at: ISO 8601 UTC timestamp
62
+ description: Optional detailed description (max 2000 chars)
63
+ priority: Priority level p0-p4 (default p2)
64
+ tags: List of lowercase tags (max 20, each 1-32 chars)
65
+ source: Origin of the intake item (max 100 chars)
66
+ requester: Person/entity who requested (max 100 chars)
67
+ idempotency_key: Optional client key for deduplication (max 64 chars)
68
+ """
69
+ schema_version: str = "intake-v1"
70
+ id: str = ""
71
+ title: str = ""
72
+ status: str = "new"
73
+ created_at: str = ""
74
+ updated_at: str = ""
75
+ description: Optional[str] = None
76
+ priority: str = "p2"
77
+ tags: list[str] = field(default_factory=list)
78
+ source: Optional[str] = None
79
+ requester: Optional[str] = None
80
+ idempotency_key: Optional[str] = None
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Convert to dictionary, excluding None values for optional fields."""
84
+ result = {
85
+ "schema_version": self.schema_version,
86
+ "id": self.id,
87
+ "title": self.title,
88
+ "status": self.status,
89
+ "created_at": self.created_at,
90
+ "updated_at": self.updated_at,
91
+ "priority": self.priority,
92
+ "tags": self.tags,
93
+ }
94
+ if self.description is not None:
95
+ result["description"] = self.description
96
+ if self.source is not None:
97
+ result["source"] = self.source
98
+ if self.requester is not None:
99
+ result["requester"] = self.requester
100
+ if self.idempotency_key is not None:
101
+ result["idempotency_key"] = self.idempotency_key
102
+ return result
103
+
104
+ @classmethod
105
+ def from_dict(cls, data: dict[str, Any]) -> "IntakeItem":
106
+ """Create an IntakeItem from a dictionary."""
107
+ return cls(
108
+ schema_version=data.get("schema_version", "intake-v1"),
109
+ id=data.get("id", ""),
110
+ title=data.get("title", ""),
111
+ status=data.get("status", "new"),
112
+ created_at=data.get("created_at", ""),
113
+ updated_at=data.get("updated_at", ""),
114
+ description=data.get("description"),
115
+ priority=data.get("priority", "p2"),
116
+ tags=data.get("tags", []),
117
+ source=data.get("source"),
118
+ requester=data.get("requester"),
119
+ idempotency_key=data.get("idempotency_key"),
120
+ )
121
+
122
+
123
+ @dataclass
124
+ class PaginationCursor:
125
+ """Cursor for pagination through intake items."""
126
+ last_id: str
127
+ line_hint: int
128
+ version: int = 1
129
+
130
+ def encode(self) -> str:
131
+ """Encode cursor to base64 string."""
132
+ payload = {
133
+ "last_id": self.last_id,
134
+ "line_hint": self.line_hint,
135
+ "version": self.version,
136
+ }
137
+ json_bytes = json.dumps(payload).encode("utf-8")
138
+ return base64.b64encode(json_bytes).decode("ascii")
139
+
140
+ @classmethod
141
+ def decode(cls, encoded: str) -> Optional["PaginationCursor"]:
142
+ """Decode cursor from base64 string. Returns None if invalid."""
143
+ try:
144
+ json_bytes = base64.b64decode(encoded.encode("ascii"))
145
+ payload = json.loads(json_bytes.decode("utf-8"))
146
+ return cls(
147
+ last_id=payload.get("last_id", ""),
148
+ line_hint=payload.get("line_hint", 0),
149
+ version=payload.get("version", 1),
150
+ )
151
+ except (ValueError, json.JSONDecodeError, KeyError) as e:
152
+ logger.warning(f"Failed to decode pagination cursor: {e}")
153
+ return None
154
+
155
+
156
+ class LockAcquisitionError(Exception):
157
+ """Raised when file lock cannot be acquired within timeout."""
158
+ pass
159
+
160
+
161
+ class IntakeStore:
162
+ """
163
+ JSONL-based intake storage with thread-safe file locking.
164
+
165
+ Provides append-only storage for intake items with:
166
+ - fcntl file locking for cross-process safety
167
+ - threading.Lock for in-memory thread safety
168
+ - Atomic writes via temp file rename pattern
169
+ - Cursor-based pagination with line hints
170
+ - File rotation when thresholds are exceeded
171
+
172
+ Directory structure:
173
+ specs/.bikelane/
174
+ intake.jsonl - Active intake items
175
+ .intake.lock - Lock file for synchronization
176
+ intake.YYYY-MM.jsonl - Archived files (rotated)
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ specs_dir: str | Path,
182
+ workspace_root: Optional[str | Path] = None,
183
+ bikelane_dir: Optional[str | Path] = None,
184
+ ):
185
+ """
186
+ Initialize the intake store.
187
+
188
+ Args:
189
+ specs_dir: Path to the specs directory (e.g., /workspace/specs)
190
+ workspace_root: Optional workspace root for path validation.
191
+ Defaults to specs_dir parent.
192
+ bikelane_dir: Optional custom path for bikelane storage.
193
+ Defaults to specs_dir/.bikelane if not provided.
194
+
195
+ Raises:
196
+ ValueError: If specs_dir or bikelane_dir is outside the workspace root
197
+ (path traversal attempt)
198
+ """
199
+ self.specs_dir = Path(specs_dir).resolve()
200
+ self.workspace_root = Path(workspace_root).resolve() if workspace_root else self.specs_dir.parent
201
+
202
+ # Validate specs_dir is within workspace (prevent path traversal)
203
+ if not self._validate_path_in_workspace(self.specs_dir):
204
+ raise ValueError(
205
+ f"specs_dir '{specs_dir}' is outside workspace root '{self.workspace_root}'"
206
+ )
207
+
208
+ # Use custom bikelane_dir if provided, otherwise default to specs/.bikelane
209
+ if bikelane_dir is not None:
210
+ self.bikelane_dir = Path(bikelane_dir).resolve()
211
+ # Validate bikelane_dir is within workspace
212
+ if not self._validate_path_in_workspace(self.bikelane_dir):
213
+ raise ValueError(
214
+ f"bikelane_dir '{bikelane_dir}' is outside workspace root '{self.workspace_root}'"
215
+ )
216
+ else:
217
+ self.bikelane_dir = self.specs_dir / ".bikelane"
218
+
219
+ self.intake_file = self.bikelane_dir / "intake.jsonl"
220
+ self.lock_file = self.bikelane_dir / ".intake.lock"
221
+
222
+ self._thread_lock = threading.Lock()
223
+ self._ensure_directory()
224
+
225
+ def _ensure_directory(self) -> None:
226
+ """Ensure the bikelane directory exists."""
227
+ self.bikelane_dir.mkdir(parents=True, exist_ok=True)
228
+
229
+ def _validate_path_in_workspace(self, path: Path) -> bool:
230
+ """
231
+ Validate that a path is within the workspace sandbox.
232
+
233
+ Uses resolve() + relative_to() pattern to prevent path traversal attacks
234
+ via symlinks or .. components.
235
+
236
+ Args:
237
+ path: Path to validate (will be resolved)
238
+
239
+ Returns:
240
+ True if path is within workspace_root, False otherwise
241
+ """
242
+ try:
243
+ resolved = path.resolve()
244
+ resolved.relative_to(self.workspace_root)
245
+ return True
246
+ except ValueError:
247
+ logger.warning(
248
+ f"Path traversal attempt blocked: '{path}' is outside workspace"
249
+ )
250
+ return False
251
+
252
+ def _acquire_lock(self, exclusive: bool = True, timeout: float = LOCK_TIMEOUT_SECONDS) -> int:
253
+ """
254
+ Acquire file lock with timeout.
255
+
256
+ Args:
257
+ exclusive: If True, acquire exclusive lock (for writes).
258
+ If False, acquire shared lock (for reads).
259
+ timeout: Maximum time to wait for lock in seconds.
260
+
261
+ Returns:
262
+ File descriptor for the lock file.
263
+
264
+ Raises:
265
+ LockAcquisitionError: If lock cannot be acquired within timeout.
266
+ """
267
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
268
+
269
+ # Ensure lock file exists
270
+ self.lock_file.touch(exist_ok=True)
271
+
272
+ fd = os.open(str(self.lock_file), os.O_RDWR | os.O_CREAT)
273
+
274
+ start_time = time.monotonic()
275
+ while True:
276
+ try:
277
+ fcntl.flock(fd, lock_type | fcntl.LOCK_NB)
278
+ return fd
279
+ except (IOError, OSError):
280
+ elapsed = time.monotonic() - start_time
281
+ if elapsed >= timeout:
282
+ os.close(fd)
283
+ raise LockAcquisitionError(
284
+ f"Failed to acquire {'exclusive' if exclusive else 'shared'} lock "
285
+ f"within {timeout} seconds"
286
+ )
287
+ time.sleep(0.01) # 10ms between retries
288
+
289
+ def _release_lock(self, fd: int) -> None:
290
+ """Release file lock and close file descriptor."""
291
+ try:
292
+ fcntl.flock(fd, fcntl.LOCK_UN)
293
+ finally:
294
+ os.close(fd)
295
+
296
+ def _generate_id(self) -> str:
297
+ """Generate a new intake ID."""
298
+ return f"intake-{uuid.uuid4()}"
299
+
300
+ def _now_utc(self) -> str:
301
+ """Get current UTC timestamp in ISO 8601 format with Z suffix."""
302
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
303
+
304
+ def _strip_control_chars(self, text: str) -> str:
305
+ """Strip control characters from text for security."""
306
+ if not text:
307
+ return text
308
+ # Remove ASCII control characters (0x00-0x1F) except common whitespace
309
+ return "".join(
310
+ char for char in text
311
+ if ord(char) >= 32 or char in "\n\r\t"
312
+ )
313
+
314
+ def _truncate_for_log(self, text: str, max_length: int = LOG_DESCRIPTION_MAX_LENGTH) -> str:
315
+ """
316
+ Truncate text for safe logging.
317
+
318
+ Args:
319
+ text: Text to truncate
320
+ max_length: Maximum length (default: LOG_DESCRIPTION_MAX_LENGTH)
321
+
322
+ Returns:
323
+ Truncated text with ellipsis if needed
324
+ """
325
+ if not text or len(text) <= max_length:
326
+ return text or ""
327
+ return text[:max_length] + "..."
328
+
329
+ def _sanitize_for_injection(self, text: str, field_name: str) -> str:
330
+ """
331
+ Check text for prompt injection patterns and sanitize if found.
332
+
333
+ Args:
334
+ text: Text to check
335
+ field_name: Name of field for logging
336
+
337
+ Returns:
338
+ Original text if clean, sanitized text if injection detected
339
+ """
340
+ if not text:
341
+ return text
342
+
343
+ if is_prompt_injection(text):
344
+ logger.warning(
345
+ f"Prompt injection pattern detected in {field_name}, "
346
+ f"input sanitized (preview: {self._truncate_for_log(text, 50)})"
347
+ )
348
+ # Replace known injection patterns with safe placeholder
349
+ # We strip the offending patterns rather than rejecting entirely
350
+ # to maintain usability while preventing the attack
351
+ sanitized = re.sub(
352
+ r"ignore\s+(all\s+)?(previous|prior)\s+(instructions?|prompts?)",
353
+ "[SANITIZED]",
354
+ text,
355
+ flags=re.IGNORECASE,
356
+ )
357
+ sanitized = re.sub(
358
+ r"disregard\s+(all\s+)?(previous|prior|above)",
359
+ "[SANITIZED]",
360
+ sanitized,
361
+ flags=re.IGNORECASE,
362
+ )
363
+ sanitized = re.sub(
364
+ r"system\s*:\s*",
365
+ "[SANITIZED]",
366
+ sanitized,
367
+ flags=re.IGNORECASE,
368
+ )
369
+ sanitized = re.sub(
370
+ r"<\s*system\s*>",
371
+ "[SANITIZED]",
372
+ sanitized,
373
+ flags=re.IGNORECASE,
374
+ )
375
+ return sanitized
376
+
377
+ return text
378
+
379
+ def _normalize_tags(self, tags: list[str]) -> list[str]:
380
+ """Normalize tags to lowercase and validate format."""
381
+ normalized = []
382
+ for tag in tags:
383
+ tag_lower = tag.lower().strip()
384
+ if tag_lower and TAG_PATTERN.match(tag_lower):
385
+ normalized.append(tag_lower)
386
+ return normalized
387
+
388
+ def _count_items(self) -> int:
389
+ """Count total items in the intake file."""
390
+ if not self.intake_file.exists():
391
+ return 0
392
+ try:
393
+ with open(self.intake_file, "r") as f:
394
+ return sum(1 for line in f if line.strip())
395
+ except OSError:
396
+ return 0
397
+
398
+ def _get_file_size(self) -> int:
399
+ """Get size of the intake file in bytes."""
400
+ if not self.intake_file.exists():
401
+ return 0
402
+ try:
403
+ return self.intake_file.stat().st_size
404
+ except OSError:
405
+ return 0
406
+
407
+ def _should_rotate(self) -> bool:
408
+ """Check if file rotation is needed."""
409
+ item_count = self._count_items()
410
+ file_size = self._get_file_size()
411
+ return item_count >= ROTATION_ITEM_THRESHOLD or file_size >= ROTATION_SIZE_THRESHOLD
412
+
413
+ def _get_oldest_item_date(self) -> Optional[str]:
414
+ """Get the created_at date of the oldest item for archive naming."""
415
+ if not self.intake_file.exists():
416
+ return None
417
+ try:
418
+ with open(self.intake_file, "r") as f:
419
+ for line in f:
420
+ line = line.strip()
421
+ if line:
422
+ try:
423
+ item = json.loads(line)
424
+ return item.get("created_at", "")[:7] # YYYY-MM
425
+ except json.JSONDecodeError:
426
+ continue
427
+ except OSError:
428
+ pass
429
+ return None
430
+
431
+ def rotate_if_needed(self) -> Optional[str]:
432
+ """
433
+ Rotate the intake file if thresholds are exceeded.
434
+
435
+ Returns:
436
+ Path to archive file if rotation occurred, None otherwise.
437
+ """
438
+ with self._thread_lock:
439
+ if not self._should_rotate():
440
+ return None
441
+
442
+ # Get date for archive naming
443
+ date_str = self._get_oldest_item_date()
444
+ if not date_str:
445
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m")
446
+
447
+ # Generate unique archive name
448
+ archive_name = f"intake.{date_str}.jsonl"
449
+ archive_path = self.bikelane_dir / archive_name
450
+
451
+ # If archive already exists, add a suffix
452
+ counter = 1
453
+ while archive_path.exists():
454
+ archive_name = f"intake.{date_str}.{counter}.jsonl"
455
+ archive_path = self.bikelane_dir / archive_name
456
+ counter += 1
457
+
458
+ fd = None
459
+ try:
460
+ fd = self._acquire_lock(exclusive=True)
461
+
462
+ # Rename current file to archive
463
+ if self.intake_file.exists():
464
+ self.intake_file.rename(archive_path)
465
+ logger.info(f"Rotated intake file to {archive_path}")
466
+
467
+ # Create fresh intake file
468
+ self.intake_file.touch()
469
+
470
+ return str(archive_path)
471
+
472
+ except LockAcquisitionError:
473
+ logger.error("Failed to acquire lock for file rotation")
474
+ raise
475
+ finally:
476
+ if fd is not None:
477
+ self._release_lock(fd)
478
+
479
+ return None
480
+
481
+ def add(
482
+ self,
483
+ *,
484
+ title: str,
485
+ description: Optional[str] = None,
486
+ priority: str = "p2",
487
+ tags: Optional[list[str]] = None,
488
+ source: Optional[str] = None,
489
+ requester: Optional[str] = None,
490
+ idempotency_key: Optional[str] = None,
491
+ dry_run: bool = False,
492
+ ) -> tuple[IntakeItem, bool, float]:
493
+ """
494
+ Add a new intake item.
495
+
496
+ Args:
497
+ title: Item title (1-140 chars, required)
498
+ description: Detailed description (max 2000 chars)
499
+ priority: Priority level p0-p4 (default p2)
500
+ tags: List of tags (max 20, normalized to lowercase)
501
+ source: Origin of the item (max 100 chars)
502
+ requester: Who requested the work (max 100 chars)
503
+ idempotency_key: Key for deduplication (max 64 chars)
504
+ dry_run: If True, validate but don't persist
505
+
506
+ Returns:
507
+ Tuple of (created IntakeItem, was_duplicate, lock_wait_ms)
508
+
509
+ Raises:
510
+ LockAcquisitionError: If lock cannot be acquired within timeout.
511
+ """
512
+ now = self._now_utc()
513
+ lock_wait_ms = 0.0
514
+
515
+ # Sanitize inputs - strip control characters
516
+ title = self._strip_control_chars(title)
517
+ if description:
518
+ description = self._strip_control_chars(description)
519
+ if source:
520
+ source = self._strip_control_chars(source)
521
+ if requester:
522
+ requester = self._strip_control_chars(requester)
523
+
524
+ # Sanitize title and description for prompt injection patterns
525
+ title = self._sanitize_for_injection(title, "title")
526
+ if description:
527
+ description = self._sanitize_for_injection(description, "description")
528
+
529
+ # Normalize tags
530
+ normalized_tags = self._normalize_tags(tags or [])
531
+
532
+ # Create item
533
+ item = IntakeItem(
534
+ id=self._generate_id(),
535
+ title=title,
536
+ description=description,
537
+ status="new",
538
+ priority=priority,
539
+ tags=normalized_tags,
540
+ source=source,
541
+ requester=requester,
542
+ idempotency_key=idempotency_key,
543
+ created_at=now,
544
+ updated_at=now,
545
+ )
546
+
547
+ if dry_run:
548
+ return item, False, lock_wait_ms
549
+
550
+ with self._thread_lock:
551
+ fd = None
552
+ lock_start = time.monotonic()
553
+
554
+ try:
555
+ fd = self._acquire_lock(exclusive=True)
556
+ lock_wait_ms = (time.monotonic() - lock_start) * 1000
557
+
558
+ # Check for idempotency key duplicate
559
+ if idempotency_key:
560
+ existing = self._find_by_idempotency_key(idempotency_key)
561
+ if existing:
562
+ return existing, True, lock_wait_ms
563
+
564
+ # Check rotation threshold
565
+ if self._should_rotate():
566
+ self._release_lock(fd)
567
+ fd = None
568
+ self.rotate_if_needed()
569
+ fd = self._acquire_lock(exclusive=True)
570
+
571
+ # Append to file
572
+ with open(self.intake_file, "a") as f:
573
+ f.write(json.dumps(item.to_dict()) + "\n")
574
+ f.flush()
575
+
576
+ logger.info(
577
+ f"Added intake item: {item.id} "
578
+ f"(title: {self._truncate_for_log(item.title)})"
579
+ )
580
+ return item, False, lock_wait_ms
581
+
582
+ finally:
583
+ if fd is not None:
584
+ self._release_lock(fd)
585
+
586
+ def _find_by_idempotency_key(self, key: str) -> Optional[IntakeItem]:
587
+ """
588
+ Search for an item by idempotency key in the last N items.
589
+
590
+ Note: This should be called while holding the lock.
591
+ """
592
+ if not self.intake_file.exists():
593
+ return None
594
+
595
+ # Read last IDEMPOTENCY_SCAN_LIMIT lines
596
+ try:
597
+ with open(self.intake_file, "r") as f:
598
+ lines = f.readlines()
599
+
600
+ # Check last N items in reverse order
601
+ for line in reversed(lines[-IDEMPOTENCY_SCAN_LIMIT:]):
602
+ line = line.strip()
603
+ if not line:
604
+ continue
605
+ try:
606
+ data = json.loads(line)
607
+ if data.get("idempotency_key") == key:
608
+ return IntakeItem.from_dict(data)
609
+ except json.JSONDecodeError:
610
+ continue
611
+
612
+ except OSError as e:
613
+ logger.warning(f"Error reading intake file for idempotency check: {e}")
614
+
615
+ return None
616
+
617
+ def list_new(
618
+ self,
619
+ *,
620
+ cursor: Optional[str] = None,
621
+ limit: int = DEFAULT_PAGE_SIZE,
622
+ ) -> tuple[list[IntakeItem], int, Optional[str], bool, float]:
623
+ """
624
+ List intake items with status='new' in FIFO order.
625
+
626
+ Args:
627
+ cursor: Pagination cursor from previous call
628
+ limit: Maximum items to return (1-200)
629
+
630
+ Returns:
631
+ Tuple of (items, total_count, next_cursor, has_more, lock_wait_ms)
632
+
633
+ Note: This operation is O(n) where n = total items in file.
634
+ File rotation bounds n to ~1000 items.
635
+ """
636
+ limit = max(1, min(limit, MAX_PAGE_SIZE))
637
+ lock_wait_ms = 0.0
638
+
639
+ with self._thread_lock:
640
+ fd = None
641
+ lock_start = time.monotonic()
642
+
643
+ try:
644
+ fd = self._acquire_lock(exclusive=False) # Shared lock for reads
645
+ lock_wait_ms = (time.monotonic() - lock_start) * 1000
646
+
647
+ if not self.intake_file.exists():
648
+ return [], 0, None, False, lock_wait_ms
649
+
650
+ # Parse cursor if provided
651
+ parsed_cursor: Optional[PaginationCursor] = None
652
+ if cursor:
653
+ parsed_cursor = PaginationCursor.decode(cursor)
654
+ if parsed_cursor is None:
655
+ logger.warning("Invalid cursor provided, starting from beginning")
656
+
657
+ items: list[IntakeItem] = []
658
+ total_count = 0
659
+ found_cursor_position = parsed_cursor is None # True if no cursor
660
+ line_num = 0
661
+ cursor_fallback = False
662
+
663
+ with open(self.intake_file, "r") as f:
664
+ # Try to seek to line hint if cursor provided
665
+ if parsed_cursor and parsed_cursor.line_hint > 0:
666
+ # Read up to line_hint to find position
667
+ for i in range(parsed_cursor.line_hint):
668
+ line = f.readline()
669
+ if not line:
670
+ break
671
+ line_num += 1
672
+ # Still need to count new items for total
673
+ line = line.strip()
674
+ if line:
675
+ try:
676
+ data = json.loads(line)
677
+ if data.get("status") == "new":
678
+ total_count += 1
679
+ except json.JSONDecodeError:
680
+ pass
681
+
682
+ # Check if we found the cursor position
683
+ line = f.readline()
684
+ if line:
685
+ try:
686
+ data = json.loads(line.strip())
687
+ if data.get("id") != parsed_cursor.last_id:
688
+ # Line hint didn't match, need full scan
689
+ cursor_fallback = True
690
+ logger.warning(
691
+ f"Cursor line hint mismatch, falling back to full scan"
692
+ )
693
+ f.seek(0)
694
+ line_num = 0
695
+ total_count = 0
696
+ else:
697
+ # Found cursor position, count this item
698
+ if data.get("status") == "new":
699
+ total_count += 1
700
+ found_cursor_position = True
701
+ line_num += 1
702
+ except json.JSONDecodeError:
703
+ cursor_fallback = True
704
+ f.seek(0)
705
+ line_num = 0
706
+ total_count = 0
707
+
708
+ # Read remaining lines
709
+ for line in f:
710
+ current_line = line_num
711
+ line_num += 1
712
+ line = line.strip()
713
+ if not line:
714
+ continue
715
+
716
+ try:
717
+ data = json.loads(line)
718
+ except json.JSONDecodeError:
719
+ continue
720
+
721
+ # Only count 'new' items
722
+ if data.get("status") != "new":
723
+ continue
724
+
725
+ total_count += 1
726
+
727
+ # Handle cursor position finding
728
+ if not found_cursor_position:
729
+ if data.get("id") == parsed_cursor.last_id:
730
+ found_cursor_position = True
731
+ continue
732
+
733
+ # Collect items up to limit
734
+ if len(items) < limit:
735
+ items.append(IntakeItem.from_dict(data))
736
+
737
+ # Build next cursor
738
+ next_cursor: Optional[str] = None
739
+ has_more = len(items) == limit and total_count > len(items)
740
+
741
+ if items and has_more:
742
+ last_item = items[-1]
743
+ # Estimate line position (not perfect but helpful)
744
+ next_cursor = PaginationCursor(
745
+ last_id=last_item.id,
746
+ line_hint=line_num - 1,
747
+ ).encode()
748
+
749
+ if cursor_fallback and lock_wait_ms > 1000:
750
+ logger.warning(f"Cursor fallback with long lock wait: {lock_wait_ms:.2f}ms")
751
+
752
+ return items, total_count, next_cursor, has_more, lock_wait_ms
753
+
754
+ finally:
755
+ if fd is not None:
756
+ self._release_lock(fd)
757
+
758
+ def dismiss(
759
+ self,
760
+ intake_id: str,
761
+ *,
762
+ reason: Optional[str] = None,
763
+ dry_run: bool = False,
764
+ ) -> tuple[Optional[IntakeItem], float]:
765
+ """
766
+ Dismiss an intake item by changing its status to 'dismissed'.
767
+
768
+ Args:
769
+ intake_id: The intake item ID to dismiss
770
+ reason: Optional reason for dismissal (max 200 chars)
771
+ dry_run: If True, find item but don't modify
772
+
773
+ Returns:
774
+ Tuple of (updated IntakeItem or None if not found, lock_wait_ms)
775
+
776
+ Raises:
777
+ LockAcquisitionError: If lock cannot be acquired within timeout.
778
+ """
779
+ lock_wait_ms = 0.0
780
+ now = self._now_utc()
781
+
782
+ # Sanitize reason
783
+ if reason:
784
+ reason = self._strip_control_chars(reason)
785
+
786
+ with self._thread_lock:
787
+ fd = None
788
+ lock_start = time.monotonic()
789
+
790
+ try:
791
+ fd = self._acquire_lock(exclusive=True)
792
+ lock_wait_ms = (time.monotonic() - lock_start) * 1000
793
+
794
+ if not self.intake_file.exists():
795
+ return None, lock_wait_ms
796
+
797
+ # Read all lines
798
+ with open(self.intake_file, "r") as f:
799
+ lines = f.readlines()
800
+
801
+ # Find and update the item
802
+ found_item: Optional[IntakeItem] = None
803
+ updated_lines: list[str] = []
804
+
805
+ for line in lines:
806
+ stripped = line.strip()
807
+ if not stripped:
808
+ updated_lines.append(line)
809
+ continue
810
+
811
+ try:
812
+ data = json.loads(stripped)
813
+ except json.JSONDecodeError:
814
+ updated_lines.append(line)
815
+ continue
816
+
817
+ if data.get("id") == intake_id:
818
+ # Found the item
819
+ data["status"] = "dismissed"
820
+ data["updated_at"] = now
821
+ found_item = IntakeItem.from_dict(data)
822
+ updated_lines.append(json.dumps(data) + "\n")
823
+ else:
824
+ updated_lines.append(line)
825
+
826
+ if found_item is None:
827
+ return None, lock_wait_ms
828
+
829
+ if dry_run:
830
+ return found_item, lock_wait_ms
831
+
832
+ # Atomic write via temp file
833
+ temp_file = self.intake_file.with_suffix(".tmp")
834
+ with open(temp_file, "w") as f:
835
+ f.writelines(updated_lines)
836
+ f.flush()
837
+ os.fsync(f.fileno())
838
+
839
+ temp_file.rename(self.intake_file)
840
+
841
+ logger.info(f"Dismissed intake item: {intake_id}")
842
+ return found_item, lock_wait_ms
843
+
844
+ finally:
845
+ if fd is not None:
846
+ self._release_lock(fd)
847
+
848
+ def get(self, intake_id: str) -> tuple[Optional[IntakeItem], float]:
849
+ """
850
+ Get a single intake item by ID.
851
+
852
+ Args:
853
+ intake_id: The intake item ID to retrieve
854
+
855
+ Returns:
856
+ Tuple of (IntakeItem or None if not found, lock_wait_ms)
857
+ """
858
+ lock_wait_ms = 0.0
859
+
860
+ with self._thread_lock:
861
+ fd = None
862
+ lock_start = time.monotonic()
863
+
864
+ try:
865
+ fd = self._acquire_lock(exclusive=False)
866
+ lock_wait_ms = (time.monotonic() - lock_start) * 1000
867
+
868
+ if not self.intake_file.exists():
869
+ return None, lock_wait_ms
870
+
871
+ with open(self.intake_file, "r") as f:
872
+ for line in f:
873
+ line = line.strip()
874
+ if not line:
875
+ continue
876
+ try:
877
+ data = json.loads(line)
878
+ if data.get("id") == intake_id:
879
+ return IntakeItem.from_dict(data), lock_wait_ms
880
+ except json.JSONDecodeError:
881
+ continue
882
+
883
+ return None, lock_wait_ms
884
+
885
+ finally:
886
+ if fd is not None:
887
+ self._release_lock(fd)
888
+
889
+ @property
890
+ def intake_path(self) -> str:
891
+ """Get the absolute path to the intake file."""
892
+ return str(self.intake_file.absolute())
893
+
894
+
895
+ # Global store instance
896
+ _intake_store: Optional[IntakeStore] = None
897
+ _store_lock = threading.Lock()
898
+
899
+
900
+ def get_intake_store(
901
+ specs_dir: Optional[str | Path] = None,
902
+ bikelane_dir: Optional[str | Path] = None,
903
+ ) -> IntakeStore:
904
+ """
905
+ Get the global intake store instance.
906
+
907
+ Args:
908
+ specs_dir: Path to specs directory. Required on first call.
909
+ bikelane_dir: Optional custom path for bikelane storage.
910
+ Defaults to specs_dir/.bikelane if not provided.
911
+
912
+ Returns:
913
+ The IntakeStore instance.
914
+
915
+ Raises:
916
+ ValueError: If specs_dir not provided on first call.
917
+ """
918
+ global _intake_store
919
+
920
+ with _store_lock:
921
+ if _intake_store is None:
922
+ if specs_dir is None:
923
+ raise ValueError("specs_dir required for first IntakeStore initialization")
924
+ _intake_store = IntakeStore(specs_dir, bikelane_dir=bikelane_dir)
925
+
926
+ return _intake_store
927
+
928
+
929
+ def reset_intake_store() -> None:
930
+ """Reset the global intake store (for testing)."""
931
+ global _intake_store
932
+ with _store_lock:
933
+ _intake_store = None