@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.75.0",
3
+ "version": "1.76.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
@@ -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
- out.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
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
- // Per-project tasks (TODOs). Backed by core/stores/tasks.js (JSONL event log).
2
- // GET /projects/:pid/tasks ?state=open|done|dropped|all&tag=X&agent=Y&due_before=ISO&limit=N
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.state || "open";
29
- const out = [];
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
- let tasks = [];
34
- try {
35
- tasks = listTasks(p.storagePath, {
36
- state: state === "all" ? undefined : state,
37
- });
38
- } catch { /* skip project */ }
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
- out.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
42
- res.json(pageEnvelope(out, req.query));
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 open|done|dropped|all] [--tag X] [--agent Y] [--due-before ISO] [--limit N]
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) params.set("state", args.flags.state);
112
- if (args.flags?.tag) params.set("tag", args.flags.tag);
113
- if (args.flags?.agent) params.set("agent", args.flags.agent);
114
- if (args.flags?.["due-before"]) params.set("due_before", args.flags["due-before"]);
115
- if (args.flags?.limit) params.set("limit", String(args.flags.limit));
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
- const rows = await http.get(`/projects/${pid}/tasks${qs ? "?" + qs : ""}`);
118
- renderTable(rows);
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: [["--project <name|id|path>", "Pin command to a specific project."]],
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
  }),