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