@agentprojectcontext/apx 1.75.0 → 1.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/runtime-skills/apx-task/SKILL.md +4 -0
- package/src/core/stores/tasks.js +70 -1
- package/src/host/daemon/api/tasks.js +36 -15
- package/src/interfaces/cli/commands/task.js +44 -13
- package/src/interfaces/cli/index.js +7 -1
- package/src/interfaces/web/dist/assets/{index-CXeqTvfy.js → index-Bjlk9ttU.js} +5 -5
- package/src/interfaces/web/dist/assets/index-Bjlk9ttU.js.map +1 -0
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/src/i18n/en.ts +1 -0
- package/src/interfaces/web/src/i18n/es.ts +1 -0
- package/src/interfaces/web/src/lib/api/tasks.ts +6 -2
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +21 -3
- package/src/interfaces/web/dist/assets/index-CXeqTvfy.js.map +0 -1
package/package.json
CHANGED
|
@@ -17,6 +17,10 @@ apx task add "Demo for tester X" --project iacrmar --agent reviewer --tag demo -
|
|
|
17
17
|
|
|
18
18
|
# List (defaults to open)
|
|
19
19
|
apx task list --project iacrmar
|
|
20
|
+
apx task list --all # every registered project, each row labelled
|
|
21
|
+
apx task list --all --status blocked # what is stuck, everywhere
|
|
22
|
+
apx task list --all --updated-since 2026-08-01T00:00:00Z # what moved
|
|
23
|
+
apx task list --project iacrmar --status in_review
|
|
20
24
|
apx task list --project iacrmar --state all
|
|
21
25
|
apx task list --project iacrmar --state done
|
|
22
26
|
apx task list --project iacrmar --tag urgent
|
package/src/core/stores/tasks.js
CHANGED
|
@@ -167,6 +167,20 @@ export function createTask(storagePath, fields) {
|
|
|
167
167
|
return getTask(storagePath, id);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Newest first, with `id` as a tiebreak.
|
|
172
|
+
*
|
|
173
|
+
* The tiebreak is not cosmetic: nowIso() strips milliseconds, so every task
|
|
174
|
+
* created within the same SECOND shares a created_at — which is the norm when a
|
|
175
|
+
* routine files several at once. Without a second key the order of those rows
|
|
176
|
+
* is whatever the sort happened to do, and a list that reshuffles between two
|
|
177
|
+
* identical calls is worse than one that is merely arbitrary.
|
|
178
|
+
*/
|
|
179
|
+
function byNewest(a, b) {
|
|
180
|
+
const t = (b.created_at || "").localeCompare(a.created_at || "");
|
|
181
|
+
return t !== 0 ? t : String(b.id || "").localeCompare(String(a.id || ""));
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
/** List tasks with optional filters. */
|
|
171
185
|
export function listTasks(storagePath, opts = {}) {
|
|
172
186
|
const events = readAllEvents(storagePath);
|
|
@@ -190,13 +204,68 @@ export function listTasks(storagePath, opts = {}) {
|
|
|
190
204
|
if (opts.due_after) {
|
|
191
205
|
out = out.filter((t) => t.due && t.due >= opts.due_after);
|
|
192
206
|
}
|
|
193
|
-
|
|
207
|
+
// Workflow sub-status of an OPEN task (pending/running/in_review/blocked).
|
|
208
|
+
// Orthogonal to `state` — "what is blocked right now" is a different question
|
|
209
|
+
// from "what is open".
|
|
210
|
+
if (opts.status) {
|
|
211
|
+
out = out.filter((t) => t.status === opts.status);
|
|
212
|
+
}
|
|
213
|
+
// Everything touched since a moment. The cheapest way to ask "what moved?".
|
|
214
|
+
if (opts.updated_since) {
|
|
215
|
+
out = out.filter((t) => (t.updated_at || t.created_at || "") >= opts.updated_since);
|
|
216
|
+
}
|
|
217
|
+
out.sort(byNewest);
|
|
194
218
|
if (opts.limit && Number.isFinite(opts.limit)) {
|
|
195
219
|
out = out.slice(0, opts.limit);
|
|
196
220
|
}
|
|
197
221
|
return out;
|
|
198
222
|
}
|
|
199
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The same query, folded across every registered project.
|
|
226
|
+
*
|
|
227
|
+
* Lives in core rather than in the daemon route because the CLI, the HTTP API
|
|
228
|
+
* and the panel all need it, and AGENTS.md rule 8 puts a shared operation in
|
|
229
|
+
* one home with the surfaces as adapters. The caller supplies the project list
|
|
230
|
+
* so this stays free of daemon and config imports.
|
|
231
|
+
*
|
|
232
|
+
* A project whose task log is unreadable is SKIPPED, not fatal: one corrupt
|
|
233
|
+
* JSONL file must not blank out the cross-project view. Skipped ids are
|
|
234
|
+
* returned so a surface can say so instead of quietly showing less.
|
|
235
|
+
*
|
|
236
|
+
* @param {{id: any, name?: string, path?: string, storagePath: string}[]} projects
|
|
237
|
+
* @param {object} opts Same filters as listTasks, plus `limit` applied AFTER
|
|
238
|
+
* the merge (a per-project limit would silently favour
|
|
239
|
+
* whichever project sorts first).
|
|
240
|
+
* @returns {{ tasks: object[], skipped: {id: any, error: string}[] }}
|
|
241
|
+
*/
|
|
242
|
+
export function listTasksAcrossProjects(projects, opts = {}) {
|
|
243
|
+
const { limit, ...perProject } = opts || {};
|
|
244
|
+
const tasks = [];
|
|
245
|
+
const skipped = [];
|
|
246
|
+
|
|
247
|
+
for (const entry of projects || []) {
|
|
248
|
+
if (!entry?.storagePath) continue;
|
|
249
|
+
try {
|
|
250
|
+
for (const t of listTasks(entry.storagePath, perProject)) {
|
|
251
|
+
tasks.push({
|
|
252
|
+
...t,
|
|
253
|
+
project_id: entry.id,
|
|
254
|
+
project_name: entry.name || entry.path || String(entry.id),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
skipped.push({ id: entry.id, error: e?.message || String(e) });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
tasks.sort(byNewest);
|
|
263
|
+
return {
|
|
264
|
+
tasks: Number.isFinite(limit) && limit > 0 ? tasks.slice(0, limit) : tasks,
|
|
265
|
+
skipped,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
200
269
|
/** Get a single task by id or by id prefix (≥ 3 chars, must be unique). */
|
|
201
270
|
export function getTask(storagePath, idOrPrefix) {
|
|
202
271
|
if (!idOrPrefix || typeof idOrPrefix !== "string") return null;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// GET /
|
|
1
|
+
// Tasks (TODOs). Backed by core/stores/tasks.js (JSONL event log).
|
|
2
|
+
// GET /tasks cross-project; same filters, plus ?offset
|
|
3
|
+
// GET /projects/:pid/tasks ?state=open|done|dropped|all&tag=X&agent=Y
|
|
4
|
+
// &due_before=ISO&due_after=ISO&limit=N
|
|
5
|
+
// &status=pending|running|in_review|blocked&updated_since=ISO
|
|
3
6
|
// POST /projects/:pid/tasks { title, body?, tags?, due?, agent?, source?, meta? }
|
|
4
7
|
// GET /projects/:pid/tasks/:id (id or prefix)
|
|
5
8
|
// PATCH /projects/:pid/tasks/:id { patch: {...} }
|
|
@@ -9,6 +12,7 @@
|
|
|
9
12
|
import {
|
|
10
13
|
createTask,
|
|
11
14
|
listTasks,
|
|
15
|
+
listTasksAcrossProjects,
|
|
12
16
|
getTask,
|
|
13
17
|
patchTask,
|
|
14
18
|
doneTask,
|
|
@@ -25,21 +29,36 @@ export function register(app, { project, projects }) {
|
|
|
25
29
|
// envelope. Paginated via ?limit & ?offset; with no limit, data is the full
|
|
26
30
|
// set as one page.
|
|
27
31
|
app.get("/tasks", (req, res) => {
|
|
28
|
-
const state = req.query
|
|
29
|
-
|
|
32
|
+
const { state, tag, agent, due_before, due_after, status, updated_since } = req.query;
|
|
33
|
+
|
|
34
|
+
// Resolve the registered projects to what core needs, dropping any the
|
|
35
|
+
// manager can no longer open.
|
|
36
|
+
const entries = [];
|
|
30
37
|
for (const entry of projects.list()) {
|
|
31
38
|
const p = projects.get(entry.id);
|
|
32
|
-
if (!p) continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
for (const t of tasks) out.push({ ...t, project_id: entry.id, project_name: entry.name || entry.path });
|
|
39
|
+
if (!p?.storagePath) continue;
|
|
40
|
+
entries.push({
|
|
41
|
+
id: entry.id,
|
|
42
|
+
name: entry.name || entry.path,
|
|
43
|
+
path: entry.path,
|
|
44
|
+
storagePath: p.storagePath,
|
|
45
|
+
});
|
|
40
46
|
}
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
|
|
48
|
+
const { tasks, skipped } = listTasksAcrossProjects(entries, {
|
|
49
|
+
state: state === "all" ? undefined : (state || "open"),
|
|
50
|
+
tag: tag || undefined,
|
|
51
|
+
agent: agent || undefined,
|
|
52
|
+
due_before: due_before || undefined,
|
|
53
|
+
due_after: due_after || undefined,
|
|
54
|
+
status: status || undefined,
|
|
55
|
+
updated_since: updated_since || undefined,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const envelope = pageEnvelope(tasks, req.query);
|
|
59
|
+
// Say when a project could not be read rather than quietly showing less.
|
|
60
|
+
if (skipped.length) envelope.meta = { ...(envelope.meta || {}), skipped };
|
|
61
|
+
res.json(envelope);
|
|
43
62
|
});
|
|
44
63
|
|
|
45
64
|
// Per-project tasks. Returns a { meta, data } envelope; with no ?limit the
|
|
@@ -47,13 +66,15 @@ export function register(app, { project, projects }) {
|
|
|
47
66
|
app.get("/projects/:pid/tasks", (req, res) => {
|
|
48
67
|
const p = project(req, res);
|
|
49
68
|
if (!p) return;
|
|
50
|
-
const { state, tag, agent, due_before, due_after } = req.query;
|
|
69
|
+
const { state, tag, agent, due_before, due_after, status, updated_since } = req.query;
|
|
51
70
|
const all = listTasks(p.storagePath, {
|
|
52
71
|
state: state || undefined,
|
|
53
72
|
tag: tag || undefined,
|
|
54
73
|
agent: agent || undefined,
|
|
55
74
|
due_before: due_before || undefined,
|
|
56
75
|
due_after: due_after || undefined,
|
|
76
|
+
status: status || undefined,
|
|
77
|
+
updated_since: updated_since || undefined,
|
|
57
78
|
});
|
|
58
79
|
res.json(pageEnvelope(all, req.query));
|
|
59
80
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// apx task — per-project TODO list. Backed by /projects/:pid/tasks.
|
|
2
2
|
//
|
|
3
3
|
// apx task add "<title>" [--project X] [--body Y] [--tag t] [--due 2026-05-30] [--agent A]
|
|
4
|
-
// apx task list [--project X] [--state
|
|
4
|
+
// apx task list [--all | --project X] [--state ...] [--status ...] [--tag X] [--agent Y]
|
|
5
|
+
// [--due-before ISO] [--due-after ISO] [--updated-since ISO] [--limit N]
|
|
5
6
|
// apx task show <id> [--project X]
|
|
6
7
|
// apx task done <id> [--project X] [--by name]
|
|
7
8
|
// apx task drop <id> [--project X] [--by name]
|
|
@@ -18,7 +19,7 @@ import { resolveProjectId } from "./project.js";
|
|
|
18
19
|
// ── Usage strings (also used by index.js help topics) ────────────────────────
|
|
19
20
|
export const TASK_USAGE = {
|
|
20
21
|
add: 'apx task add "<title>" [--project X] [--body Y] [--tag t]... [--due 2026-05-30] [--agent A]',
|
|
21
|
-
list: "apx task list [--project X] [--state open|done|dropped|all] [--tag X] [--agent Y] [--due-before ISO] [--limit N]",
|
|
22
|
+
list: "apx task list [--all | --project X] [--state open|done|dropped|all] [--status pending|running|in_review|blocked] [--tag X] [--agent Y] [--due-before ISO] [--due-after ISO] [--updated-since ISO] [--limit N]",
|
|
22
23
|
show: "apx task show <id> [--project X]",
|
|
23
24
|
done: "apx task done <id> [--project X] [--by name]",
|
|
24
25
|
drop: "apx task drop <id> [--project X] [--by name]",
|
|
@@ -44,14 +45,20 @@ function shortTs(iso) {
|
|
|
44
45
|
return String(iso).replace(/T/, " ").replace(/Z$/, "").slice(0, 16);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function renderTable(rows) {
|
|
48
|
+
function renderTable(rows, { showProject = false } = {}) {
|
|
48
49
|
if (!rows.length) {
|
|
49
50
|
console.log("(no tasks)");
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
|
-
const idW = Math.max(...rows.map((r) => r.id.length), 4);
|
|
53
|
+
const idW = Math.max(...rows.map((r) => String(r.id).length), 4);
|
|
54
|
+
const projW = showProject
|
|
55
|
+
? Math.min(Math.max(...rows.map((r) => String(r.project_name || "").length), 7), 20)
|
|
56
|
+
: 0;
|
|
57
|
+
const proj = (t) => (showProject ? String(t.project_name || "").slice(0, projW).padEnd(projW) + " " : "");
|
|
58
|
+
|
|
53
59
|
console.log(
|
|
54
60
|
"ID".padEnd(idW) + " " +
|
|
61
|
+
(showProject ? "PROJECT".padEnd(projW) + " " : "") +
|
|
55
62
|
"STATE".padEnd(7) + " " +
|
|
56
63
|
"DUE".padEnd(10) + " " +
|
|
57
64
|
"TAGS".padEnd(18) + " " +
|
|
@@ -61,7 +68,8 @@ function renderTable(rows) {
|
|
|
61
68
|
const tags = (t.tags || []).join(",").slice(0, 18).padEnd(18);
|
|
62
69
|
const title = (t.title || "").slice(0, 60);
|
|
63
70
|
console.log(
|
|
64
|
-
t.id.padEnd(idW) + " " +
|
|
71
|
+
String(t.id).padEnd(idW) + " " +
|
|
72
|
+
proj(t) +
|
|
65
73
|
(t.state || "open").padEnd(7) + " " +
|
|
66
74
|
(t.due || "—").padEnd(10) + " " +
|
|
67
75
|
tags + " " +
|
|
@@ -105,17 +113,40 @@ export async function cmdTaskAdd(args) {
|
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
// ── list ──────────────────────────────────────────────────────────────────────
|
|
116
|
+
// The list endpoints answer with a { meta, data } envelope. Older callers here
|
|
117
|
+
// treated the response as a bare array, which made `apx task list` print
|
|
118
|
+
// "(no tasks)" no matter what — the rows were sitting in `.data`.
|
|
119
|
+
function unwrap(res) {
|
|
120
|
+
if (Array.isArray(res)) return { rows: res, meta: null };
|
|
121
|
+
return { rows: Array.isArray(res?.data) ? res.data : [], meta: res?.meta || null };
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
export async function cmdTaskList(args) {
|
|
109
|
-
const pid = await resolveProjectId(args?.flags?.project);
|
|
110
125
|
const params = new URLSearchParams();
|
|
111
|
-
if (args.flags?.state)
|
|
112
|
-
if (args.flags?.tag)
|
|
113
|
-
if (args.flags?.agent)
|
|
114
|
-
if (args.flags?.
|
|
115
|
-
if (args.flags?.
|
|
126
|
+
if (args.flags?.state) params.set("state", args.flags.state);
|
|
127
|
+
if (args.flags?.tag) params.set("tag", args.flags.tag);
|
|
128
|
+
if (args.flags?.agent) params.set("agent", args.flags.agent);
|
|
129
|
+
if (args.flags?.status) params.set("status", args.flags.status);
|
|
130
|
+
if (args.flags?.["due-before"]) params.set("due_before", args.flags["due-before"]);
|
|
131
|
+
if (args.flags?.["due-after"]) params.set("due_after", args.flags["due-after"]);
|
|
132
|
+
if (args.flags?.["updated-since"]) params.set("updated_since", args.flags["updated-since"]);
|
|
133
|
+
if (args.flags?.limit) params.set("limit", String(args.flags.limit));
|
|
116
134
|
const qs = params.toString();
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
|
|
136
|
+
// --all folds every registered project into one list, each row carrying the
|
|
137
|
+
// project it came from. Without it, behaviour is exactly as before.
|
|
138
|
+
const all = !!args.flags?.all;
|
|
139
|
+
const path = all
|
|
140
|
+
? `/tasks${qs ? "?" + qs : ""}`
|
|
141
|
+
: `/projects/${await resolveProjectId(args?.flags?.project)}/tasks${qs ? "?" + qs : ""}`;
|
|
142
|
+
|
|
143
|
+
const { rows, meta } = unwrap(await http.get(path));
|
|
144
|
+
renderTable(rows, { showProject: all });
|
|
145
|
+
|
|
146
|
+
// A project whose task log could not be read is reported, never swallowed.
|
|
147
|
+
for (const s of meta?.skipped || []) {
|
|
148
|
+
console.error(`warning: project #${s.id} skipped — ${s.error}`);
|
|
149
|
+
}
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
// ── show ──────────────────────────────────────────────────────────────────────
|
|
@@ -1666,10 +1666,16 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
1666
1666
|
["reopen <id>", "Reopen a done or dropped task."],
|
|
1667
1667
|
["patch | edit <id>", "Edit fields on an existing task."],
|
|
1668
1668
|
],
|
|
1669
|
-
options: [
|
|
1669
|
+
options: [
|
|
1670
|
+
["--project <name|id|path>", "Pin command to a specific project."],
|
|
1671
|
+
["--all", "list: fold every registered project into one list, each row labelled."],
|
|
1672
|
+
["--status <s>", "list: workflow sub-status — pending | running | in_review | blocked."],
|
|
1673
|
+
["--updated-since <ISO>", "list: only what moved since that moment."],
|
|
1674
|
+
],
|
|
1670
1675
|
examples: [
|
|
1671
1676
|
"apx task add \"Ship release notes\" --tag release --due 2026-06-01",
|
|
1672
1677
|
"apx task list --state open --tag release",
|
|
1678
|
+
"apx task list --all --status blocked",
|
|
1673
1679
|
"apx task done t_abc123",
|
|
1674
1680
|
],
|
|
1675
1681
|
}),
|