feed-the-machine 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +170 -170
- package/bin/brain.py +1340 -0
- package/bin/convert_claude_skills_to_codex.py +490 -0
- package/bin/generate-manifest.mjs +463 -463
- package/bin/harden_codex_skills.py +141 -0
- package/bin/install.mjs +491 -491
- package/bin/migrate-eng-buddy-data.py +875 -0
- package/bin/playbook_engine/__init__.py +1 -0
- package/bin/playbook_engine/conftest.py +8 -0
- package/bin/playbook_engine/extractor.py +33 -0
- package/bin/playbook_engine/manager.py +102 -0
- package/bin/playbook_engine/models.py +84 -0
- package/bin/playbook_engine/registry.py +35 -0
- package/bin/playbook_engine/test_extractor.py +72 -0
- package/bin/playbook_engine/test_integration.py +129 -0
- package/bin/playbook_engine/test_manager.py +85 -0
- package/bin/playbook_engine/test_models.py +166 -0
- package/bin/playbook_engine/test_registry.py +67 -0
- package/bin/playbook_engine/test_tracer.py +86 -0
- package/bin/playbook_engine/tracer.py +93 -0
- package/bin/tasks_db.py +456 -0
- package/docs/HOOKS.md +243 -243
- package/docs/INBOX.md +233 -233
- package/ftm/SKILL.md +125 -122
- package/ftm-audit/SKILL.md +623 -623
- package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
- package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
- package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
- package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
- package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
- package/ftm-audit/scripts/run-knip.sh +23 -23
- package/ftm-audit.yml +2 -2
- package/ftm-brainstorm/SKILL.md +1003 -498
- package/ftm-brainstorm/evals/evals.json +180 -100
- package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
- package/ftm-brainstorm/references/agent-prompts.md +552 -224
- package/ftm-brainstorm/references/plan-template.md +209 -121
- package/ftm-brainstorm.yml +2 -2
- package/ftm-browse/SKILL.md +454 -454
- package/ftm-browse/daemon/browser-manager.ts +206 -206
- package/ftm-browse/daemon/bun.lock +30 -30
- package/ftm-browse/daemon/cli.ts +347 -347
- package/ftm-browse/daemon/commands.ts +410 -410
- package/ftm-browse/daemon/main.ts +357 -357
- package/ftm-browse/daemon/package.json +17 -17
- package/ftm-browse/daemon/server.ts +189 -189
- package/ftm-browse/daemon/snapshot.ts +519 -519
- package/ftm-browse/daemon/tsconfig.json +22 -22
- package/ftm-browse.yml +4 -4
- package/ftm-capture/SKILL.md +370 -370
- package/ftm-capture.yml +4 -4
- package/ftm-codex-gate/SKILL.md +361 -361
- package/ftm-codex-gate.yml +2 -2
- package/ftm-config/SKILL.md +422 -345
- package/ftm-config.default.yml +125 -82
- package/ftm-config.yml +44 -2
- package/ftm-council/SKILL.md +416 -416
- package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
- package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
- package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
- package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
- package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
- package/ftm-council.yml +2 -2
- package/ftm-dashboard/SKILL.md +163 -163
- package/ftm-dashboard.yml +4 -4
- package/ftm-debug/SKILL.md +1037 -1037
- package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
- package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
- package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
- package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
- package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
- package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
- package/ftm-debug.yml +2 -2
- package/ftm-diagram/SKILL.md +277 -277
- package/ftm-diagram.yml +2 -2
- package/ftm-executor/SKILL.md +777 -777
- package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
- package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
- package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
- package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
- package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +72 -72
- package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
- package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
- package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
- package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
- package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
- package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
- package/ftm-executor/runtime/package.json +8 -8
- package/ftm-executor.yml +2 -2
- package/ftm-git/SKILL.md +441 -441
- package/ftm-git/evals/evals.json +26 -26
- package/ftm-git/evals/promptfoo.yaml +75 -75
- package/ftm-git/hooks/post-commit-experience.sh +92 -92
- package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
- package/ftm-git/references/protocols/REMEDIATION.md +139 -139
- package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
- package/ftm-git.yml +2 -2
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -64
- package/ftm-inbox/backend/adapters/base.py +230 -230
- package/ftm-inbox/backend/adapters/freshservice.py +104 -104
- package/ftm-inbox/backend/adapters/gmail.py +125 -125
- package/ftm-inbox/backend/adapters/jira.py +136 -136
- package/ftm-inbox/backend/adapters/registry.py +192 -192
- package/ftm-inbox/backend/adapters/slack.py +110 -110
- package/ftm-inbox/backend/db/connection.py +54 -54
- package/ftm-inbox/backend/db/schema.py +78 -78
- package/ftm-inbox/backend/executor/__init__.py +7 -7
- package/ftm-inbox/backend/executor/engine.py +149 -149
- package/ftm-inbox/backend/executor/step_runner.py +98 -98
- package/ftm-inbox/backend/main.py +103 -103
- package/ftm-inbox/backend/models/__init__.py +1 -1
- package/ftm-inbox/backend/models/unified_task.py +36 -36
- package/ftm-inbox/backend/planner/__init__.py +6 -6
- package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/generator.py +127 -127
- package/ftm-inbox/backend/planner/schema.py +34 -34
- package/ftm-inbox/backend/requirements.txt +5 -5
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -186
- package/ftm-inbox/backend/routes/health.py +52 -52
- package/ftm-inbox/backend/routes/inbox.py +68 -68
- package/ftm-inbox/backend/routes/plan.py +271 -271
- package/ftm-inbox/bin/launchagent.mjs +91 -91
- package/ftm-inbox/bin/setup.mjs +188 -188
- package/ftm-inbox/bin/start.sh +10 -10
- package/ftm-inbox/bin/status.sh +17 -17
- package/ftm-inbox/bin/stop.sh +8 -8
- package/ftm-inbox/config.example.yml +55 -55
- package/ftm-inbox/package-lock.json +2898 -2898
- package/ftm-inbox/package.json +26 -26
- package/ftm-inbox/postcss.config.js +6 -6
- package/ftm-inbox/src/app.css +199 -199
- package/ftm-inbox/src/app.html +18 -18
- package/ftm-inbox/src/lib/api.ts +166 -166
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
- package/ftm-inbox/src/lib/theme.ts +47 -47
- package/ftm-inbox/src/routes/+layout.svelte +76 -76
- package/ftm-inbox/src/routes/+page.svelte +401 -401
- package/ftm-inbox/svelte.config.js +12 -12
- package/ftm-inbox/tailwind.config.ts +63 -63
- package/ftm-inbox/tsconfig.json +13 -13
- package/ftm-inbox/vite.config.ts +6 -6
- package/ftm-intent/SKILL.md +241 -241
- package/ftm-intent.yml +2 -2
- package/ftm-manifest.json +3794 -3794
- package/ftm-map/SKILL.md +291 -291
- package/ftm-map/scripts/db.py +712 -712
- package/ftm-map/scripts/index.py +415 -415
- package/ftm-map/scripts/parser.py +224 -224
- package/ftm-map/scripts/queries/go-tags.scm +20 -20
- package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
- package/ftm-map/scripts/queries/python-tags.scm +31 -31
- package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
- package/ftm-map/scripts/queries/rust-tags.scm +37 -37
- package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
- package/ftm-map/scripts/query.py +301 -301
- package/ftm-map/scripts/ranker.py +377 -377
- package/ftm-map/scripts/requirements.txt +5 -5
- package/ftm-map/scripts/setup-hooks.sh +27 -27
- package/ftm-map/scripts/setup.sh +56 -56
- package/ftm-map/scripts/test_db.py +364 -364
- package/ftm-map/scripts/test_parser.py +174 -174
- package/ftm-map/scripts/test_query.py +183 -183
- package/ftm-map/scripts/test_ranker.py +199 -199
- package/ftm-map/scripts/views.py +591 -591
- package/ftm-map.yml +2 -2
- package/ftm-mind/SKILL.md +201 -1943
- package/ftm-mind/evals/promptfoo.yaml +142 -142
- package/ftm-mind/references/blackboard-protocol.md +110 -0
- package/ftm-mind/references/blackboard-schema.md +328 -328
- package/ftm-mind/references/complexity-guide.md +110 -110
- package/ftm-mind/references/complexity-sizing.md +138 -0
- package/ftm-mind/references/decide-act-protocol.md +172 -0
- package/ftm-mind/references/direct-execution.md +51 -0
- package/ftm-mind/references/environment-discovery.md +77 -0
- package/ftm-mind/references/event-registry.md +319 -319
- package/ftm-mind/references/mcp-inventory.md +300 -296
- package/ftm-mind/references/ops-routing.md +47 -0
- package/ftm-mind/references/orient-protocol.md +234 -0
- package/ftm-mind/references/personality.md +40 -0
- package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
- package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
- package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
- package/ftm-mind/references/reflexion-protocol.md +249 -249
- package/ftm-mind/references/routing/SCENARIOS.md +22 -22
- package/ftm-mind/references/routing-scenarios.md +35 -35
- package/ftm-mind.yml +2 -2
- package/ftm-ops.yml +4 -0
- package/ftm-pause/SKILL.md +395 -395
- package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
- package/ftm-pause/references/protocols/VALIDATION.md +80 -80
- package/ftm-pause.yml +2 -2
- package/ftm-researcher/SKILL.md +275 -275
- package/ftm-researcher/evals/agent-diversity.yaml +17 -17
- package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
- package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
- package/ftm-researcher/references/adaptive-search.md +116 -116
- package/ftm-researcher/references/agent-prompts.md +193 -193
- package/ftm-researcher/references/council-integration.md +193 -193
- package/ftm-researcher/references/output-format.md +203 -203
- package/ftm-researcher/references/synthesis-pipeline.md +165 -165
- package/ftm-researcher/scripts/score_credibility.py +234 -234
- package/ftm-researcher/scripts/validate_research.py +92 -92
- package/ftm-researcher.yml +2 -2
- package/ftm-resume/SKILL.md +518 -518
- package/ftm-resume/references/protocols/VALIDATION.md +172 -172
- package/ftm-resume.yml +2 -2
- package/ftm-retro/SKILL.md +380 -380
- package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
- package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
- package/ftm-retro.yml +2 -2
- package/ftm-routine/SKILL.md +170 -170
- package/ftm-routine.yml +4 -4
- package/ftm-state/blackboard/capabilities.json +5 -5
- package/ftm-state/blackboard/capabilities.schema.json +27 -27
- package/ftm-state/blackboard/context.json +37 -23
- package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
- package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
- package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
- package/ftm-state/blackboard/experiences/index.json +58 -9
- package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
- package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
- package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
- package/ftm-state/blackboard/patterns.json +6 -6
- package/ftm-state/schemas/context.schema.json +130 -130
- package/ftm-state/schemas/experience-index.schema.json +77 -77
- package/ftm-state/schemas/experience.schema.json +78 -78
- package/ftm-state/schemas/patterns.schema.json +44 -44
- package/ftm-upgrade/SKILL.md +194 -194
- package/ftm-upgrade/scripts/check-version.sh +76 -76
- package/ftm-upgrade/scripts/upgrade.sh +143 -143
- package/ftm-upgrade.yml +2 -2
- package/ftm-verify.yml +2 -2
- package/ftm.yml +2 -2
- package/hooks/ftm-auto-log.sh +137 -0
- package/hooks/ftm-blackboard-enforcer.sh +93 -93
- package/hooks/ftm-discovery-reminder.sh +90 -90
- package/hooks/ftm-drafts-gate.sh +61 -61
- package/hooks/ftm-event-logger.mjs +107 -107
- package/hooks/ftm-install-hooks.sh +240 -0
- package/hooks/ftm-learning-capture.sh +117 -0
- package/hooks/ftm-map-autodetect.sh +79 -79
- package/hooks/ftm-pending-sync-check.sh +22 -22
- package/hooks/ftm-plan-gate.sh +92 -92
- package/hooks/ftm-post-commit-trigger.sh +57 -57
- package/hooks/ftm-post-compaction.sh +138 -0
- package/hooks/ftm-pre-compaction.sh +147 -0
- package/hooks/ftm-session-end.sh +52 -0
- package/hooks/ftm-session-snapshot.sh +213 -0
- package/hooks/settings-template.json +81 -81
- package/install.sh +363 -363
- package/package.json +84 -84
- package/uninstall.sh +25 -25
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Migrate eng-buddy markdown data to inbox.db ops tables.
|
|
3
|
+
|
|
4
|
+
Reads ~/.claude/eng-buddy/{daily,patterns,capacity,stakeholders}/ markdown files,
|
|
5
|
+
parses structured data with graceful fallback for inconsistent formatting, and
|
|
6
|
+
inserts into the ops tracking tables in inbox.db.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 bin/migrate-eng-buddy-data.py --dry-run # validate only
|
|
10
|
+
python3 bin/migrate-eng-buddy-data.py # full migration
|
|
11
|
+
python3 bin/migrate-eng-buddy-data.py --db-path /path/to/inbox.db
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Constants
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
ENG_BUDDY_DIR = Path.home() / ".claude" / "eng-buddy"
|
|
30
|
+
|
|
31
|
+
# Subdirs that get archived and parsed
|
|
32
|
+
DATA_SUBDIRS = ["daily", "patterns", "capacity", "stakeholders"]
|
|
33
|
+
|
|
34
|
+
# Severity keywords used to classify burnout / incident entries
|
|
35
|
+
SEVERITY_KEYWORDS = {
|
|
36
|
+
"critical": ["🚨", "critical", "crisis", "emergency", "exhausted", "no sleep"],
|
|
37
|
+
"high": ["⚠️", "high", "warning", "stress", "blocked", "overload"],
|
|
38
|
+
"medium": ["medium", "moderate", "watch", "monitor"],
|
|
39
|
+
"low": ["low", "minor", "note"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Helpers
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha256(text: str) -> str:
|
|
49
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _first(patterns: list, text: str, group: int = 1, flags: int = re.IGNORECASE) -> str:
|
|
53
|
+
"""Return the first match for any of the given regex patterns, or empty string."""
|
|
54
|
+
for pat in patterns:
|
|
55
|
+
m = re.search(pat, text, flags)
|
|
56
|
+
if m:
|
|
57
|
+
try:
|
|
58
|
+
return m.group(group).strip()
|
|
59
|
+
except IndexError:
|
|
60
|
+
return m.group(0).strip()
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _all_matches(pattern: str, text: str, group: int = 1, flags: int = re.IGNORECASE) -> list:
|
|
65
|
+
return [m.group(group).strip() for m in re.finditer(pattern, text, flags)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _infer_severity(text: str) -> str:
|
|
69
|
+
text_lower = text.lower()
|
|
70
|
+
for level in ("critical", "high", "medium", "low"):
|
|
71
|
+
for kw in SEVERITY_KEYWORDS[level]:
|
|
72
|
+
if kw.lower() in text_lower:
|
|
73
|
+
return level
|
|
74
|
+
return "medium"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_date_from_filename(path: Path) -> str:
|
|
78
|
+
"""Pull YYYY-MM-DD from filename if present."""
|
|
79
|
+
m = re.search(r"(\d{4}-\d{2}-\d{2})", path.stem)
|
|
80
|
+
return m.group(1) if m else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _split_sections(text: str, heading_re: str = r"^#{1,3} ") -> list:
|
|
84
|
+
"""Split markdown into (heading, body) tuples."""
|
|
85
|
+
sections = []
|
|
86
|
+
current_heading = ""
|
|
87
|
+
current_lines = []
|
|
88
|
+
for line in text.splitlines():
|
|
89
|
+
if re.match(heading_re, line):
|
|
90
|
+
if current_heading or current_lines:
|
|
91
|
+
sections.append((current_heading, "\n".join(current_lines).strip()))
|
|
92
|
+
current_heading = line.lstrip("#").strip()
|
|
93
|
+
current_lines = []
|
|
94
|
+
else:
|
|
95
|
+
current_lines.append(line)
|
|
96
|
+
if current_heading or current_lines:
|
|
97
|
+
sections.append((current_heading, "\n".join(current_lines).strip()))
|
|
98
|
+
return sections
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def warn(msg: str) -> None:
|
|
102
|
+
print(f" [WARN] {msg}", file=sys.stderr)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Schema migration helpers
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ensure_fingerprint_columns(conn: sqlite3.Connection) -> None:
|
|
111
|
+
"""Add raw_content / content_sha256 columns if they don't exist yet."""
|
|
112
|
+
tables_needing_fingerprint = [
|
|
113
|
+
"capacity_logs",
|
|
114
|
+
"stakeholder_contacts",
|
|
115
|
+
"incidents",
|
|
116
|
+
"pattern_observations",
|
|
117
|
+
"follow_ups",
|
|
118
|
+
"burnout_indicators",
|
|
119
|
+
]
|
|
120
|
+
cursor = conn.cursor()
|
|
121
|
+
for table in tables_needing_fingerprint:
|
|
122
|
+
# Check existing columns
|
|
123
|
+
cursor.execute(f"PRAGMA table_info({table})")
|
|
124
|
+
existing = {row[1] for row in cursor.fetchall()}
|
|
125
|
+
if "raw_content" not in existing:
|
|
126
|
+
cursor.execute(f"ALTER TABLE {table} ADD COLUMN raw_content TEXT")
|
|
127
|
+
if "content_sha256" not in existing:
|
|
128
|
+
cursor.execute(f"ALTER TABLE {table} ADD COLUMN content_sha256 TEXT")
|
|
129
|
+
if "source_file" not in existing and table not in ("pattern_observations",):
|
|
130
|
+
cursor.execute(f"ALTER TABLE {table} ADD COLUMN source_file TEXT")
|
|
131
|
+
conn.commit()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Parsers
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_daily_logs(data_dir: Path, warnings: list) -> list:
|
|
140
|
+
"""Parse ~/.claude/eng-buddy/daily/*.md → records for multiple tables."""
|
|
141
|
+
daily_dir = data_dir / "daily"
|
|
142
|
+
if not daily_dir.exists():
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
records = {
|
|
146
|
+
"follow_ups": [],
|
|
147
|
+
"incidents": [],
|
|
148
|
+
"burnout_indicators": [],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for md_file in sorted(daily_dir.glob("*.md")):
|
|
152
|
+
text = md_file.read_text(errors="replace")
|
|
153
|
+
src = str(md_file)
|
|
154
|
+
date_str = _extract_date_from_filename(md_file) or _first(
|
|
155
|
+
[r"(\d{4}-\d{2}-\d{2})"], text
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Follow-ups: lines that look like tasks with "From X" or due-date markers
|
|
159
|
+
fu_pattern = r"[-*]\s+\*\*(?:From|By|Tomorrow|Next)\s+([^*]+?)\*\*[:\s]*(.+)"
|
|
160
|
+
for m in re.finditer(fu_pattern, text, re.IGNORECASE):
|
|
161
|
+
raw = m.group(0)
|
|
162
|
+
stakeholder = m.group(1).strip().rstrip(":")
|
|
163
|
+
topic = m.group(2).strip()
|
|
164
|
+
records["follow_ups"].append(
|
|
165
|
+
{
|
|
166
|
+
"stakeholder": stakeholder,
|
|
167
|
+
"topic": topic[:200],
|
|
168
|
+
"due_date": date_str,
|
|
169
|
+
"status": "pending",
|
|
170
|
+
"notes": "",
|
|
171
|
+
"raw_content": raw,
|
|
172
|
+
"content_sha256": sha256(raw),
|
|
173
|
+
"source_file": src,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Incidents: blockers / bugs sections
|
|
178
|
+
blocker_section = re.search(
|
|
179
|
+
r"#{1,3}\s+(?:Blockers?|Bugs?|Issues?)[^\n]*\n(.*?)(?=\n#{1,3} |\Z)",
|
|
180
|
+
text,
|
|
181
|
+
re.DOTALL | re.IGNORECASE,
|
|
182
|
+
)
|
|
183
|
+
if blocker_section:
|
|
184
|
+
body = blocker_section.group(1)
|
|
185
|
+
# Each sub-heading inside that section = one incident
|
|
186
|
+
for sub_m in re.finditer(r"#{2,4}\s+(.+)\n(.*?)(?=\n#{2,4} |\Z)", body, re.DOTALL):
|
|
187
|
+
title = sub_m.group(1).strip()
|
|
188
|
+
details = sub_m.group(2).strip()
|
|
189
|
+
raw = sub_m.group(0)
|
|
190
|
+
if not title:
|
|
191
|
+
continue
|
|
192
|
+
severity_str = _first(
|
|
193
|
+
[r"\*\*Severity\*\*:\s*(\w+)", r"Severity:\s*(\w+)"], details
|
|
194
|
+
) or _infer_severity(raw)
|
|
195
|
+
status = "open"
|
|
196
|
+
if re.search(r"(✅|RESOLVED|CLOSED|COMPLETE)", raw, re.IGNORECASE):
|
|
197
|
+
status = "resolved"
|
|
198
|
+
records["incidents"].append(
|
|
199
|
+
{
|
|
200
|
+
"title": title[:200],
|
|
201
|
+
"severity": severity_str.lower(),
|
|
202
|
+
"status": status,
|
|
203
|
+
"timeline": date_str,
|
|
204
|
+
"root_cause": _first(
|
|
205
|
+
[r"root[_ ]cause[:\s]+([^\n]+)", r"\*\*Root cause\*\*:\s*([^\n]+)"],
|
|
206
|
+
details,
|
|
207
|
+
)[:500],
|
|
208
|
+
"resolution": _first(
|
|
209
|
+
[r"resolution[:\s]+([^\n]+)", r"\*\*Resolution\*\*:\s*([^\n]+)"],
|
|
210
|
+
details,
|
|
211
|
+
)[:500],
|
|
212
|
+
"raw_content": raw,
|
|
213
|
+
"content_sha256": sha256(raw),
|
|
214
|
+
"source_file": src,
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Burnout indicators from "Red Flags" / "Burnout" sections
|
|
219
|
+
burnout_section = re.search(
|
|
220
|
+
r"#{1,3}\s+(?:Red Flags?|Burnout)[^\n]*\n(.*?)(?=\n#{1,3} |\Z)",
|
|
221
|
+
text,
|
|
222
|
+
re.DOTALL | re.IGNORECASE,
|
|
223
|
+
)
|
|
224
|
+
if burnout_section:
|
|
225
|
+
for line in burnout_section.group(1).splitlines():
|
|
226
|
+
line = line.strip()
|
|
227
|
+
if not line or not re.match(r"[-*🚨⚠️]", line):
|
|
228
|
+
continue
|
|
229
|
+
raw = line
|
|
230
|
+
severity = _infer_severity(line)
|
|
231
|
+
indicator = re.sub(r"^[-*🚨⚠️]+\s*", "", line).strip()
|
|
232
|
+
records["burnout_indicators"].append(
|
|
233
|
+
{
|
|
234
|
+
"date": date_str,
|
|
235
|
+
"indicator": indicator[:200],
|
|
236
|
+
"severity": severity,
|
|
237
|
+
"details": "",
|
|
238
|
+
"raw_content": raw,
|
|
239
|
+
"content_sha256": sha256(raw),
|
|
240
|
+
"source_file": src,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return records
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_patterns(data_dir: Path, warnings: list) -> list:
|
|
248
|
+
"""Parse ~/.claude/eng-buddy/patterns/*.md → pattern_observations rows."""
|
|
249
|
+
patterns_dir = data_dir / "patterns"
|
|
250
|
+
if not patterns_dir.exists():
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
rows = []
|
|
254
|
+
for md_file in sorted(patterns_dir.glob("*.md")):
|
|
255
|
+
text = md_file.read_text(errors="replace")
|
|
256
|
+
src = str(md_file)
|
|
257
|
+
|
|
258
|
+
# Infer type from filename
|
|
259
|
+
fname = md_file.stem.lower()
|
|
260
|
+
if "success" in fname:
|
|
261
|
+
pat_type = "success"
|
|
262
|
+
elif "failure" in fname or "anti" in fname:
|
|
263
|
+
pat_type = "failure"
|
|
264
|
+
elif "burnout" in fname:
|
|
265
|
+
pat_type = "burnout"
|
|
266
|
+
elif "recurring" in fname:
|
|
267
|
+
pat_type = "recurring"
|
|
268
|
+
elif "task" in fname or "execution" in fname:
|
|
269
|
+
pat_type = "execution"
|
|
270
|
+
elif "time" in fname:
|
|
271
|
+
pat_type = "time_estimate"
|
|
272
|
+
else:
|
|
273
|
+
pat_type = "observation"
|
|
274
|
+
|
|
275
|
+
# Each ### heading = one pattern entry
|
|
276
|
+
for heading, body in _split_sections(text, r"^#{2,4} "):
|
|
277
|
+
if not heading or not body:
|
|
278
|
+
continue
|
|
279
|
+
raw = f"### {heading}\n{body}"
|
|
280
|
+
date_str = _first(
|
|
281
|
+
[r"(\d{4}-\d{2}-\d{2})", r"\((\d{4}-\d{2}-\d{2})\)"], heading + " " + body
|
|
282
|
+
)
|
|
283
|
+
# Frequency from explicit field or occurrence count
|
|
284
|
+
freq_raw = _first(
|
|
285
|
+
[r"count[:\s]+(\d+)", r"frequency[:\s]+(\d+)", r"occurrences?[:\s]+(\d+)"],
|
|
286
|
+
body,
|
|
287
|
+
)
|
|
288
|
+
frequency = int(freq_raw) if freq_raw.isdigit() else 1
|
|
289
|
+
|
|
290
|
+
description = body[:1000]
|
|
291
|
+
evidence = _first(
|
|
292
|
+
[r"(?:evidence|example|data)[:\s]+(.+?)(?:\n\n|\Z)", r"\*\*Result\*\*:\s*(.+)"],
|
|
293
|
+
body,
|
|
294
|
+
flags=re.DOTALL | re.IGNORECASE,
|
|
295
|
+
)[:500]
|
|
296
|
+
|
|
297
|
+
rows.append(
|
|
298
|
+
{
|
|
299
|
+
"type": pat_type,
|
|
300
|
+
"title": heading[:200],
|
|
301
|
+
"description": description,
|
|
302
|
+
"confidence": None,
|
|
303
|
+
"evidence": evidence,
|
|
304
|
+
"frequency": frequency,
|
|
305
|
+
"first_seen": date_str,
|
|
306
|
+
"last_seen": date_str,
|
|
307
|
+
"source_file": src,
|
|
308
|
+
"raw_content": raw,
|
|
309
|
+
"content_sha256": sha256(raw),
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return rows
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def parse_capacity(data_dir: Path, warnings: list) -> list:
|
|
317
|
+
"""Parse ~/.claude/eng-buddy/capacity/*.md → capacity_logs rows.
|
|
318
|
+
|
|
319
|
+
Skips burnout-indicators.md (handled separately by parse_burnout_indicators).
|
|
320
|
+
"""
|
|
321
|
+
capacity_dir = data_dir / "capacity"
|
|
322
|
+
if not capacity_dir.exists():
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
SKIP_FILES = {"burnout-indicators.md"}
|
|
326
|
+
rows = []
|
|
327
|
+
any_file_had_rows = False
|
|
328
|
+
|
|
329
|
+
for md_file in sorted(capacity_dir.glob("*.md")):
|
|
330
|
+
if md_file.name in SKIP_FILES:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
text = md_file.read_text(errors="replace")
|
|
334
|
+
src = str(md_file)
|
|
335
|
+
date_ctx = _extract_date_from_filename(md_file)
|
|
336
|
+
file_rows = []
|
|
337
|
+
|
|
338
|
+
# Pattern 1: "- **Total capacity**: 40 hours"
|
|
339
|
+
for m in re.finditer(
|
|
340
|
+
r"-?\s*\*\*([^*]+?)\*\*[:\s]+([\d.]+)\s*(?:hours?)?", text, re.IGNORECASE
|
|
341
|
+
):
|
|
342
|
+
metric = m.group(1).strip()
|
|
343
|
+
try:
|
|
344
|
+
value = float(m.group(2))
|
|
345
|
+
except ValueError:
|
|
346
|
+
warnings.append(f"{md_file.name}: cannot parse value '{m.group(2)}' for '{metric}'")
|
|
347
|
+
continue
|
|
348
|
+
raw = m.group(0)
|
|
349
|
+
date_str = date_ctx or _first([r"(\d{4}-\d{2}-\d{2})"], text)
|
|
350
|
+
file_rows.append(
|
|
351
|
+
{
|
|
352
|
+
"date": date_str,
|
|
353
|
+
"metric": metric[:100],
|
|
354
|
+
"value": value,
|
|
355
|
+
"notes": "",
|
|
356
|
+
"raw_content": raw,
|
|
357
|
+
"content_sha256": sha256(raw),
|
|
358
|
+
"source_file": src,
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Pattern 2: "sleep: 4 hours" / "Sleep (4h)" / "4-hour sleep"
|
|
363
|
+
for m in re.finditer(
|
|
364
|
+
r"(\bsleep\b[^:,\n]*)[:\s]+([\d.]+)\s*(?:hours?|h\b)"
|
|
365
|
+
r"|(\d+\.?\d*)[- ]hour(?:s)?\s+sleep",
|
|
366
|
+
text,
|
|
367
|
+
re.IGNORECASE,
|
|
368
|
+
):
|
|
369
|
+
metric = "sleep_hours"
|
|
370
|
+
raw = m.group(0)
|
|
371
|
+
# Extract numeric value from whichever capture group matched
|
|
372
|
+
val_str = m.group(2) if m.group(2) else m.group(3)
|
|
373
|
+
try:
|
|
374
|
+
value = float(val_str)
|
|
375
|
+
except (ValueError, TypeError):
|
|
376
|
+
continue
|
|
377
|
+
date_str = date_ctx or _first([r"(\d{4}-\d{2}-\d{2})"], raw + "\n" + text[:500])
|
|
378
|
+
note = (m.group(1) or "").strip()
|
|
379
|
+
file_rows.append(
|
|
380
|
+
{
|
|
381
|
+
"date": date_str,
|
|
382
|
+
"metric": metric,
|
|
383
|
+
"value": value,
|
|
384
|
+
"notes": note[:200],
|
|
385
|
+
"raw_content": raw,
|
|
386
|
+
"content_sha256": sha256(raw),
|
|
387
|
+
"source_file": src,
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if not file_rows:
|
|
392
|
+
warnings.append(f"{md_file.name}: no capacity metrics extracted")
|
|
393
|
+
else:
|
|
394
|
+
any_file_had_rows = True
|
|
395
|
+
|
|
396
|
+
rows.extend(file_rows)
|
|
397
|
+
|
|
398
|
+
return rows
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def parse_burnout_indicators(data_dir: Path, warnings: list) -> list:
|
|
402
|
+
"""Parse capacity/burnout-indicators.md → burnout_indicators rows."""
|
|
403
|
+
bi_file = data_dir / "capacity" / "burnout-indicators.md"
|
|
404
|
+
if not bi_file.exists():
|
|
405
|
+
return []
|
|
406
|
+
|
|
407
|
+
text = bi_file.read_text(errors="replace")
|
|
408
|
+
src = str(bi_file)
|
|
409
|
+
rows = []
|
|
410
|
+
|
|
411
|
+
# Each ### subheading is an indicator category; each bullet is an entry
|
|
412
|
+
for heading, body in _split_sections(text, r"^#{2,4} "):
|
|
413
|
+
if not heading or not body:
|
|
414
|
+
continue
|
|
415
|
+
if re.search(r"(action|recommendation|immediate|recovery)", heading, re.IGNORECASE):
|
|
416
|
+
continue # skip action sections
|
|
417
|
+
|
|
418
|
+
for line in body.splitlines():
|
|
419
|
+
line = line.strip()
|
|
420
|
+
if not line or not re.match(r"[-*]|🚨|⚠️|✅", line):
|
|
421
|
+
continue
|
|
422
|
+
# Skip sub-bullet context lines (indented continuation)
|
|
423
|
+
raw = line
|
|
424
|
+
severity = _infer_severity(heading + " " + line)
|
|
425
|
+
indicator = re.sub(r"^[-*🚨⚠️✅]+\s*", "", line).strip()
|
|
426
|
+
if not indicator:
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
# Date: from line or from heading
|
|
430
|
+
date_str = _first([r"(\d{4}-\d{2}-\d{2})"], heading + " " + line)
|
|
431
|
+
|
|
432
|
+
rows.append(
|
|
433
|
+
{
|
|
434
|
+
"date": date_str,
|
|
435
|
+
"indicator": indicator[:200],
|
|
436
|
+
"severity": severity,
|
|
437
|
+
"details": heading[:200],
|
|
438
|
+
"raw_content": raw,
|
|
439
|
+
"content_sha256": sha256(raw),
|
|
440
|
+
"source_file": src,
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return rows
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def parse_incidents_dir(data_dir: Path, warnings: list) -> list:
|
|
448
|
+
"""Parse ~/.claude/eng-buddy/incidents/*.md → incidents rows."""
|
|
449
|
+
incidents_dir = data_dir / "incidents"
|
|
450
|
+
if not incidents_dir.exists():
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
SKIP_FILES = {"incident-index.md"}
|
|
454
|
+
rows = []
|
|
455
|
+
|
|
456
|
+
for md_file in sorted(incidents_dir.glob("*.md")):
|
|
457
|
+
if md_file.name in SKIP_FILES:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
text = md_file.read_text(errors="replace")
|
|
461
|
+
src = str(md_file)
|
|
462
|
+
raw_full = text[:2000] # fingerprint first 2KB
|
|
463
|
+
|
|
464
|
+
# Title: first H1 or from filename
|
|
465
|
+
title = _first([r"^#\s+(?:Incident[:\s]*)?(.+)$"], text, flags=re.MULTILINE) or md_file.stem
|
|
466
|
+
|
|
467
|
+
# Remove emoji/status suffix from title
|
|
468
|
+
title = re.sub(r"\s*[✅❌🚨⚠️]+.*$", "", title).strip()
|
|
469
|
+
|
|
470
|
+
# Date
|
|
471
|
+
date_str = _first([r"\*\*Date\*\*:\s*(\d{4}-\d{2}-\d{2})", r"(\d{4}-\d{2}-\d{2})"], text)
|
|
472
|
+
|
|
473
|
+
# Severity
|
|
474
|
+
severity_raw = _first(
|
|
475
|
+
[r"\*\*Severity\*\*:\s*([^\n]+)", r"severity[:\s]+([^\n,]+)"], text
|
|
476
|
+
)
|
|
477
|
+
severity = severity_raw.lower().split()[0] if severity_raw else _infer_severity(text[:500])
|
|
478
|
+
# Normalize e.g. "Critical → Resolved" → "critical"
|
|
479
|
+
severity = re.sub(r"[^a-z].*", "", severity)
|
|
480
|
+
|
|
481
|
+
# Status
|
|
482
|
+
status = "open"
|
|
483
|
+
if re.search(r"(✅\s*COMPLETE|RESOLVED|CLOSED|status.*:\s*✅)", text, re.IGNORECASE):
|
|
484
|
+
status = "resolved"
|
|
485
|
+
|
|
486
|
+
# Timeline: the explicit Timeline section
|
|
487
|
+
timeline = _first(
|
|
488
|
+
[r"## Timeline\n(.*?)(?=\n## |\Z)"], text, flags=re.DOTALL | re.IGNORECASE
|
|
489
|
+
)[:500]
|
|
490
|
+
|
|
491
|
+
# Root cause
|
|
492
|
+
root_cause = _first(
|
|
493
|
+
[r"Root Cause[:\s]+([^\n]+)", r"\*\*Root Cause\*\*:\s*([^\n]+)"], text
|
|
494
|
+
)[:300]
|
|
495
|
+
|
|
496
|
+
# Resolution
|
|
497
|
+
resolution = _first(
|
|
498
|
+
[r"Resolution[:\s]+([^\n]+)", r"\*\*Resolution\*\*:\s*([^\n]+)"], text
|
|
499
|
+
)[:300]
|
|
500
|
+
|
|
501
|
+
rows.append(
|
|
502
|
+
{
|
|
503
|
+
"title": title[:200],
|
|
504
|
+
"severity": severity[:50],
|
|
505
|
+
"status": status,
|
|
506
|
+
"timeline": timeline or date_str,
|
|
507
|
+
"root_cause": root_cause,
|
|
508
|
+
"resolution": resolution,
|
|
509
|
+
"raw_content": raw_full,
|
|
510
|
+
"content_sha256": sha256(raw_full),
|
|
511
|
+
"source_file": src,
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
return rows
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def parse_stakeholders(data_dir: Path, warnings: list) -> list:
|
|
519
|
+
"""Parse ~/.clone/eng-buddy/stakeholders/*.md → stakeholder_contacts rows."""
|
|
520
|
+
stakeholders_dir = data_dir / "stakeholders"
|
|
521
|
+
if not stakeholders_dir.exists():
|
|
522
|
+
return []
|
|
523
|
+
|
|
524
|
+
rows = []
|
|
525
|
+
for md_file in sorted(stakeholders_dir.glob("*.md")):
|
|
526
|
+
text = md_file.read_text(errors="replace")
|
|
527
|
+
src = str(md_file)
|
|
528
|
+
|
|
529
|
+
# Sections delineated by ### Name headings
|
|
530
|
+
for heading, body in _split_sections(text, r"^#{2,3} "):
|
|
531
|
+
if not heading or not body:
|
|
532
|
+
continue
|
|
533
|
+
raw = f"### {heading}\n{body}"
|
|
534
|
+
|
|
535
|
+
# Name
|
|
536
|
+
name = heading.strip()
|
|
537
|
+
if len(name) > 100 or re.search(r"(pending|waiting|vendor|overview|notes)", name, re.IGNORECASE):
|
|
538
|
+
continue # skip section headings that aren't person names
|
|
539
|
+
|
|
540
|
+
# Role
|
|
541
|
+
role = _first(
|
|
542
|
+
[
|
|
543
|
+
r"\*\*Role\*\*:\s*([^\n]+)",
|
|
544
|
+
r"Role:\s*([^\n]+)",
|
|
545
|
+
r"\*\*Title\*\*:\s*([^\n]+)",
|
|
546
|
+
],
|
|
547
|
+
body,
|
|
548
|
+
)[:200]
|
|
549
|
+
|
|
550
|
+
# Preferences / communication style
|
|
551
|
+
preferences = _first(
|
|
552
|
+
[
|
|
553
|
+
r"\*\*Communication preference\*\*:\s*([^\n]+)",
|
|
554
|
+
r"Communication[^:]*:\s*([^\n]+)",
|
|
555
|
+
r"\*\*Prefer[^*]*\*\*:\s*([^\n]+)",
|
|
556
|
+
],
|
|
557
|
+
body,
|
|
558
|
+
)[:300]
|
|
559
|
+
|
|
560
|
+
# Last contact date
|
|
561
|
+
last_contact = _first(
|
|
562
|
+
[
|
|
563
|
+
r"\*\*Last contact\*\*:\s*(\d{4}-\d{2}-\d{2})",
|
|
564
|
+
r"(\d{4}-\d{2}-\d{2})",
|
|
565
|
+
],
|
|
566
|
+
body,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
rows.append(
|
|
570
|
+
{
|
|
571
|
+
"name": name[:100],
|
|
572
|
+
"role": role,
|
|
573
|
+
"preferences": preferences,
|
|
574
|
+
"last_contact": last_contact,
|
|
575
|
+
"raw_content": raw,
|
|
576
|
+
"content_sha256": sha256(raw),
|
|
577
|
+
"source_file": src,
|
|
578
|
+
}
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
return rows
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
# Archive
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def archive_originals(data_dir: Path) -> Path:
|
|
590
|
+
"""Tar the data subdirs that exist and return the archive path."""
|
|
591
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
592
|
+
archive_path = data_dir / f"archive-{today}.tar.gz"
|
|
593
|
+
|
|
594
|
+
dirs_to_archive = [str(data_dir / d) for d in DATA_SUBDIRS if (data_dir / d).exists()]
|
|
595
|
+
if not dirs_to_archive:
|
|
596
|
+
print(" [archive] No source dirs found — skipping archive step.")
|
|
597
|
+
return archive_path
|
|
598
|
+
|
|
599
|
+
cmd = ["tar", "czf", str(archive_path)] + dirs_to_archive
|
|
600
|
+
print(f" [archive] {' '.join(cmd)}")
|
|
601
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
602
|
+
if result.returncode != 0:
|
|
603
|
+
print(f" [archive] WARNING: tar failed: {result.stderr}", file=sys.stderr)
|
|
604
|
+
else:
|
|
605
|
+
print(f" [archive] Created {archive_path}")
|
|
606
|
+
return archive_path
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
# DB insertion
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _insert_rows(conn, table: str, rows: list, dry_run: bool) -> int:
|
|
615
|
+
if not rows:
|
|
616
|
+
return 0
|
|
617
|
+
# Build column list from first row keys (exclude None-value-only columns)
|
|
618
|
+
cols = list(rows[0].keys())
|
|
619
|
+
placeholders = ", ".join(["?"] * len(cols))
|
|
620
|
+
col_str = ", ".join(cols)
|
|
621
|
+
sql = f"INSERT OR IGNORE INTO {table} ({col_str}) VALUES ({placeholders})"
|
|
622
|
+
|
|
623
|
+
if dry_run:
|
|
624
|
+
return len(rows)
|
|
625
|
+
|
|
626
|
+
cursor = conn.cursor()
|
|
627
|
+
for row in rows:
|
|
628
|
+
values = [row.get(c) for c in cols]
|
|
629
|
+
try:
|
|
630
|
+
cursor.execute(sql, values)
|
|
631
|
+
except sqlite3.OperationalError as e:
|
|
632
|
+
warn(f"Insert into {table} failed: {e} — row keys: {list(row.keys())}")
|
|
633
|
+
return len(rows)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
# Reconciliation
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def reconcile(conn, table: str, expected_sources: set) -> bool:
|
|
642
|
+
"""Verify that all expected source files appear in the DB."""
|
|
643
|
+
cursor = conn.execute(f"SELECT DISTINCT source_file FROM {table}")
|
|
644
|
+
db_sources = {row[0] for row in cursor.fetchall() if row[0]}
|
|
645
|
+
missing = expected_sources - db_sources
|
|
646
|
+
if missing:
|
|
647
|
+
for f in sorted(missing):
|
|
648
|
+
warn(f"Reconciliation: {table} missing source_file '{f}'")
|
|
649
|
+
return False
|
|
650
|
+
return True
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
# Dry-run report
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def print_report(all_data: dict, warnings: list) -> None:
|
|
659
|
+
print("\n" + "=" * 60)
|
|
660
|
+
print("DRY-RUN VALIDATION REPORT")
|
|
661
|
+
print("=" * 60)
|
|
662
|
+
|
|
663
|
+
total = 0
|
|
664
|
+
for table, rows in all_data.items():
|
|
665
|
+
if isinstance(rows, list):
|
|
666
|
+
count = len(rows)
|
|
667
|
+
elif isinstance(rows, dict):
|
|
668
|
+
count = sum(len(v) for v in rows.values())
|
|
669
|
+
else:
|
|
670
|
+
count = 0
|
|
671
|
+
|
|
672
|
+
sources = set()
|
|
673
|
+
if isinstance(rows, list):
|
|
674
|
+
sources = {r.get("source_file", "") for r in rows}
|
|
675
|
+
elif isinstance(rows, dict):
|
|
676
|
+
for v in rows.values():
|
|
677
|
+
sources |= {r.get("source_file", "") for r in v}
|
|
678
|
+
|
|
679
|
+
sha_coverage = 0
|
|
680
|
+
flat_rows = rows if isinstance(rows, list) else [r for v in rows.values() for r in v]
|
|
681
|
+
sha_coverage = sum(1 for r in flat_rows if r.get("content_sha256"))
|
|
682
|
+
field_pct = (sha_coverage / count * 100) if count else 0
|
|
683
|
+
|
|
684
|
+
print(f"\n {table}:")
|
|
685
|
+
print(f" rows : {count}")
|
|
686
|
+
print(f" source files : {len(sources)}")
|
|
687
|
+
print(f" sha256 cover : {sha_coverage}/{count} ({field_pct:.0f}%)")
|
|
688
|
+
total += count
|
|
689
|
+
|
|
690
|
+
print(f"\n TOTAL ROWS: {total}")
|
|
691
|
+
|
|
692
|
+
if warnings:
|
|
693
|
+
print(f"\n WARNINGS ({len(warnings)}):")
|
|
694
|
+
for w in warnings:
|
|
695
|
+
print(f" - {w}")
|
|
696
|
+
else:
|
|
697
|
+
print("\n No warnings.")
|
|
698
|
+
|
|
699
|
+
print("=" * 60)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
# Main
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def main() -> int:
|
|
708
|
+
parser = argparse.ArgumentParser(
|
|
709
|
+
description="Migrate eng-buddy markdown to inbox.db ops tables."
|
|
710
|
+
)
|
|
711
|
+
parser.add_argument(
|
|
712
|
+
"--dry-run",
|
|
713
|
+
action="store_true",
|
|
714
|
+
help="Parse and validate without writing to the DB.",
|
|
715
|
+
)
|
|
716
|
+
parser.add_argument(
|
|
717
|
+
"--db-path",
|
|
718
|
+
help="Path to inbox.db (auto-detected from brain.py path if not provided).",
|
|
719
|
+
)
|
|
720
|
+
parser.add_argument(
|
|
721
|
+
"--data-dir",
|
|
722
|
+
default=str(ENG_BUDDY_DIR),
|
|
723
|
+
help="Path to eng-buddy data directory (default: ~/.claude/eng-buddy).",
|
|
724
|
+
)
|
|
725
|
+
args = parser.parse_args()
|
|
726
|
+
|
|
727
|
+
data_dir = Path(args.data_dir).expanduser()
|
|
728
|
+
|
|
729
|
+
# Resolve DB path
|
|
730
|
+
if args.db_path:
|
|
731
|
+
db_path = Path(args.db_path).expanduser()
|
|
732
|
+
else:
|
|
733
|
+
db_path = Path.home() / ".claude" / "eng-buddy" / "inbox.db"
|
|
734
|
+
|
|
735
|
+
if not db_path.exists():
|
|
736
|
+
print(f"ERROR: inbox.db not found at {db_path}", file=sys.stderr)
|
|
737
|
+
print("Pass --db-path to specify location.", file=sys.stderr)
|
|
738
|
+
return 1
|
|
739
|
+
|
|
740
|
+
print(f"eng-buddy migration")
|
|
741
|
+
print(f" data_dir : {data_dir}")
|
|
742
|
+
print(f" db_path : {db_path}")
|
|
743
|
+
print(f" dry_run : {args.dry_run}")
|
|
744
|
+
print()
|
|
745
|
+
|
|
746
|
+
# ---- Parse ----
|
|
747
|
+
warnings: list = []
|
|
748
|
+
print("Parsing daily logs...")
|
|
749
|
+
daily_records = parse_daily_logs(data_dir, warnings)
|
|
750
|
+
|
|
751
|
+
print("Parsing pattern files...")
|
|
752
|
+
pattern_rows = parse_patterns(data_dir, warnings)
|
|
753
|
+
|
|
754
|
+
print("Parsing capacity files...")
|
|
755
|
+
capacity_rows = parse_capacity(data_dir, warnings)
|
|
756
|
+
|
|
757
|
+
print("Parsing burnout indicators...")
|
|
758
|
+
burnout_rows = parse_burnout_indicators(data_dir, warnings)
|
|
759
|
+
|
|
760
|
+
print("Parsing incidents directory...")
|
|
761
|
+
incident_rows = parse_incidents_dir(data_dir, warnings)
|
|
762
|
+
|
|
763
|
+
print("Parsing stakeholder files...")
|
|
764
|
+
stakeholder_rows = parse_stakeholders(data_dir, warnings)
|
|
765
|
+
|
|
766
|
+
# daily_records is a dict of lists keyed by table name
|
|
767
|
+
follow_up_rows = daily_records.get("follow_ups", [])
|
|
768
|
+
# Merge incidents from daily logs + incidents dir (daily has inline blockers)
|
|
769
|
+
incident_rows = incident_rows + daily_records.get("incidents", [])
|
|
770
|
+
# Merge burnout from capacity/burnout-indicators.md + inline daily log sections
|
|
771
|
+
burnout_rows = burnout_rows + daily_records.get("burnout_indicators", [])
|
|
772
|
+
|
|
773
|
+
all_data = {
|
|
774
|
+
"capacity_logs": capacity_rows,
|
|
775
|
+
"stakeholder_contacts": stakeholder_rows,
|
|
776
|
+
"incidents": incident_rows,
|
|
777
|
+
"pattern_observations": pattern_rows,
|
|
778
|
+
"follow_ups": follow_up_rows,
|
|
779
|
+
"burnout_indicators": burnout_rows,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if args.dry_run:
|
|
783
|
+
print_report(all_data, warnings)
|
|
784
|
+
return 0
|
|
785
|
+
|
|
786
|
+
# ---- Archive originals ----
|
|
787
|
+
print("\nArchiving originals...")
|
|
788
|
+
archive_originals(data_dir)
|
|
789
|
+
|
|
790
|
+
# ---- Write to DB (single transaction) ----
|
|
791
|
+
print("\nWriting to DB...")
|
|
792
|
+
conn = sqlite3.connect(str(db_path))
|
|
793
|
+
try:
|
|
794
|
+
# Ensure fingerprint columns exist
|
|
795
|
+
_ensure_fingerprint_columns(conn)
|
|
796
|
+
|
|
797
|
+
# Begin single transaction
|
|
798
|
+
conn.execute("BEGIN")
|
|
799
|
+
|
|
800
|
+
counts = {}
|
|
801
|
+
counts["capacity_logs"] = _insert_rows(conn, "capacity_logs", capacity_rows, False)
|
|
802
|
+
counts["stakeholder_contacts"] = _insert_rows(
|
|
803
|
+
conn, "stakeholder_contacts", stakeholder_rows, False
|
|
804
|
+
)
|
|
805
|
+
counts["incidents"] = _insert_rows(conn, "incidents", incident_rows, False)
|
|
806
|
+
counts["pattern_observations"] = _insert_rows(
|
|
807
|
+
conn, "pattern_observations", pattern_rows, False
|
|
808
|
+
)
|
|
809
|
+
counts["follow_ups"] = _insert_rows(conn, "follow_ups", follow_up_rows, False)
|
|
810
|
+
counts["burnout_indicators"] = _insert_rows(
|
|
811
|
+
conn, "burnout_indicators", burnout_rows, False
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
conn.commit()
|
|
815
|
+
print(" Transaction committed.")
|
|
816
|
+
|
|
817
|
+
except Exception as e:
|
|
818
|
+
conn.rollback()
|
|
819
|
+
print(f"ERROR: Transaction rolled back: {e}", file=sys.stderr)
|
|
820
|
+
conn.close()
|
|
821
|
+
return 2
|
|
822
|
+
|
|
823
|
+
# ---- Reconciliation pass ----
|
|
824
|
+
print("\nRunning reconciliation pass...")
|
|
825
|
+
ok = True
|
|
826
|
+
|
|
827
|
+
table_source_map = {
|
|
828
|
+
"capacity_logs": {r["source_file"] for r in capacity_rows if r.get("source_file")},
|
|
829
|
+
"stakeholder_contacts": {
|
|
830
|
+
r["source_file"] for r in stakeholder_rows if r.get("source_file")
|
|
831
|
+
},
|
|
832
|
+
"incidents": {r["source_file"] for r in incident_rows if r.get("source_file")},
|
|
833
|
+
"pattern_observations": {
|
|
834
|
+
r["source_file"] for r in pattern_rows if r.get("source_file")
|
|
835
|
+
},
|
|
836
|
+
"follow_ups": {r["source_file"] for r in follow_up_rows if r.get("source_file")},
|
|
837
|
+
"burnout_indicators": {
|
|
838
|
+
r["source_file"] for r in burnout_rows if r.get("source_file")
|
|
839
|
+
},
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
for table, expected_sources in table_source_map.items():
|
|
843
|
+
if not expected_sources:
|
|
844
|
+
continue
|
|
845
|
+
passed = reconcile(conn, table, expected_sources)
|
|
846
|
+
status = "OK" if passed else "MISMATCH"
|
|
847
|
+
print(f" {table}: {status} ({len(expected_sources)} source files)")
|
|
848
|
+
if not passed:
|
|
849
|
+
ok = False
|
|
850
|
+
|
|
851
|
+
conn.close()
|
|
852
|
+
|
|
853
|
+
# ---- Summary ----
|
|
854
|
+
print("\nMigration summary:")
|
|
855
|
+
total = 0
|
|
856
|
+
for table, n in counts.items():
|
|
857
|
+
print(f" {table}: {n} rows inserted")
|
|
858
|
+
total += n
|
|
859
|
+
print(f" TOTAL: {total} rows")
|
|
860
|
+
|
|
861
|
+
if warnings:
|
|
862
|
+
print(f"\nWarnings ({len(warnings)}):")
|
|
863
|
+
for w in warnings:
|
|
864
|
+
print(f" - {w}")
|
|
865
|
+
|
|
866
|
+
if not ok:
|
|
867
|
+
print("\nERROR: Reconciliation mismatch — check warnings above.", file=sys.stderr)
|
|
868
|
+
return 3
|
|
869
|
+
|
|
870
|
+
print("\nMigration complete.")
|
|
871
|
+
return 0
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
if __name__ == "__main__":
|
|
875
|
+
sys.exit(main())
|