@hasna/todos 0.10.19 → 0.10.21
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.
- package/dist/cli/components/Dashboard.d.ts +7 -0
- package/dist/cli/components/Dashboard.d.ts.map +1 -0
- package/dist/cli/index.js +2151 -559
- package/dist/db/agents.d.ts +19 -4
- package/dist/db/agents.d.ts.map +1 -1
- package/dist/db/audit.d.ts +46 -0
- package/dist/db/audit.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +468 -23
- package/dist/lib/burndown.d.ts +18 -0
- package/dist/lib/burndown.d.ts.map +1 -0
- package/dist/lib/extract.d.ts +51 -0
- package/dist/lib/extract.d.ts.map +1 -0
- package/dist/lib/github.d.ts +25 -0
- package/dist/lib/github.d.ts.map +1 -0
- package/dist/mcp/index.js +1014 -187
- package/dist/server/index.js +129 -25
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -746,6 +746,15 @@ var MIGRATIONS = [
|
|
|
746
746
|
CREATE INDEX IF NOT EXISTS idx_file_locks_agent ON file_locks(agent_id);
|
|
747
747
|
CREATE INDEX IF NOT EXISTS idx_file_locks_expires ON file_locks(expires_at);
|
|
748
748
|
INSERT OR IGNORE INTO _migrations (id) VALUES (33);
|
|
749
|
+
`,
|
|
750
|
+
`
|
|
751
|
+
ALTER TABLE tasks ADD COLUMN started_at TEXT;
|
|
752
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (34);
|
|
753
|
+
`,
|
|
754
|
+
`
|
|
755
|
+
ALTER TABLE tasks ADD COLUMN task_type TEXT;
|
|
756
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type);
|
|
757
|
+
INSERT OR IGNORE INTO _migrations (id) VALUES (35);
|
|
749
758
|
`
|
|
750
759
|
];
|
|
751
760
|
var _db = null;
|
|
@@ -926,6 +935,7 @@ function ensureSchema(db) {
|
|
|
926
935
|
ensureColumn("tasks", "spawned_from_session", "TEXT");
|
|
927
936
|
ensureColumn("tasks", "assigned_by", "TEXT");
|
|
928
937
|
ensureColumn("tasks", "assigned_from_project", "TEXT");
|
|
938
|
+
ensureColumn("tasks", "started_at", "TEXT");
|
|
929
939
|
ensureColumn("agents", "role", "TEXT DEFAULT 'agent'");
|
|
930
940
|
ensureColumn("agents", "permissions", `TEXT DEFAULT '["*"]'`);
|
|
931
941
|
ensureColumn("agents", "reports_to", "TEXT");
|
|
@@ -1043,6 +1053,16 @@ function resolvePartialId(db, table, partialId) {
|
|
|
1043
1053
|
return shortIdRows[0].id;
|
|
1044
1054
|
}
|
|
1045
1055
|
}
|
|
1056
|
+
if (table === "task_lists") {
|
|
1057
|
+
const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
|
|
1058
|
+
if (slugRow)
|
|
1059
|
+
return slugRow.id;
|
|
1060
|
+
}
|
|
1061
|
+
if (table === "projects") {
|
|
1062
|
+
const nameRow = db.query("SELECT id FROM projects WHERE lower(name) = ?").get(partialId.toLowerCase());
|
|
1063
|
+
if (nameRow)
|
|
1064
|
+
return nameRow.id;
|
|
1065
|
+
}
|
|
1046
1066
|
return null;
|
|
1047
1067
|
}
|
|
1048
1068
|
// src/types/index.ts
|
|
@@ -1484,6 +1504,32 @@ function getRecentActivity(limit = 50, db) {
|
|
|
1484
1504
|
const d = db || getDatabase();
|
|
1485
1505
|
return d.query("SELECT * FROM task_history ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
1486
1506
|
}
|
|
1507
|
+
function getRecap(hours = 8, projectId, db) {
|
|
1508
|
+
const d = db || getDatabase();
|
|
1509
|
+
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
1510
|
+
const staleWindow = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
1511
|
+
const pf = projectId ? " AND project_id = ?" : "";
|
|
1512
|
+
const tpf = projectId ? " AND t.project_id = ?" : "";
|
|
1513
|
+
const completed = projectId ? d.query(`SELECT id, short_id, title, assigned_to, completed_at, started_at FROM tasks WHERE status = 'completed' AND completed_at > ?${pf} ORDER BY completed_at DESC`).all(since, projectId) : d.query(`SELECT id, short_id, title, assigned_to, completed_at, started_at FROM tasks WHERE status = 'completed' AND completed_at > ? ORDER BY completed_at DESC`).all(since);
|
|
1514
|
+
const created = projectId ? d.query(`SELECT id, short_id, title, agent_id, created_at FROM tasks WHERE created_at > ?${pf} ORDER BY created_at DESC`).all(since, projectId) : d.query(`SELECT id, short_id, title, agent_id, created_at FROM tasks WHERE created_at > ? ORDER BY created_at DESC`).all(since);
|
|
1515
|
+
const in_progress = projectId ? d.query(`SELECT id, short_id, title, assigned_to, started_at FROM tasks WHERE status = 'in_progress' AND project_id = ? ORDER BY updated_at DESC`).all(projectId) : d.query(`SELECT id, short_id, title, assigned_to, started_at FROM tasks WHERE status = 'in_progress' ORDER BY updated_at DESC`).all();
|
|
1516
|
+
const blocked = projectId ? d.query(`SELECT DISTINCT t.id, t.short_id, t.title, t.assigned_to FROM tasks t JOIN task_dependencies td ON td.task_id = t.id JOIN tasks dep ON dep.id = td.depends_on AND dep.status NOT IN ('completed','cancelled') WHERE t.status = 'pending'${tpf}`).all(projectId) : d.query(`SELECT DISTINCT t.id, t.short_id, t.title, t.assigned_to FROM tasks t JOIN task_dependencies td ON td.task_id = t.id JOIN tasks dep ON dep.id = td.depends_on AND dep.status NOT IN ('completed','cancelled') WHERE t.status = 'pending'`).all();
|
|
1517
|
+
const stale = projectId ? d.query(`SELECT id, short_id, title, assigned_to, updated_at FROM tasks WHERE status = 'in_progress' AND updated_at < ? AND project_id = ? ORDER BY updated_at ASC`).all(staleWindow, projectId) : d.query(`SELECT id, short_id, title, assigned_to, updated_at FROM tasks WHERE status = 'in_progress' AND updated_at < ? ORDER BY updated_at ASC`).all(staleWindow);
|
|
1518
|
+
const agents = projectId ? d.query(`SELECT a.name, a.last_seen_at, (SELECT COUNT(*) FROM tasks t WHERE (t.assigned_to = a.id OR t.agent_id = a.id) AND t.status = 'completed' AND t.completed_at > ?${tpf}) as completed_count, (SELECT COUNT(*) FROM tasks t WHERE (t.assigned_to = a.id OR t.agent_id = a.id) AND t.status = 'in_progress'${tpf}) as in_progress_count FROM agents a WHERE a.status = 'active' AND a.last_seen_at > ? ORDER BY completed_count DESC`).all(since, projectId, projectId, since) : d.query(`SELECT a.name, a.last_seen_at, (SELECT COUNT(*) FROM tasks t WHERE (t.assigned_to = a.id OR t.agent_id = a.id) AND t.status = 'completed' AND t.completed_at > ?) as completed_count, (SELECT COUNT(*) FROM tasks t WHERE (t.assigned_to = a.id OR t.agent_id = a.id) AND t.status = 'in_progress') as in_progress_count FROM agents a WHERE a.status = 'active' AND a.last_seen_at > ? ORDER BY completed_count DESC`).all(since, since);
|
|
1519
|
+
return {
|
|
1520
|
+
hours,
|
|
1521
|
+
since,
|
|
1522
|
+
completed: completed.map((r) => ({
|
|
1523
|
+
...r,
|
|
1524
|
+
duration_minutes: r.started_at && r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()) / 60000) : null
|
|
1525
|
+
})),
|
|
1526
|
+
created,
|
|
1527
|
+
in_progress,
|
|
1528
|
+
blocked,
|
|
1529
|
+
stale,
|
|
1530
|
+
agents
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1487
1533
|
|
|
1488
1534
|
// src/lib/recurrence.ts
|
|
1489
1535
|
var DAY_NAMES = {
|
|
@@ -1762,8 +1808,8 @@ function createTask(input, db) {
|
|
|
1762
1808
|
const title = shortId ? `${shortId}: ${input.title}` : input.title;
|
|
1763
1809
|
const assignedBy = input.assigned_by || input.agent_id;
|
|
1764
1810
|
const assignedFromProject = input.assigned_from_project || null;
|
|
1765
|
-
d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id, spawns_template_id, reason, spawned_from_session, assigned_by, assigned_from_project)
|
|
1766
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1811
|
+
d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id, spawns_template_id, reason, spawned_from_session, assigned_by, assigned_from_project, task_type)
|
|
1812
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1767
1813
|
id,
|
|
1768
1814
|
shortId,
|
|
1769
1815
|
input.project_id || null,
|
|
@@ -1793,7 +1839,8 @@ function createTask(input, db) {
|
|
|
1793
1839
|
input.reason || null,
|
|
1794
1840
|
input.spawned_from_session || null,
|
|
1795
1841
|
assignedBy || null,
|
|
1796
|
-
assignedFromProject || null
|
|
1842
|
+
assignedFromProject || null,
|
|
1843
|
+
input.task_type || null
|
|
1797
1844
|
]);
|
|
1798
1845
|
if (tags.length > 0) {
|
|
1799
1846
|
insertTaskTags(id, tags, d);
|
|
@@ -1902,6 +1949,15 @@ function listTasks(filter = {}, db) {
|
|
|
1902
1949
|
} else if (filter.has_recurrence === false) {
|
|
1903
1950
|
conditions.push("recurrence_rule IS NULL");
|
|
1904
1951
|
}
|
|
1952
|
+
if (filter.task_type) {
|
|
1953
|
+
if (Array.isArray(filter.task_type)) {
|
|
1954
|
+
conditions.push(`task_type IN (${filter.task_type.map(() => "?").join(",")})`);
|
|
1955
|
+
params.push(...filter.task_type);
|
|
1956
|
+
} else {
|
|
1957
|
+
conditions.push("task_type = ?");
|
|
1958
|
+
params.push(filter.task_type);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1905
1961
|
const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
|
|
1906
1962
|
if (filter.cursor) {
|
|
1907
1963
|
try {
|
|
@@ -2061,6 +2117,10 @@ function updateTask(id, input, db) {
|
|
|
2061
2117
|
sets.push("recurrence_rule = ?");
|
|
2062
2118
|
params.push(input.recurrence_rule);
|
|
2063
2119
|
}
|
|
2120
|
+
if (input.task_type !== undefined) {
|
|
2121
|
+
sets.push("task_type = ?");
|
|
2122
|
+
params.push(input.task_type ?? null);
|
|
2123
|
+
}
|
|
2064
2124
|
params.push(id, input.version);
|
|
2065
2125
|
const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
|
|
2066
2126
|
if (result.changes === 0) {
|
|
@@ -2130,8 +2190,8 @@ function startTask(id, agentId, db) {
|
|
|
2130
2190
|
}
|
|
2131
2191
|
const cutoff = lockExpiryCutoff();
|
|
2132
2192
|
const timestamp = now();
|
|
2133
|
-
const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
|
|
2134
|
-
WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, id, agentId, cutoff]);
|
|
2193
|
+
const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, started_at = COALESCE(started_at, ?), version = version + 1, updated_at = ?
|
|
2194
|
+
WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, timestamp, id, agentId, cutoff]);
|
|
2135
2195
|
if (result.changes === 0) {
|
|
2136
2196
|
if (task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
|
|
2137
2197
|
throw new LockError(id, task.locked_by);
|
|
@@ -2139,7 +2199,7 @@ function startTask(id, agentId, db) {
|
|
|
2139
2199
|
}
|
|
2140
2200
|
logTaskChange(id, "start", "status", "pending", "in_progress", agentId, d);
|
|
2141
2201
|
dispatchWebhook("task.started", { id, agent_id: agentId, title: task.title }, d).catch(() => {});
|
|
2142
|
-
return { ...task, status: "in_progress", assigned_to: agentId, locked_by: agentId, locked_at: timestamp, version: task.version + 1, updated_at: timestamp };
|
|
2202
|
+
return { ...task, status: "in_progress", assigned_to: agentId, locked_by: agentId, locked_at: timestamp, started_at: task.started_at || timestamp, version: task.version + 1, updated_at: timestamp };
|
|
2143
2203
|
}
|
|
2144
2204
|
function completeTask(id, agentId, db, options) {
|
|
2145
2205
|
const d = db || getDatabase();
|
|
@@ -2877,9 +2937,26 @@ function deleteComment(id, db) {
|
|
|
2877
2937
|
return result.changes > 0;
|
|
2878
2938
|
}
|
|
2879
2939
|
// src/db/agents.ts
|
|
2880
|
-
|
|
2940
|
+
function getActiveWindowMs() {
|
|
2941
|
+
const env = process.env["TODOS_AGENT_TIMEOUT_MS"];
|
|
2942
|
+
if (env) {
|
|
2943
|
+
const parsed = parseInt(env, 10);
|
|
2944
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
2945
|
+
return parsed;
|
|
2946
|
+
}
|
|
2947
|
+
return 30 * 60 * 1000;
|
|
2948
|
+
}
|
|
2949
|
+
function autoReleaseStaleAgents(db) {
|
|
2950
|
+
if (process.env["TODOS_AGENT_AUTO_RELEASE"] !== "true")
|
|
2951
|
+
return 0;
|
|
2952
|
+
const d = db || getDatabase();
|
|
2953
|
+
const cutoff = new Date(Date.now() - getActiveWindowMs()).toISOString();
|
|
2954
|
+
const result = d.run("UPDATE agents SET session_id = NULL WHERE status = 'active' AND session_id IS NOT NULL AND last_seen_at < ?", [cutoff]);
|
|
2955
|
+
return result.changes;
|
|
2956
|
+
}
|
|
2881
2957
|
function getAvailableNamesFromPool(pool, db) {
|
|
2882
|
-
|
|
2958
|
+
autoReleaseStaleAgents(db);
|
|
2959
|
+
const cutoff = new Date(Date.now() - getActiveWindowMs()).toISOString();
|
|
2883
2960
|
const activeNames = new Set(db.query("SELECT name FROM agents WHERE status = 'active' AND last_seen_at > ?").all(cutoff).map((r) => r.name.toLowerCase()));
|
|
2884
2961
|
return pool.filter((name) => !activeNames.has(name.toLowerCase()));
|
|
2885
2962
|
}
|
|
@@ -2901,22 +2978,19 @@ function registerAgent(input, db) {
|
|
|
2901
2978
|
const existing = getAgentByName(normalizedName, d);
|
|
2902
2979
|
if (existing) {
|
|
2903
2980
|
const lastSeenMs = new Date(existing.last_seen_at).getTime();
|
|
2904
|
-
const
|
|
2981
|
+
const activeWindowMs = getActiveWindowMs();
|
|
2982
|
+
const isActive = Date.now() - lastSeenMs < activeWindowMs;
|
|
2905
2983
|
const sameSession = input.session_id && existing.session_id && input.session_id === existing.session_id;
|
|
2906
2984
|
const differentSession = input.session_id && existing.session_id && input.session_id !== existing.session_id;
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
working_dir: existing.working_dir,
|
|
2917
|
-
suggestions: suggestions.slice(0, 5),
|
|
2918
|
-
message: `Agent "${normalizedName}" is already active (last seen ${minutesAgo}m ago, session \u2026${existing.session_id?.slice(-4)}, dir: ${existing.working_dir ?? "unknown"}). Cannot reclaim an active agent \u2014 choose a different name, or wait for the session to go stale.${suggestions.length > 0 ? ` Available: ${suggestions.slice(0, 3).join(", ")}` : ""}`
|
|
2919
|
-
};
|
|
2985
|
+
const callerHasNoSession = !input.session_id;
|
|
2986
|
+
const existingHasActiveSession = existing.session_id && isActive;
|
|
2987
|
+
if (!input.force) {
|
|
2988
|
+
if (isActive && differentSession) {
|
|
2989
|
+
return buildConflictError(existing, lastSeenMs, input.pool, d);
|
|
2990
|
+
}
|
|
2991
|
+
if (callerHasNoSession && existingHasActiveSession) {
|
|
2992
|
+
return buildConflictError(existing, lastSeenMs, input.pool, d);
|
|
2993
|
+
}
|
|
2920
2994
|
}
|
|
2921
2995
|
const updates = ["last_seen_at = ?", "status = 'active'"];
|
|
2922
2996
|
const params = [now()];
|
|
@@ -2961,6 +3035,32 @@ function registerAgent(input, db) {
|
|
|
2961
3035
|
function isAgentConflict(result) {
|
|
2962
3036
|
return result.conflict === true;
|
|
2963
3037
|
}
|
|
3038
|
+
function buildConflictError(existing, lastSeenMs, pool, d) {
|
|
3039
|
+
const minutesAgo = Math.round((Date.now() - lastSeenMs) / 60000);
|
|
3040
|
+
const suggestions = pool ? getAvailableNamesFromPool(pool, d) : [];
|
|
3041
|
+
return {
|
|
3042
|
+
conflict: true,
|
|
3043
|
+
existing_id: existing.id,
|
|
3044
|
+
existing_name: existing.name,
|
|
3045
|
+
last_seen_at: existing.last_seen_at,
|
|
3046
|
+
session_hint: existing.session_id ? existing.session_id.slice(0, 8) : null,
|
|
3047
|
+
working_dir: existing.working_dir,
|
|
3048
|
+
suggestions: suggestions.slice(0, 5),
|
|
3049
|
+
message: `Agent "${existing.name}" is already active (last seen ${minutesAgo}m ago, session ${existing.session_id ? "\u2026" + existing.session_id.slice(-4) : "none"}, dir: ${existing.working_dir ?? "unknown"}). Pass force: true to take over, or choose a different name.${suggestions.length > 0 ? ` Available: ${suggestions.slice(0, 3).join(", ")}` : ""}`
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
3052
|
+
function releaseAgent(id, session_id, db) {
|
|
3053
|
+
const d = db || getDatabase();
|
|
3054
|
+
const agent = getAgent(id, d);
|
|
3055
|
+
if (!agent)
|
|
3056
|
+
return false;
|
|
3057
|
+
if (session_id && agent.session_id && agent.session_id !== session_id) {
|
|
3058
|
+
return false;
|
|
3059
|
+
}
|
|
3060
|
+
const epoch = new Date(0).toISOString();
|
|
3061
|
+
d.run("UPDATE agents SET session_id = NULL, last_seen_at = ? WHERE id = ?", [epoch, id]);
|
|
3062
|
+
return true;
|
|
3063
|
+
}
|
|
2964
3064
|
function getAgent(id, db) {
|
|
2965
3065
|
const d = db || getDatabase();
|
|
2966
3066
|
const row = d.query("SELECT * FROM agents WHERE id = ?").get(id);
|
|
@@ -2981,6 +3081,7 @@ function listAgents(opts, db) {
|
|
|
2981
3081
|
includeArchived = opts?.include_archived ?? false;
|
|
2982
3082
|
d = db || getDatabase();
|
|
2983
3083
|
}
|
|
3084
|
+
autoReleaseStaleAgents(d);
|
|
2984
3085
|
if (includeArchived) {
|
|
2985
3086
|
return d.query("SELECT * FROM agents ORDER BY name").all().map(rowToAgent);
|
|
2986
3087
|
}
|
|
@@ -2998,8 +3099,19 @@ function updateAgent(id, input, db) {
|
|
|
2998
3099
|
const sets = ["last_seen_at = ?"];
|
|
2999
3100
|
const params = [now()];
|
|
3000
3101
|
if (input.name !== undefined) {
|
|
3102
|
+
const newName = input.name.trim().toLowerCase();
|
|
3103
|
+
const holder = getAgentByName(newName, d);
|
|
3104
|
+
if (holder && holder.id !== id) {
|
|
3105
|
+
const lastSeenMs = new Date(holder.last_seen_at).getTime();
|
|
3106
|
+
const isActive = Date.now() - lastSeenMs < getActiveWindowMs();
|
|
3107
|
+
if (isActive && holder.status === "active") {
|
|
3108
|
+
throw new Error(`Cannot rename: name "${newName}" is held by active agent ${holder.id} (last seen ${Math.round((Date.now() - lastSeenMs) / 60000)}m ago)`);
|
|
3109
|
+
}
|
|
3110
|
+
const evictedName = `${holder.name}__evicted_${holder.id}`;
|
|
3111
|
+
d.run("UPDATE agents SET name = ? WHERE id = ?", [evictedName, holder.id]);
|
|
3112
|
+
}
|
|
3001
3113
|
sets.push("name = ?");
|
|
3002
|
-
params.push(
|
|
3114
|
+
params.push(newName);
|
|
3003
3115
|
}
|
|
3004
3116
|
if (input.description !== undefined) {
|
|
3005
3117
|
sets.push("description = ?");
|
|
@@ -4564,6 +4676,328 @@ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both"
|
|
|
4564
4676
|
}
|
|
4565
4677
|
return { pushed, pulled, errors };
|
|
4566
4678
|
}
|
|
4679
|
+
// src/lib/extract.ts
|
|
4680
|
+
import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
4681
|
+
import { relative, resolve as resolve2, join as join6 } from "path";
|
|
4682
|
+
var EXTRACT_TAGS = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"];
|
|
4683
|
+
var DEFAULT_EXTENSIONS = new Set([
|
|
4684
|
+
".ts",
|
|
4685
|
+
".tsx",
|
|
4686
|
+
".js",
|
|
4687
|
+
".jsx",
|
|
4688
|
+
".mjs",
|
|
4689
|
+
".cjs",
|
|
4690
|
+
".py",
|
|
4691
|
+
".rb",
|
|
4692
|
+
".go",
|
|
4693
|
+
".rs",
|
|
4694
|
+
".c",
|
|
4695
|
+
".cpp",
|
|
4696
|
+
".h",
|
|
4697
|
+
".hpp",
|
|
4698
|
+
".java",
|
|
4699
|
+
".kt",
|
|
4700
|
+
".swift",
|
|
4701
|
+
".cs",
|
|
4702
|
+
".php",
|
|
4703
|
+
".sh",
|
|
4704
|
+
".bash",
|
|
4705
|
+
".zsh",
|
|
4706
|
+
".lua",
|
|
4707
|
+
".sql",
|
|
4708
|
+
".r",
|
|
4709
|
+
".R",
|
|
4710
|
+
".yaml",
|
|
4711
|
+
".yml",
|
|
4712
|
+
".toml",
|
|
4713
|
+
".css",
|
|
4714
|
+
".scss",
|
|
4715
|
+
".less",
|
|
4716
|
+
".vue",
|
|
4717
|
+
".svelte",
|
|
4718
|
+
".ex",
|
|
4719
|
+
".exs",
|
|
4720
|
+
".erl",
|
|
4721
|
+
".hs",
|
|
4722
|
+
".ml",
|
|
4723
|
+
".mli",
|
|
4724
|
+
".clj",
|
|
4725
|
+
".cljs"
|
|
4726
|
+
]);
|
|
4727
|
+
var SKIP_DIRS = new Set([
|
|
4728
|
+
"node_modules",
|
|
4729
|
+
".git",
|
|
4730
|
+
"dist",
|
|
4731
|
+
"build",
|
|
4732
|
+
"out",
|
|
4733
|
+
".next",
|
|
4734
|
+
".turbo",
|
|
4735
|
+
"coverage",
|
|
4736
|
+
"__pycache__",
|
|
4737
|
+
".venv",
|
|
4738
|
+
"venv",
|
|
4739
|
+
"vendor",
|
|
4740
|
+
"target",
|
|
4741
|
+
".cache",
|
|
4742
|
+
".parcel-cache"
|
|
4743
|
+
]);
|
|
4744
|
+
function tagToPriority(tag) {
|
|
4745
|
+
switch (tag) {
|
|
4746
|
+
case "BUG":
|
|
4747
|
+
case "FIXME":
|
|
4748
|
+
return "high";
|
|
4749
|
+
case "HACK":
|
|
4750
|
+
case "XXX":
|
|
4751
|
+
return "medium";
|
|
4752
|
+
case "TODO":
|
|
4753
|
+
return "medium";
|
|
4754
|
+
case "NOTE":
|
|
4755
|
+
return "low";
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
function buildTagRegex(tags) {
|
|
4759
|
+
const tagPattern = tags.join("|");
|
|
4760
|
+
return new RegExp(`(?:^|\\s)(?:\\/\\/|\\/\\*|#|\\*|--|;;|%|<!--|\\{-)\\s*(?:@?)(${tagPattern})\\s*[:(]?\\s*(.*)$`, "i");
|
|
4761
|
+
}
|
|
4762
|
+
function extractFromSource(source, filePath, tags = [...EXTRACT_TAGS]) {
|
|
4763
|
+
const regex = buildTagRegex(tags);
|
|
4764
|
+
const results = [];
|
|
4765
|
+
const lines = source.split(`
|
|
4766
|
+
`);
|
|
4767
|
+
for (let i = 0;i < lines.length; i++) {
|
|
4768
|
+
const line = lines[i];
|
|
4769
|
+
const match = line.match(regex);
|
|
4770
|
+
if (match) {
|
|
4771
|
+
const tag = match[1].toUpperCase();
|
|
4772
|
+
let message = match[2].trim();
|
|
4773
|
+
message = message.replace(/\s*\*\/\s*$/, "").replace(/\s*-->\s*$/, "").replace(/\s*-\}\s*$/, "").trim();
|
|
4774
|
+
if (message) {
|
|
4775
|
+
results.push({
|
|
4776
|
+
tag,
|
|
4777
|
+
message,
|
|
4778
|
+
file: filePath,
|
|
4779
|
+
line: i + 1,
|
|
4780
|
+
raw: line
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
return results;
|
|
4786
|
+
}
|
|
4787
|
+
function collectFiles(basePath, extensions) {
|
|
4788
|
+
const stat = statSync2(basePath);
|
|
4789
|
+
if (stat.isFile()) {
|
|
4790
|
+
return [basePath];
|
|
4791
|
+
}
|
|
4792
|
+
const glob = new Bun.Glob("**/*");
|
|
4793
|
+
const files = [];
|
|
4794
|
+
for (const entry of glob.scanSync({ cwd: basePath, onlyFiles: true, dot: false })) {
|
|
4795
|
+
const parts = entry.split("/");
|
|
4796
|
+
if (parts.some((p) => SKIP_DIRS.has(p)))
|
|
4797
|
+
continue;
|
|
4798
|
+
const dotIdx = entry.lastIndexOf(".");
|
|
4799
|
+
if (dotIdx === -1)
|
|
4800
|
+
continue;
|
|
4801
|
+
const ext = entry.slice(dotIdx);
|
|
4802
|
+
if (!extensions.has(ext))
|
|
4803
|
+
continue;
|
|
4804
|
+
files.push(entry);
|
|
4805
|
+
}
|
|
4806
|
+
return files.sort();
|
|
4807
|
+
}
|
|
4808
|
+
function extractTodos(options, db) {
|
|
4809
|
+
const basePath = resolve2(options.path);
|
|
4810
|
+
const tags = options.patterns || [...EXTRACT_TAGS];
|
|
4811
|
+
const extensions = options.extensions ? new Set(options.extensions.map((e) => e.startsWith(".") ? e : `.${e}`)) : DEFAULT_EXTENSIONS;
|
|
4812
|
+
const files = collectFiles(basePath, extensions);
|
|
4813
|
+
const allComments = [];
|
|
4814
|
+
for (const file of files) {
|
|
4815
|
+
const fullPath = statSync2(basePath).isFile() ? basePath : join6(basePath, file);
|
|
4816
|
+
try {
|
|
4817
|
+
const source = readFileSync3(fullPath, "utf-8");
|
|
4818
|
+
const relPath = statSync2(basePath).isFile() ? relative(resolve2(basePath, ".."), fullPath) : file;
|
|
4819
|
+
const comments = extractFromSource(source, relPath, tags);
|
|
4820
|
+
allComments.push(...comments);
|
|
4821
|
+
} catch {}
|
|
4822
|
+
}
|
|
4823
|
+
if (options.dry_run) {
|
|
4824
|
+
return { comments: allComments, tasks: [], skipped: 0 };
|
|
4825
|
+
}
|
|
4826
|
+
const tasks = [];
|
|
4827
|
+
let skipped = 0;
|
|
4828
|
+
const existingTasks = options.project_id ? listTasks({ project_id: options.project_id, tags: ["extracted"] }, db) : listTasks({ tags: ["extracted"] }, db);
|
|
4829
|
+
const existingKeys = new Set;
|
|
4830
|
+
for (const t of existingTasks) {
|
|
4831
|
+
const meta = t.metadata;
|
|
4832
|
+
if (meta?.["source_file"] && meta?.["source_line"]) {
|
|
4833
|
+
existingKeys.add(`${meta["source_file"]}:${meta["source_line"]}`);
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
for (const comment of allComments) {
|
|
4837
|
+
const dedupKey = `${comment.file}:${comment.line}`;
|
|
4838
|
+
if (existingKeys.has(dedupKey)) {
|
|
4839
|
+
skipped++;
|
|
4840
|
+
continue;
|
|
4841
|
+
}
|
|
4842
|
+
const taskTags = ["extracted", comment.tag.toLowerCase(), ...options.tags || []];
|
|
4843
|
+
const task = createTask({
|
|
4844
|
+
title: `[${comment.tag}] ${comment.message}`,
|
|
4845
|
+
description: `Extracted from code comment in \`${comment.file}\` at line ${comment.line}:
|
|
4846
|
+
\`\`\`
|
|
4847
|
+
${comment.raw.trim()}
|
|
4848
|
+
\`\`\``,
|
|
4849
|
+
priority: tagToPriority(comment.tag),
|
|
4850
|
+
project_id: options.project_id,
|
|
4851
|
+
task_list_id: options.task_list_id,
|
|
4852
|
+
assigned_to: options.assigned_to,
|
|
4853
|
+
agent_id: options.agent_id,
|
|
4854
|
+
tags: taskTags,
|
|
4855
|
+
metadata: {
|
|
4856
|
+
source: "code_comment",
|
|
4857
|
+
comment_type: comment.tag,
|
|
4858
|
+
source_file: comment.file,
|
|
4859
|
+
source_line: comment.line
|
|
4860
|
+
}
|
|
4861
|
+
}, db);
|
|
4862
|
+
addTaskFile({
|
|
4863
|
+
task_id: task.id,
|
|
4864
|
+
path: comment.file,
|
|
4865
|
+
note: `Line ${comment.line}: ${comment.tag} comment`
|
|
4866
|
+
}, db);
|
|
4867
|
+
tasks.push(task);
|
|
4868
|
+
existingKeys.add(dedupKey);
|
|
4869
|
+
}
|
|
4870
|
+
return { comments: allComments, tasks, skipped };
|
|
4871
|
+
}
|
|
4872
|
+
// src/lib/burndown.ts
|
|
4873
|
+
function getBurndown(opts, db) {
|
|
4874
|
+
const d = db || getDatabase();
|
|
4875
|
+
const conditions = [];
|
|
4876
|
+
const params = [];
|
|
4877
|
+
if (opts.plan_id) {
|
|
4878
|
+
conditions.push("plan_id = ?");
|
|
4879
|
+
params.push(opts.plan_id);
|
|
4880
|
+
}
|
|
4881
|
+
if (opts.project_id) {
|
|
4882
|
+
conditions.push("project_id = ?");
|
|
4883
|
+
params.push(opts.project_id);
|
|
4884
|
+
}
|
|
4885
|
+
if (opts.task_list_id) {
|
|
4886
|
+
conditions.push("task_list_id = ?");
|
|
4887
|
+
params.push(opts.task_list_id);
|
|
4888
|
+
}
|
|
4889
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
4890
|
+
const total = d.query(`SELECT COUNT(*) as c FROM tasks ${where}`).get(...params).c;
|
|
4891
|
+
const completed = d.query(`SELECT COUNT(*) as c FROM tasks ${where}${where ? " AND" : " WHERE"} status = 'completed'`).get(...params).c;
|
|
4892
|
+
const completions = d.query(`SELECT DATE(completed_at) as date, COUNT(*) as count FROM tasks ${where}${where ? " AND" : " WHERE"} status = 'completed' AND completed_at IS NOT NULL GROUP BY DATE(completed_at) ORDER BY date`).all(...params);
|
|
4893
|
+
const firstTask = d.query(`SELECT MIN(created_at) as min_date FROM tasks ${where}`).get(...params);
|
|
4894
|
+
const startDate = firstTask?.min_date ? new Date(firstTask.min_date) : new Date;
|
|
4895
|
+
const endDate = new Date;
|
|
4896
|
+
const days = [];
|
|
4897
|
+
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)));
|
|
4898
|
+
let cumulative = 0;
|
|
4899
|
+
const completionMap = new Map(completions.map((c) => [c.date, c.count]));
|
|
4900
|
+
const current = new Date(startDate);
|
|
4901
|
+
for (let i = 0;i <= totalDays; i++) {
|
|
4902
|
+
const dateStr = current.toISOString().slice(0, 10);
|
|
4903
|
+
cumulative += completionMap.get(dateStr) || 0;
|
|
4904
|
+
days.push({
|
|
4905
|
+
date: dateStr,
|
|
4906
|
+
completed_cumulative: cumulative,
|
|
4907
|
+
ideal: Math.round(total / totalDays * i)
|
|
4908
|
+
});
|
|
4909
|
+
current.setDate(current.getDate() + 1);
|
|
4910
|
+
}
|
|
4911
|
+
const chart = renderBurndownChart(total, days);
|
|
4912
|
+
return { total, completed, remaining: total - completed, days, chart };
|
|
4913
|
+
}
|
|
4914
|
+
function renderBurndownChart(total, days) {
|
|
4915
|
+
const height = 12;
|
|
4916
|
+
const width = Math.min(60, days.length);
|
|
4917
|
+
const step = Math.max(1, Math.floor(days.length / width));
|
|
4918
|
+
const sampled = days.filter((_, i) => i % step === 0 || i === days.length - 1).slice(0, width);
|
|
4919
|
+
const lines = [];
|
|
4920
|
+
lines.push(` ${total} \u2524`);
|
|
4921
|
+
for (let row = height - 1;row >= 0; row--) {
|
|
4922
|
+
const threshold = Math.round(total / height * row);
|
|
4923
|
+
let line = "";
|
|
4924
|
+
for (const day of sampled) {
|
|
4925
|
+
const remaining = total - day.completed_cumulative;
|
|
4926
|
+
const idealRemaining = total - day.ideal;
|
|
4927
|
+
if (remaining >= threshold && remaining > threshold - Math.round(total / height)) {
|
|
4928
|
+
line += "\u2588";
|
|
4929
|
+
} else if (idealRemaining >= threshold && idealRemaining > threshold - Math.round(total / height)) {
|
|
4930
|
+
line += "\xB7";
|
|
4931
|
+
} else {
|
|
4932
|
+
line += " ";
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
const label = String(threshold).padStart(4);
|
|
4936
|
+
lines.push(`${label} \u2524${line}`);
|
|
4937
|
+
}
|
|
4938
|
+
lines.push(` 0 \u2524${"\u2500".repeat(sampled.length)}`);
|
|
4939
|
+
lines.push(` \u2514${"\u2500".repeat(sampled.length)}`);
|
|
4940
|
+
if (sampled.length > 0) {
|
|
4941
|
+
const first = sampled[0].date.slice(5);
|
|
4942
|
+
const last = sampled[sampled.length - 1].date.slice(5);
|
|
4943
|
+
const pad = sampled.length - first.length - last.length;
|
|
4944
|
+
lines.push(` ${first}${" ".repeat(Math.max(1, pad))}${last}`);
|
|
4945
|
+
}
|
|
4946
|
+
lines.push("");
|
|
4947
|
+
lines.push(` \u2588 actual remaining \xB7 ideal burndown`);
|
|
4948
|
+
return lines.join(`
|
|
4949
|
+
`);
|
|
4950
|
+
}
|
|
4951
|
+
// src/lib/github.ts
|
|
4952
|
+
import { execSync } from "child_process";
|
|
4953
|
+
function parseGitHubUrl(url) {
|
|
4954
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
|
|
4955
|
+
if (!match)
|
|
4956
|
+
return null;
|
|
4957
|
+
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) };
|
|
4958
|
+
}
|
|
4959
|
+
function fetchGitHubIssue(owner, repo, number) {
|
|
4960
|
+
const json = execSync(`gh api repos/${owner}/${repo}/issues/${number}`, { encoding: "utf-8", timeout: 15000 });
|
|
4961
|
+
const data = JSON.parse(json);
|
|
4962
|
+
return {
|
|
4963
|
+
number: data.number,
|
|
4964
|
+
title: data.title,
|
|
4965
|
+
body: data.body,
|
|
4966
|
+
labels: (data.labels || []).map((l) => l.name),
|
|
4967
|
+
state: data.state,
|
|
4968
|
+
assignee: data.assignee?.login || null,
|
|
4969
|
+
url: data.html_url
|
|
4970
|
+
};
|
|
4971
|
+
}
|
|
4972
|
+
function issueToTask(issue, opts) {
|
|
4973
|
+
const labelToPriority = {
|
|
4974
|
+
critical: "critical",
|
|
4975
|
+
"priority:critical": "critical",
|
|
4976
|
+
high: "high",
|
|
4977
|
+
"priority:high": "high",
|
|
4978
|
+
urgent: "high",
|
|
4979
|
+
low: "low",
|
|
4980
|
+
"priority:low": "low"
|
|
4981
|
+
};
|
|
4982
|
+
let priority = "medium";
|
|
4983
|
+
for (const label of issue.labels) {
|
|
4984
|
+
const mapped = labelToPriority[label.toLowerCase()];
|
|
4985
|
+
if (mapped) {
|
|
4986
|
+
priority = mapped;
|
|
4987
|
+
break;
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
return {
|
|
4991
|
+
title: `[GH#${issue.number}] ${issue.title}`,
|
|
4992
|
+
description: issue.body ? issue.body.slice(0, 4000) : undefined,
|
|
4993
|
+
tags: issue.labels.slice(0, 10),
|
|
4994
|
+
priority,
|
|
4995
|
+
metadata: { github_url: issue.url, github_number: issue.number, github_state: issue.state },
|
|
4996
|
+
project_id: opts?.project_id,
|
|
4997
|
+
task_list_id: opts?.task_list_id,
|
|
4998
|
+
agent_id: opts?.agent_id
|
|
4999
|
+
};
|
|
5000
|
+
}
|
|
4567
5001
|
export {
|
|
4568
5002
|
uuid,
|
|
4569
5003
|
updateTaskList,
|
|
@@ -4579,6 +5013,7 @@ export {
|
|
|
4579
5013
|
unlockTask,
|
|
4580
5014
|
unarchiveAgent,
|
|
4581
5015
|
taskFromTemplate,
|
|
5016
|
+
tagToPriority,
|
|
4582
5017
|
syncWithAgents,
|
|
4583
5018
|
syncWithAgent,
|
|
4584
5019
|
syncKgEdges,
|
|
@@ -4598,10 +5033,12 @@ export {
|
|
|
4598
5033
|
removeDependency,
|
|
4599
5034
|
removeChecklistItem,
|
|
4600
5035
|
releaseLock,
|
|
5036
|
+
releaseAgent,
|
|
4601
5037
|
registerAgent,
|
|
4602
5038
|
redistributeStaleTasks,
|
|
4603
5039
|
patrolTasks,
|
|
4604
5040
|
parseRecurrenceRule,
|
|
5041
|
+
parseGitHubUrl,
|
|
4605
5042
|
now,
|
|
4606
5043
|
nextTaskShortId,
|
|
4607
5044
|
nextOccurrence,
|
|
@@ -4624,6 +5061,7 @@ export {
|
|
|
4624
5061
|
listHandoffs,
|
|
4625
5062
|
listComments,
|
|
4626
5063
|
listAgents,
|
|
5064
|
+
issueToTask,
|
|
4627
5065
|
isValidRecurrenceRule,
|
|
4628
5066
|
isAgentConflict,
|
|
4629
5067
|
getWebhook,
|
|
@@ -4647,6 +5085,7 @@ export {
|
|
|
4647
5085
|
getReviewQueue,
|
|
4648
5086
|
getRelated,
|
|
4649
5087
|
getRecentActivity,
|
|
5088
|
+
getRecap,
|
|
4650
5089
|
getProjectWithSources,
|
|
4651
5090
|
getProjectByPath,
|
|
4652
5091
|
getProject,
|
|
@@ -4667,6 +5106,7 @@ export {
|
|
|
4667
5106
|
getChecklistStats,
|
|
4668
5107
|
getChecklist,
|
|
4669
5108
|
getCapableAgents,
|
|
5109
|
+
getBurndown,
|
|
4670
5110
|
getBlockingDeps,
|
|
4671
5111
|
getAgentMetrics,
|
|
4672
5112
|
getAgentByName,
|
|
@@ -4675,7 +5115,10 @@ export {
|
|
|
4675
5115
|
findTasksByFile,
|
|
4676
5116
|
findRelatedTaskIds,
|
|
4677
5117
|
findPath,
|
|
5118
|
+
fetchGitHubIssue,
|
|
4678
5119
|
failTask,
|
|
5120
|
+
extractTodos,
|
|
5121
|
+
extractFromSource,
|
|
4679
5122
|
ensureTaskList,
|
|
4680
5123
|
ensureProject,
|
|
4681
5124
|
dispatchWebhook,
|
|
@@ -4714,6 +5157,7 @@ export {
|
|
|
4714
5157
|
bulkUpdateTasks,
|
|
4715
5158
|
bulkCreateTasks,
|
|
4716
5159
|
bulkAddTaskFiles,
|
|
5160
|
+
autoReleaseStaleAgents,
|
|
4717
5161
|
autoDetectFileRelationships,
|
|
4718
5162
|
archiveAgent,
|
|
4719
5163
|
addTaskRelationship,
|
|
@@ -4735,6 +5179,7 @@ export {
|
|
|
4735
5179
|
PlanNotFoundError,
|
|
4736
5180
|
PLAN_STATUSES,
|
|
4737
5181
|
LockError,
|
|
5182
|
+
EXTRACT_TAGS,
|
|
4738
5183
|
DependencyCycleError,
|
|
4739
5184
|
CompletionGuardError,
|
|
4740
5185
|
AgentNotFoundError
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
export interface BurndownData {
|
|
3
|
+
total: number;
|
|
4
|
+
completed: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
days: {
|
|
7
|
+
date: string;
|
|
8
|
+
completed_cumulative: number;
|
|
9
|
+
ideal: number;
|
|
10
|
+
}[];
|
|
11
|
+
chart: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function getBurndown(opts: {
|
|
14
|
+
plan_id?: string;
|
|
15
|
+
project_id?: string;
|
|
16
|
+
task_list_id?: string;
|
|
17
|
+
}, db?: Database): BurndownData;
|
|
18
|
+
//# sourceMappingURL=burndown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"burndown.d.ts","sourceRoot":"","sources":["../../src/lib/burndown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAG3C,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,oBAAoB,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,EAAE,CAAC,EAAE,QAAQ,GAAG,YAAY,CA8C/H"}
|