feed-the-machine 1.6.1 → 1.7.1

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