context-rail 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {context_rail-0.2.0/src/context_rail.egg-info → context_rail-0.2.2}/PKG-INFO +1 -1
- {context_rail-0.2.0 → context_rail-0.2.2}/pyproject.toml +36 -36
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/_tool_surface.py +5 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_find.py +47 -9
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_lifecycle.py +23 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_notes.py +93 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_plan_review.py +112 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_tasks.py +146 -19
- context_rail-0.2.2/src/context_rail/skills/daily/SKILL.md +114 -0
- context_rail-0.2.2/src/context_rail/skills/governance/SKILL.md +135 -0
- context_rail-0.2.2/src/context_rail/skills/orchestration/SKILL.md +168 -0
- context_rail-0.2.2/src/context_rail/skills/setup/SKILL.md +249 -0
- context_rail-0.2.2/src/context_rail/skills/wiki/SKILL.md +227 -0
- {context_rail-0.2.0 → context_rail-0.2.2/src/context_rail.egg-info}/PKG-INFO +1 -1
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/SOURCES.txt +5 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/LICENSE +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/README.md +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/setup.cfg +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/__init__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/__main__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/__init__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_contracts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_drift.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_evidence_harvest.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_intent_match.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_pdca.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_rails.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_runs.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy/_runtime.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/autonomy.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/__init__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/_phase_drafts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/_plan_drafts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm/drafts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_artifacts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_conductor.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_core.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/brainstorm_synthesis.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/cli.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/context_pack.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/__init__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/_gates.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema/_schema.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/db_schema.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/focus.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/gates.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/rules.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/__init__.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/_imports.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_artifacts.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_autonomy.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_brainstorm.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_categories.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_configure.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_context.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_decisions.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_focus.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_gates.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_intent.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_orient.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_phase_brainstorm.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_planning.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_projects.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_retro.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_risks.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_roadmap.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_rules.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server/tools_runtime.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail/server.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/dependency_links.txt +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/entry_points.txt +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/requires.txt +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/src/context_rail.egg-info/top_level.txt +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_agent_lanes.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_ambient_context.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_autonomy_drift.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_autonomy_record.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_brainstorm.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_brainstorm_tools.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_cli_agents.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_context_learning_loop.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_db.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_deps.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_evidence_harvest.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_evidence_validation.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_find.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_focus_tools.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_gates.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_governance_mode.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_governance_tiers.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_integration_lifecycle.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_intent_match.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_non_blocking_pdca.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_notes.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_orient.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_parallel_chat_safety.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_plan_review.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_project_create.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_retro.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_routing.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_runtime_check.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_runtime_integration.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_schema_invariants.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_smoke_v2.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_smoke_vibe.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_task_lifecycle.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_task_title_validation.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_tool_registry_integrity.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_tool_surface.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_usability_fixes.py +0 -0
- {context_rail-0.2.0 → context_rail-0.2.2}/tests/test_web.py +0 -0
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "context-rail"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "Context Rail — agentic task-management that keeps your project on rails."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
license = {text = "MIT"}
|
|
11
|
-
requires-python = ">=3.11"
|
|
12
|
-
dependencies = [
|
|
13
|
-
"mcp>=1.0.0",
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
[project.optional-dependencies]
|
|
17
|
-
web = [
|
|
18
|
-
"context-rail-web",
|
|
19
|
-
]
|
|
20
|
-
test = [
|
|
21
|
-
"pytest>=8",
|
|
22
|
-
"pytest-asyncio>=0.24",
|
|
23
|
-
"fastapi>=0.115",
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
[project.scripts]
|
|
27
|
-
ctx-rail = "context_rail.cli:main"
|
|
28
|
-
|
|
29
|
-
[project.urls]
|
|
30
|
-
Repository = "https://github.com/nguyenhoanhson797/Context-Rail"
|
|
31
|
-
|
|
32
|
-
[tool.setuptools.packages.find]
|
|
33
|
-
where = ["src"]
|
|
34
|
-
|
|
35
|
-
[tool.setuptools.package-data]
|
|
36
|
-
context_rail = []
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "context-rail"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "Context Rail — agentic task-management that keeps your project on rails."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp>=1.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
web = [
|
|
18
|
+
"context-rail-web",
|
|
19
|
+
]
|
|
20
|
+
test = [
|
|
21
|
+
"pytest>=8",
|
|
22
|
+
"pytest-asyncio>=0.24",
|
|
23
|
+
"fastapi>=0.115",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
ctx-rail = "context_rail.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/nguyenhoanhson797/Context-Rail"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
context_rail = ["skills/**/*"]
|
|
@@ -24,6 +24,10 @@ CORE_TOOLS = frozenset({
|
|
|
24
24
|
"task_edit",
|
|
25
25
|
"done",
|
|
26
26
|
"block",
|
|
27
|
+
# Phase discovery (NCR-1)
|
|
28
|
+
"phase_tasks",
|
|
29
|
+
# Contract management (B5/NCR-3)
|
|
30
|
+
"contract_refresh",
|
|
27
31
|
# Discovery
|
|
28
32
|
"find",
|
|
29
33
|
# Session
|
|
@@ -40,6 +44,7 @@ CORE_TOOLS = frozenset({
|
|
|
40
44
|
# Knowledge capture
|
|
41
45
|
"note",
|
|
42
46
|
"learn",
|
|
47
|
+
"quick_learn",
|
|
43
48
|
"decision_create",
|
|
44
49
|
# Review
|
|
45
50
|
"retro",
|
|
@@ -5,12 +5,15 @@ from ._imports import * # noqa: F403
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
@mcp.tool()
|
|
8
|
-
async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
|
|
8
|
+
async def find(query: str, workdir: str = "", agent_id: str = "", phase_id: str = "") -> str:
|
|
9
9
|
"""Search tasks, goals, lessons, notes, risks, and artifacts by keyword.
|
|
10
10
|
|
|
11
11
|
Searches titles AND descriptions. Also detects entity-type keywords
|
|
12
12
|
(\"task\", \"goal\", \"phase\", \"roadmap\", etc.) and includes recent items
|
|
13
13
|
of that type even when the keyword doesn't appear in their text.
|
|
14
|
+
|
|
15
|
+
Pass phase_id to filter results to tasks and goals in a specific phase.
|
|
16
|
+
When phase_id is provided without a query, returns all tasks/goals in that phase.
|
|
14
17
|
"""
|
|
15
18
|
conn = get_db()
|
|
16
19
|
init_db(conn)
|
|
@@ -22,6 +25,16 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
|
|
|
22
25
|
q = f"%{query}%"
|
|
23
26
|
q_lower = query.strip().lower()
|
|
24
27
|
|
|
28
|
+
# B2 fix: validate phase_id if provided
|
|
29
|
+
if phase_id:
|
|
30
|
+
phase = conn.execute(
|
|
31
|
+
"SELECT id FROM phases WHERE id = ? AND project_id = ? AND deleted_at IS NULL",
|
|
32
|
+
(phase_id, pid),
|
|
33
|
+
).fetchone()
|
|
34
|
+
if not phase:
|
|
35
|
+
conn.close()
|
|
36
|
+
return json.dumps({"error": f"Phase {phase_id} not found in project {pid}"})
|
|
37
|
+
|
|
25
38
|
# Entity-type keywords: when the user types "task", also list recent tasks
|
|
26
39
|
type_keywords = {
|
|
27
40
|
"task": ("tasks", "SELECT id, title, status, priority, goal_id, assignee FROM tasks WHERE project_id = ? AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10"),
|
|
@@ -36,16 +49,32 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
# --- Tasks ---
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
if phase_id:
|
|
53
|
+
tasks = conn.execute("""SELECT id, title, status, priority, goal_id, assignee, description
|
|
54
|
+
FROM tasks WHERE project_id = ? AND phase_id = ?
|
|
55
|
+
AND (title LIKE ? OR description LIKE ? OR assignee LIKE ? OR ? = '')
|
|
56
|
+
AND deleted_at IS NULL ORDER BY priority DESC, sort_order ASC LIMIT 50""",
|
|
57
|
+
(pid, phase_id, q, q, q, query),
|
|
58
|
+
).fetchall()
|
|
59
|
+
else:
|
|
60
|
+
tasks = conn.execute("""SELECT id, title, status, priority, goal_id, assignee, description
|
|
61
|
+
FROM tasks WHERE project_id = ? AND (title LIKE ? OR description LIKE ? OR assignee LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 20""",
|
|
62
|
+
(pid, q, q, q),
|
|
63
|
+
).fetchall()
|
|
43
64
|
|
|
44
65
|
# --- Goals ---
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
if phase_id:
|
|
67
|
+
goals = conn.execute("""SELECT id, title, status, priority, description
|
|
68
|
+
FROM goals WHERE project_id = ? AND phase_id = ?
|
|
69
|
+
AND (title LIKE ? OR description LIKE ? OR ? = '')
|
|
70
|
+
AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10""",
|
|
71
|
+
(pid, phase_id, q, q, query),
|
|
72
|
+
).fetchall()
|
|
73
|
+
else:
|
|
74
|
+
goals = conn.execute("""SELECT id, title, status, priority, description
|
|
75
|
+
FROM goals WHERE project_id = ? AND (title LIKE ? OR description LIKE ?) AND deleted_at IS NULL ORDER BY priority DESC LIMIT 10""",
|
|
76
|
+
(pid, q, q),
|
|
77
|
+
).fetchall()
|
|
49
78
|
|
|
50
79
|
# --- Entity-type fallback: include recent items of the requested type ---
|
|
51
80
|
entity_fallbacks: dict[str, list] = {}
|
|
@@ -138,6 +167,15 @@ async def find(query: str, workdir: str = "", agent_id: str = "") -> str:
|
|
|
138
167
|
else:
|
|
139
168
|
result["_note"] = f"Also showing recent {key} (entity-type match)."
|
|
140
169
|
|
|
170
|
+
# B2 fix: annotate result when filtered by phase
|
|
171
|
+
if phase_id:
|
|
172
|
+
phase_name = conn.execute(
|
|
173
|
+
"SELECT name FROM phases WHERE id = ?", (phase_id,)
|
|
174
|
+
).fetchone()
|
|
175
|
+
result["_phase_id"] = phase_id
|
|
176
|
+
if phase_name:
|
|
177
|
+
result["_phase_name"] = phase_name["name"]
|
|
178
|
+
|
|
141
179
|
conn.close()
|
|
142
180
|
return json.dumps(result, indent=2)
|
|
143
181
|
|
|
@@ -109,6 +109,29 @@ async def session_start(agent_id: str = "", workdir: str = "") -> str:
|
|
|
109
109
|
"progress": rd.get("progress", 0),
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
# --- NCR-2: include all phase tasks (compact) for discovery ---
|
|
113
|
+
result["phase_tasks"] = []
|
|
114
|
+
if active_phase:
|
|
115
|
+
phase_tasks = conn.execute(
|
|
116
|
+
"""SELECT id, title, status, priority, lane, goal_id, sort_order, blocked_reason
|
|
117
|
+
FROM tasks
|
|
118
|
+
WHERE project_id = ? AND phase_id = ? AND deleted_at IS NULL
|
|
119
|
+
ORDER BY sort_order ASC, priority DESC""",
|
|
120
|
+
(pid, active_phase["id"]),
|
|
121
|
+
).fetchall()
|
|
122
|
+
result["phase_tasks"] = [
|
|
123
|
+
{
|
|
124
|
+
"id": t["id"],
|
|
125
|
+
"title": t["title"],
|
|
126
|
+
"status": t["status"],
|
|
127
|
+
"priority": t["priority"],
|
|
128
|
+
"lane": t["lane"] or "normal",
|
|
129
|
+
"sort_order": t["sort_order"],
|
|
130
|
+
"blocked_reason": t["blocked_reason"],
|
|
131
|
+
}
|
|
132
|
+
for t in phase_tasks
|
|
133
|
+
]
|
|
134
|
+
|
|
112
135
|
# --- Next task ---
|
|
113
136
|
next_task = find_next_task(conn, pid)
|
|
114
137
|
if next_task and not next_task.get("_no_available_task"):
|
|
@@ -100,6 +100,99 @@ async def note(content: str, kind: str = "note", task_id: str = "", workdir: str
|
|
|
100
100
|
return f"Project note saved ({kind}). Use find(\"{title[:30]}\") to search."
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
async def quick_learn(
|
|
105
|
+
content: str,
|
|
106
|
+
kind: str = "",
|
|
107
|
+
task_id: str = "",
|
|
108
|
+
workdir: str = "",
|
|
109
|
+
agent_id: str = "",
|
|
110
|
+
) -> str:
|
|
111
|
+
"""One-param capture with auto-inference. Same friction as memory.add().
|
|
112
|
+
|
|
113
|
+
Auto-infers:
|
|
114
|
+
- task_id from active task (if not provided)
|
|
115
|
+
- kind from content patterns ("bug" if contains 'bug', 'fix'/'fixed', etc.)
|
|
116
|
+
- scope (task-scoped when task_id is available, project-scoped otherwise)
|
|
117
|
+
|
|
118
|
+
Saves as both a lesson and a note — same content, dual persistence.
|
|
119
|
+
Use this for quick bug findings, fixes, and observations during execution.
|
|
120
|
+
"""
|
|
121
|
+
conn = get_db()
|
|
122
|
+
init_db(conn)
|
|
123
|
+
|
|
124
|
+
# Auto-infer task_id
|
|
125
|
+
if not task_id:
|
|
126
|
+
task_id = load_state().get("active_task_id", "")
|
|
127
|
+
|
|
128
|
+
# Auto-infer kind
|
|
129
|
+
if not kind:
|
|
130
|
+
content_lower = content.lower()
|
|
131
|
+
if any(w in content_lower for w in ("bug", "crash", "error", "null", "undefined", "typeerror", "traceback", "fails")):
|
|
132
|
+
kind = "pitfall"
|
|
133
|
+
elif any(w in content_lower for w in ("fixed", "fix", "resolved", "patch", "workaround")):
|
|
134
|
+
kind = "pattern"
|
|
135
|
+
else:
|
|
136
|
+
kind = "decision"
|
|
137
|
+
|
|
138
|
+
# Save as lesson (auto-links to task)
|
|
139
|
+
pid = resolve_pid_for_tool(workdir, agent_id) or load_state().get("active_project_id", "")
|
|
140
|
+
if not pid:
|
|
141
|
+
conn.close()
|
|
142
|
+
return "No active project. Call project_login or project_set_active first."
|
|
143
|
+
|
|
144
|
+
tag_list = ["quick_learn"]
|
|
145
|
+
lid = str(uuid.uuid4())
|
|
146
|
+
|
|
147
|
+
# Resolve FK fields from task
|
|
148
|
+
gid = None
|
|
149
|
+
phase_id = None
|
|
150
|
+
if task_id:
|
|
151
|
+
task = conn.execute(
|
|
152
|
+
"SELECT goal_id, phase_id FROM tasks WHERE id = ? AND deleted_at IS NULL",
|
|
153
|
+
(task_id,),
|
|
154
|
+
).fetchone()
|
|
155
|
+
if task:
|
|
156
|
+
gid = task["goal_id"]
|
|
157
|
+
phase_id = task["phase_id"]
|
|
158
|
+
|
|
159
|
+
conn.execute(
|
|
160
|
+
"""INSERT INTO lessons (id, project_id, goal_id, phase_id, task_id, content, kind, tags, last_verified_at)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))""",
|
|
162
|
+
(lid, pid, gid, phase_id, task_id, content, kind, json.dumps(tag_list)),
|
|
163
|
+
)
|
|
164
|
+
log_activity(conn, "lesson", lid, "created", {"kind": kind, "content": content[:100], "_via": "quick_learn"})
|
|
165
|
+
|
|
166
|
+
# Also save as note on the task (dual persistence)
|
|
167
|
+
nid = ""
|
|
168
|
+
if task_id:
|
|
169
|
+
nid = str(uuid.uuid4())
|
|
170
|
+
conn.execute(
|
|
171
|
+
"INSERT INTO task_context (id, task_id, content, kind) VALUES (?, ?, ?, ?)",
|
|
172
|
+
(nid, task_id, content, kind),
|
|
173
|
+
)
|
|
174
|
+
log_activity(conn, "task_context", nid, "created",
|
|
175
|
+
{"kind": kind, "_via": "quick_learn"})
|
|
176
|
+
|
|
177
|
+
conn.commit()
|
|
178
|
+
conn.close()
|
|
179
|
+
|
|
180
|
+
attach_activity_to_active_cycle(
|
|
181
|
+
pid,
|
|
182
|
+
task_id=task_id or "",
|
|
183
|
+
kind="lesson",
|
|
184
|
+
summary=content,
|
|
185
|
+
entity_type="lesson",
|
|
186
|
+
entity_id=lid,
|
|
187
|
+
metadata={"lesson_kind": kind, "tags": tag_list},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
msg = f"Quick-learn saved ({kind}): lesson={lid}"
|
|
191
|
+
if nid:
|
|
192
|
+
msg += f", note={nid}"
|
|
193
|
+
return msg
|
|
194
|
+
|
|
195
|
+
|
|
103
196
|
@mcp.tool()
|
|
104
197
|
async def learn(
|
|
105
198
|
content: str,
|
|
@@ -518,3 +518,115 @@ async def plan_contract_view(contract_id: str = "", workdir: str = "") -> str:
|
|
|
518
518
|
pass # Never break view on annotation failure
|
|
519
519
|
|
|
520
520
|
return json.dumps(contract, indent=2)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@mcp.tool()
|
|
524
|
+
async def contract_refresh(contract_id: str = "", workdir: str = "") -> str:
|
|
525
|
+
"""Refresh the plan contract snapshot from live project data.
|
|
526
|
+
|
|
527
|
+
Re-snapshots the active (or specified) contract with current task titles,
|
|
528
|
+
statuses, goals, and phases. Eliminates stale-plan confusion from
|
|
529
|
+
_snapshot_drift by updating the snapshot to match reality.
|
|
530
|
+
|
|
531
|
+
Returns the refreshed contract with before/after drift summary.
|
|
532
|
+
"""
|
|
533
|
+
state = load_state()
|
|
534
|
+
pid = state.get("active_project_id") if state else resolve_pid_for_tool(workdir)
|
|
535
|
+
if not pid:
|
|
536
|
+
pid = resolve_pid_for_tool(workdir)
|
|
537
|
+
if not pid:
|
|
538
|
+
return json.dumps({"error": "No active project"})
|
|
539
|
+
|
|
540
|
+
contract = active_plan_contract(pid) if not contract_id else None
|
|
541
|
+
if contract_id:
|
|
542
|
+
conn = get_db()
|
|
543
|
+
init_db(conn)
|
|
544
|
+
try:
|
|
545
|
+
row = conn.execute(
|
|
546
|
+
"""SELECT * FROM plan_contracts
|
|
547
|
+
WHERE id = ? AND project_id = ? AND deleted_at IS NULL""",
|
|
548
|
+
(contract_id, pid),
|
|
549
|
+
).fetchone()
|
|
550
|
+
if row:
|
|
551
|
+
from ..autonomy._contracts import _row_contract
|
|
552
|
+
contract = _row_contract(row)
|
|
553
|
+
finally:
|
|
554
|
+
conn.close()
|
|
555
|
+
|
|
556
|
+
if not contract:
|
|
557
|
+
return json.dumps({"error": "No active plan contract found"})
|
|
558
|
+
|
|
559
|
+
contract_cid = contract["id"]
|
|
560
|
+
|
|
561
|
+
conn = get_db()
|
|
562
|
+
init_db(conn)
|
|
563
|
+
try:
|
|
564
|
+
# Rebuild snapshot from live data
|
|
565
|
+
phases = conn.execute(
|
|
566
|
+
"""SELECT id, name, sort_order FROM phases
|
|
567
|
+
WHERE project_id = ? AND deleted_at IS NULL
|
|
568
|
+
ORDER BY sort_order ASC""",
|
|
569
|
+
(pid,),
|
|
570
|
+
).fetchall()
|
|
571
|
+
|
|
572
|
+
goals = conn.execute(
|
|
573
|
+
"""SELECT id, phase_id, title, priority FROM goals
|
|
574
|
+
WHERE project_id = ? AND deleted_at IS NULL""",
|
|
575
|
+
(pid,),
|
|
576
|
+
).fetchall()
|
|
577
|
+
|
|
578
|
+
tasks = conn.execute(
|
|
579
|
+
"""SELECT id, phase_id, goal_id, title, priority, status
|
|
580
|
+
FROM tasks
|
|
581
|
+
WHERE project_id = ? AND deleted_at IS NULL""",
|
|
582
|
+
(pid,),
|
|
583
|
+
).fetchall()
|
|
584
|
+
|
|
585
|
+
new_snapshot = {
|
|
586
|
+
"scope": contract.get("plan_snapshot", {}).get("scope", "project"),
|
|
587
|
+
"level": contract.get("plan_snapshot", {}).get("level", ""),
|
|
588
|
+
"phases": [{"id": p["id"], "name": p["name"], "sort_order": p["sort_order"]}
|
|
589
|
+
for p in phases],
|
|
590
|
+
"goals": [{"id": g["id"], "phase_id": g["phase_id"], "title": g["title"],
|
|
591
|
+
"priority": g["priority"]} for g in goals],
|
|
592
|
+
"tasks": [{"id": t["id"], "phase_id": t["phase_id"], "goal_id": t["goal_id"],
|
|
593
|
+
"title": t["title"], "priority": t["priority"], "status": t["status"]}
|
|
594
|
+
for t in tasks],
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
# Compute drift between old and new snapshots
|
|
598
|
+
old_snapshot = contract.get("plan_snapshot", {})
|
|
599
|
+
old_titles = {t["id"]: t.get("title") for t in old_snapshot.get("tasks", [])}
|
|
600
|
+
new_titles = {t["id"]: t["title"] for t in tasks}
|
|
601
|
+
drift = [
|
|
602
|
+
{"task_id": tid, "snapshot_title": old_titles[tid], "live_title": new_titles[tid]}
|
|
603
|
+
for tid in old_titles if tid in new_titles and old_titles[tid] != new_titles[tid]
|
|
604
|
+
]
|
|
605
|
+
|
|
606
|
+
# Update digest
|
|
607
|
+
from ..autonomy._contracts import _contract_digest
|
|
608
|
+
updated_contract = dict(contract)
|
|
609
|
+
updated_contract["plan_snapshot"] = new_snapshot
|
|
610
|
+
new_digest = _contract_digest(updated_contract)
|
|
611
|
+
|
|
612
|
+
conn.execute(
|
|
613
|
+
"""UPDATE plan_contracts
|
|
614
|
+
SET plan_snapshot = ?, digest = ?, updated_at = datetime('now')
|
|
615
|
+
WHERE id = ?""",
|
|
616
|
+
(json.dumps(new_snapshot, sort_keys=True, separators=(",", ":")),
|
|
617
|
+
new_digest, contract_cid),
|
|
618
|
+
)
|
|
619
|
+
log_activity(conn, "project", pid, "contract_refreshed",
|
|
620
|
+
{"contract_id": contract_cid, "resolved_drifts": len(drift)})
|
|
621
|
+
conn.commit()
|
|
622
|
+
finally:
|
|
623
|
+
conn.close()
|
|
624
|
+
|
|
625
|
+
return json.dumps({
|
|
626
|
+
"contract_id": contract_cid,
|
|
627
|
+
"version": contract.get("version", 1),
|
|
628
|
+
"refreshed": True,
|
|
629
|
+
"resolved_drifts": len(drift),
|
|
630
|
+
"drift_resolved": drift if drift else [],
|
|
631
|
+
"_note": "Snapshot updated from live data. Call plan_contract_view() to see the refreshed contract.",
|
|
632
|
+
}, indent=2)
|
|
@@ -222,12 +222,27 @@ def _context_checkpoint(task_id: str) -> dict:
|
|
|
222
222
|
"""Check if the agent touched any context items for this task.
|
|
223
223
|
|
|
224
224
|
Returns:
|
|
225
|
-
dict with items_presented,
|
|
226
|
-
|
|
225
|
+
dict with items_presented, items_touched, and should_warn.
|
|
226
|
+
should_warn is True when items were presented but none were touched,
|
|
227
|
+
AND no prior checkpoint warning was logged for this task in the last hour.
|
|
228
|
+
On the second call the warning is always skipped (speed bump, not wall).
|
|
227
229
|
"""
|
|
228
230
|
conn = get_db()
|
|
229
231
|
init_db(conn)
|
|
230
232
|
try:
|
|
233
|
+
# --- B1 fix: check if already warned for this task recently ---
|
|
234
|
+
prior_warn = conn.execute(
|
|
235
|
+
"""SELECT COUNT(*) as cnt FROM activity_log
|
|
236
|
+
WHERE entity_type = 'task'
|
|
237
|
+
AND entity_id = ?
|
|
238
|
+
AND action = 'context_checkpoint_warned'
|
|
239
|
+
AND created_at > datetime('now', '-1 hour')""",
|
|
240
|
+
(task_id,),
|
|
241
|
+
).fetchone()
|
|
242
|
+
if prior_warn and prior_warn["cnt"] > 0:
|
|
243
|
+
return {"should_warn": False, "items_presented": 0, "items_touched": 0,
|
|
244
|
+
"_bypass": "second_call"}
|
|
245
|
+
|
|
231
246
|
# Count context items visible to this task (same scope resolution as focus)
|
|
232
247
|
task = conn.execute(
|
|
233
248
|
"SELECT t.*, p.roadmap_id FROM tasks t "
|
|
@@ -236,8 +251,8 @@ def _context_checkpoint(task_id: str) -> dict:
|
|
|
236
251
|
(task_id,),
|
|
237
252
|
).fetchone()
|
|
238
253
|
if not task:
|
|
239
|
-
return {"should_warn": False, "items_presented": 0,
|
|
240
|
-
"
|
|
254
|
+
return {"should_warn": False, "items_presented": 0,
|
|
255
|
+
"items_touched": 0}
|
|
241
256
|
|
|
242
257
|
task_dict = dict(task)
|
|
243
258
|
gid = task_dict.get("goal_id") or ""
|
|
@@ -246,26 +261,29 @@ def _context_checkpoint(task_id: str) -> dict:
|
|
|
246
261
|
rid = task_dict.get("roadmap_id") or ""
|
|
247
262
|
pid = task_dict.get("project_id") or ""
|
|
248
263
|
|
|
249
|
-
#
|
|
264
|
+
# D1 fix: only count task-scoped items and required_for_completion items.
|
|
265
|
+
# Noisy project/phase-scoped reference items are excluded from the gate.
|
|
250
266
|
conditions = ["project_id = ?", "deleted_at IS NULL", "expand_mode != 'never'"]
|
|
251
267
|
params: list = [pid]
|
|
252
268
|
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
scope_parts = []
|
|
270
|
+
# Task-scoped items are always relevant
|
|
271
|
+
scope_parts.append("(scope_type = 'task' AND scope_id = ?)")
|
|
255
272
|
params.append(task_id)
|
|
256
273
|
if gid:
|
|
257
|
-
scope_parts.append("(scope_type = 'goal' AND scope_id = ?)")
|
|
274
|
+
scope_parts.append("(scope_type = 'goal' AND scope_id = ? AND required_for_completion = 1)")
|
|
258
275
|
params.append(gid)
|
|
259
276
|
if phid:
|
|
260
|
-
scope_parts.append("(scope_type = 'phase' AND scope_id = ?)")
|
|
277
|
+
scope_parts.append("(scope_type = 'phase' AND scope_id = ? AND required_for_completion = 1)")
|
|
261
278
|
params.append(phid)
|
|
262
279
|
if cid:
|
|
263
|
-
scope_parts.append("(scope_type = 'category' AND scope_id = ?)")
|
|
280
|
+
scope_parts.append("(scope_type = 'category' AND scope_id = ? AND required_for_completion = 1)")
|
|
264
281
|
params.append(cid)
|
|
265
282
|
if rid:
|
|
266
|
-
scope_parts.append("(scope_type = 'roadmap' AND scope_id = ?)")
|
|
283
|
+
scope_parts.append("(scope_type = 'roadmap' AND scope_id = ? AND required_for_completion = 1)")
|
|
267
284
|
params.append(rid)
|
|
268
|
-
|
|
285
|
+
# Project-scoped items only count if required_for_completion
|
|
286
|
+
scope_parts.append("(scope_type = 'project' AND scope_id = ? AND required_for_completion = 1)")
|
|
269
287
|
params.append(pid)
|
|
270
288
|
|
|
271
289
|
count_sql = (
|
|
@@ -276,8 +294,8 @@ def _context_checkpoint(task_id: str) -> dict:
|
|
|
276
294
|
items_presented = count_row["cnt"] if count_row else 0
|
|
277
295
|
|
|
278
296
|
if items_presented == 0:
|
|
279
|
-
return {"should_warn": False, "items_presented": 0,
|
|
280
|
-
"
|
|
297
|
+
return {"should_warn": False, "items_presented": 0,
|
|
298
|
+
"items_touched": 0}
|
|
281
299
|
|
|
282
300
|
# Check recent activity log for context actions on items linked to this task's scope
|
|
283
301
|
# We check: context_verify, context_apply, context_update in the activity log
|
|
@@ -294,8 +312,8 @@ def _context_checkpoint(task_id: str) -> dict:
|
|
|
294
312
|
ctx_ids = [r["id"] for r in ctx_ids_rows]
|
|
295
313
|
|
|
296
314
|
if not ctx_ids:
|
|
297
|
-
return {"should_warn": False, "items_presented": 0,
|
|
298
|
-
"
|
|
315
|
+
return {"should_warn": False, "items_presented": 0,
|
|
316
|
+
"items_touched": 0}
|
|
299
317
|
|
|
300
318
|
# Check activity log for context actions on these items recently (last 24h)
|
|
301
319
|
# This is more accurate than checking applied_count (which accumulates across tasks)
|
|
@@ -351,11 +369,18 @@ async def done(task_id: str) -> str:
|
|
|
351
369
|
conn.close()
|
|
352
370
|
|
|
353
371
|
# --- Context checkpoint: soft gate ---
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
#
|
|
372
|
+
# B1 fix: On first done() call, items_presented > 0 with no touches triggers a
|
|
373
|
+
# warning and logs the event. The second done() call finds the log entry and
|
|
374
|
+
# skips the checkpoint entirely, delivering on the "always succeed" guarantee.
|
|
357
375
|
checkpoint = _context_checkpoint(task_id)
|
|
358
376
|
if checkpoint.get("should_warn"):
|
|
377
|
+
# Log the warning so the second call can detect and bypass it
|
|
378
|
+
conn = get_db()
|
|
379
|
+
init_db(conn)
|
|
380
|
+
log_activity(conn, "task", task_id, "context_checkpoint_warned",
|
|
381
|
+
{"items_presented": checkpoint["items_presented"],
|
|
382
|
+
"items_touched": checkpoint["items_touched"]})
|
|
383
|
+
conn.close()
|
|
359
384
|
return json.dumps({
|
|
360
385
|
"warning": "context_checkpoint",
|
|
361
386
|
"message": (
|
|
@@ -937,6 +962,108 @@ async def task_bulk_delete(task_ids: str = "") -> str:
|
|
|
937
962
|
}, indent=2)
|
|
938
963
|
|
|
939
964
|
|
|
965
|
+
# ---------------------------------------------------------------------------
|
|
966
|
+
# NCR-1: Phase tasks endpoint — list all tasks in a phase
|
|
967
|
+
# ---------------------------------------------------------------------------
|
|
968
|
+
|
|
969
|
+
@mcp.tool()
|
|
970
|
+
async def phase_tasks(
|
|
971
|
+
phase_id: str = "",
|
|
972
|
+
workdir: str = "",
|
|
973
|
+
agent_id: str = "",
|
|
974
|
+
) -> str:
|
|
975
|
+
"""List all tasks in a phase. Returns tasks grouped by goal with status,
|
|
976
|
+
priority, lane, and dependencies. Use this to discover what needs to be
|
|
977
|
+
done in a phase without speculative find() calls.
|
|
978
|
+
|
|
979
|
+
If phase_id is omitted, returns tasks for the active (first non-completed) phase.
|
|
980
|
+
"""
|
|
981
|
+
conn = get_db()
|
|
982
|
+
init_db(conn)
|
|
983
|
+
pid = resolve_pid_for_tool(workdir, agent_id)
|
|
984
|
+
if not pid:
|
|
985
|
+
conn.close()
|
|
986
|
+
return json.dumps({"error": "No active project"})
|
|
987
|
+
|
|
988
|
+
if not phase_id:
|
|
989
|
+
phase = conn.execute(
|
|
990
|
+
"""SELECT id, name FROM phases
|
|
991
|
+
WHERE project_id = ? AND status != 'completed' AND deleted_at IS NULL
|
|
992
|
+
ORDER BY sort_order ASC LIMIT 1""",
|
|
993
|
+
(pid,),
|
|
994
|
+
).fetchone()
|
|
995
|
+
if not phase:
|
|
996
|
+
conn.close()
|
|
997
|
+
return json.dumps({"error": "No active phase found"})
|
|
998
|
+
phase_id = phase["id"]
|
|
999
|
+
phase_name = phase["name"]
|
|
1000
|
+
else:
|
|
1001
|
+
phase_row = conn.execute(
|
|
1002
|
+
"SELECT id, name FROM phases WHERE id = ? AND project_id = ? AND deleted_at IS NULL",
|
|
1003
|
+
(phase_id, pid),
|
|
1004
|
+
).fetchone()
|
|
1005
|
+
if not phase_row:
|
|
1006
|
+
conn.close()
|
|
1007
|
+
return json.dumps({"error": f"Phase {phase_id} not found"})
|
|
1008
|
+
phase_name = phase_row["name"]
|
|
1009
|
+
|
|
1010
|
+
tasks = conn.execute(
|
|
1011
|
+
"""SELECT t.id, t.title, t.status, t.priority, t.lane, t.goal_id,
|
|
1012
|
+
t.parent_task_id, t.assignee, t.blocked_reason, t.planned,
|
|
1013
|
+
t.sort_order, t.estimated_hours, t.review_required
|
|
1014
|
+
FROM tasks t
|
|
1015
|
+
WHERE t.project_id = ? AND t.phase_id = ? AND t.deleted_at IS NULL
|
|
1016
|
+
ORDER BY t.sort_order ASC, t.priority DESC""",
|
|
1017
|
+
(pid, phase_id),
|
|
1018
|
+
).fetchall()
|
|
1019
|
+
|
|
1020
|
+
goals = conn.execute(
|
|
1021
|
+
"""SELECT g.id, g.title, g.status, g.priority
|
|
1022
|
+
FROM goals g
|
|
1023
|
+
WHERE g.project_id = ? AND g.phase_id = ? AND g.deleted_at IS NULL
|
|
1024
|
+
ORDER BY g.priority DESC""",
|
|
1025
|
+
(pid, phase_id),
|
|
1026
|
+
).fetchall()
|
|
1027
|
+
|
|
1028
|
+
total = len(tasks)
|
|
1029
|
+
completed = sum(1 for t in tasks if t["status"] == "completed")
|
|
1030
|
+
blocked = sum(1 for t in tasks if t["status"] == "blocked")
|
|
1031
|
+
pending = total - completed - blocked
|
|
1032
|
+
|
|
1033
|
+
# Group tasks by goal
|
|
1034
|
+
goal_map: dict[str, dict] = {g["id"]: {**dict(g), "tasks": []} for g in goals}
|
|
1035
|
+
unlinked: list[dict] = []
|
|
1036
|
+
|
|
1037
|
+
for t in tasks:
|
|
1038
|
+
td = dict(t)
|
|
1039
|
+
gid = td.get("goal_id")
|
|
1040
|
+
if gid and gid in goal_map:
|
|
1041
|
+
goal_map[gid]["tasks"].append(td)
|
|
1042
|
+
else:
|
|
1043
|
+
unlinked.append(td)
|
|
1044
|
+
|
|
1045
|
+
result = {
|
|
1046
|
+
"phase_id": phase_id,
|
|
1047
|
+
"phase_name": phase_name,
|
|
1048
|
+
"summary": {
|
|
1049
|
+
"total": total,
|
|
1050
|
+
"completed": completed,
|
|
1051
|
+
"pending": pending,
|
|
1052
|
+
"blocked": blocked,
|
|
1053
|
+
},
|
|
1054
|
+
"goals": [
|
|
1055
|
+
{"id": gid, "title": g["title"], "status": g["status"],
|
|
1056
|
+
"priority": g["priority"], "task_count": len(g["tasks"]),
|
|
1057
|
+
"tasks": g["tasks"]}
|
|
1058
|
+
for gid, g in goal_map.items() if g["tasks"]
|
|
1059
|
+
],
|
|
1060
|
+
"unlinked_tasks": unlinked,
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
conn.close()
|
|
1064
|
+
return json.dumps(result, indent=2)
|
|
1065
|
+
|
|
1066
|
+
|
|
940
1067
|
# ---------------------------------------------------------------------------
|
|
941
1068
|
# Project notes CRUD (update/delete)
|
|
942
1069
|
# ---------------------------------------------------------------------------
|