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
package/bin/brain.py ADDED
@@ -0,0 +1,1340 @@
1
+ """
2
+ eng-buddy Learning Engine.
3
+ Builds context prompts from persistent memory and parses Claude responses
4
+ for new patterns, stakeholder updates, and automation opportunities.
5
+ """
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sqlite3
11
+ import sys
12
+ import tasks_db
13
+ from datetime import date, datetime
14
+ from pathlib import Path
15
+
16
+ # Feature flag: set BRAIN_ENABLE_POLLER=1 to re-enable poller intake code paths.
17
+ # When unset or "0" (default), poller code is skipped.
18
+ # This flag exists as a rollback escape hatch; poller intake is disabled by default.
19
+ BRAIN_ENABLE_POLLER = os.environ.get("BRAIN_ENABLE_POLLER", "0") == "1"
20
+
21
+ ENG_BUDDY_DIR = Path.home() / ".claude" / "eng-buddy"
22
+ MEMORY_DIR = ENG_BUDDY_DIR / "memory"
23
+ MEMORY_DIR.mkdir(exist_ok=True)
24
+
25
+ DB_PATH = ENG_BUDDY_DIR / "inbox.db"
26
+ DAILY_DIR = ENG_BUDDY_DIR / "daily"
27
+ PATTERNS_DIR = ENG_BUDDY_DIR / "patterns"
28
+ STAKEHOLDERS_DIR = ENG_BUDDY_DIR / "stakeholders"
29
+ KNOWLEDGE_DIR = ENG_BUDDY_DIR / "knowledge"
30
+
31
+ UNMAPPED_LEARNING_PATH = PATTERNS_DIR / "uncategorized-learning.md"
32
+ UNMAPPED_LEARNING_HEADING = "## AI Captured Uncategorized Learning"
33
+
34
+ WRITE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
35
+ TASK_TOOLS = {"Bash", "Task"}
36
+ ACTION_MCP_PROVIDERS = {
37
+ "mcp-atlassian",
38
+ "freshservice-mcp",
39
+ "gmail",
40
+ "google-calendar",
41
+ "slack",
42
+ "lusha",
43
+ "git",
44
+ }
45
+ READ_ONLY_MCP_PROVIDERS = {
46
+ "context7",
47
+ "apple-doc-mcp",
48
+ "glean_default",
49
+ "playwright",
50
+ "sequential-thinking",
51
+ }
52
+
53
+
54
+ def _load(name, default=None):
55
+ p = MEMORY_DIR / name
56
+ if p.exists():
57
+ try:
58
+ return json.loads(p.read_text())
59
+ except json.JSONDecodeError:
60
+ pass
61
+ return default if default is not None else {}
62
+
63
+
64
+ def _save(name, data):
65
+ (MEMORY_DIR / name).write_text(json.dumps(data, indent=2))
66
+
67
+
68
+ def load_context():
69
+ return _load("context.json", {})
70
+
71
+
72
+ def load_stakeholders():
73
+ return _load("stakeholders.json", {})
74
+
75
+
76
+ def load_patterns():
77
+ return _load("patterns.json", {"patterns": [], "automation_opportunities": []})
78
+
79
+
80
+ def load_traces():
81
+ return _load("traces.json", {"traces": []})
82
+
83
+
84
+ def _normalize_category(name: str) -> str:
85
+ if not name:
86
+ return ""
87
+ normalized = re.sub(r"[^a-z0-9_-]+", "-", str(name).strip().lower())
88
+ normalized = re.sub(r"-+", "-", normalized).strip("-")
89
+ return normalized
90
+
91
+
92
+ def _default_learning_routes():
93
+ today_file = DAILY_DIR / f"{date.today().isoformat()}.md"
94
+ return {
95
+ "playbook": {
96
+ "path": KNOWLEDGE_DIR / "runbooks.md",
97
+ "heading": "## AI Captured Playbooks",
98
+ "description": "Reusable work playbooks and runbook snippets",
99
+ "source": "system",
100
+ },
101
+ "stakeholder": {
102
+ "path": STAKEHOLDERS_DIR / "communication-log.md",
103
+ "heading": "## AI Captured Stakeholder Notes",
104
+ "description": "Stakeholder communication notes",
105
+ "source": "system",
106
+ },
107
+ "personal": {
108
+ "path": today_file,
109
+ "heading": "## Personal Notes",
110
+ "description": "Personal productivity notes for today",
111
+ "source": "system",
112
+ },
113
+ "troubleshooting": {
114
+ "path": PATTERNS_DIR / "recurring-issues.md",
115
+ "heading": "## AI Captured Troubleshooting Patterns",
116
+ "description": "Recurring issues and fixes",
117
+ "source": "system",
118
+ },
119
+ "success-pattern": {
120
+ "path": PATTERNS_DIR / "success-patterns.md",
121
+ "heading": "## AI Captured Success Patterns",
122
+ "description": "Patterns behind successful outcomes",
123
+ "source": "system",
124
+ },
125
+ "failure-pattern": {
126
+ "path": PATTERNS_DIR / "failure-patterns.md",
127
+ "heading": "## AI Captured Failure Patterns",
128
+ "description": "Patterns behind failed outcomes",
129
+ "source": "system",
130
+ },
131
+ "recurring-question": {
132
+ "path": PATTERNS_DIR / "recurring-questions.md",
133
+ "heading": "## AI Captured Questions",
134
+ "description": "Frequently recurring questions",
135
+ "source": "system",
136
+ },
137
+ "documentation-gap": {
138
+ "path": PATTERNS_DIR / "documentation-gaps.md",
139
+ "heading": "## AI Captured Documentation Gaps",
140
+ "description": "Missing docs and runbook gaps",
141
+ "source": "system",
142
+ },
143
+ "task-execution": {
144
+ "path": PATTERNS_DIR / "task-execution.md",
145
+ "heading": "## AI Captured Task Execution Learnings",
146
+ "description": "Learned signals from finished task operations",
147
+ "source": "system",
148
+ },
149
+ "writing-update": {
150
+ "path": KNOWLEDGE_DIR / "writing-updates.md",
151
+ "heading": "## AI Captured Writing Updates",
152
+ "description": "Learned signals from file writes and edits",
153
+ "source": "system",
154
+ },
155
+ }
156
+
157
+
158
+ def _load_custom_learning_categories():
159
+ data = _load("learning-categories.json", {"categories": {}})
160
+ if not isinstance(data, dict):
161
+ return {"categories": {}}
162
+ categories = data.get("categories")
163
+ if not isinstance(categories, dict):
164
+ return {"categories": {}}
165
+ return {"categories": categories}
166
+
167
+
168
+ def _save_custom_learning_categories(data):
169
+ _save("learning-categories.json", data)
170
+
171
+
172
+ def get_learning_routes():
173
+ routes = _default_learning_routes()
174
+ custom = _load_custom_learning_categories().get("categories", {})
175
+
176
+ for raw_name, meta in custom.items():
177
+ if not isinstance(meta, dict):
178
+ continue
179
+ bucket = _normalize_category(raw_name)
180
+ if not bucket:
181
+ continue
182
+
183
+ path_raw = str(meta.get("path", "")).strip()
184
+ if path_raw:
185
+ path = Path(path_raw).expanduser()
186
+ if not path.is_absolute():
187
+ path = ENG_BUDDY_DIR / path
188
+ else:
189
+ path = KNOWLEDGE_DIR / f"{bucket}.md"
190
+
191
+ heading = str(meta.get("heading", "")).strip() or f"## AI Captured {bucket.replace('-', ' ').title()}"
192
+ description = str(meta.get("description", "")).strip() or "User-defined learning category"
193
+
194
+ routes[bucket] = {
195
+ "path": path,
196
+ "heading": heading,
197
+ "description": description,
198
+ "source": "custom",
199
+ }
200
+
201
+ return routes
202
+
203
+
204
+ def list_learning_buckets():
205
+ return sorted(get_learning_routes().keys())
206
+
207
+
208
+ def _ensure_learning_schema():
209
+ conn = sqlite3.connect(DB_PATH)
210
+ try:
211
+ conn.execute(
212
+ """CREATE TABLE IF NOT EXISTS learning_categories (
213
+ name TEXT PRIMARY KEY,
214
+ description TEXT,
215
+ source TEXT NOT NULL DEFAULT 'system',
216
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
217
+ )"""
218
+ )
219
+ conn.execute(
220
+ """CREATE TABLE IF NOT EXISTS learning_events (
221
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
222
+ session_id TEXT,
223
+ hook_event TEXT,
224
+ source TEXT,
225
+ scope TEXT,
226
+ tool_name TEXT,
227
+ category TEXT,
228
+ title TEXT,
229
+ note TEXT,
230
+ status TEXT NOT NULL DEFAULT 'captured',
231
+ requires_category_expansion INTEGER NOT NULL DEFAULT 0,
232
+ proposed_category TEXT,
233
+ metadata TEXT,
234
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
235
+ )"""
236
+ )
237
+ conn.execute(
238
+ "CREATE INDEX IF NOT EXISTS idx_learning_events_session ON learning_events(session_id, created_at)"
239
+ )
240
+ conn.execute(
241
+ "CREATE INDEX IF NOT EXISTS idx_learning_events_category ON learning_events(category, created_at)"
242
+ )
243
+ conn.execute(
244
+ "CREATE INDEX IF NOT EXISTS idx_learning_events_pending ON learning_events(requires_category_expansion, created_at)"
245
+ )
246
+
247
+ for bucket, meta in get_learning_routes().items():
248
+ conn.execute(
249
+ """INSERT OR IGNORE INTO learning_categories (name, description, source)
250
+ VALUES (?, ?, ?)""",
251
+ [bucket, meta.get("description", ""), meta.get("source", "system")],
252
+ )
253
+ conn.commit()
254
+ finally:
255
+ conn.close()
256
+
257
+
258
+ def _ensure_ops_schema():
259
+ """Create ops tracking tables: capacity, stakeholders, incidents, patterns, follow-ups, burnout."""
260
+ conn = sqlite3.connect(DB_PATH)
261
+ try:
262
+ conn.execute(
263
+ """CREATE TABLE IF NOT EXISTS capacity_logs (
264
+ id INTEGER PRIMARY KEY,
265
+ date TEXT,
266
+ metric TEXT,
267
+ value REAL,
268
+ notes TEXT,
269
+ created_at TEXT DEFAULT (datetime('now'))
270
+ )"""
271
+ )
272
+ conn.execute(
273
+ """CREATE TABLE IF NOT EXISTS stakeholder_contacts (
274
+ id INTEGER PRIMARY KEY,
275
+ name TEXT,
276
+ role TEXT,
277
+ preferences TEXT,
278
+ last_contact TEXT,
279
+ created_at TEXT DEFAULT (datetime('now'))
280
+ )"""
281
+ )
282
+ conn.execute(
283
+ """CREATE TABLE IF NOT EXISTS incidents (
284
+ id INTEGER PRIMARY KEY,
285
+ title TEXT,
286
+ severity TEXT,
287
+ status TEXT DEFAULT 'open',
288
+ timeline TEXT,
289
+ root_cause TEXT,
290
+ resolution TEXT,
291
+ created_at TEXT DEFAULT (datetime('now'))
292
+ )"""
293
+ )
294
+ conn.execute(
295
+ """CREATE TABLE IF NOT EXISTS pattern_observations (
296
+ id INTEGER PRIMARY KEY,
297
+ type TEXT,
298
+ title TEXT,
299
+ description TEXT,
300
+ confidence REAL,
301
+ evidence TEXT,
302
+ frequency INTEGER DEFAULT 1,
303
+ first_seen TEXT,
304
+ last_seen TEXT,
305
+ source_file TEXT,
306
+ created_at TEXT DEFAULT (datetime('now'))
307
+ )"""
308
+ )
309
+ conn.execute(
310
+ """CREATE TABLE IF NOT EXISTS follow_ups (
311
+ id INTEGER PRIMARY KEY,
312
+ stakeholder TEXT,
313
+ topic TEXT,
314
+ due_date TEXT,
315
+ status TEXT DEFAULT 'pending',
316
+ notes TEXT,
317
+ created_at TEXT DEFAULT (datetime('now'))
318
+ )"""
319
+ )
320
+ conn.execute(
321
+ """CREATE TABLE IF NOT EXISTS burnout_indicators (
322
+ id INTEGER PRIMARY KEY,
323
+ date TEXT,
324
+ indicator TEXT,
325
+ severity TEXT,
326
+ details TEXT,
327
+ created_at TEXT DEFAULT (datetime('now'))
328
+ )"""
329
+ )
330
+ # FTS5 virtual table for pattern search
331
+ conn.execute(
332
+ """CREATE VIRTUAL TABLE IF NOT EXISTS pattern_observations_fts USING fts5(
333
+ title, description, evidence,
334
+ content='pattern_observations',
335
+ content_rowid='id'
336
+ )"""
337
+ )
338
+ conn.commit()
339
+ finally:
340
+ conn.close()
341
+
342
+
343
+ def _record_learning_event(
344
+ *,
345
+ session_id: str = "",
346
+ hook_event: str = "",
347
+ source: str = "",
348
+ scope: str = "",
349
+ tool_name: str = "",
350
+ category: str = "",
351
+ title: str = "",
352
+ note: str = "",
353
+ status: str = "captured",
354
+ requires_category_expansion: bool = False,
355
+ proposed_category: str = "",
356
+ metadata=None,
357
+ ):
358
+ _ensure_learning_schema()
359
+ conn = sqlite3.connect(DB_PATH)
360
+ try:
361
+ conn.execute(
362
+ """INSERT INTO learning_events (
363
+ session_id, hook_event, source, scope, tool_name,
364
+ category, title, note, status,
365
+ requires_category_expansion, proposed_category, metadata
366
+ )
367
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
368
+ """,
369
+ [
370
+ session_id or "",
371
+ hook_event or "",
372
+ source or "",
373
+ scope or "",
374
+ tool_name or "",
375
+ category or "",
376
+ title or "",
377
+ note or "",
378
+ status,
379
+ 1 if requires_category_expansion else 0,
380
+ proposed_category or "",
381
+ json.dumps(metadata or {}),
382
+ ],
383
+ )
384
+ conn.commit()
385
+ finally:
386
+ conn.close()
387
+
388
+
389
+ def register_learning_category(name: str, description: str = "", path: str = "", heading: str = ""):
390
+ bucket = _normalize_category(name)
391
+ if not bucket:
392
+ raise ValueError("category name is required")
393
+
394
+ resolved_path = path.strip() if path else f"knowledge/{bucket}.md"
395
+ resolved_heading = heading.strip() if heading else f"## AI Captured {bucket.replace('-', ' ').title()}"
396
+ resolved_description = description.strip() if description else "User-approved custom learning category"
397
+
398
+ custom = _load_custom_learning_categories()
399
+ custom.setdefault("categories", {})[bucket] = {
400
+ "path": resolved_path,
401
+ "heading": resolved_heading,
402
+ "description": resolved_description,
403
+ }
404
+ _save_custom_learning_categories(custom)
405
+
406
+ _ensure_learning_schema()
407
+ conn = sqlite3.connect(DB_PATH)
408
+ try:
409
+ conn.execute(
410
+ """INSERT INTO learning_categories (name, description, source)
411
+ VALUES (?, ?, 'custom')
412
+ ON CONFLICT(name) DO UPDATE SET
413
+ description = excluded.description,
414
+ source = 'custom'""",
415
+ [bucket, resolved_description],
416
+ )
417
+ conn.commit()
418
+ finally:
419
+ conn.close()
420
+
421
+ return {
422
+ "added": True,
423
+ "category": bucket,
424
+ "path": resolved_path,
425
+ "heading": resolved_heading,
426
+ "description": resolved_description,
427
+ }
428
+
429
+
430
+ def _append_markdown_note(path: Path, heading: str, line: str):
431
+ """Append a single bullet line under a heading in markdown file."""
432
+ path.parent.mkdir(parents=True, exist_ok=True)
433
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
434
+ bullet = f"- {timestamp} | {line.strip()}"
435
+
436
+ if not path.exists():
437
+ title = path.stem.replace("-", " ").title()
438
+ path.write_text(f"# {title}\n\n{heading}\n{bullet}\n", encoding="utf-8")
439
+ return
440
+
441
+ content = path.read_text(encoding="utf-8")
442
+ if bullet in content:
443
+ return
444
+
445
+ lines = content.splitlines()
446
+ heading_idx = next((i for i, h in enumerate(lines) if h.strip() == heading), None)
447
+
448
+ if heading_idx is None:
449
+ if lines and lines[-1].strip():
450
+ lines.append("")
451
+ lines.extend([heading, bullet])
452
+ else:
453
+ insert_at = heading_idx + 1
454
+ if insert_at < len(lines) and lines[insert_at].strip() == "":
455
+ insert_at += 1
456
+ lines.insert(insert_at, bullet)
457
+
458
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
459
+
460
+
461
+ def _route_learning_logs(entries):
462
+ """Route structured learning notes into long-lived markdown knowledge files."""
463
+ if not entries:
464
+ return []
465
+
466
+ routes = get_learning_routes()
467
+ pending_expansion = []
468
+
469
+ for entry in entries:
470
+ if not isinstance(entry, dict):
471
+ continue
472
+
473
+ bucket = _normalize_category(str(entry.get("bucket", "troubleshooting")))
474
+ title = str(entry.get("title", "")).strip()
475
+ note = str(entry.get("note", "")).strip()
476
+ if not note:
477
+ continue
478
+
479
+ line = f"{title}: {note}" if title else note
480
+
481
+ if bucket in routes:
482
+ route = routes[bucket]
483
+ _append_markdown_note(route["path"], route["heading"], line)
484
+ _record_learning_event(
485
+ source="learning-log",
486
+ scope="ai_response",
487
+ category=bucket,
488
+ title=title,
489
+ note=note,
490
+ status="captured",
491
+ metadata={"entry": entry},
492
+ )
493
+ continue
494
+
495
+ proposed_bucket = bucket or "uncategorized"
496
+ pending_expansion.append(proposed_bucket)
497
+ _append_markdown_note(UNMAPPED_LEARNING_PATH, UNMAPPED_LEARNING_HEADING, line)
498
+ _record_learning_event(
499
+ source="learning-log",
500
+ scope="ai_response",
501
+ category="",
502
+ title=title,
503
+ note=note,
504
+ status="needs_category_expansion",
505
+ requires_category_expansion=True,
506
+ proposed_category=proposed_bucket,
507
+ metadata={"entry": entry},
508
+ )
509
+
510
+ return sorted(set(pending_expansion))
511
+
512
+
513
+ def load_decisions(query, limit=5):
514
+ """Search past decisions by keywords. Returns list of dicts."""
515
+ if not DB_PATH.exists():
516
+ return []
517
+ conn = sqlite3.connect(DB_PATH)
518
+ conn.row_factory = sqlite3.Row
519
+ try:
520
+ # Try FTS5 (sanitize query by quoting each term)
521
+ try:
522
+ safe_query = " ".join(f'"{w}"' for w in query.split() if w)
523
+ if not safe_query:
524
+ safe_query = '""'
525
+ rows = conn.execute(
526
+ """SELECT d.summary, d.action, d.source, d.context_notes,
527
+ d.draft_response, d.decision_at
528
+ FROM decisions d
529
+ JOIN decisions_fts fts ON d.id = fts.rowid
530
+ WHERE decisions_fts MATCH ?
531
+ ORDER BY d.decision_at DESC LIMIT ?""",
532
+ [safe_query, limit]
533
+ ).fetchall()
534
+ except sqlite3.OperationalError:
535
+ like = f"%{query}%"
536
+ rows = conn.execute(
537
+ """SELECT summary, action, source, context_notes,
538
+ draft_response, decision_at
539
+ FROM decisions
540
+ WHERE summary LIKE ? OR context_notes LIKE ?
541
+ OR draft_response LIKE ? OR tags LIKE ?
542
+ ORDER BY decision_at DESC LIMIT ?""",
543
+ [like, like, like, like, limit]
544
+ ).fetchall()
545
+ return [dict(r) for r in rows]
546
+ except Exception:
547
+ return []
548
+ finally:
549
+ conn.close()
550
+
551
+
552
+ def build_context_prompt(batch_items=None):
553
+ """Build the persistent context block injected into every Claude CLI call."""
554
+ ctx = load_context()
555
+ stakeholders = load_stakeholders()
556
+ patterns = load_patterns()
557
+
558
+ # Pick relevant stakeholders if batch has sender info
559
+ relevant = {}
560
+ if batch_items:
561
+ senders = set()
562
+ for item in batch_items:
563
+ s = item.get("sender_email", "") or item.get("from", "") or item.get("sender", "")
564
+ if s:
565
+ # Normalize to username
566
+ username = s.split("@")[0].replace(".", "_") if "@" in s else s.lower().replace(" ", "_")
567
+ senders.add(username)
568
+ for key, val in stakeholders.items():
569
+ normalized = key.replace(".", "_")
570
+ if normalized in senders or any(normalized in s for s in senders):
571
+ relevant[key] = val
572
+
573
+ priorities_str = "\n".join(f"- {p}" for p in ctx.get("current_priorities", [])) or "None set"
574
+ rules_str = "\n".join(f"- {r}" for r in ctx.get("learned_rules", [])) or "None yet"
575
+
576
+ stakeholder_str = ""
577
+ if relevant:
578
+ parts = []
579
+ for name, info in relevant.items():
580
+ parts.append(f" {name}: {info.get('role', 'unknown')} — {info.get('relationship', '')} — expects response in {info.get('avg_response_expectation', 'unknown')}")
581
+ stakeholder_str = "\n".join(parts)
582
+ else:
583
+ stakeholder_str = " No matching stakeholders for this batch."
584
+
585
+ playbook_str = ""
586
+ known = patterns.get("patterns", [])
587
+ if known:
588
+ parts = []
589
+ for p in known[:10]:
590
+ parts.append(f" - {p['id']}: trigger={p.get('trigger', '?')}, steps={len(p.get('steps', []))}, used {p.get('times_used', 0)} times")
591
+ playbook_str = "\n".join(parts)
592
+ else:
593
+ playbook_str = " No playbooks captured yet."
594
+
595
+ # Find similar past decisions based on batch item summaries
596
+ decisions_str = ""
597
+ if batch_items:
598
+ seen = set()
599
+ all_decisions = []
600
+ for item in batch_items:
601
+ summary = item.get("summary", "") or item.get("subject", "") or ""
602
+ # Extract key words for search
603
+ words = [w for w in summary.split() if len(w) > 3]
604
+ if words:
605
+ query = " ".join(words[:5])
606
+ for d in load_decisions(query, limit=3):
607
+ key = d.get("summary", "")
608
+ if key not in seen:
609
+ seen.add(key)
610
+ all_decisions.append(d)
611
+ if all_decisions:
612
+ parts = []
613
+ for d in all_decisions[:5]:
614
+ parts.append(f" - [{d.get('decision_at', '?')[:10]}] {d.get('action', '?')}: {d.get('summary', '?')}")
615
+ if d.get("draft_response"):
616
+ parts.append(f" Response sent: {d['draft_response'][:100]}...")
617
+ decisions_str = "\n".join(parts)
618
+
619
+ if not decisions_str:
620
+ decisions_str = " No similar past decisions found."
621
+
622
+ learning_buckets = "|".join(list_learning_buckets())
623
+
624
+ return f"""You are eng-buddy, an intelligent work assistant for {ctx.get('role', 'an engineer')} at {ctx.get('company', 'a company')}.
625
+ Manager: {ctx.get('manager', 'unknown')}
626
+ Team: {ctx.get('team', 'unknown')}
627
+ Response tone: {ctx.get('preferences', {}).get('response_tone', 'professional')}
628
+
629
+ Current priorities:
630
+ {priorities_str}
631
+
632
+ Learned rules (APPLY THESE):
633
+ {rules_str}
634
+
635
+ Relevant stakeholders:
636
+ {stakeholder_str}
637
+
638
+ Known playbooks:
639
+ {playbook_str}
640
+
641
+ Similar past decisions (use these for consistency):
642
+ {decisions_str}
643
+
644
+ AFTER completing your primary task, also output these sections if applicable (as JSON blocks):
645
+ - <!--STAKEHOLDER_UPDATES-->: [{{"name": "...", "field": "...", "value": "..."}}]
646
+ - <!--NEW_PATTERNS-->: [{{"trigger": "...", "steps": [...], "category": "..."}}]
647
+ - <!--AUTOMATION_OPPORTUNITIES-->: [{{"observation": "...", "suggestion": "..."}}]
648
+ - <!--LEARNED_RULES-->: ["rule text", ...]
649
+ - <!--WORK_TRACES-->: [{{"trigger": "...", "category": "...", "step_observed": "..."}}]
650
+ - <!--LEARNING_LOGS-->: [{{"bucket":"{learning_buckets}","title":"...","note":"..."}}]
651
+ """
652
+
653
+
654
+ def parse_learning(claude_response):
655
+ """Parse Claude's response for learning sections and merge into memory."""
656
+ sections = {
657
+ "STAKEHOLDER_UPDATES": _parse_section(claude_response, "STAKEHOLDER_UPDATES"),
658
+ "NEW_PATTERNS": _parse_section(claude_response, "NEW_PATTERNS"),
659
+ "AUTOMATION_OPPORTUNITIES": _parse_section(claude_response, "AUTOMATION_OPPORTUNITIES"),
660
+ "LEARNED_RULES": _parse_section(claude_response, "LEARNED_RULES"),
661
+ "WORK_TRACES": _parse_section(claude_response, "WORK_TRACES"),
662
+ "LEARNING_LOGS": _parse_section(claude_response, "LEARNING_LOGS"),
663
+ }
664
+
665
+ if sections["STAKEHOLDER_UPDATES"]:
666
+ sh = load_stakeholders()
667
+ for update in sections["STAKEHOLDER_UPDATES"]:
668
+ name = update.get("name", "")
669
+ if name:
670
+ if name not in sh:
671
+ sh[name] = {}
672
+ field = update.get("field", "")
673
+ if field:
674
+ sh[name][field] = update.get("value", "")
675
+ sh[name]["last_updated"] = datetime.now().isoformat()
676
+ _save("stakeholders.json", sh)
677
+
678
+ if sections["NEW_PATTERNS"]:
679
+ pt = load_patterns()
680
+ for pattern in sections["NEW_PATTERNS"]:
681
+ pid = pattern.get("category", "unknown") + "-" + str(len(pt["patterns"]))
682
+ pt["patterns"].append({
683
+ "id": pid,
684
+ "trigger": pattern.get("trigger", ""),
685
+ "steps": pattern.get("steps", []),
686
+ "category": pattern.get("category", ""),
687
+ "automation_level": "observe",
688
+ "times_used": 1,
689
+ "detected_at": datetime.now().isoformat(),
690
+ })
691
+ _save("patterns.json", pt)
692
+
693
+ if sections["AUTOMATION_OPPORTUNITIES"]:
694
+ pt = load_patterns()
695
+ for opp in sections["AUTOMATION_OPPORTUNITIES"]:
696
+ pt["automation_opportunities"].append({
697
+ "observation": opp.get("observation", ""),
698
+ "suggestion": opp.get("suggestion", ""),
699
+ "status": "pending_review",
700
+ "detected_at": datetime.now().isoformat(),
701
+ })
702
+ _save("patterns.json", pt)
703
+
704
+ if sections["LEARNED_RULES"]:
705
+ ctx = load_context()
706
+ existing = set(ctx.get("learned_rules", []))
707
+ for rule in sections["LEARNED_RULES"]:
708
+ if isinstance(rule, str) and rule not in existing:
709
+ ctx.setdefault("learned_rules", []).append(rule)
710
+ _save("context.json", ctx)
711
+
712
+ if sections["WORK_TRACES"]:
713
+ tr = load_traces()
714
+ for trace in sections["WORK_TRACES"]:
715
+ tr["traces"].append({
716
+ **trace,
717
+ "timestamp": datetime.now().isoformat(),
718
+ })
719
+ # Cap at 500 traces
720
+ tr["traces"] = tr["traces"][-500:]
721
+ _save("traces.json", tr)
722
+
723
+ pending_categories = []
724
+ if sections["LEARNING_LOGS"]:
725
+ pending_categories = _route_learning_logs(sections["LEARNING_LOGS"])
726
+
727
+ sections["PENDING_CATEGORY_EXPANSIONS"] = pending_categories
728
+ return sections
729
+
730
+
731
+ def _parse_section(text, section_name):
732
+ """Extract a JSON block between <!--SECTION--> markers."""
733
+ pattern = rf'<!--{section_name}-->\s*(\[.*?\])'
734
+ match = re.search(pattern, text, re.DOTALL)
735
+ if match:
736
+ try:
737
+ return json.loads(match.group(1))
738
+ except json.JSONDecodeError:
739
+ pass
740
+ return []
741
+
742
+
743
+ def _extract_tool_name_parts(tool_name: str):
744
+ if not tool_name.startswith("mcp__"):
745
+ return "", ""
746
+ parts = tool_name.split("__")
747
+ provider = parts[1] if len(parts) > 1 else ""
748
+ action = parts[2] if len(parts) > 2 else ""
749
+ return provider, action
750
+
751
+
752
+ def _classify_post_tool_category(tool_name: str, tool_input: dict):
753
+ if tool_name in WRITE_TOOLS:
754
+ return "writing-update", ""
755
+
756
+ if tool_name in TASK_TOOLS:
757
+ return "task-execution", ""
758
+
759
+ if tool_name.startswith("mcp__"):
760
+ provider, _action = _extract_tool_name_parts(tool_name)
761
+ if provider in ACTION_MCP_PROVIDERS:
762
+ return "task-execution", ""
763
+ if provider in READ_ONLY_MCP_PROVIDERS:
764
+ return "", ""
765
+
766
+ proposed = _normalize_category(f"integration-{provider or 'unknown'}")
767
+ return "", proposed
768
+
769
+ return "", ""
770
+
771
+
772
+ def _summarize_post_tool_learning(tool_name: str, tool_input: dict):
773
+ if not isinstance(tool_input, dict):
774
+ tool_input = {}
775
+
776
+ if tool_name in WRITE_TOOLS:
777
+ file_path = str(tool_input.get("file_path", "")).strip()
778
+ if not file_path and isinstance(tool_input.get("files"), list):
779
+ file_path = ", ".join(str(p) for p in tool_input.get("files", [])[:3])
780
+ if file_path:
781
+ return "File update", f"{tool_name} completed on {file_path}"
782
+ return "File update", f"{tool_name} completed"
783
+
784
+ if tool_name == "Bash":
785
+ command = str(tool_input.get("command", "")).strip()
786
+ if len(command) > 180:
787
+ command = command[:177] + "..."
788
+ if command:
789
+ return "Task execution", f"Bash command completed: {command}"
790
+ return "Task execution", "Bash command completed"
791
+
792
+ if tool_name.startswith("mcp__"):
793
+ provider, action = _extract_tool_name_parts(tool_name)
794
+ provider_label = provider or "unknown"
795
+ action_label = action or "operation"
796
+ return "External integration", f"{provider_label} {action_label} completed"
797
+
798
+ return "Task execution", f"{tool_name} completed"
799
+
800
+
801
+ def capture_post_tool_learning(payload: dict):
802
+ """Capture PostToolUse learning into DB/markdown routes."""
803
+ if not isinstance(payload, dict):
804
+ return {"recorded": False, "reason": "invalid_payload"}
805
+
806
+ tool_name = str(payload.get("tool_name", "")).strip()
807
+ tool_input = payload.get("tool_input")
808
+ if isinstance(tool_input, str):
809
+ try:
810
+ tool_input = json.loads(tool_input)
811
+ except json.JSONDecodeError:
812
+ tool_input = {"raw": tool_input}
813
+ if not isinstance(tool_input, dict):
814
+ tool_input = {}
815
+
816
+ category, proposed_category = _classify_post_tool_category(tool_name, tool_input)
817
+ if not category and not proposed_category:
818
+ return {"recorded": False, "reason": "untracked_tool"}
819
+
820
+ title, note = _summarize_post_tool_learning(tool_name, tool_input)
821
+ session_id = str(payload.get("session_id", ""))
822
+
823
+ if category:
824
+ routes = get_learning_routes()
825
+ route = routes.get(category)
826
+ if route:
827
+ _append_markdown_note(route["path"], route["heading"], note)
828
+ _record_learning_event(
829
+ session_id=session_id,
830
+ hook_event="PostToolUse",
831
+ source="hook",
832
+ scope="tool_completion",
833
+ tool_name=tool_name,
834
+ category=category,
835
+ title=title,
836
+ note=note,
837
+ status="captured",
838
+ metadata={"tool_input": tool_input},
839
+ )
840
+ return {
841
+ "recorded": True,
842
+ "category": category,
843
+ "needs_category_expansion": False,
844
+ "title": title,
845
+ "note": note,
846
+ }
847
+
848
+ proposed_category = category
849
+
850
+ # Category couldn't be routed: capture as pending and ask user later.
851
+ _append_markdown_note(UNMAPPED_LEARNING_PATH, UNMAPPED_LEARNING_HEADING, note)
852
+ _record_learning_event(
853
+ session_id=session_id,
854
+ hook_event="PostToolUse",
855
+ source="hook",
856
+ scope="tool_completion",
857
+ tool_name=tool_name,
858
+ category="",
859
+ title=title,
860
+ note=note,
861
+ status="needs_category_expansion",
862
+ requires_category_expansion=True,
863
+ proposed_category=proposed_category,
864
+ metadata={"tool_input": tool_input},
865
+ )
866
+ return {
867
+ "recorded": True,
868
+ "category": "",
869
+ "needs_category_expansion": True,
870
+ "proposed_category": proposed_category,
871
+ "title": title,
872
+ "note": note,
873
+ }
874
+
875
+
876
+ def _cli():
877
+ parser = argparse.ArgumentParser(description="eng-buddy learning engine utilities")
878
+ parser.add_argument("--register-learning-category", dest="register_learning_category", default="")
879
+ parser.add_argument("--description", default="")
880
+ parser.add_argument("--path", default="")
881
+ parser.add_argument("--heading", default="")
882
+ parser.add_argument(
883
+ "--capture-post-tool",
884
+ action="store_true",
885
+ help="Read PostToolUse payload JSON from stdin and capture learning event",
886
+ )
887
+
888
+ # --- Playbook Engine Commands ---
889
+ parser.add_argument("--playbook-trace-event", action="store_true",
890
+ help="Record a trace event (reads JSON from stdin: {trace_id, event})")
891
+ parser.add_argument("--playbook-extract", type=str, metavar="TRACE_ID",
892
+ help="Extract a draft playbook from a completed trace")
893
+ parser.add_argument("--playbook-extract-name", type=str, default="Untitled",
894
+ help="Name for the extracted playbook (used with --playbook-extract)")
895
+ parser.add_argument("--playbook-match", type=str, metavar="TEXT",
896
+ help="Find playbooks matching ticket text")
897
+ parser.add_argument("--playbook-match-type", type=str, default="",
898
+ help="Ticket type for matching (used with --playbook-match)")
899
+ parser.add_argument("--playbook-match-source", type=str, default="freshservice",
900
+ help="Source system for matching (used with --playbook-match)")
901
+ parser.add_argument("--playbook-list", action="store_true",
902
+ help="List all approved playbooks")
903
+ parser.add_argument("--playbook-list-drafts", action="store_true",
904
+ help="List all draft playbooks")
905
+ parser.add_argument("--playbook-promote", type=str, metavar="PLAYBOOK_ID",
906
+ help="Promote a draft playbook to approved")
907
+
908
+ # --- Task Management Commands ---
909
+ parser.add_argument("--tasks", action="store_true",
910
+ help="List all non-completed tasks")
911
+ parser.add_argument("--tasks-all", action="store_true",
912
+ help="List ALL tasks including completed")
913
+ parser.add_argument("--task", type=int, metavar="N",
914
+ help="Show full detail for task N")
915
+ parser.add_argument("--task-add", action="store_true",
916
+ help="Create a new task (requires --title)")
917
+ parser.add_argument("--task-update", type=int, metavar="N",
918
+ help="Update task N (use with --status, --priority, --deferred-until)")
919
+ parser.add_argument("--task-search", type=str, metavar="KEYWORD",
920
+ help="Full-text search for tasks")
921
+ parser.add_argument("--task-json", action="store_true",
922
+ help="Output task results as JSON instead of table format")
923
+ parser.add_argument("--task-export", type=int, metavar="N",
924
+ help="Export task N as markdown context block")
925
+ parser.add_argument("--title", type=str, default="",
926
+ help="Title for --task-add")
927
+ parser.add_argument("--status", type=str, default="",
928
+ help="Status for --task-update")
929
+ parser.add_argument("--priority", type=str, default="",
930
+ help="Priority for --task-add or --task-update")
931
+ parser.add_argument("--jira-key", type=str, default="",
932
+ help="Jira key for --task-add")
933
+ parser.add_argument("--deferred-until", type=str, default="",
934
+ help="Deferred date for --task-update")
935
+
936
+ # --- Ops Tracking Commands ---
937
+ parser.add_argument("--capacity-log", action="store_true",
938
+ help="Add a capacity log entry (requires --metric, --value; optional --date, --notes)")
939
+ parser.add_argument("--stakeholder-add", action="store_true",
940
+ help="Add a stakeholder contact (requires --name; optional --role, --preferences)")
941
+ parser.add_argument("--stakeholder-list", action="store_true",
942
+ help="List all stakeholder contacts (JSON output)")
943
+ parser.add_argument("--incident-add", action="store_true",
944
+ help="Add an incident (requires --title, --severity; optional --timeline)")
945
+ parser.add_argument("--incident-list", action="store_true",
946
+ help="List incidents (JSON output; optional --status filter)")
947
+ parser.add_argument("--pattern-add", action="store_true",
948
+ help="Add a pattern observation (requires --title; optional --type, --description, --confidence)")
949
+ parser.add_argument("--pattern-list", action="store_true",
950
+ help="List pattern observations (JSON output)")
951
+ parser.add_argument("--followup-add", action="store_true",
952
+ help="Add a follow-up item (requires --stakeholder, --topic; optional --due-date)")
953
+ parser.add_argument("--followup-list", action="store_true",
954
+ help="List follow-up items (JSON output; optional --status filter)")
955
+ # Shared optional args for ops commands
956
+ parser.add_argument("--date", type=str, default="",
957
+ help="Date string for --capacity-log")
958
+ parser.add_argument("--metric", type=str, default="",
959
+ help="Metric name for --capacity-log")
960
+ parser.add_argument("--value", type=float, default=None,
961
+ help="Numeric value for --capacity-log")
962
+ parser.add_argument("--notes", type=str, default="",
963
+ help="Notes for --capacity-log or --followup-add")
964
+ parser.add_argument("--name", type=str, default="",
965
+ help="Name for --stakeholder-add")
966
+ parser.add_argument("--role", type=str, default="",
967
+ help="Role for --stakeholder-add")
968
+ parser.add_argument("--preferences", type=str, default="",
969
+ help="Preferences for --stakeholder-add")
970
+ parser.add_argument("--severity", type=str, default="",
971
+ help="Severity for --incident-add or --burnout-add")
972
+ parser.add_argument("--timeline", type=str, default="",
973
+ help="Timeline notes for --incident-add")
974
+ parser.add_argument("--type", type=str, default="",
975
+ dest="obs_type",
976
+ help="Observation type for --pattern-add")
977
+ parser.add_argument("--confidence", type=float, default=None,
978
+ help="Confidence score (0.0-1.0) for --pattern-add")
979
+ parser.add_argument("--stakeholder", type=str, default="",
980
+ help="Stakeholder name for --followup-add or --followup-list")
981
+ parser.add_argument("--topic", type=str, default="",
982
+ help="Topic for --followup-add")
983
+ parser.add_argument("--due-date", type=str, default="",
984
+ help="Due date for --followup-add")
985
+
986
+ args = parser.parse_args()
987
+
988
+ # --- Task Management Handlers ---
989
+ if args.tasks or args.tasks_all:
990
+ rows = tasks_db.list_tasks(status=None)
991
+ if not args.tasks_all:
992
+ rows = [r for r in rows if r.get("status") != "completed"]
993
+ if args.task_json:
994
+ print(json.dumps(rows, indent=2, default=str))
995
+ else:
996
+ print(f"{'ID':>4} {'Status':<14}{'Priority':<10}{'Jira':<16}Title")
997
+ print(f"{'--':>4} {'------':<14}{'--------':<10}{'----':<16}-----")
998
+ for r in rows:
999
+ print(f"{r.get('id', ''):>4} {r.get('status', ''):<14}{r.get('priority', ''):<10}{(r.get('jira_key') or ''):<16}{r.get('title', '')}")
1000
+ return 0
1001
+
1002
+ if args.task is not None:
1003
+ t = tasks_db.get_task(args.task)
1004
+ if not t:
1005
+ print(f"Error: task #{args.task} not found", file=sys.stderr)
1006
+ return 1
1007
+ if args.task_json:
1008
+ print(json.dumps(t, indent=2, default=str))
1009
+ else:
1010
+ for k, v in t.items():
1011
+ print(f"{k:>20}: {v}")
1012
+ return 0
1013
+
1014
+ if args.task_add:
1015
+ if not args.title:
1016
+ print("Error: --title is required for --task-add", file=sys.stderr)
1017
+ return 1
1018
+ task_id = tasks_db.add_task(
1019
+ title=args.title,
1020
+ description=args.description or None,
1021
+ priority=args.priority or "medium",
1022
+ jira_key=args.jira_key or None,
1023
+ )
1024
+ if args.task_json:
1025
+ print(json.dumps({"id": task_id, "title": args.title}))
1026
+ else:
1027
+ print(f"Created task #{task_id}: {args.title}")
1028
+ return 0
1029
+
1030
+ if args.task_update is not None:
1031
+ kwargs = {}
1032
+ if args.status:
1033
+ kwargs["status"] = args.status
1034
+ if args.priority:
1035
+ kwargs["priority"] = args.priority
1036
+ if args.deferred_until:
1037
+ kwargs["deferred_until"] = args.deferred_until
1038
+ ok = tasks_db.update_task(args.task_update, **kwargs)
1039
+ if ok:
1040
+ print(f"Updated task #{args.task_update}")
1041
+ else:
1042
+ print(f"Error: task #{args.task_update} not found or update failed", file=sys.stderr)
1043
+ return 1
1044
+ return 0
1045
+
1046
+ if args.task_search:
1047
+ rows = tasks_db.search_tasks(args.task_search)
1048
+ if args.task_json:
1049
+ print(json.dumps(rows, indent=2, default=str))
1050
+ else:
1051
+ print(f"{'ID':>4} {'Status':<14}{'Priority':<10}{'Jira':<16}Title")
1052
+ print(f"{'--':>4} {'------':<14}{'--------':<10}{'----':<16}-----")
1053
+ for r in rows:
1054
+ print(f"{r.get('id', ''):>4} {r.get('status', ''):<14}{r.get('priority', ''):<10}{(r.get('jira_key') or ''):<16}{r.get('title', '')}")
1055
+ return 0
1056
+
1057
+ if args.task_export is not None:
1058
+ t = tasks_db.get_task(args.task_export)
1059
+ if not t:
1060
+ print(f"Error: task #{args.task_export} not found", file=sys.stderr)
1061
+ return 1
1062
+ print(f"## Task #{t['id']}: {t.get('title', '')}")
1063
+ print(f"**Jira**: {t.get('jira_key') or 'None'}")
1064
+ print(f"**Status**: {t.get('status', '')}")
1065
+ print(f"**Priority**: {t.get('priority', '')}")
1066
+ print(f"**Description**: {t.get('description') or ''}")
1067
+ return 0
1068
+
1069
+ if args.register_learning_category:
1070
+ result = register_learning_category(
1071
+ name=args.register_learning_category,
1072
+ description=args.description,
1073
+ path=args.path,
1074
+ heading=args.heading,
1075
+ )
1076
+ print(json.dumps(result))
1077
+ return 0
1078
+
1079
+ if args.capture_post_tool:
1080
+ payload_text = sys.stdin.read().strip()
1081
+ if not payload_text:
1082
+ print(json.dumps({"recorded": False, "reason": "empty_payload"}))
1083
+ return 0
1084
+ try:
1085
+ payload = json.loads(payload_text)
1086
+ except json.JSONDecodeError:
1087
+ print(json.dumps({"recorded": False, "reason": "invalid_json"}))
1088
+ return 0
1089
+
1090
+ print(json.dumps(capture_post_tool_learning(payload)))
1091
+ return 0
1092
+
1093
+ # --- Playbook Engine Handlers ---
1094
+ import os
1095
+ PLAYBOOKS_DIR = os.path.expanduser("~/.claude/eng-buddy/playbooks")
1096
+ TRACES_DIR = os.path.expanduser("~/.claude/eng-buddy/traces")
1097
+ REGISTRY_DIR = os.path.join(PLAYBOOKS_DIR, "tool-registry")
1098
+
1099
+ # Add playbook_engine to sys.path
1100
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "playbook_engine"))
1101
+
1102
+ if args.playbook_trace_event:
1103
+ from playbook_engine.tracer import WorkflowTracer, TraceEvent
1104
+ payload = json.load(sys.stdin)
1105
+ tracer = WorkflowTracer(traces_dir=TRACES_DIR)
1106
+ trace_id = payload["trace_id"]
1107
+ tracer.load_trace(trace_id) or tracer.start_trace(trace_id)
1108
+ event_data = payload["event"]
1109
+ tracer.add_event(TraceEvent.from_dict(event_data))
1110
+ tracer.flush(trace_id)
1111
+ print(json.dumps({"status": "ok", "trace_id": trace_id}))
1112
+ return 0
1113
+
1114
+ if args.playbook_extract:
1115
+ from playbook_engine.tracer import WorkflowTracer
1116
+ from playbook_engine.registry import ToolRegistry
1117
+ from playbook_engine.extractor import PlaybookExtractor
1118
+ from playbook_engine.manager import PlaybookManager
1119
+ tracer = WorkflowTracer(traces_dir=TRACES_DIR)
1120
+ trace = tracer.load_trace(args.playbook_extract)
1121
+ if not trace:
1122
+ print(json.dumps({"error": f"Trace {args.playbook_extract} not found"}))
1123
+ return 1
1124
+ registry = ToolRegistry(REGISTRY_DIR)
1125
+ extractor = PlaybookExtractor(registry=registry)
1126
+ pb = extractor.extract_from_trace(trace, name=args.playbook_extract_name)
1127
+ manager = PlaybookManager(PLAYBOOKS_DIR)
1128
+ path = manager.save_draft(pb)
1129
+ print(json.dumps({"status": "ok", "playbook_id": pb.id, "path": path, "steps": len(pb.steps)}))
1130
+ return 0
1131
+
1132
+ if args.playbook_match:
1133
+ from playbook_engine.manager import PlaybookManager
1134
+ manager = PlaybookManager(PLAYBOOKS_DIR)
1135
+ matches = manager.match_ticket(
1136
+ ticket_type=args.playbook_match_type,
1137
+ text=args.playbook_match,
1138
+ source=args.playbook_match_source,
1139
+ )
1140
+ print(json.dumps({"matches": [{"id": m.id, "name": m.name, "confidence": m.confidence, "executions": m.executions} for m in matches]}))
1141
+ return 0
1142
+
1143
+ if args.playbook_list:
1144
+ from playbook_engine.manager import PlaybookManager
1145
+ manager = PlaybookManager(PLAYBOOKS_DIR)
1146
+ pbs = manager.list_playbooks()
1147
+ print(json.dumps({"playbooks": [{"id": p.id, "name": p.name, "confidence": p.confidence, "version": p.version, "executions": p.executions} for p in pbs]}))
1148
+ return 0
1149
+
1150
+ if args.playbook_list_drafts:
1151
+ from playbook_engine.manager import PlaybookManager
1152
+ manager = PlaybookManager(PLAYBOOKS_DIR)
1153
+ drafts = manager.list_drafts()
1154
+ print(json.dumps({"drafts": [{"id": d.id, "name": d.name, "confidence": d.confidence, "steps": len(d.steps)} for d in drafts]}))
1155
+ return 0
1156
+
1157
+ if args.playbook_promote:
1158
+ from playbook_engine.manager import PlaybookManager
1159
+ manager = PlaybookManager(PLAYBOOKS_DIR)
1160
+ pb = manager.promote_draft(args.playbook_promote)
1161
+ if pb:
1162
+ print(json.dumps({"status": "ok", "playbook_id": pb.id}))
1163
+ return 0
1164
+ print(json.dumps({"error": f"Draft {args.playbook_promote} not found"}))
1165
+ return 1
1166
+
1167
+ # --- Ops Tracking Handlers ---
1168
+ _ensure_ops_schema()
1169
+
1170
+ if args.capacity_log:
1171
+ if not args.metric:
1172
+ print("Error: --metric is required for --capacity-log", file=sys.stderr)
1173
+ return 1
1174
+ if args.value is None:
1175
+ print("Error: --value is required for --capacity-log", file=sys.stderr)
1176
+ return 1
1177
+ conn = sqlite3.connect(DB_PATH)
1178
+ try:
1179
+ cur = conn.execute(
1180
+ "INSERT INTO capacity_logs (date, metric, value, notes) VALUES (?, ?, ?, ?)",
1181
+ [args.date or date.today().isoformat(), args.metric, args.value, args.notes or ""],
1182
+ )
1183
+ conn.commit()
1184
+ print(json.dumps({"id": cur.lastrowid, "metric": args.metric, "value": args.value}))
1185
+ finally:
1186
+ conn.close()
1187
+ return 0
1188
+
1189
+ if args.stakeholder_add:
1190
+ if not args.name:
1191
+ print("Error: --name is required for --stakeholder-add", file=sys.stderr)
1192
+ return 1
1193
+ conn = sqlite3.connect(DB_PATH)
1194
+ try:
1195
+ cur = conn.execute(
1196
+ "INSERT INTO stakeholder_contacts (name, role, preferences) VALUES (?, ?, ?)",
1197
+ [args.name, args.role or "", args.preferences or ""],
1198
+ )
1199
+ conn.commit()
1200
+ print(json.dumps({"id": cur.lastrowid, "name": args.name}))
1201
+ finally:
1202
+ conn.close()
1203
+ return 0
1204
+
1205
+ if args.stakeholder_list:
1206
+ conn = sqlite3.connect(DB_PATH)
1207
+ conn.row_factory = sqlite3.Row
1208
+ try:
1209
+ rows = conn.execute(
1210
+ "SELECT * FROM stakeholder_contacts ORDER BY created_at DESC"
1211
+ ).fetchall()
1212
+ print(json.dumps([dict(r) for r in rows], indent=2, default=str))
1213
+ finally:
1214
+ conn.close()
1215
+ return 0
1216
+
1217
+ if args.incident_add:
1218
+ if not args.title:
1219
+ print("Error: --title is required for --incident-add", file=sys.stderr)
1220
+ return 1
1221
+ if not args.severity:
1222
+ print("Error: --severity is required for --incident-add", file=sys.stderr)
1223
+ return 1
1224
+ conn = sqlite3.connect(DB_PATH)
1225
+ try:
1226
+ cur = conn.execute(
1227
+ "INSERT INTO incidents (title, severity, timeline) VALUES (?, ?, ?)",
1228
+ [args.title, args.severity, args.timeline or ""],
1229
+ )
1230
+ conn.commit()
1231
+ print(json.dumps({"id": cur.lastrowid, "title": args.title, "severity": args.severity}))
1232
+ finally:
1233
+ conn.close()
1234
+ return 0
1235
+
1236
+ if args.incident_list:
1237
+ conn = sqlite3.connect(DB_PATH)
1238
+ conn.row_factory = sqlite3.Row
1239
+ try:
1240
+ if args.status:
1241
+ rows = conn.execute(
1242
+ "SELECT * FROM incidents WHERE status = ? ORDER BY created_at DESC",
1243
+ [args.status],
1244
+ ).fetchall()
1245
+ else:
1246
+ rows = conn.execute(
1247
+ "SELECT * FROM incidents ORDER BY created_at DESC"
1248
+ ).fetchall()
1249
+ print(json.dumps([dict(r) for r in rows], indent=2, default=str))
1250
+ finally:
1251
+ conn.close()
1252
+ return 0
1253
+
1254
+ if args.pattern_add:
1255
+ if not args.title:
1256
+ print("Error: --title is required for --pattern-add", file=sys.stderr)
1257
+ return 1
1258
+ now = datetime.now().isoformat()
1259
+ conn = sqlite3.connect(DB_PATH)
1260
+ try:
1261
+ cur = conn.execute(
1262
+ """INSERT INTO pattern_observations
1263
+ (type, title, description, confidence, first_seen, last_seen)
1264
+ VALUES (?, ?, ?, ?, ?, ?)""",
1265
+ [
1266
+ args.obs_type or "",
1267
+ args.title,
1268
+ args.description or "",
1269
+ args.confidence if args.confidence is not None else 0.5,
1270
+ now,
1271
+ now,
1272
+ ],
1273
+ )
1274
+ new_id = cur.lastrowid
1275
+ # Sync FTS5 index
1276
+ conn.execute(
1277
+ "INSERT INTO pattern_observations_fts(rowid, title, description, evidence) VALUES (?, ?, ?, ?)",
1278
+ [new_id, args.title, args.description or "", ""],
1279
+ )
1280
+ conn.commit()
1281
+ print(json.dumps({"id": new_id, "title": args.title}))
1282
+ finally:
1283
+ conn.close()
1284
+ return 0
1285
+
1286
+ if args.pattern_list:
1287
+ conn = sqlite3.connect(DB_PATH)
1288
+ conn.row_factory = sqlite3.Row
1289
+ try:
1290
+ rows = conn.execute(
1291
+ "SELECT * FROM pattern_observations ORDER BY created_at DESC"
1292
+ ).fetchall()
1293
+ print(json.dumps([dict(r) for r in rows], indent=2, default=str))
1294
+ finally:
1295
+ conn.close()
1296
+ return 0
1297
+
1298
+ if args.followup_add:
1299
+ if not args.stakeholder:
1300
+ print("Error: --stakeholder is required for --followup-add", file=sys.stderr)
1301
+ return 1
1302
+ if not args.topic:
1303
+ print("Error: --topic is required for --followup-add", file=sys.stderr)
1304
+ return 1
1305
+ conn = sqlite3.connect(DB_PATH)
1306
+ try:
1307
+ cur = conn.execute(
1308
+ "INSERT INTO follow_ups (stakeholder, topic, due_date, notes) VALUES (?, ?, ?, ?)",
1309
+ [args.stakeholder, args.topic, args.due_date or "", args.notes or ""],
1310
+ )
1311
+ conn.commit()
1312
+ print(json.dumps({"id": cur.lastrowid, "stakeholder": args.stakeholder, "topic": args.topic}))
1313
+ finally:
1314
+ conn.close()
1315
+ return 0
1316
+
1317
+ if args.followup_list:
1318
+ conn = sqlite3.connect(DB_PATH)
1319
+ conn.row_factory = sqlite3.Row
1320
+ try:
1321
+ if args.status:
1322
+ rows = conn.execute(
1323
+ "SELECT * FROM follow_ups WHERE status = ? ORDER BY due_date, created_at DESC",
1324
+ [args.status],
1325
+ ).fetchall()
1326
+ else:
1327
+ rows = conn.execute(
1328
+ "SELECT * FROM follow_ups ORDER BY due_date, created_at DESC"
1329
+ ).fetchall()
1330
+ print(json.dumps([dict(r) for r in rows], indent=2, default=str))
1331
+ finally:
1332
+ conn.close()
1333
+ return 0
1334
+
1335
+ parser.print_help()
1336
+ return 1
1337
+
1338
+
1339
+ if __name__ == "__main__":
1340
+ raise SystemExit(_cli())