feed-the-machine 1.6.1 → 1.7.1

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 (272) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +262 -170
  3. package/bin/__pycache__/tasks_db.cpython-314.pyc +0 -0
  4. package/bin/brain.py +1340 -0
  5. package/bin/convert_claude_skills_to_codex.py +490 -0
  6. package/bin/generate-manifest.mjs +463 -463
  7. package/bin/harden_codex_skills.py +141 -0
  8. package/bin/install.mjs +491 -491
  9. package/bin/migrate-eng-buddy-data.py +875 -0
  10. package/bin/playbook_engine/__init__.py +1 -0
  11. package/bin/playbook_engine/conftest.py +8 -0
  12. package/bin/playbook_engine/extractor.py +33 -0
  13. package/bin/playbook_engine/manager.py +102 -0
  14. package/bin/playbook_engine/models.py +84 -0
  15. package/bin/playbook_engine/registry.py +35 -0
  16. package/bin/playbook_engine/test_extractor.py +72 -0
  17. package/bin/playbook_engine/test_integration.py +129 -0
  18. package/bin/playbook_engine/test_manager.py +85 -0
  19. package/bin/playbook_engine/test_models.py +166 -0
  20. package/bin/playbook_engine/test_registry.py +67 -0
  21. package/bin/playbook_engine/test_tracer.py +86 -0
  22. package/bin/playbook_engine/tracer.py +93 -0
  23. package/bin/tasks_db.py +456 -0
  24. package/docs/HOOKS.md +243 -243
  25. package/docs/INBOX.md +233 -233
  26. package/ftm/SKILL.md +125 -122
  27. package/ftm-audit/SKILL.md +673 -623
  28. package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
  29. package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
  30. package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
  31. package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
  32. package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
  33. package/ftm-audit/scripts/run-knip.sh +23 -23
  34. package/ftm-audit.yml +2 -2
  35. package/ftm-brainstorm/SKILL.md +1003 -498
  36. package/ftm-brainstorm/evals/evals.json +180 -100
  37. package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
  38. package/ftm-brainstorm/references/agent-prompts.md +552 -224
  39. package/ftm-brainstorm/references/plan-template.md +209 -121
  40. package/ftm-brainstorm.yml +2 -2
  41. package/ftm-browse/SKILL.md +454 -454
  42. package/ftm-browse/daemon/browser-manager.ts +206 -206
  43. package/ftm-browse/daemon/bun.lock +30 -30
  44. package/ftm-browse/daemon/cli.ts +347 -347
  45. package/ftm-browse/daemon/commands.ts +410 -410
  46. package/ftm-browse/daemon/main.ts +357 -357
  47. package/ftm-browse/daemon/package.json +17 -17
  48. package/ftm-browse/daemon/server.ts +189 -189
  49. package/ftm-browse/daemon/snapshot.ts +519 -519
  50. package/ftm-browse/daemon/tsconfig.json +22 -22
  51. package/ftm-browse.yml +4 -4
  52. package/ftm-capture/SKILL.md +370 -370
  53. package/ftm-capture.yml +4 -4
  54. package/ftm-codex-gate/SKILL.md +361 -361
  55. package/ftm-codex-gate.yml +2 -2
  56. package/ftm-config/SKILL.md +422 -345
  57. package/ftm-config.default.yml +125 -82
  58. package/ftm-config.yml +44 -2
  59. package/ftm-council/SKILL.md +416 -416
  60. package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
  61. package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
  62. package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
  63. package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
  64. package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
  65. package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
  66. package/ftm-council-chat.yml +2 -0
  67. package/ftm-council.yml +2 -2
  68. package/ftm-dashboard/SKILL.md +163 -163
  69. package/ftm-dashboard.yml +4 -4
  70. package/ftm-debug/SKILL.md +1037 -1037
  71. package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
  72. package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
  73. package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
  74. package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
  75. package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
  76. package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
  77. package/ftm-debug.yml +2 -2
  78. package/ftm-diagram/SKILL.md +277 -277
  79. package/ftm-diagram.yml +2 -2
  80. package/ftm-executor/SKILL.md +777 -777
  81. package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
  82. package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
  83. package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
  84. package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
  85. package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +81 -72
  86. package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
  87. package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
  88. package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
  89. package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
  90. package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
  91. package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
  92. package/ftm-executor/runtime/package.json +8 -8
  93. package/ftm-executor.yml +2 -2
  94. package/ftm-git/SKILL.md +441 -441
  95. package/ftm-git/evals/evals.json +26 -26
  96. package/ftm-git/evals/promptfoo.yaml +75 -75
  97. package/ftm-git/hooks/post-commit-experience.sh +92 -92
  98. package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
  99. package/ftm-git/references/protocols/REMEDIATION.md +139 -139
  100. package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
  101. package/ftm-git.yml +2 -2
  102. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  103. package/ftm-inbox/backend/adapters/_retry.py +64 -64
  104. package/ftm-inbox/backend/adapters/base.py +230 -230
  105. package/ftm-inbox/backend/adapters/freshservice.py +104 -104
  106. package/ftm-inbox/backend/adapters/gmail.py +125 -125
  107. package/ftm-inbox/backend/adapters/jira.py +136 -136
  108. package/ftm-inbox/backend/adapters/registry.py +192 -192
  109. package/ftm-inbox/backend/adapters/slack.py +110 -110
  110. package/ftm-inbox/backend/db/connection.py +54 -54
  111. package/ftm-inbox/backend/db/schema.py +78 -78
  112. package/ftm-inbox/backend/executor/__init__.py +7 -7
  113. package/ftm-inbox/backend/executor/engine.py +149 -149
  114. package/ftm-inbox/backend/executor/step_runner.py +98 -98
  115. package/ftm-inbox/backend/main.py +103 -103
  116. package/ftm-inbox/backend/models/__init__.py +1 -1
  117. package/ftm-inbox/backend/models/unified_task.py +36 -36
  118. package/ftm-inbox/backend/planner/__init__.py +6 -6
  119. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  120. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  121. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  122. package/ftm-inbox/backend/planner/generator.py +127 -127
  123. package/ftm-inbox/backend/planner/schema.py +34 -34
  124. package/ftm-inbox/backend/requirements.txt +5 -5
  125. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  126. package/ftm-inbox/backend/routes/execute.py +186 -186
  127. package/ftm-inbox/backend/routes/health.py +52 -52
  128. package/ftm-inbox/backend/routes/inbox.py +68 -68
  129. package/ftm-inbox/backend/routes/plan.py +271 -271
  130. package/ftm-inbox/bin/launchagent.mjs +91 -91
  131. package/ftm-inbox/bin/setup.mjs +188 -188
  132. package/ftm-inbox/bin/start.sh +10 -10
  133. package/ftm-inbox/bin/status.sh +17 -17
  134. package/ftm-inbox/bin/stop.sh +8 -8
  135. package/ftm-inbox/config.example.yml +55 -55
  136. package/ftm-inbox/package-lock.json +2898 -2898
  137. package/ftm-inbox/package.json +26 -26
  138. package/ftm-inbox/postcss.config.js +6 -6
  139. package/ftm-inbox/src/app.css +199 -199
  140. package/ftm-inbox/src/app.html +18 -18
  141. package/ftm-inbox/src/lib/api.ts +166 -166
  142. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
  143. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
  144. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
  145. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
  146. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
  147. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
  148. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
  149. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
  150. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
  151. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
  152. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
  153. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
  154. package/ftm-inbox/src/lib/theme.ts +47 -47
  155. package/ftm-inbox/src/routes/+layout.svelte +76 -76
  156. package/ftm-inbox/src/routes/+page.svelte +401 -401
  157. package/ftm-inbox/svelte.config.js +12 -12
  158. package/ftm-inbox/tailwind.config.ts +63 -63
  159. package/ftm-inbox/tsconfig.json +13 -13
  160. package/ftm-inbox/vite.config.ts +6 -6
  161. package/ftm-intent/SKILL.md +241 -241
  162. package/ftm-intent.yml +2 -2
  163. package/ftm-manifest.json +3794 -3794
  164. package/ftm-map/SKILL.md +291 -291
  165. package/ftm-map/scripts/db.py +712 -712
  166. package/ftm-map/scripts/index.py +415 -415
  167. package/ftm-map/scripts/parser.py +224 -224
  168. package/ftm-map/scripts/queries/go-tags.scm +20 -20
  169. package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
  170. package/ftm-map/scripts/queries/python-tags.scm +31 -31
  171. package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
  172. package/ftm-map/scripts/queries/rust-tags.scm +37 -37
  173. package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
  174. package/ftm-map/scripts/query.py +301 -301
  175. package/ftm-map/scripts/ranker.py +377 -377
  176. package/ftm-map/scripts/requirements.txt +5 -5
  177. package/ftm-map/scripts/setup-hooks.sh +27 -27
  178. package/ftm-map/scripts/setup.sh +56 -56
  179. package/ftm-map/scripts/test_db.py +364 -364
  180. package/ftm-map/scripts/test_parser.py +174 -174
  181. package/ftm-map/scripts/test_query.py +183 -183
  182. package/ftm-map/scripts/test_ranker.py +199 -199
  183. package/ftm-map/scripts/views.py +591 -591
  184. package/ftm-map.yml +2 -2
  185. package/ftm-mind/SKILL.md +201 -1943
  186. package/ftm-mind/evals/promptfoo.yaml +142 -142
  187. package/ftm-mind/references/blackboard-protocol.md +110 -0
  188. package/ftm-mind/references/blackboard-schema.md +328 -328
  189. package/ftm-mind/references/complexity-guide.md +110 -110
  190. package/ftm-mind/references/complexity-sizing.md +138 -0
  191. package/ftm-mind/references/decide-act-protocol.md +172 -0
  192. package/ftm-mind/references/direct-execution.md +51 -0
  193. package/ftm-mind/references/environment-discovery.md +77 -0
  194. package/ftm-mind/references/event-registry.md +319 -319
  195. package/ftm-mind/references/mcp-inventory.md +300 -296
  196. package/ftm-mind/references/ops-routing.md +47 -0
  197. package/ftm-mind/references/orient-protocol.md +234 -0
  198. package/ftm-mind/references/personality.md +40 -0
  199. package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
  200. package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
  201. package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
  202. package/ftm-mind/references/reflexion-protocol.md +249 -249
  203. package/ftm-mind/references/routing/SCENARIOS.md +22 -22
  204. package/ftm-mind/references/routing-scenarios.md +35 -35
  205. package/ftm-mind.yml +2 -2
  206. package/ftm-ops.yml +4 -0
  207. package/ftm-pause/SKILL.md +395 -395
  208. package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
  209. package/ftm-pause/references/protocols/VALIDATION.md +80 -80
  210. package/ftm-pause.yml +2 -2
  211. package/ftm-researcher/SKILL.md +275 -275
  212. package/ftm-researcher/evals/agent-diversity.yaml +17 -17
  213. package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
  214. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
  215. package/ftm-researcher/references/adaptive-search.md +116 -116
  216. package/ftm-researcher/references/agent-prompts.md +193 -193
  217. package/ftm-researcher/references/council-integration.md +193 -193
  218. package/ftm-researcher/references/output-format.md +203 -203
  219. package/ftm-researcher/references/synthesis-pipeline.md +165 -165
  220. package/ftm-researcher/scripts/score_credibility.py +234 -234
  221. package/ftm-researcher/scripts/validate_research.py +92 -92
  222. package/ftm-researcher.yml +2 -2
  223. package/ftm-resume/SKILL.md +518 -518
  224. package/ftm-resume/references/protocols/VALIDATION.md +172 -172
  225. package/ftm-resume.yml +2 -2
  226. package/ftm-retro/SKILL.md +380 -380
  227. package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
  228. package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
  229. package/ftm-retro.yml +2 -2
  230. package/ftm-routine/SKILL.md +170 -170
  231. package/ftm-routine.yml +4 -4
  232. package/ftm-state/blackboard/capabilities.json +5 -5
  233. package/ftm-state/blackboard/capabilities.schema.json +27 -27
  234. package/ftm-state/blackboard/context.json +37 -23
  235. package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
  236. package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
  237. package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
  238. package/ftm-state/blackboard/experiences/index.json +58 -9
  239. package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
  240. package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
  241. package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
  242. package/ftm-state/blackboard/patterns.json +6 -6
  243. package/ftm-state/schemas/context.schema.json +130 -130
  244. package/ftm-state/schemas/experience-index.schema.json +77 -77
  245. package/ftm-state/schemas/experience.schema.json +78 -78
  246. package/ftm-state/schemas/patterns.schema.json +44 -44
  247. package/ftm-upgrade/SKILL.md +194 -194
  248. package/ftm-upgrade/scripts/check-version.sh +76 -76
  249. package/ftm-upgrade/scripts/upgrade.sh +143 -143
  250. package/ftm-upgrade.yml +2 -2
  251. package/ftm-verify.yml +2 -2
  252. package/ftm.yml +2 -2
  253. package/hooks/ftm-auto-log.sh +137 -0
  254. package/hooks/ftm-blackboard-enforcer.sh +93 -93
  255. package/hooks/ftm-discovery-reminder.sh +90 -90
  256. package/hooks/ftm-drafts-gate.sh +61 -61
  257. package/hooks/ftm-event-logger.mjs +107 -107
  258. package/hooks/ftm-install-hooks.sh +240 -0
  259. package/hooks/ftm-learning-capture.sh +117 -0
  260. package/hooks/ftm-map-autodetect.sh +79 -79
  261. package/hooks/ftm-pending-sync-check.sh +22 -22
  262. package/hooks/ftm-plan-gate.sh +92 -92
  263. package/hooks/ftm-post-commit-trigger.sh +57 -57
  264. package/hooks/ftm-post-compaction.sh +138 -0
  265. package/hooks/ftm-pre-compaction.sh +147 -0
  266. package/hooks/ftm-session-end.sh +52 -0
  267. package/hooks/ftm-session-snapshot.sh +213 -0
  268. package/hooks/ftm-task-loader.sh +100 -0
  269. package/hooks/settings-template.json +91 -81
  270. package/install.sh +363 -363
  271. package/package.json +84 -84
  272. 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())