feed-the-machine 1.6.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +262 -170
  3. package/bin/__pycache__/tasks_db.cpython-314.pyc +0 -0
  4. package/bin/brain.py +1340 -0
  5. package/bin/convert_claude_skills_to_codex.py +490 -0
  6. package/bin/generate-manifest.mjs +463 -463
  7. package/bin/harden_codex_skills.py +141 -0
  8. package/bin/install.mjs +491 -491
  9. package/bin/migrate-eng-buddy-data.py +875 -0
  10. package/bin/playbook_engine/__init__.py +1 -0
  11. package/bin/playbook_engine/conftest.py +8 -0
  12. package/bin/playbook_engine/extractor.py +33 -0
  13. package/bin/playbook_engine/manager.py +102 -0
  14. package/bin/playbook_engine/models.py +84 -0
  15. package/bin/playbook_engine/registry.py +35 -0
  16. package/bin/playbook_engine/test_extractor.py +72 -0
  17. package/bin/playbook_engine/test_integration.py +129 -0
  18. package/bin/playbook_engine/test_manager.py +85 -0
  19. package/bin/playbook_engine/test_models.py +166 -0
  20. package/bin/playbook_engine/test_registry.py +67 -0
  21. package/bin/playbook_engine/test_tracer.py +86 -0
  22. package/bin/playbook_engine/tracer.py +93 -0
  23. package/bin/tasks_db.py +456 -0
  24. package/docs/HOOKS.md +243 -243
  25. package/docs/INBOX.md +233 -233
  26. package/ftm/SKILL.md +125 -122
  27. package/ftm-audit/SKILL.md +673 -623
  28. package/ftm-audit/references/protocols/PROJECT-PATTERNS.md +91 -91
  29. package/ftm-audit/references/protocols/RUNTIME-WIRING.md +66 -66
  30. package/ftm-audit/references/protocols/WIRING-CONTRACTS.md +135 -135
  31. package/ftm-audit/references/strategies/AUTO-FIX-STRATEGIES.md +69 -69
  32. package/ftm-audit/references/templates/REPORT-FORMAT.md +96 -96
  33. package/ftm-audit/scripts/run-knip.sh +23 -23
  34. package/ftm-audit.yml +2 -2
  35. package/ftm-brainstorm/SKILL.md +1003 -498
  36. package/ftm-brainstorm/evals/evals.json +180 -100
  37. package/ftm-brainstorm/evals/promptfoo.yaml +109 -109
  38. package/ftm-brainstorm/references/agent-prompts.md +552 -224
  39. package/ftm-brainstorm/references/plan-template.md +209 -121
  40. package/ftm-brainstorm.yml +2 -2
  41. package/ftm-browse/SKILL.md +454 -454
  42. package/ftm-browse/daemon/browser-manager.ts +206 -206
  43. package/ftm-browse/daemon/bun.lock +30 -30
  44. package/ftm-browse/daemon/cli.ts +347 -347
  45. package/ftm-browse/daemon/commands.ts +410 -410
  46. package/ftm-browse/daemon/main.ts +357 -357
  47. package/ftm-browse/daemon/package.json +17 -17
  48. package/ftm-browse/daemon/server.ts +189 -189
  49. package/ftm-browse/daemon/snapshot.ts +519 -519
  50. package/ftm-browse/daemon/tsconfig.json +22 -22
  51. package/ftm-browse.yml +4 -4
  52. package/ftm-capture/SKILL.md +370 -370
  53. package/ftm-capture.yml +4 -4
  54. package/ftm-codex-gate/SKILL.md +361 -361
  55. package/ftm-codex-gate.yml +2 -2
  56. package/ftm-config/SKILL.md +422 -345
  57. package/ftm-config.default.yml +125 -82
  58. package/ftm-config.yml +44 -2
  59. package/ftm-council/SKILL.md +416 -416
  60. package/ftm-council/references/prompts/CLAUDE-INVESTIGATION.md +60 -60
  61. package/ftm-council/references/prompts/CODEX-INVESTIGATION.md +58 -58
  62. package/ftm-council/references/prompts/GEMINI-INVESTIGATION.md +58 -58
  63. package/ftm-council/references/prompts/REBUTTAL-TEMPLATE.md +57 -57
  64. package/ftm-council/references/protocols/PREREQUISITES.md +47 -47
  65. package/ftm-council/references/protocols/STEP-0-FRAMING.md +46 -46
  66. package/ftm-council-chat.yml +2 -0
  67. package/ftm-council.yml +2 -2
  68. package/ftm-dashboard/SKILL.md +163 -163
  69. package/ftm-dashboard.yml +4 -4
  70. package/ftm-debug/SKILL.md +1037 -1037
  71. package/ftm-debug/references/phases/PHASE-0-INTAKE.md +58 -58
  72. package/ftm-debug/references/phases/PHASE-1-TRIAGE.md +46 -46
  73. package/ftm-debug/references/phases/PHASE-2-WAR-ROOM-AGENTS.md +279 -279
  74. package/ftm-debug/references/phases/PHASE-3-TO-6-EXECUTION.md +436 -436
  75. package/ftm-debug/references/protocols/BLACKBOARD.md +86 -86
  76. package/ftm-debug/references/protocols/EDGE-CASES.md +103 -103
  77. package/ftm-debug.yml +2 -2
  78. package/ftm-diagram/SKILL.md +277 -277
  79. package/ftm-diagram.yml +2 -2
  80. package/ftm-executor/SKILL.md +777 -777
  81. package/ftm-executor/references/STYLE-TEMPLATE.md +73 -73
  82. package/ftm-executor/references/phases/PHASE-0-VERIFICATION.md +62 -62
  83. package/ftm-executor/references/phases/PHASE-2-AGENT-ASSEMBLY.md +34 -34
  84. package/ftm-executor/references/phases/PHASE-3-WORKTREES.md +38 -38
  85. package/ftm-executor/references/phases/PHASE-4-5-AUDIT.md +81 -72
  86. package/ftm-executor/references/phases/PHASE-4-DISPATCH.md +66 -66
  87. package/ftm-executor/references/phases/PHASE-5-5-CODEX-GATE.md +73 -73
  88. package/ftm-executor/references/protocols/DOCUMENTATION-BOOTSTRAP.md +36 -36
  89. package/ftm-executor/references/protocols/MODEL-PROFILE.md +59 -59
  90. package/ftm-executor/references/protocols/PROGRESS-TRACKING.md +66 -66
  91. package/ftm-executor/runtime/ftm-runtime.mjs +252 -252
  92. package/ftm-executor/runtime/package.json +8 -8
  93. package/ftm-executor.yml +2 -2
  94. package/ftm-git/SKILL.md +441 -441
  95. package/ftm-git/evals/evals.json +26 -26
  96. package/ftm-git/evals/promptfoo.yaml +75 -75
  97. package/ftm-git/hooks/post-commit-experience.sh +92 -92
  98. package/ftm-git/references/patterns/SECRET-PATTERNS.md +104 -104
  99. package/ftm-git/references/protocols/REMEDIATION.md +139 -139
  100. package/ftm-git/scripts/pre-commit-secrets.sh +110 -110
  101. package/ftm-git.yml +2 -2
  102. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  103. package/ftm-inbox/backend/adapters/_retry.py +64 -64
  104. package/ftm-inbox/backend/adapters/base.py +230 -230
  105. package/ftm-inbox/backend/adapters/freshservice.py +104 -104
  106. package/ftm-inbox/backend/adapters/gmail.py +125 -125
  107. package/ftm-inbox/backend/adapters/jira.py +136 -136
  108. package/ftm-inbox/backend/adapters/registry.py +192 -192
  109. package/ftm-inbox/backend/adapters/slack.py +110 -110
  110. package/ftm-inbox/backend/db/connection.py +54 -54
  111. package/ftm-inbox/backend/db/schema.py +78 -78
  112. package/ftm-inbox/backend/executor/__init__.py +7 -7
  113. package/ftm-inbox/backend/executor/engine.py +149 -149
  114. package/ftm-inbox/backend/executor/step_runner.py +98 -98
  115. package/ftm-inbox/backend/main.py +103 -103
  116. package/ftm-inbox/backend/models/__init__.py +1 -1
  117. package/ftm-inbox/backend/models/unified_task.py +36 -36
  118. package/ftm-inbox/backend/planner/__init__.py +6 -6
  119. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  120. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  121. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  122. package/ftm-inbox/backend/planner/generator.py +127 -127
  123. package/ftm-inbox/backend/planner/schema.py +34 -34
  124. package/ftm-inbox/backend/requirements.txt +5 -5
  125. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  126. package/ftm-inbox/backend/routes/execute.py +186 -186
  127. package/ftm-inbox/backend/routes/health.py +52 -52
  128. package/ftm-inbox/backend/routes/inbox.py +68 -68
  129. package/ftm-inbox/backend/routes/plan.py +271 -271
  130. package/ftm-inbox/bin/launchagent.mjs +91 -91
  131. package/ftm-inbox/bin/setup.mjs +188 -188
  132. package/ftm-inbox/bin/start.sh +10 -10
  133. package/ftm-inbox/bin/status.sh +17 -17
  134. package/ftm-inbox/bin/stop.sh +8 -8
  135. package/ftm-inbox/config.example.yml +55 -55
  136. package/ftm-inbox/package-lock.json +2898 -2898
  137. package/ftm-inbox/package.json +26 -26
  138. package/ftm-inbox/postcss.config.js +6 -6
  139. package/ftm-inbox/src/app.css +199 -199
  140. package/ftm-inbox/src/app.html +18 -18
  141. package/ftm-inbox/src/lib/api.ts +166 -166
  142. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -81
  143. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -143
  144. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -271
  145. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -206
  146. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -99
  147. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -190
  148. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -63
  149. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -86
  150. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -106
  151. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -67
  152. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -149
  153. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -80
  154. package/ftm-inbox/src/lib/theme.ts +47 -47
  155. package/ftm-inbox/src/routes/+layout.svelte +76 -76
  156. package/ftm-inbox/src/routes/+page.svelte +401 -401
  157. package/ftm-inbox/svelte.config.js +12 -12
  158. package/ftm-inbox/tailwind.config.ts +63 -63
  159. package/ftm-inbox/tsconfig.json +13 -13
  160. package/ftm-inbox/vite.config.ts +6 -6
  161. package/ftm-intent/SKILL.md +241 -241
  162. package/ftm-intent.yml +2 -2
  163. package/ftm-manifest.json +3794 -3794
  164. package/ftm-map/SKILL.md +291 -291
  165. package/ftm-map/scripts/db.py +712 -712
  166. package/ftm-map/scripts/index.py +415 -415
  167. package/ftm-map/scripts/parser.py +224 -224
  168. package/ftm-map/scripts/queries/go-tags.scm +20 -20
  169. package/ftm-map/scripts/queries/javascript-tags.scm +35 -35
  170. package/ftm-map/scripts/queries/python-tags.scm +31 -31
  171. package/ftm-map/scripts/queries/ruby-tags.scm +19 -19
  172. package/ftm-map/scripts/queries/rust-tags.scm +37 -37
  173. package/ftm-map/scripts/queries/typescript-tags.scm +41 -41
  174. package/ftm-map/scripts/query.py +301 -301
  175. package/ftm-map/scripts/ranker.py +377 -377
  176. package/ftm-map/scripts/requirements.txt +5 -5
  177. package/ftm-map/scripts/setup-hooks.sh +27 -27
  178. package/ftm-map/scripts/setup.sh +56 -56
  179. package/ftm-map/scripts/test_db.py +364 -364
  180. package/ftm-map/scripts/test_parser.py +174 -174
  181. package/ftm-map/scripts/test_query.py +183 -183
  182. package/ftm-map/scripts/test_ranker.py +199 -199
  183. package/ftm-map/scripts/views.py +591 -591
  184. package/ftm-map.yml +2 -2
  185. package/ftm-mind/SKILL.md +201 -1943
  186. package/ftm-mind/evals/promptfoo.yaml +142 -142
  187. package/ftm-mind/references/blackboard-protocol.md +110 -0
  188. package/ftm-mind/references/blackboard-schema.md +328 -328
  189. package/ftm-mind/references/complexity-guide.md +110 -110
  190. package/ftm-mind/references/complexity-sizing.md +138 -0
  191. package/ftm-mind/references/decide-act-protocol.md +172 -0
  192. package/ftm-mind/references/direct-execution.md +51 -0
  193. package/ftm-mind/references/environment-discovery.md +77 -0
  194. package/ftm-mind/references/event-registry.md +319 -319
  195. package/ftm-mind/references/mcp-inventory.md +300 -296
  196. package/ftm-mind/references/ops-routing.md +47 -0
  197. package/ftm-mind/references/orient-protocol.md +234 -0
  198. package/ftm-mind/references/personality.md +40 -0
  199. package/ftm-mind/references/protocols/COMPLEXITY-SIZING.md +72 -72
  200. package/ftm-mind/references/protocols/MCP-HEURISTICS.md +32 -32
  201. package/ftm-mind/references/protocols/PLAN-APPROVAL.md +80 -80
  202. package/ftm-mind/references/reflexion-protocol.md +249 -249
  203. package/ftm-mind/references/routing/SCENARIOS.md +22 -22
  204. package/ftm-mind/references/routing-scenarios.md +35 -35
  205. package/ftm-mind.yml +2 -2
  206. package/ftm-ops.yml +4 -0
  207. package/ftm-pause/SKILL.md +395 -395
  208. package/ftm-pause/references/protocols/SKILL-RESTORE-PROTOCOLS.md +186 -186
  209. package/ftm-pause/references/protocols/VALIDATION.md +80 -80
  210. package/ftm-pause.yml +2 -2
  211. package/ftm-researcher/SKILL.md +275 -275
  212. package/ftm-researcher/evals/agent-diversity.yaml +17 -17
  213. package/ftm-researcher/evals/synthesis-quality.yaml +12 -12
  214. package/ftm-researcher/evals/trigger-accuracy.yaml +39 -39
  215. package/ftm-researcher/references/adaptive-search.md +116 -116
  216. package/ftm-researcher/references/agent-prompts.md +193 -193
  217. package/ftm-researcher/references/council-integration.md +193 -193
  218. package/ftm-researcher/references/output-format.md +203 -203
  219. package/ftm-researcher/references/synthesis-pipeline.md +165 -165
  220. package/ftm-researcher/scripts/score_credibility.py +234 -234
  221. package/ftm-researcher/scripts/validate_research.py +92 -92
  222. package/ftm-researcher.yml +2 -2
  223. package/ftm-resume/SKILL.md +518 -518
  224. package/ftm-resume/references/protocols/VALIDATION.md +172 -172
  225. package/ftm-resume.yml +2 -2
  226. package/ftm-retro/SKILL.md +380 -380
  227. package/ftm-retro/references/protocols/SCORING-RUBRICS.md +89 -89
  228. package/ftm-retro/references/templates/REPORT-FORMAT.md +109 -109
  229. package/ftm-retro.yml +2 -2
  230. package/ftm-routine/SKILL.md +170 -170
  231. package/ftm-routine.yml +4 -4
  232. package/ftm-state/blackboard/capabilities.json +5 -5
  233. package/ftm-state/blackboard/capabilities.schema.json +27 -27
  234. package/ftm-state/blackboard/context.json +37 -23
  235. package/ftm-state/blackboard/experiences/doom-statusline-fix.json +26 -0
  236. package/ftm-state/blackboard/experiences/hackathon-pages-site.json +26 -0
  237. package/ftm-state/blackboard/experiences/hindsight-sso-kickoff.json +42 -0
  238. package/ftm-state/blackboard/experiences/index.json +58 -9
  239. package/ftm-state/blackboard/experiences/learning-ragnarok-api-access.json +23 -0
  240. package/ftm-state/blackboard/experiences/nordlayer-members-auto-assign.json +26 -0
  241. package/ftm-state/blackboard/experiences/saml2aws-stale-session-fix.json +41 -0
  242. package/ftm-state/blackboard/patterns.json +6 -6
  243. package/ftm-state/schemas/context.schema.json +130 -130
  244. package/ftm-state/schemas/experience-index.schema.json +77 -77
  245. package/ftm-state/schemas/experience.schema.json +78 -78
  246. package/ftm-state/schemas/patterns.schema.json +44 -44
  247. package/ftm-upgrade/SKILL.md +194 -194
  248. package/ftm-upgrade/scripts/check-version.sh +76 -76
  249. package/ftm-upgrade/scripts/upgrade.sh +143 -143
  250. package/ftm-upgrade.yml +2 -2
  251. package/ftm-verify.yml +2 -2
  252. package/ftm.yml +2 -2
  253. package/hooks/ftm-auto-log.sh +137 -0
  254. package/hooks/ftm-blackboard-enforcer.sh +93 -93
  255. package/hooks/ftm-discovery-reminder.sh +90 -90
  256. package/hooks/ftm-drafts-gate.sh +61 -61
  257. package/hooks/ftm-event-logger.mjs +107 -107
  258. package/hooks/ftm-install-hooks.sh +240 -0
  259. package/hooks/ftm-learning-capture.sh +117 -0
  260. package/hooks/ftm-map-autodetect.sh +79 -79
  261. package/hooks/ftm-pending-sync-check.sh +22 -22
  262. package/hooks/ftm-plan-gate.sh +92 -92
  263. package/hooks/ftm-post-commit-trigger.sh +57 -57
  264. package/hooks/ftm-post-compaction.sh +138 -0
  265. package/hooks/ftm-pre-compaction.sh +147 -0
  266. package/hooks/ftm-session-end.sh +52 -0
  267. package/hooks/ftm-session-snapshot.sh +213 -0
  268. package/hooks/ftm-task-loader.sh +100 -0
  269. package/hooks/settings-template.json +91 -81
  270. package/install.sh +363 -363
  271. package/package.json +84 -84
  272. package/uninstall.sh +25 -25
