feed-the-machine 1.6.0 → 1.7.0

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