code-context-control 2.52.0__py3-none-any.whl → 2.53.0__py3-none-any.whl
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.
- cli/c3.py +1 -1
- cli/hub_server.py +147 -24
- cli/hub_ui/components/task_board.js +94 -16
- cli/mcp_server.py +27 -4
- cli/server.py +104 -16
- cli/tools/tasks.py +182 -9
- cli/ui/components/tasks.js +565 -31
- cli/ui/pm_shared.js +72 -0
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/METADATA +2 -2
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/RECORD +17 -16
- services/runtime.py +6 -0
- services/task_store.py +638 -108
- services/time_tracker.py +285 -0
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.52.0.dist-info → code_context_control-2.53.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
cli/hub_server.py
CHANGED
|
@@ -1770,21 +1770,20 @@ def api_pm_task():
|
|
|
1770
1770
|
task_id = (data.get("id") or "").strip()
|
|
1771
1771
|
if not task_id:
|
|
1772
1772
|
return jsonify({"error": "id is required"}), 400
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
if fields:
|
|
1776
|
-
res = store.update_task(task_id, **fields)
|
|
1773
|
+
if data.get("restore"):
|
|
1774
|
+
res = store.restore_task(task_id, actor="hub")
|
|
1777
1775
|
if "error" in res:
|
|
1778
1776
|
return jsonify(res), 400
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
before_id=move.get("before_id"),
|
|
1783
|
-
after_id=move.get("after_id"))
|
|
1784
|
-
if "error" in res:
|
|
1785
|
-
return jsonify(res), 400
|
|
1786
|
-
if res is None:
|
|
1777
|
+
_pm_audit(resolved, "task", "restore", res["id"])
|
|
1778
|
+
return jsonify({"updated": True, "task": res})
|
|
1779
|
+
if not (data.get("fields") or data.get("move")):
|
|
1787
1780
|
return jsonify({"error": "fields or move required"}), 400
|
|
1781
|
+
res = store.mutate_task(task_id, fields=data.get("fields"),
|
|
1782
|
+
move=data.get("move"),
|
|
1783
|
+
expected_rev=data.get("expected_rev"),
|
|
1784
|
+
actor="hub")
|
|
1785
|
+
if "error" in res:
|
|
1786
|
+
return jsonify(res), 409 if res.get("code") == "rev_conflict" else 400
|
|
1788
1787
|
_pm_audit(resolved, "task", "update", res["id"])
|
|
1789
1788
|
return jsonify({"updated": True, "task": res})
|
|
1790
1789
|
|
|
@@ -1796,7 +1795,7 @@ def api_pm_task():
|
|
|
1796
1795
|
task_id = (data.get("id") or "").strip()
|
|
1797
1796
|
if not task_id:
|
|
1798
1797
|
return jsonify({"error": "id is required"}), 400
|
|
1799
|
-
res = store.archive_task(task_id)
|
|
1798
|
+
res = store.archive_task(task_id, actor="hub")
|
|
1800
1799
|
if "error" in res:
|
|
1801
1800
|
return jsonify(res), 400
|
|
1802
1801
|
_pm_audit(resolved, "task", "archive", res["id"])
|
|
@@ -1825,9 +1824,10 @@ def api_pm_milestone():
|
|
|
1825
1824
|
return jsonify({"error": "id is required"}), 400
|
|
1826
1825
|
|
|
1827
1826
|
if request.method == "PUT":
|
|
1828
|
-
res = store.update_milestone(ms_id,
|
|
1827
|
+
res = store.update_milestone(ms_id, expected_rev=data.get("expected_rev"),
|
|
1828
|
+
**(data.get("fields") or {}))
|
|
1829
1829
|
if "error" in res:
|
|
1830
|
-
return jsonify(res), 400
|
|
1830
|
+
return jsonify(res), 409 if res.get("code") == "rev_conflict" else 400
|
|
1831
1831
|
_pm_audit(resolved, "milestone", "update", res["id"])
|
|
1832
1832
|
return jsonify({"updated": True, "milestone": res})
|
|
1833
1833
|
|
|
@@ -1860,9 +1860,10 @@ def api_pm_note():
|
|
|
1860
1860
|
return jsonify({"error": "id is required"}), 400
|
|
1861
1861
|
|
|
1862
1862
|
if request.method == "PUT":
|
|
1863
|
-
res = store.update_note(note_id,
|
|
1863
|
+
res = store.update_note(note_id, expected_rev=data.get("expected_rev"),
|
|
1864
|
+
**(data.get("fields") or {}))
|
|
1864
1865
|
if "error" in res:
|
|
1865
|
-
return jsonify(res), 400
|
|
1866
|
+
return jsonify(res), 409 if res.get("code") == "rev_conflict" else 400
|
|
1866
1867
|
_pm_audit(resolved, "note", "update", res["id"])
|
|
1867
1868
|
return jsonify({"updated": True, "note": res})
|
|
1868
1869
|
|
|
@@ -1898,6 +1899,105 @@ def api_pm_link():
|
|
|
1898
1899
|
return jsonify({"task": res})
|
|
1899
1900
|
|
|
1900
1901
|
|
|
1902
|
+
@app.route("/api/projects/pm/events", methods=["GET"])
|
|
1903
|
+
def api_pm_events():
|
|
1904
|
+
"""PM event history for one project, newest first.
|
|
1905
|
+
Query: path, entity?, id?, op?, limit?"""
|
|
1906
|
+
resolved, err = _pm_resolve((request.args.get("path") or "").strip())
|
|
1907
|
+
if err:
|
|
1908
|
+
return err
|
|
1909
|
+
try:
|
|
1910
|
+
limit = max(1, min(int(request.args.get("limit") or 50), 500))
|
|
1911
|
+
except ValueError:
|
|
1912
|
+
limit = 50
|
|
1913
|
+
return jsonify({"path": str(resolved), "events": _pm_store(resolved).history(
|
|
1914
|
+
entity=(request.args.get("entity") or None),
|
|
1915
|
+
item_id=(request.args.get("id") or None),
|
|
1916
|
+
op=(request.args.get("op") or None),
|
|
1917
|
+
limit=limit)})
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
@app.route("/api/projects/pm/deps", methods=["POST"])
|
|
1921
|
+
def api_pm_deps():
|
|
1922
|
+
"""Add/remove a blocked-by dependency: {path, id, blocker, op: add|remove}."""
|
|
1923
|
+
data = request.get_json(force=True) or {}
|
|
1924
|
+
resolved, err = _pm_resolve((data.get("path") or "").strip())
|
|
1925
|
+
if err:
|
|
1926
|
+
return err
|
|
1927
|
+
task_id = (data.get("id") or "").strip()
|
|
1928
|
+
blocker = (data.get("blocker") or "").strip()
|
|
1929
|
+
op = (data.get("op") or "add").strip()
|
|
1930
|
+
if not task_id or not blocker:
|
|
1931
|
+
return jsonify({"error": "id and blocker are required"}), 400
|
|
1932
|
+
if op not in ("add", "remove"):
|
|
1933
|
+
return jsonify({"error": "op must be add|remove"}), 400
|
|
1934
|
+
store = _pm_store(resolved)
|
|
1935
|
+
res = (store.add_dependency(task_id, blocker, actor="hub") if op == "add"
|
|
1936
|
+
else store.remove_dependency(task_id, blocker, actor="hub"))
|
|
1937
|
+
if "error" in res:
|
|
1938
|
+
return jsonify(res), 400
|
|
1939
|
+
_pm_audit(resolved, "deps", op, res["id"])
|
|
1940
|
+
return jsonify({"task": res})
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
@app.route("/api/projects/pm/report", methods=["GET"])
|
|
1944
|
+
def api_pm_report():
|
|
1945
|
+
"""PM health report for one project. Query: path"""
|
|
1946
|
+
resolved, err = _pm_resolve((request.args.get("path") or "").strip())
|
|
1947
|
+
if err:
|
|
1948
|
+
return err
|
|
1949
|
+
return jsonify({"path": str(resolved),
|
|
1950
|
+
"report": _pm_store(resolved).report()})
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def _time_tracker(path):
|
|
1954
|
+
from services.time_tracker import TimeTracker
|
|
1955
|
+
return TimeTracker(str(path))
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
@app.route("/api/projects/time", methods=["GET"])
|
|
1959
|
+
def api_projects_time():
|
|
1960
|
+
"""Time summary for one project. Query: path"""
|
|
1961
|
+
resolved, err = _pm_resolve((request.args.get("path") or "").strip())
|
|
1962
|
+
if err:
|
|
1963
|
+
return err
|
|
1964
|
+
tracker = _time_tracker(resolved)
|
|
1965
|
+
return jsonify({"path": str(resolved), "summary": tracker.summary(),
|
|
1966
|
+
"sessions": tracker.sessions()[:20],
|
|
1967
|
+
"entries": tracker.list_entries(limit=100)})
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
@app.route("/api/projects/time/entry", methods=["POST", "PUT", "DELETE"])
|
|
1971
|
+
def api_projects_time_entry():
|
|
1972
|
+
data = request.get_json(force=True) or {}
|
|
1973
|
+
resolved, err = _pm_resolve((data.get("path") or "").strip())
|
|
1974
|
+
if err:
|
|
1975
|
+
return err
|
|
1976
|
+
tracker = _time_tracker(resolved)
|
|
1977
|
+
if request.method == "POST":
|
|
1978
|
+
res = tracker.add_entry(data.get("minutes"), note=data.get("note", ""),
|
|
1979
|
+
date=data.get("date") or None,
|
|
1980
|
+
task_id=data.get("task_id"), created_by="hub")
|
|
1981
|
+
if "error" in res:
|
|
1982
|
+
return jsonify(res), 400
|
|
1983
|
+
_pm_audit(resolved, "time", "create", res["id"])
|
|
1984
|
+
return jsonify({"created": True, "entry": res}), 201
|
|
1985
|
+
entry_id = (data.get("id") or "").strip()
|
|
1986
|
+
if not entry_id:
|
|
1987
|
+
return jsonify({"error": "id is required"}), 400
|
|
1988
|
+
if request.method == "PUT":
|
|
1989
|
+
res = tracker.update_entry(entry_id, **(data.get("fields") or {}))
|
|
1990
|
+
if "error" in res:
|
|
1991
|
+
return jsonify(res), 400
|
|
1992
|
+
_pm_audit(resolved, "time", "update", res["id"])
|
|
1993
|
+
return jsonify({"updated": True, "entry": res})
|
|
1994
|
+
res = tracker.delete_entry(entry_id)
|
|
1995
|
+
if "error" in res:
|
|
1996
|
+
return jsonify(res), 400
|
|
1997
|
+
_pm_audit(resolved, "time", "delete", entry_id)
|
|
1998
|
+
return jsonify(res)
|
|
1999
|
+
|
|
2000
|
+
|
|
1901
2001
|
# ── Agent artifacts: config tracking (v2.46.0) ────────────────────────────
|
|
1902
2002
|
# Direct ArtifactStore per request (load-per-op store — no runtime build
|
|
1903
2003
|
# needed); every mutation audited to the target project's activity log.
|
|
@@ -2012,24 +2112,47 @@ def api_pm_global():
|
|
|
2012
2112
|
skipped.append({"path": ppath, "reason": "not initialized"})
|
|
2013
2113
|
continue
|
|
2014
2114
|
try:
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
else:
|
|
2018
|
-
rows = TaskStore(ppath).list_tasks(limit=1000)
|
|
2019
|
-
if status_filter != "all":
|
|
2020
|
-
rows = [t for t in rows if t.get("status") != "done"]
|
|
2115
|
+
everything = TaskStore(ppath).list_tasks(include_archived=True,
|
|
2116
|
+
limit=1000)
|
|
2021
2117
|
except Exception as e:
|
|
2022
2118
|
skipped.append({"path": ppath, "reason": str(e)})
|
|
2023
2119
|
continue
|
|
2120
|
+
active = [t for t in everything
|
|
2121
|
+
if t.get("lifecycle", "active") == "active"]
|
|
2122
|
+
if status_filter and status_filter != "all":
|
|
2123
|
+
rows = [t for t in active if t.get("status") == status_filter]
|
|
2124
|
+
elif status_filter == "all":
|
|
2125
|
+
rows = active
|
|
2126
|
+
else:
|
|
2127
|
+
rows = [t for t in active if t.get("status") != "done"]
|
|
2024
2128
|
if not rows:
|
|
2025
2129
|
continue
|
|
2130
|
+
# Resolve blockers server-side: the aggregate ships only open tasks,
|
|
2131
|
+
# so clients cannot tell a done/archived blocker from an open one.
|
|
2132
|
+
by_id = {t["id"]: t for t in everything}
|
|
2133
|
+
for t in rows:
|
|
2134
|
+
deps = t.get("blocked_by") or []
|
|
2135
|
+
if not deps:
|
|
2136
|
+
continue
|
|
2137
|
+
found = [(d, by_id.get(d)) for d in deps]
|
|
2138
|
+
t["blockers_open"] = sum(
|
|
2139
|
+
1 for _, b in found
|
|
2140
|
+
if b is not None and b.get("lifecycle", "active") == "active"
|
|
2141
|
+
and b.get("status") != "done")
|
|
2142
|
+
t["blocker_titles"] = [
|
|
2143
|
+
(b.get("title") or d[:8]) if b is not None else d[:8]
|
|
2144
|
+
for d, b in found]
|
|
2026
2145
|
proj_info = {"name": p.get("name"), "path": ppath}
|
|
2027
2146
|
if p.get("parent_path"):
|
|
2028
2147
|
proj_info["parent_path"] = p["parent_path"]
|
|
2029
2148
|
for t in rows:
|
|
2030
2149
|
t["project"] = proj_info
|
|
2031
2150
|
tasks.extend(rows)
|
|
2032
|
-
by_project[ppath] = {
|
|
2151
|
+
by_project[ppath] = {
|
|
2152
|
+
"name": p.get("name"),
|
|
2153
|
+
"open": sum(1 for t in rows if t.get("status") != "done"),
|
|
2154
|
+
"shown": len(rows),
|
|
2155
|
+
}
|
|
2033
2156
|
|
|
2034
2157
|
# priority asc, due asc, updated desc (stable two-stage sort)
|
|
2035
2158
|
tasks.sort(key=lambda t: t.get("updated_at") or "", reverse=True)
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// Shared PM primitives (PM_COLUMNS, PriorityDot, DueBadge, statusColor,
|
|
7
7
|
// ...) come from ui/pm_shared.js, loaded earlier in the bundle.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
let _pmDragTask = null; // active drag payload (module-level; no useRef in this bundle)
|
|
10
|
+
|
|
11
|
+
function TaskBoardCard({ task, mode, colTasks, index, onMoveStatus, onMoveRank, onOpenProject, onMoveTo, onDropOnCard, byId }) {
|
|
10
12
|
const keys = PM_COLUMNS.map(c => c[0]);
|
|
11
13
|
const ci = keys.indexOf(task.status);
|
|
12
14
|
const navBtn = (label, title, onClick) => (
|
|
@@ -18,10 +20,15 @@ function TaskBoardCard({ task, mode, colTasks, index, onMoveStatus, onMoveRank,
|
|
|
18
20
|
}}>{label}</button>
|
|
19
21
|
);
|
|
20
22
|
return (
|
|
21
|
-
<div className="fade-up"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
<div className="fade-up" draggable
|
|
24
|
+
onDragStart={() => { _pmDragTask = task; }}
|
|
25
|
+
onDragOver={e => e.preventDefault()}
|
|
26
|
+
onDrop={e => { e.preventDefault(); e.stopPropagation(); onDropOnCard(task); }}
|
|
27
|
+
style={{
|
|
28
|
+
background: T.surface, border: `1px solid ${T.border}`, borderRadius: 8,
|
|
29
|
+
padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6,
|
|
30
|
+
cursor: 'grab',
|
|
31
|
+
}}>
|
|
25
32
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
|
26
33
|
<span style={{ marginTop: 3, display: 'inline-flex', flexShrink: 0 }}>
|
|
27
34
|
<PriorityDot priority={task.priority} />
|
|
@@ -37,10 +44,13 @@ function TaskBoardCard({ task, mode, colTasks, index, onMoveStatus, onMoveRank,
|
|
|
37
44
|
<Badge color={T.purple}>{task.project.name || 'project'}</Badge>
|
|
38
45
|
</span>
|
|
39
46
|
)}
|
|
47
|
+
<DepsBadge task={task} byId={byId} />
|
|
40
48
|
<DueBadge task={task} />
|
|
41
49
|
{(task.tags || []).map(tag => <Badge key={tag} color={T.blue}>{tag}</Badge>)}
|
|
42
50
|
</div>
|
|
43
51
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
52
|
+
{isTaskReady(task, byId) &&
|
|
53
|
+
navBtn('✓', 'Unblocked — move to Backlog', () => onMoveTo(task, 'backlog'))}
|
|
44
54
|
{ci > 0 && navBtn('◀', `Move to ${PM_COLUMNS[ci - 1][1]}`, () => onMoveStatus(task, -1))}
|
|
45
55
|
{ci >= 0 && ci < keys.length - 1 &&
|
|
46
56
|
navBtn('▶', `Move to ${PM_COLUMNS[ci + 1][1]}`, () => onMoveStatus(task, +1))}
|
|
@@ -61,6 +71,7 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
61
71
|
const [msFilter, setMsFilter] = useState(''); // milestone id ('' = all)
|
|
62
72
|
const [err, setErr] = useState('');
|
|
63
73
|
const [newTitle, setNewTitle] = useState('');
|
|
74
|
+
const [timeData, setTimeData] = useState(null);
|
|
64
75
|
|
|
65
76
|
const mode = scope === 'global' ? 'global' : 'project';
|
|
66
77
|
|
|
@@ -72,6 +83,10 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
72
83
|
const q = msFilter ? `&milestone=${encodeURIComponent(msFilter)}` : '';
|
|
73
84
|
const b = await api.get(`/api/projects/pm?path=${encodeURIComponent(scope)}${q}`);
|
|
74
85
|
setBoardData(b);
|
|
86
|
+
try {
|
|
87
|
+
setTimeData(await api.get(
|
|
88
|
+
`/api/projects/time?path=${encodeURIComponent(scope)}`));
|
|
89
|
+
} catch (e2) { setTimeData(null); }
|
|
75
90
|
}
|
|
76
91
|
setErr('');
|
|
77
92
|
} catch (e) {
|
|
@@ -85,7 +100,7 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
85
100
|
}, [scope, msFilter]);
|
|
86
101
|
|
|
87
102
|
// Reset per-project state when the scope changes (avoid stale board flash).
|
|
88
|
-
useEffect(() => { setBoardData(null); setMsFilter(''); setNewTitle(''); setErr(''); }, [scope]);
|
|
103
|
+
useEffect(() => { setBoardData(null); setMsFilter(''); setNewTitle(''); setErr(''); setTimeData(null); }, [scope]);
|
|
89
104
|
useEffect(() => { load(); }, [load]);
|
|
90
105
|
usePoll(load, 10000);
|
|
91
106
|
|
|
@@ -98,10 +113,19 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
98
113
|
const path = mode === 'global' ? (task.project && task.project.path) : scope;
|
|
99
114
|
if (!path) return;
|
|
100
115
|
try {
|
|
101
|
-
|
|
116
|
+
const body = { path, id: task.id, move: { status: next } };
|
|
117
|
+
if (mode === 'project' && boardData && boardData.board && boardData.board.rev != null) {
|
|
118
|
+
body.expected_rev = boardData.board.rev;
|
|
119
|
+
}
|
|
120
|
+
await api.put('/api/projects/pm/task', body);
|
|
102
121
|
notify(`Moved to ${PM_COLUMNS[ci + dir][1]}`);
|
|
103
122
|
load();
|
|
104
|
-
} catch (e) {
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e && e.status === 409) {
|
|
125
|
+
notify('Board changed elsewhere — refreshed', 'err');
|
|
126
|
+
load();
|
|
127
|
+
} else { notify(e.message || 'Move failed', 'err'); }
|
|
128
|
+
}
|
|
105
129
|
};
|
|
106
130
|
|
|
107
131
|
const moveRank = async (task, colTasks, index, dir) => {
|
|
@@ -109,10 +133,47 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
109
133
|
? { before_id: colTasks[index - 1].id }
|
|
110
134
|
: { after_id: colTasks[index + 1].id };
|
|
111
135
|
try {
|
|
112
|
-
|
|
136
|
+
const body = { path: scope, id: task.id, move };
|
|
137
|
+
if (boardData && boardData.board && boardData.board.rev != null) {
|
|
138
|
+
body.expected_rev = boardData.board.rev;
|
|
139
|
+
}
|
|
140
|
+
await api.put('/api/projects/pm/task', body);
|
|
113
141
|
notify(dir < 0 ? 'Moved up' : 'Moved down');
|
|
114
142
|
load();
|
|
115
|
-
} catch (e) {
|
|
143
|
+
} catch (e) {
|
|
144
|
+
if (e && e.status === 409) {
|
|
145
|
+
notify('Board changed elsewhere — refreshed', 'err');
|
|
146
|
+
load();
|
|
147
|
+
} else { notify(e.message || 'Reorder failed', 'err'); }
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const moveTo = async (task, status) => {
|
|
152
|
+
const path = mode === 'global' ? (task.project && task.project.path) : scope;
|
|
153
|
+
if (!path || task.status === status) return;
|
|
154
|
+
try {
|
|
155
|
+
await api.put('/api/projects/pm/task', { path, id: task.id, move: { status } });
|
|
156
|
+
notify(`Moved to ${(PM_COLUMNS.find(c => c[0] === status) || [status, status])[1]}`);
|
|
157
|
+
load();
|
|
158
|
+
} catch (e) { notify(e.message || 'Move failed', 'err'); }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const dropOnColumn = (key) => {
|
|
162
|
+
const t = _pmDragTask; _pmDragTask = null;
|
|
163
|
+
if (!t) return;
|
|
164
|
+
moveTo(t, key);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const dropOnCard = (target) => {
|
|
168
|
+
const t = _pmDragTask; _pmDragTask = null;
|
|
169
|
+
if (!t || t.id === target.id) return;
|
|
170
|
+
if (mode !== 'project') { moveTo(t, target.status); return; }
|
|
171
|
+
const move = t.status === target.status
|
|
172
|
+
? { before_id: target.id }
|
|
173
|
+
: { status: target.status, before_id: target.id };
|
|
174
|
+
api.put('/api/projects/pm/task', { path: scope, id: t.id, move })
|
|
175
|
+
.then(() => { notify('Moved'); load(); })
|
|
176
|
+
.catch(e => notify(e.message || 'Move failed', 'err'));
|
|
116
177
|
};
|
|
117
178
|
|
|
118
179
|
const addTask = async () => {
|
|
@@ -145,7 +206,11 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
145
206
|
const cols = (((boardData || {}).board) || {}).columns || {};
|
|
146
207
|
PM_COLUMNS.forEach(([k]) => { columns[k] = cols[k] || []; });
|
|
147
208
|
}
|
|
209
|
+
const boardById = {};
|
|
210
|
+
Object.values(columns).forEach(list =>
|
|
211
|
+
list.forEach(t => { boardById[t.id] = t; }));
|
|
148
212
|
const milestones = (((boardData || {}).board) || {}).milestones || [];
|
|
213
|
+
const recovery = (((boardData || {}).board) || {}).recovery || null;
|
|
149
214
|
const byProject = (globalData || {}).by_project || {};
|
|
150
215
|
const projChips = Object.entries(byProject)
|
|
151
216
|
.sort((a, b) => (b[1].open || 0) - (a[1].open || 0) ||
|
|
@@ -191,7 +256,16 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
191
256
|
</div>
|
|
192
257
|
)}
|
|
193
258
|
|
|
259
|
+
{mode === 'project' && timeData && timeData.summary && (
|
|
260
|
+
<div className="mono" style={{ fontSize: 11, color: T.textDim }}>
|
|
261
|
+
⏱ {fmtMinutes(timeData.summary.today.total_min)} today
|
|
262
|
+
{' · '}{fmtMinutes(timeData.summary.last_7d.total_min)} last 7d
|
|
263
|
+
{' · '}{fmtMinutes(timeData.summary.last_30d.total_min)} last 30d
|
|
264
|
+
{' · '}{(timeData.entries || []).length} manual
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
194
267
|
{err && <div style={{ fontSize: 12, color: T.error }}>{err}</div>}
|
|
268
|
+
{mode === 'project' && <RecoveryBanner recovery={recovery} />}
|
|
195
269
|
|
|
196
270
|
{/* Board */}
|
|
197
271
|
{loadingBoard ? (
|
|
@@ -214,10 +288,13 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
214
288
|
{hiddenDone ? '—' : colTasks.length}
|
|
215
289
|
</span>
|
|
216
290
|
</div>
|
|
217
|
-
<div
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
291
|
+
<div
|
|
292
|
+
onDragOver={e => e.preventDefault()}
|
|
293
|
+
onDrop={e => { e.preventDefault(); dropOnColumn(key); }}
|
|
294
|
+
style={{
|
|
295
|
+
display: 'flex', flexDirection: 'column', gap: 8,
|
|
296
|
+
overflowY: 'auto', maxHeight: 'calc(100vh - 220px)', paddingRight: 2,
|
|
297
|
+
}}>
|
|
221
298
|
{mode === 'project' && key === 'backlog' && (
|
|
222
299
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
223
300
|
<input value={newTitle}
|
|
@@ -246,9 +323,10 @@ function TaskBoard({ projects, onOpenDrill }) {
|
|
|
246
323
|
<React.Fragment>
|
|
247
324
|
{colTasks.map((t, i) => (
|
|
248
325
|
<TaskBoardCard key={t.id} task={t} mode={mode}
|
|
249
|
-
colTasks={colTasks} index={i}
|
|
326
|
+
colTasks={colTasks} index={i} byId={boardById}
|
|
250
327
|
onMoveStatus={moveStatus} onMoveRank={moveRank}
|
|
251
|
-
onOpenProject={openProject}
|
|
328
|
+
onOpenProject={openProject}
|
|
329
|
+
onMoveTo={moveTo} onDropOnCard={dropOnCard} />
|
|
252
330
|
))}
|
|
253
331
|
{colTasks.length === 0 && !(mode === 'project' && key === 'backlog') && (
|
|
254
332
|
<div style={{ fontSize: 11, color: T.textDim, fontStyle: 'italic', padding: '4px 2px' }}>
|
cli/mcp_server.py
CHANGED
|
@@ -88,7 +88,7 @@ def _build_instructions(ide_name: str) -> str:
|
|
|
88
88
|
" DISTILL terminal/log output >10 lines → c3_filter\n"
|
|
89
89
|
" EXECUTE shell (tests/git/build) → c3_shell\n"
|
|
90
90
|
" RECALL cross-session knowledge → c3_memory(action='recall') (index+fetch for large stores)\n"
|
|
91
|
-
" TRACK durable tasks/milestones/decisions → c3_task\n"
|
|
91
|
+
" TRACK durable tasks/milestones/decisions + time → c3_task\n"
|
|
92
92
|
" AGENT CONFIG inventory/history/diff/restore → c3_artifacts\n"
|
|
93
93
|
" SNAPSHOT before /clear → c3_session(action='snapshot')\n"
|
|
94
94
|
" HEALTH/budget checks → c3_status\n"
|
|
@@ -102,6 +102,13 @@ async def lifespan(server):
|
|
|
102
102
|
"""Initialize all services, auto-start session, start file watcher."""
|
|
103
103
|
project = PROJECT_PATH
|
|
104
104
|
services = build_runtime(project, ide_name=_IDE_NAME)
|
|
105
|
+
if getattr(services, "time_tracker", None) is not None:
|
|
106
|
+
try:
|
|
107
|
+
# The IDE loading the c3 MCP server marks the start of a work
|
|
108
|
+
# session for this project.
|
|
109
|
+
services.time_tracker.ping("startup")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
105
112
|
transcript_index = TranscriptIndex(project)
|
|
106
113
|
services.transcript_index = transcript_index
|
|
107
114
|
snapshots = services.snapshots or ContextSnapshot(project)
|
|
@@ -367,6 +374,12 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
|
|
|
367
374
|
|
|
368
375
|
svc.session_mgr.log_tool_call(tool_name, args, summary)
|
|
369
376
|
svc.activity_log.log("tool_call", {"tool": tool_name, "args": args, "result_summary": summary})
|
|
377
|
+
tracker = getattr(svc, "time_tracker", None)
|
|
378
|
+
if tracker is not None:
|
|
379
|
+
try:
|
|
380
|
+
tracker.ping("tool") # throttled heartbeat; idle gaps close sessions
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
370
383
|
svc.session_mgr.track_response(tool_name, response, response_tokens=response_tokens)
|
|
371
384
|
|
|
372
385
|
hybrid_cfg = svc.hybrid_config or {}
|
|
@@ -846,15 +859,24 @@ async def c3_task(
|
|
|
846
859
|
target_date: str = "",
|
|
847
860
|
query: str = "",
|
|
848
861
|
limit: int = 50,
|
|
862
|
+
parent: str = "",
|
|
863
|
+
minutes: int = 0,
|
|
849
864
|
ctx: Context = None,
|
|
850
865
|
) -> str:
|
|
851
866
|
"""TRACK WORK — durable per-project tasks, milestones, and decision notes; use when asked to create/update/complete tasks (reads safe in plan mode).
|
|
852
|
-
Tasks: add (title [+description/priority p0-p3/due_date YYYY-MM-DD/tags CSV/milestone]),
|
|
867
|
+
Tasks: add (title [+description/priority p0-p3/due_date YYYY-MM-DD/tags CSV/milestone/parent]),
|
|
853
868
|
update (task_id + changed fields incl. status backlog|in_progress|blocked|done), done (task_id),
|
|
854
869
|
list (filters: status/priority/tags/milestone/query), get, board (kanban columns + milestone progress), archive,
|
|
855
|
-
link/unlink (task_id + link_type file|commit|edit + ref — ties tasks to code)
|
|
870
|
+
link/unlink (task_id + link_type file|commit|edit + ref — ties tasks to code),
|
|
871
|
+
block/unblock (task_id + ref=blocking task id; cycle-safe; completing the last open blocker auto-releases dependents to backlog),
|
|
872
|
+
report (overdue, blocked chains + aging, ready-to-unblock, milestone health/at-risk, throughput).
|
|
873
|
+
Subtasks: one level via parent (add/update; update parent='none' clears).
|
|
874
|
+
Time: time_add (minutes 1-1440 [+note/due_date=date/task_id]), time_update / time_delete (ref=entry id),
|
|
875
|
+
time_list (manual entries + recent auto sessions), time_summary (today/7d/30d, auto vs manual).
|
|
876
|
+
Auto-tracking: server startup + tool calls ping .c3/time; idle gaps >15min close a session.
|
|
856
877
|
Milestones: milestone_add (name [+target_date]), milestone_update, milestone_list (with progress %), milestone_archive.
|
|
857
878
|
Notes: note_add (note [+kind=decision] [+task_id]), note_list.
|
|
879
|
+
History: history ([+task_id] [+limit]) — append-only event log (who/what/when, before->after), newest first.
|
|
858
880
|
task_id accepts any unique id prefix (>=4 chars); milestone accepts id or unique name.
|
|
859
881
|
Ephemeral session plans stay in c3_session(action='plan')."""
|
|
860
882
|
svc = _svc(ctx)
|
|
@@ -868,7 +890,8 @@ async def c3_task(
|
|
|
868
890
|
title=title, task_id=task_id, status=status, priority=priority,
|
|
869
891
|
due_date=due_date, tags=tags, description=description, milestone=milestone,
|
|
870
892
|
note=note, kind=kind, link_type=link_type, ref=ref, label=label,
|
|
871
|
-
name=name, target_date=target_date, query=query, limit=limit
|
|
893
|
+
name=name, target_date=target_date, query=query, limit=limit,
|
|
894
|
+
parent=parent, minutes=minutes)
|
|
872
895
|
|
|
873
896
|
|
|
874
897
|
@mcp.tool()
|