@@ -0,0 +1,456 @@
1
+ """Shared task management module backed by SQLite.
2
+
3
+ All consumers should ``from tasks_db import …`` to interact with the
4
+ eng-buddy task database. The schema is created lazily on the first
5
+ call to :func:`get_conn`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ DB_PATH: Path = Path.home() / ".claude" / "eng-buddy" / "tasks.db"
16
+
17
+ _schema_ensured: bool = False
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Priority helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _PRIORITY_ORDER_EXPR = """
24
+ CASE priority
25
+ WHEN 'high' THEN 1
26
+ WHEN 'medium' THEN 2
27
+ WHEN 'low' THEN 3
28
+ ELSE 4
29
+ END
30
+ """
31
+
32
+ _JIRA_PRIORITY_MAP: Dict[str, str] = {
33
+ "highest": "high",
34
+ "high": "high",
35
+ "medium": "medium",
36
+ "low": "low",
37
+ "lowest": "low",
38
+ }
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Connection & schema
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def get_conn() -> sqlite3.Connection:
46
+ """Return a connection with Row factory, WAL mode, and foreign keys ON.
47
+
48
+ On the first call the schema is created automatically via
49
+ :func:`ensure_schema`.
50
+ """
51
+ global _schema_ensured
52
+
53
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ conn = sqlite3.connect(str(DB_PATH))
56
+ conn.row_factory = sqlite3.Row
57
+ conn.execute("PRAGMA journal_mode=WAL")
58
+ conn.execute("PRAGMA foreign_keys=ON")
59
+
60
+ if not _schema_ensured:
61
+ ensure_schema(conn)
62
+ _schema_ensured = True
63
+
64
+ return conn
65
+
66
+
67
+ def ensure_schema(conn: Optional[sqlite3.Connection] = None) -> None:
68
+ """Idempotently create all tables, indexes, triggers and FTS index."""
69
+ own_conn = conn is None
70
+ if own_conn:
71
+ conn = get_conn()
72
+
73
+ cur = conn.cursor()
74
+
75
+ # -- core tables ---------------------------------------------------------
76
+ cur.executescript(
77
+ """
78
+ CREATE TABLE IF NOT EXISTS tasks (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ jira_key TEXT UNIQUE,
81
+ freshservice_url TEXT,
82
+ title TEXT NOT NULL,
83
+ description TEXT,
84
+ status TEXT NOT NULL DEFAULT 'pending',
85
+ priority TEXT NOT NULL DEFAULT 'medium',
86
+ jira_status TEXT,
87
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
89
+ completed_at TEXT,
90
+ deferred_until TEXT,
91
+ metadata TEXT DEFAULT '{}'
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_tasks_status
95
+ ON tasks(status);
96
+ CREATE INDEX IF NOT EXISTS idx_tasks_jira_key
97
+ ON tasks(jira_key) WHERE jira_key IS NOT NULL;
98
+ CREATE INDEX IF NOT EXISTS idx_tasks_priority
99
+ ON tasks(priority, status);
100
+
101
+ CREATE TABLE IF NOT EXISTS task_events (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
104
+ event_type TEXT NOT NULL,
105
+ detail TEXT,
106
+ actor TEXT NOT NULL DEFAULT 'system',
107
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_task_events_task
111
+ ON task_events(task_id, created_at);
112
+ """
113
+ )
114
+
115
+ # -- FTS5 virtual table --------------------------------------------------
116
+ # executescript cannot handle virtual-table DDL reliably; use execute.
117
+ cur.execute(
118
+ """
119
+ CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
120
+ title, description, jira_key, metadata,
121
+ content='tasks', content_rowid='id'
122
+ )
123
+ """
124
+ )
125
+
126
+ # -- FTS sync triggers ---------------------------------------------------
127
+ cur.executescript(
128
+ """
129
+ CREATE TRIGGER IF NOT EXISTS tasks_ai AFTER INSERT ON tasks BEGIN
130
+ INSERT INTO tasks_fts(rowid, title, description, jira_key, metadata)
131
+ VALUES (new.id, new.title, new.description, new.jira_key, new.metadata);
132
+ END;
133
+
134
+ CREATE TRIGGER IF NOT EXISTS tasks_ad AFTER DELETE ON tasks BEGIN
135
+ INSERT INTO tasks_fts(tasks_fts, rowid, title, description, jira_key, metadata)
136
+ VALUES ('delete', old.id, old.title, old.description, old.jira_key, old.metadata);
137
+ END;
138
+
139
+ CREATE TRIGGER IF NOT EXISTS tasks_au AFTER UPDATE ON tasks BEGIN
140
+ INSERT INTO tasks_fts(tasks_fts, rowid, title, description, jira_key, metadata)
141
+ VALUES ('delete', old.id, old.title, old.description, old.jira_key, old.metadata);
142
+ INSERT INTO tasks_fts(rowid, title, description, jira_key, metadata)
143
+ VALUES (new.id, new.title, new.description, new.jira_key, new.metadata);
144
+ END;
145
+ """
146
+ )
147
+
148
+ conn.commit()
149
+
150
+ if own_conn:
151
+ conn.close()
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Row helpers
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def task_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
159
+ """Convert a ``sqlite3.Row`` to a plain dict, parsing metadata JSON."""
160
+ d = dict(row)
161
+ raw = d.get("metadata")
162
+ if isinstance(raw, str):
163
+ try:
164
+ d["metadata"] = json.loads(raw)
165
+ except (json.JSONDecodeError, TypeError):
166
+ d["metadata"] = {}
167
+ return d
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # CRUD
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def list_tasks(
175
+ status: Optional[str] = None,
176
+ limit: int = 50,
177
+ ) -> List[Dict[str, Any]]:
178
+ """List tasks, optionally filtered by *status*.
179
+
180
+ Ordering: priority DESC (high first), then created_at ASC.
181
+ """
182
+ conn = get_conn()
183
+ try:
184
+ sql = f"SELECT * FROM tasks"
185
+ params: list[Any] = []
186
+ if status is not None:
187
+ sql += " WHERE status = ?"
188
+ params.append(status)
189
+ sql += f" ORDER BY {_PRIORITY_ORDER_EXPR}, created_at ASC LIMIT ?"
190
+ params.append(limit)
191
+ rows = conn.execute(sql, params).fetchall()
192
+ return [task_to_dict(r) for r in rows]
193
+ finally:
194
+ conn.close()
195
+
196
+
197
+ def get_task(task_id: int) -> Optional[Dict[str, Any]]:
198
+ """Get a single task by its local ID."""
199
+ conn = get_conn()
200
+ try:
201
+ row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
202
+ return task_to_dict(row) if row else None
203
+ finally:
204
+ conn.close()
205
+
206
+
207
+ def get_task_by_jira_key(jira_key: str) -> Optional[Dict[str, Any]]:
208
+ """Lookup a task by its Jira key."""
209
+ conn = get_conn()
210
+ try:
211
+ row = conn.execute(
212
+ "SELECT * FROM tasks WHERE jira_key = ?", (jira_key,)
213
+ ).fetchone()
214
+ return task_to_dict(row) if row else None
215
+ finally:
216
+ conn.close()
217
+
218
+
219
+ def add_task(
220
+ title: str,
221
+ description: Optional[str] = None,
222
+ priority: str = "medium",
223
+ jira_key: Optional[str] = None,
224
+ freshservice_url: Optional[str] = None,
225
+ metadata: Optional[dict] = None,
226
+ ) -> int:
227
+ """Insert a new task and record a 'created' event. Returns the new ID."""
228
+ meta_str = json.dumps(metadata or {})
229
+ conn = get_conn()
230
+ try:
231
+ cur = conn.execute(
232
+ """
233
+ INSERT INTO tasks (title, description, priority, jira_key,
234
+ freshservice_url, metadata)
235
+ VALUES (?, ?, ?, ?, ?, ?)
236
+ """,
237
+ (title, description, priority, jira_key, freshservice_url, meta_str),
238
+ )
239
+ task_id = cur.lastrowid
240
+ conn.execute(
241
+ """
242
+ INSERT INTO task_events (task_id, event_type, detail, actor)
243
+ VALUES (?, 'created', ?, 'system')
244
+ """,
245
+ (task_id, f"Task created: {title}"),
246
+ )
247
+ conn.commit()
248
+ return task_id
249
+ finally:
250
+ conn.close()
251
+
252
+
253
+ _VALID_TASK_FIELDS = {
254
+ "title",
255
+ "description",
256
+ "status",
257
+ "priority",
258
+ "jira_key",
259
+ "jira_status",
260
+ "freshservice_url",
261
+ "completed_at",
262
+ "deferred_until",
263
+ "metadata",
264
+ }
265
+
266
+
267
+ def update_task(task_id: int, **fields: Any) -> bool:
268
+ """Update arbitrary valid fields on a task.
269
+
270
+ Automatically sets ``updated_at``, records an 'updated' event, and
271
+ sets ``completed_at`` when *status* changes to ``'completed'``.
272
+
273
+ Returns ``True`` if a row was updated, ``False`` otherwise.
274
+ """
275
+ invalid = set(fields) - _VALID_TASK_FIELDS
276
+ if invalid:
277
+ raise ValueError(f"Invalid task fields: {invalid}")
278
+ if not fields:
279
+ return False
280
+
281
+ # Serialise metadata if present
282
+ if "metadata" in fields and not isinstance(fields["metadata"], str):
283
+ fields["metadata"] = json.dumps(fields["metadata"])
284
+
285
+ conn = get_conn()
286
+ try:
287
+ # Handle completed_at automatically
288
+ if fields.get("status") == "completed" and "completed_at" not in fields:
289
+ fields["completed_at"] = "datetime('now')"
290
+
291
+ set_clauses: list[str] = []
292
+ params: list[Any] = []
293
+ for key, val in fields.items():
294
+ if key == "completed_at" and val == "datetime('now')":
295
+ set_clauses.append("completed_at = datetime('now')")
296
+ else:
297
+ set_clauses.append(f"{key} = ?")
298
+ params.append(val)
299
+
300
+ set_clauses.append("updated_at = datetime('now')")
301
+ params.append(task_id)
302
+
303
+ sql = f"UPDATE tasks SET {', '.join(set_clauses)} WHERE id = ?"
304
+ cur = conn.execute(sql, params)
305
+
306
+ if cur.rowcount == 0:
307
+ return False
308
+
309
+ changed = ", ".join(f"{k}={v!r}" for k, v in fields.items())
310
+ conn.execute(
311
+ """
312
+ INSERT INTO task_events (task_id, event_type, detail, actor)
313
+ VALUES (?, 'updated', ?, 'system')
314
+ """,
315
+ (task_id, f"Fields changed: {changed}"),
316
+ )
317
+ conn.commit()
318
+ return True
319
+ finally:
320
+ conn.close()
321
+
322
+
323
+ def upsert_jira_task(
324
+ jira_key: str,
325
+ title: str,
326
+ jira_status: str,
327
+ priority: str,
328
+ metadata: Optional[dict] = None,
329
+ ) -> int:
330
+ """Insert or update a task keyed by *jira_key*.
331
+
332
+ On conflict the Jira status and priority are updated but user-edited
333
+ title/description are preserved. If the Jira status maps to ``Done``
334
+ the local status is set to ``'completed'``.
335
+
336
+ Returns the task ID.
337
+ """
338
+ mapped_priority = _JIRA_PRIORITY_MAP.get(priority.lower(), "medium")
339
+ meta_str = json.dumps(metadata or {})
340
+ is_done = jira_status.lower() == "done"
341
+
342
+ conn = get_conn()
343
+ try:
344
+ existing = conn.execute(
345
+ "SELECT * FROM tasks WHERE jira_key = ?", (jira_key,)
346
+ ).fetchone()
347
+
348
+ if existing is None:
349
+ # Insert new
350
+ status = "completed" if is_done else "pending"
351
+ cur = conn.execute(
352
+ """
353
+ INSERT INTO tasks (title, jira_key, jira_status, priority,
354
+ status, metadata)
355
+ VALUES (?, ?, ?, ?, ?, ?)
356
+ """,
357
+ (title, jira_key, jira_status, mapped_priority, status, meta_str),
358
+ )
359
+ task_id = cur.lastrowid
360
+ conn.execute(
361
+ """
362
+ INSERT INTO task_events (task_id, event_type, detail, actor)
363
+ VALUES (?, 'created', ?, 'jira-sync')
364
+ """,
365
+ (task_id, f"Synced from Jira: {jira_key}"),
366
+ )
367
+ if is_done:
368
+ conn.execute(
369
+ "UPDATE tasks SET completed_at = datetime('now') WHERE id = ?",
370
+ (task_id,),
371
+ )
372
+ conn.commit()
373
+ return task_id
374
+ else:
375
+ # Update existing — preserve user-edited title/description
376
+ task_id = existing["id"]
377
+ update_parts = [
378
+ "jira_status = ?",
379
+ "priority = ?",
380
+ "updated_at = datetime('now')",
381
+ ]
382
+ params: list[Any] = [jira_status, mapped_priority]
383
+
384
+ if is_done and existing["status"] != "completed":
385
+ update_parts.append("status = 'completed'")
386
+ update_parts.append("completed_at = datetime('now')")
387
+
388
+ params.append(task_id)
389
+ conn.execute(
390
+ f"UPDATE tasks SET {', '.join(update_parts)} WHERE id = ?",
391
+ params,
392
+ )
393
+ conn.execute(
394
+ """
395
+ INSERT INTO task_events (task_id, event_type, detail, actor)
396
+ VALUES (?, 'updated', ?, 'jira-sync')
397
+ """,
398
+ (
399
+ task_id,
400
+ f"Jira sync: status={jira_status}, priority={mapped_priority}",
401
+ ),
402
+ )
403
+ conn.commit()
404
+ return task_id
405
+ finally:
406
+ conn.close()
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Search
411
+ # ---------------------------------------------------------------------------
412
+
413
+ def search_tasks(query: str, limit: int = 20) -> List[Dict[str, Any]]:
414
+ """Full-text search across tasks using FTS5."""
415
+ conn = get_conn()
416
+ try:
417
+ rows = conn.execute(
418
+ """
419
+ SELECT t.*
420
+ FROM tasks_fts fts
421
+ JOIN tasks t ON t.id = fts.rowid
422
+ WHERE tasks_fts MATCH ?
423
+ ORDER BY rank
424
+ LIMIT ?
425
+ """,
426
+ (query, limit),
427
+ ).fetchall()
428
+ return [task_to_dict(r) for r in rows]
429
+ finally:
430
+ conn.close()
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Events
435
+ # ---------------------------------------------------------------------------
436
+
437
+ def record_event(
438
+ task_id: int,
439
+ event_type: str,
440
+ detail: Optional[str] = None,
441
+ actor: str = "system",
442
+ ) -> int:
443
+ """Insert a row into ``task_events`` and return its ID."""
444
+ conn = get_conn()
445
+ try:
446
+ cur = conn.execute(
447
+ """
448
+ INSERT INTO task_events (task_id, event_type, detail, actor)
449
+ VALUES (?, ?, ?, ?)
450
+ """,
451
+ (task_id, event_type, detail, actor),
452
+ )
453
+ conn.commit()
454
+ return cur.lastrowid
455
+ finally:
456
+ conn.close()