project-mgr 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-10-29
4
+
5
+ ### Added
6
+
7
+ - Store new snapshot next work as `nextTodoUids` references. `add --next` creates Todos and those refs in one transaction; `add --next-todo` pins existing UIDs.
8
+ - Reject mixed prose and refs, duplicate UIDs, combining `--next` with `--next-todo`, cross-project refs, and project changes that conflict with any historical reference.
9
+ - Export version 4 carries refs and every Todo they need, including unassigned Todos referenced by a project export.
10
+ - `todos --status open` lists open work. With `--project`, current snapshot pins are ordered first.
11
+ - Dashboard activity history and project cards show referenced Todos with live status.
12
+
13
+ ### Changed
14
+
15
+ - New snapshots no longer persist next-step prose. Legacy string `nextSteps` stay readable. Per-index conversion does not rewrite history; completion copies the full string list until every source index has a Todo, then materializes refs without creating Todos or changing their status.
16
+
17
+ ### Fixed
18
+
19
+ - Detach the dashboard browser launch so a long-lived browser does not keep the process alive.
20
+ - Reject dangling and cross-project `nextTodoUids` from `importStatuses`, and require imported refs to name Todos in the same payload. Entry-only imports declare no Todos, so they cannot reference existing rows.
21
+ - Treat a fixed-UID retry of `--next` text as the same write after it has been stored as refs. Portable imports still require an exact snapshot match.
22
+
3
23
  ## 0.4.0 - 2026-10-28
4
24
 
5
25
  ### Added
package/README.md CHANGED
@@ -225,7 +225,7 @@ pnpm start projects --query "mvp" --json
225
225
 
226
226
  ## Bulk import
227
227
 
228
- `import` accepts a JSON object, a JSON array, or a JSONL file containing one object per line. Every record is validated before the whole batch is written in one SQLite transaction; any failure rolls back the entire batch.
228
+ `import` accepts a JSON object, a JSON array, or a JSONL file containing one object per line. Every record is validated before the whole batch is written in one SQLite transaction; any failure rolls back the entire batch. The example below is a legacy entry: string `nextSteps` are kept and do not create Todos. New snapshots created with `add` store `nextTodoUids` instead.
229
229
 
230
230
  ```json
231
231
  {
@@ -336,22 +336,49 @@ Independently migrated copies of legacy data receive different UUIDs. Cross-impo
336
336
 
337
337
  ### Todos and dashboard actions
338
338
 
339
- New `add --next` values create independent Todos linked to the new activity.
340
- Historical next steps stay as text until explicitly converted in the dashboard.
341
- The Todos tab supports standalone creation, optional project assignment, source
342
- links, completion, cancellation, and reopening. Project cards filter this list.
343
- Activity cards provide **Mark completed**, recording a follow-up with the current
344
- project status; this neither completes the project nor its Todos.
339
+ Todos own actionable work. A new status snapshot stores ordered `nextTodoUids`
340
+ and does not persist next-step prose. `add --next` creates one Todo per title
341
+ and stores those UIDs in the same transaction. Repeating the same text on a
342
+ later snapshot creates new Todos; pass `--next-todo` to keep existing work.
343
+ Do not combine `--next` with `--next-todo`. Duplicate UIDs in one list are rejected.
345
344
 
346
345
  ```sh
347
- pmgr todo-add --title "Review results" --project demo --json
346
+ pmgr add --project demo --status active --summary "Review queued" \
347
+ --next "Collect logs" --next "Write the note" --source agent --json
348
348
  pmgr todo-add --title "Unassigned idea" --json
349
- pmgr todos --project demo --json
349
+ pmgr add --project demo --status active --summary "Pin the idea" \
350
+ --next-todo <todo-uuid> --source agent --json
351
+ pmgr todos --project demo --status open --json
350
352
  pmgr todo-update --uid <todo-uuid> --status completed --json
353
+ pmgr todo-update --uid <todo-uuid> --status cancelled --json
351
354
  ```
352
355
 
353
- Exports with Todos use version 3 and preserve their identities and state;
354
- version-2 and legacy imports remain supported without creating new Todos from
355
- old next-step text. Conflicting Todo snapshots are rejected atomically on import.
356
- Dashboard writes are restricted to same-origin localhost requests with JSON bodies;
357
- remote-host dashboards remain read-only.
356
+ `todos --status open` answers open work from Todos, not from string next steps.
357
+ With `--project`, UIDs pinned on the current snapshot are ordered first.
358
+ Completing or cancelling a Todo updates every resolved view and does not rewrite
359
+ snapshots. Omitting a UID from a newer snapshot unpins it and does not change
360
+ the Todo status.
361
+
362
+ Legacy snapshots keep string `nextSteps` and omit `nextTodoUids`. Convert each
363
+ index explicitly; conversion does not append a snapshot. While any source index
364
+ is still unconverted, a completion follow-up copies the full string list and
365
+ `nextStepsSourceUid`. After every index has a Todo, the follow-up materializes
366
+ `nextTodoUids` in source-index order and does not create Todos or change status.
367
+ A snapshot never stores both prose and refs.
368
+
369
+ `current` and `history` include stored `nextTodoUids` plus live `nextTodos`
370
+ (`uid`, `title`, `status`, `project`). That view is the Todo now, not its state
371
+ at the snapshot time. `at` returns the stored snapshot, including refs when
372
+ present, and does not add the live view.
373
+
374
+ A snapshot may reference Todos that are unassigned or already in that project.
375
+ If a Todo appears in any `nextTodoUids`, or is linked to a source entry, its
376
+ project cannot be changed. Unpinning the current snapshot does not lift that lock.
377
+
378
+ Exports that contain refs use version 4 and include every referenced Todo.
379
+ Version 3 remains the Todo bundle without refs. Version 2 and legacy imports
380
+ do not invent open work. Dangling, duplicate, mixed, or cross-project refs fail
381
+ the whole import. Dashboard writes stay same-origin localhost JSON. Activity
382
+ cards still offer **Mark completed**, which appends a follow-up and does not
383
+ complete the project or its Todos. Adding a Todo from the dashboard does not
384
+ pin it; pinning requires a new snapshot.
package/README_CN.md CHANGED
@@ -223,7 +223,7 @@ pnpm start projects --query "mvp" --json
223
223
 
224
224
  ## 批量导入
225
225
 
226
- `import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。
226
+ `import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。下面的示例是旧格式条目:字符串 `nextSteps` 会保留,且不会创建待办。用 `add` 写入的新快照会改为存储 `nextTodoUids`。
227
227
 
228
228
  ```json
229
229
  {
@@ -334,20 +334,44 @@ pmgr revoke-completion --entry 12 --by 18 --reason "验收仍未完成" --source
334
334
 
335
335
  ### 待办与仪表板操作
336
336
 
337
- 新的 `add --next` 会创建独立待办,并关联到这条新动态。
338
- 历史中的下一步仍只是文本,直到在仪表板里显式转为待办。
339
- 待办页支持单独创建、可选关联项目、查看来源动态、标为已完成、取消和重新打开。项目卡片可以筛选这份列表。
340
- 动态卡片提供 **标为已完成**,会用当前项目状态记一条后续动态;这既不会把项目标成完成,也不会完成它的待办。
337
+ 待办是具体工作的来源。新的状态快照按顺序存储 `nextTodoUids`,不再把下一步原文写入快照。
338
+ `add --next` 会为每条标题创建一个待办,并在同一事务里写入这些 UID。
339
+ 之后的快照如果再次输入相同文本,会创建新的待办;要沿用已有工作,请传 `--next-todo`。
340
+ 不要把 `--next` 和 `--next-todo` 写在同一次 `add` 里。同一列表里的重复 UID 会被拒绝。
341
341
 
342
342
  ```sh
343
- pmgr todo-add --title "复查结果" --project demo --json
343
+ pmgr add --project demo --status active --summary "已排队复查" \
344
+ --next "收集日志" --next "写说明" --source agent --json
344
345
  pmgr todo-add --title "未归属的想法" --json
345
- pmgr todos --project demo --json
346
+ pmgr add --project demo --status active --summary "钉住这个想法" \
347
+ --next-todo <todo-uuid> --source agent --json
348
+ pmgr todos --project demo --status open --json
346
349
  pmgr todo-update --uid <todo-uuid> --status completed --json
350
+ pmgr todo-update --uid <todo-uuid> --status cancelled --json
347
351
  ```
348
352
 
349
- 带待办的导出使用 version 3,并保留待办的身份与状态;
350
- version 2 和旧版导入仍然可用,且不会把旧的下一步文本自动变成新待办。
351
- 冲突的待办快照会在导入时整批拒绝。
352
- 仪表板写入仅接受同源的 localhost JSON 请求;
353
- 绑定到远程主机时,仪表板保持只读。
353
+ `todos --status open` 从待办而不是字符串下一步回答「还有哪些未完成工作」。
354
+ 加上 `--project` 时,当前快照钉住的 UID 会排在前面。
355
+ 完成或取消待办会立刻反映在所有解析视图上,且不会改写历史快照。
356
+ 新快照不再列入某个 UID,只表示它不再是当前下一步,不会改变该待办的状态。
357
+
358
+ 旧快照保留字符串 `nextSteps`,并省略 `nextTodoUids`。转换仍按来源下标显式进行,且不会追加快照。
359
+ 只要还有来源下标未转换,完成活动时的后续快照会复制完整字符串列表和 `nextStepsSourceUid`。
360
+ 每个下标都已有待办之后,后续快照才按原下标顺序写入 `nextTodoUids`,不新建待办,也不改变状态。
361
+ 一条快照不能同时保存原文和引用。
362
+
363
+ `current` 和 `history` 会带上存储的 `nextTodoUids`,以及实时的 `nextTodos`(`uid`、`title`、`status`、`project`)。
364
+ 这个视图是待办的当前状态,不是快照发生时的历史状态。
365
+ `at` 返回当时的快照;如果存了引用就包含引用,但不会附加实时视图。
366
+
367
+ 快照只能引用未归属的待办,或已经属于该项目的待办。
368
+ 待办一旦出现在任意 `nextTodoUids` 中,或关联了来源动态,就不能再改项目。
369
+ 从当前快照拿掉引用,也不会解除这个限制。
370
+
371
+ 带引用的导出使用 version 4,并包含被引用的待办。
372
+ 没有引用、但有待办的导出仍是 version 3。
373
+ version 2 和旧版导入不会凭空创建待办。
374
+ 悬空、重复、混用或跨项目引用会使整批导入失败。
375
+ 仪表板写入仍只接受同源的 localhost JSON 请求。
376
+ 动态卡片上的 **标为已完成** 会追加一条后续动态,既不完成项目,也不完成待办。
377
+ 在仪表板里新增待办不会把它钉进当前下一步;钉住需要再写一条快照。
package/dist/cli.js CHANGED
@@ -12,7 +12,14 @@ const booleanFlags = new Set([
12
12
  "help",
13
13
  "version",
14
14
  ]);
15
- const repeats = new Set(["next", "blocker", "decision", "tag", "completes"]);
15
+ const repeats = new Set([
16
+ "next",
17
+ "next-todo",
18
+ "blocker",
19
+ "decision",
20
+ "tag",
21
+ "completes",
22
+ ]);
16
23
  function parse(argv) {
17
24
  const command = [];
18
25
  const flags = Object.create(null);
@@ -58,6 +65,7 @@ const allowedFlags = new Map([
58
65
  "progress",
59
66
  "blocker",
60
67
  "next",
68
+ "next-todo",
61
69
  "decision",
62
70
  "tag",
63
71
  "at",
@@ -68,7 +76,7 @@ const allowedFlags = new Map([
68
76
  ]),
69
77
  ],
70
78
  ["todo-add", new Set(["title", "project", "entry", "json"])],
71
- ["todos", new Set(["project", "json"])],
79
+ ["todos", new Set(["project", "status", "json"])],
72
80
  ["todo-update", new Set(["uid", "status", "project", "json"])],
73
81
  ["complete", new Set(["entry", "by", "reason", "source", "json"])],
74
82
  ["revoke-completion", new Set(["entry", "by", "reason", "source", "json"])],
@@ -101,14 +109,17 @@ const commandHelp = {
101
109
  "Create an independent or linked Todo",
102
110
  "--title <text> [--project <name>] [--entry <uid>] [--json]",
103
111
  ],
104
- todos: ["List Todos", "[--project <name>] [--json]"],
112
+ todos: [
113
+ "List Todos",
114
+ "[--project <name>] [--status open|completed|cancelled] [--json]",
115
+ ],
105
116
  "todo-update": [
106
117
  "Change Todo status or assign a project",
107
118
  "--uid <uuid> [--status open|completed|cancelled] [--project <name>] [--json]",
108
119
  ],
109
120
  add: [
110
121
  "Append a status snapshot",
111
- "--project <name> --summary <text> [options]",
122
+ "--project <name> --summary <text> [--next <text> | --next-todo <uid>] [options]",
112
123
  ],
113
124
  complete: [
114
125
  "Link a completed activity to its follow-up",
@@ -309,7 +320,15 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
309
320
  }), json, write);
310
321
  break;
311
322
  case "todos": {
312
- const todos = listTodos(db, stringFlag(flags, "project"));
323
+ const status = stringFlag(flags, "status");
324
+ if (status !== undefined &&
325
+ status !== "open" &&
326
+ status !== "completed" &&
327
+ status !== "cancelled")
328
+ throw new ValidationError("--status must be open, completed, or cancelled");
329
+ const todos = listTodos(db, stringFlag(flags, "project"), status === undefined
330
+ ? {}
331
+ : { status: status });
313
332
  if (json)
314
333
  output(todos, true, write);
315
334
  else
@@ -328,6 +347,10 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
328
347
  const project = stringFlag(flags, "project", true);
329
348
  const description = stringFlag(flags, "description");
330
349
  const notes = stringFlag(flags, "notes");
350
+ const next = listFlag(flags, "next");
351
+ const nextTodo = listFlag(flags, "next-todo");
352
+ if (next.length > 0 && nextTodo.length > 0)
353
+ throw new ValidationError("cannot combine --next and --next-todo");
331
354
  const result = db
332
355
  .transaction(() => {
333
356
  if (description !== undefined || notes !== undefined) {
@@ -339,7 +362,8 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
339
362
  summary: stringFlag(flags, "summary", true),
340
363
  progress: stringFlag(flags, "progress"),
341
364
  blockers: listFlag(flags, "blocker"),
342
- nextSteps: listFlag(flags, "next"),
365
+ nextSteps: next,
366
+ ...(nextTodo.length > 0 ? { nextTodoUids: nextTodo } : {}),
343
367
  decisions: listFlag(flags, "decision"),
344
368
  tags: listFlag(flags, "tag"),
345
369
  occurredAt: stringFlag(flags, "at"),
package/dist/db.d.ts CHANGED
@@ -37,12 +37,13 @@ export declare function getEntry(db: Ledger, ref: string): StatusEntry | undefin
37
37
  export declare function changeCompletion(db: Ledger, targetRef: string, byRef: string, action?: CompletionEvent["action"], reason?: string, source?: Source): CompletionEvent;
38
38
  export declare function exportLedger(db: Ledger, project?: string): {
39
39
  todos?: Todo[] | undefined;
40
- version: number;
40
+ version: 3 | 2 | 4;
41
41
  entries: {
42
42
  recordedAt: string;
43
43
  progress?: string | undefined;
44
44
  externalRef?: string | undefined;
45
45
  nextStepsSourceUid?: string | undefined;
46
+ nextTodoUids?: string[] | undefined;
46
47
  uid: string;
47
48
  project: string;
48
49
  status: import("./domain.js").ProjectStatus;
@@ -67,7 +68,9 @@ export type Todo = {
67
68
  createdAt: string;
68
69
  completedAt: string | null;
69
70
  };
70
- export declare function listTodos(db: Ledger, project?: string): Todo[];
71
+ export declare function listTodos(db: Ledger, project?: string, options?: {
72
+ status?: Todo["status"];
73
+ }): Todo[];
71
74
  export declare function createTodo(db: Ledger, value: unknown): Todo;
72
75
  export declare function convertNextStep(db: Ledger, ref: string, index: number): Todo;
73
76
  export declare function updateTodo(db: Ledger, uid: string, value: unknown): Todo;
package/dist/db.js CHANGED
@@ -1,3 +1,4 @@
1
+ import assert from "node:assert/strict";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { isDeepStrictEqual } from "node:util";
3
4
  import Database from "better-sqlite3";
@@ -88,6 +89,10 @@ export function openLedger(path = databasePath()) {
88
89
  CREATE INDEX idx_todos_project ON todos(project);
89
90
  INSERT INTO schema_migrations(version) VALUES (4);`);
90
91
  }
92
+ if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 5").get()) {
93
+ db.exec(`ALTER TABLE status_entries ADD COLUMN next_todo_uids_json TEXT;
94
+ INSERT INTO schema_migrations(version) VALUES (5);`);
95
+ }
91
96
  }).immediate();
92
97
  return db;
93
98
  }
@@ -101,6 +106,12 @@ function entryFromRow(row) {
101
106
  ...(row.progress ? { progress: row.progress } : {}),
102
107
  blockers: JSON.parse(row.blockers_json),
103
108
  nextSteps: JSON.parse(row.next_steps_json),
109
+ // NULL means legacy (field absent). An explicit JSON array, including [], is new format.
110
+ ...(row.next_todo_uids_json == null
111
+ ? {}
112
+ : {
113
+ nextTodoUids: JSON.parse(row.next_todo_uids_json),
114
+ }),
104
115
  ...(row.next_steps_source_uid
105
116
  ? { nextStepsSourceUid: row.next_steps_source_uid }
106
117
  : {}),
@@ -215,20 +226,11 @@ export function listProjects(db, options = {}) {
215
226
  return false;
216
227
  });
217
228
  }
218
- export function addStatus(db, value, defaultSource = "human", completes = []) {
219
- const input = validateStatusEntry(value, defaultSource);
220
- return db
221
- .transaction(() => {
222
- const result = addValidatedStatus(db, input);
223
- if (result.inserted && !result.entry.nextStepsSourceUid)
224
- result.entry.nextSteps.forEach((_, index) => convertNextStep(db, result.entry.uid, index));
225
- for (const target of completes)
226
- changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
227
- return result;
228
- })
229
- .immediate();
229
+ function linkCompletions(db, targets, byUid, source) {
230
+ for (const target of targets)
231
+ changeCompletion(db, target, byUid, "complete", undefined, source);
230
232
  }
231
- function addValidatedStatus(db, input, recordedAt) {
233
+ function existingIdentity(db, input, recordedAt, textRetry = false) {
232
234
  if (input.uid) {
233
235
  const existing = one(db, `${select} WHERE e.uid = ?`, input.uid);
234
236
  if (existing) {
@@ -237,7 +239,10 @@ function addValidatedStatus(db, input, recordedAt) {
237
239
  id: existing.id,
238
240
  recordedAt: existing.recordedAt,
239
241
  });
240
- if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
242
+ // Text/ref equivalence is only for addStatus sugar retries, not imports.
243
+ const sameWrite = isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
244
+ (textRetry && textRefEquivalent(db, existing, input));
245
+ if (!sameWrite ||
241
246
  (recordedAt !== undefined && recordedAt !== existing.recordedAt))
242
247
  throw new ValidationError("conflicting entry uid");
243
248
  return { entry: existing, inserted: false };
@@ -253,6 +258,74 @@ function addValidatedStatus(db, input, recordedAt) {
253
258
  return { entry: existing, inserted: false };
254
259
  }
255
260
  }
261
+ return undefined;
262
+ }
263
+ function textRefEquivalent(db, existing, input) {
264
+ // A text --next retry is the same write as the refs snapshot it produced.
265
+ if (existing.nextTodoUids === undefined || input.nextTodoUids !== undefined)
266
+ return false;
267
+ assert.ok(input.nextSteps);
268
+ const titles = input.nextSteps;
269
+ if (titles.length !== existing.nextTodoUids.length)
270
+ return false;
271
+ const candidate = toPortableStatusEntry({
272
+ ...input,
273
+ nextSteps: [],
274
+ nextTodoUids: existing.nextTodoUids,
275
+ id: existing.id,
276
+ recordedAt: existing.recordedAt,
277
+ });
278
+ if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate))
279
+ return false;
280
+ return existing.nextTodoUids.every((uid, index) => {
281
+ const todo = todoByUid(db, uid);
282
+ return todo !== undefined && todo.title === titles[index];
283
+ });
284
+ }
285
+ export function addStatus(db, value, defaultSource = "human", completes = []) {
286
+ const input = validateStatusEntry(value, defaultSource);
287
+ if (input.nextStepsSourceUid)
288
+ throw new ValidationError("new snapshots cannot set nextStepsSourceUid");
289
+ return db
290
+ .transaction(() => {
291
+ const replay = existingIdentity(db, input, undefined, true);
292
+ if (replay) {
293
+ linkCompletions(db, completes, replay.entry.uid, input.source);
294
+ return replay;
295
+ }
296
+ if (input.nextTodoUids !== undefined) {
297
+ assertSnapshotRefs(db, input.project, input.nextTodoUids);
298
+ const result = addValidatedStatus(db, { ...input, nextSteps: [] });
299
+ linkCompletions(db, completes, result.entry.uid, input.source);
300
+ return result;
301
+ }
302
+ // Text --next sugar: one new Todo per title, stored only as refs.
303
+ const uid = input.uid ?? randomUUID();
304
+ const nextTodoUids = input.nextSteps.map(() => randomUUID());
305
+ const recordedAt = new Date().toISOString();
306
+ const result = addValidatedStatus(db, { ...input, uid, nextSteps: [], nextTodoUids }, recordedAt);
307
+ assert.equal(result.inserted, true);
308
+ input.nextSteps.forEach((title, index) => {
309
+ insertTodo(db, {
310
+ uid: nextTodoUids[index],
311
+ title,
312
+ project: result.entry.project,
313
+ entryUid: result.entry.uid,
314
+ stepIndex: index,
315
+ status: "open",
316
+ createdAt: recordedAt,
317
+ completedAt: null,
318
+ });
319
+ });
320
+ linkCompletions(db, completes, result.entry.uid, input.source);
321
+ return result;
322
+ })
323
+ .immediate();
324
+ }
325
+ function addValidatedStatus(db, input, recordedAt) {
326
+ const existing = existingIdentity(db, input, recordedAt);
327
+ if (existing)
328
+ return existing;
256
329
  if (input.nextStepsSourceUid) {
257
330
  const origin = getEntry(db, input.nextStepsSourceUid);
258
331
  if (!origin ||
@@ -268,9 +341,11 @@ function addValidatedStatus(db, input, recordedAt) {
268
341
  .prepare("SELECT id FROM projects WHERE slug = ?")
269
342
  .get(input.project);
270
343
  const result = db
271
- .prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid, next_steps_source_uid)
272
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
273
- .run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID(), input.nextStepsSourceUid ?? null);
344
+ .prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid, next_steps_source_uid, next_todo_uids_json)
345
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
346
+ .run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID(), input.nextStepsSourceUid ?? null, input.nextTodoUids === undefined
347
+ ? null
348
+ : JSON.stringify(input.nextTodoUids));
274
349
  return {
275
350
  entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
276
351
  inserted: true,
@@ -278,7 +353,14 @@ function addValidatedStatus(db, input, recordedAt) {
278
353
  }
279
354
  export function importStatuses(db, values) {
280
355
  const inputs = values.map((value) => validateStatusEntry(value, "import"));
281
- return db.transaction(() => inputs.map((input) => addValidatedStatus(db, input)))();
356
+ return db.transaction(() => {
357
+ const results = inputs.map((input) => addValidatedStatus(db, input));
358
+ // Entry-only imports declare no Todos, so every ref is an omitted dependency.
359
+ const payloadTodos = new Set();
360
+ for (const result of results)
361
+ assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
362
+ return results;
363
+ })();
282
364
  }
283
365
  export function current(db, project) {
284
366
  const where = project ? " AND p.slug = ?" : "";
@@ -310,6 +392,7 @@ export function history(db, project, options = {}) {
310
392
  return rows.map((row) => annotateEntry(db, entryFromRow(row)));
311
393
  }
312
394
  export function at(db, project, occurredAt) {
395
+ // Stored snapshot only. Refs may be present; live Todo titles are not historical.
313
396
  return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
314
397
  }
315
398
  export function countEntries(db, project) {
@@ -358,6 +441,9 @@ function annotateEntry(db, entry) {
358
441
  ...entry,
359
442
  effectiveStatus: followUp ? "completed" : entry.status,
360
443
  isCurrent: newest?.id === entry.id,
444
+ ...(entry.nextTodoUids !== undefined
445
+ ? { nextTodos: entry.nextTodoUids.map((uid) => resolveNextTodo(db, uid)) }
446
+ : {}),
361
447
  ...(followUp && latest
362
448
  ? {
363
449
  completedBy: {
@@ -450,16 +536,13 @@ export function exportLedger(db, project) {
450
536
  const rows = db
451
537
  .prepare(`SELECT c.* FROM completion_events c JOIN status_entries e ON e.uid = c.target_uid JOIN projects p ON p.id = e.project_id ${project ? "WHERE p.slug = ?" : ""} ORDER BY c.id`)
452
538
  .all(...(project ? [project] : []));
453
- const todos = listTodos(db, project);
539
+ const todos = project ? todosVisibleToProject(db, project) : listTodos(db);
540
+ const version = ledgerVersion(entries, todos);
454
541
  return {
455
- version: todos.length || entries.some((entry) => entry.nextStepsSourceUid)
456
- ? 3
457
- : 2,
542
+ version,
458
543
  entries,
459
544
  completions: rows.map(completionFromRow),
460
- ...(todos.length || entries.some((entry) => entry.nextStepsSourceUid)
461
- ? { todos }
462
- : {}),
545
+ ...(version >= 3 ? { todos } : {}),
463
546
  };
464
547
  })();
465
548
  }
@@ -505,11 +588,31 @@ function validateCompletionEvent(value) {
505
588
  source: row.source,
506
589
  };
507
590
  }
591
+ function declaredTodoUids(values) {
592
+ const uids = new Set();
593
+ for (const value of values) {
594
+ if (!value || typeof value !== "object" || !("todos" in value))
595
+ continue;
596
+ const todos = value.todos;
597
+ if (!Array.isArray(todos))
598
+ continue;
599
+ for (const todo of todos) {
600
+ if (todo &&
601
+ typeof todo === "object" &&
602
+ "uid" in todo &&
603
+ typeof todo.uid === "string")
604
+ uids.add(todo.uid);
605
+ }
606
+ }
607
+ return uids;
608
+ }
508
609
  export function importLedger(db, values) {
509
610
  // Both legacy entries and versioned bundles are accepted atomically, including JSONL.
510
611
  return db
511
612
  .transaction(() => {
512
613
  const results = [];
614
+ // Refs must name Todos declared in this payload, not only rows already stored.
615
+ const payloadTodos = declaredTodoUids(values);
513
616
  for (const value of values) {
514
617
  if (value && typeof value === "object" && "version" in value) {
515
618
  const bundle = record(value, [
@@ -518,27 +621,37 @@ export function importLedger(db, values) {
518
621
  "completions",
519
622
  "todos",
520
623
  ]);
521
- if ((bundle.version !== 2 && bundle.version !== 3) ||
522
- (bundle.version === 3 && !Array.isArray(bundle.todos)) ||
523
- (bundle.version === 2 && bundle.todos !== undefined) ||
624
+ const version = bundle.version;
625
+ if ((version !== 2 && version !== 3 && version !== 4) ||
626
+ ((version === 3 || version === 4) &&
627
+ !Array.isArray(bundle.todos)) ||
628
+ (version === 2 && bundle.todos !== undefined) ||
524
629
  !Array.isArray(bundle.entries) ||
525
630
  !Array.isArray(bundle.completions))
526
631
  throw new ValidationError("invalid ledger bundle");
632
+ const imported = [];
527
633
  for (const item of bundle.entries) {
528
634
  if (!item || typeof item !== "object" || Array.isArray(item))
529
635
  throw new ValidationError("entry must be an object");
530
636
  const { recordedAt, ...input } = item;
531
637
  validateUid(input.uid);
532
- results.push(addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt")));
638
+ const result = addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt"));
639
+ imported.push(result.entry);
640
+ results.push(result);
533
641
  }
534
642
  for (const event of bundle.completions)
535
643
  insertCompletion(db, validateCompletionEvent(event));
536
- if (bundle.version === 3)
644
+ if (version === 3 || version === 4)
537
645
  for (const todo of bundle.todos)
538
646
  importTodo(db, todo);
647
+ for (const entry of imported)
648
+ assertSnapshotRefs(db, entry.project, entry.nextTodoUids, payloadTodos);
649
+ }
650
+ else {
651
+ const result = addValidatedStatus(db, validateStatusEntry(value, "import"));
652
+ results.push(result);
653
+ assertSnapshotRefs(db, result.entry.project, result.entry.nextTodoUids, payloadTodos);
539
654
  }
540
- else
541
- results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
542
655
  }
543
656
  return results;
544
657
  })
@@ -546,12 +659,121 @@ export function importLedger(db, values) {
546
659
  }
547
660
  const todoSelect = `SELECT uid, title, project, entry_uid AS entryUid,
548
661
  step_index AS stepIndex, status, created_at AS createdAt, completed_at AS completedAt FROM todos`;
549
- export function listTodos(db, project) {
662
+ function ledgerVersion(entries, todos) {
663
+ if (entries.some((entry) => entry.nextTodoUids !== undefined))
664
+ return 4;
665
+ if (todos.length > 0 ||
666
+ entries.some((entry) => entry.nextStepsSourceUid !== undefined))
667
+ return 3;
668
+ return 2;
669
+ }
670
+ function todosVisibleToProject(db, project) {
671
+ return db
672
+ .prepare(`${todoSelect} WHERE project = ?
673
+ OR uid IN (
674
+ SELECT j.value FROM status_entries e
675
+ JOIN projects p ON p.id = e.project_id
676
+ JOIN json_each(e.next_todo_uids_json) j
677
+ WHERE p.slug = ? AND e.next_todo_uids_json IS NOT NULL
678
+ )
679
+ ORDER BY created_at, uid`)
680
+ .all(project, project);
681
+ }
682
+ function todoByUid(db, uid) {
683
+ return db.prepare(todoSelect + " WHERE uid = ?").get(uid);
684
+ }
685
+ function resolveNextTodo(db, uid) {
686
+ const todo = todoByUid(db, uid);
687
+ if (!todo)
688
+ return { uid, title: uid, status: "open", project: null };
689
+ return {
690
+ uid: todo.uid,
691
+ title: todo.title,
692
+ status: todo.status,
693
+ project: todo.project,
694
+ };
695
+ }
696
+ function assertSnapshotRefs(db, project, uids, payloadTodos) {
697
+ if (uids === undefined)
698
+ return;
699
+ for (const uid of uids) {
700
+ const todo = todoByUid(db, uid);
701
+ if (!todo)
702
+ throw new ValidationError("dangling next todo uid");
703
+ if (todo.project !== null && todo.project !== project)
704
+ throw new ValidationError("cross-project next todo ref");
705
+ if (payloadTodos && !payloadTodos.has(uid))
706
+ throw new ValidationError("dangling next todo uid");
707
+ }
708
+ }
709
+ function todoIsReferenced(db, uid) {
710
+ return (db
711
+ .prepare(`SELECT 1 AS found FROM status_entries, json_each(next_todo_uids_json)
712
+ WHERE next_todo_uids_json IS NOT NULL AND json_each.value = ? LIMIT 1`)
713
+ .get(uid) !== undefined);
714
+ }
715
+ function stepTodo(db, entryUid, index) {
550
716
  return db
717
+ .prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
718
+ .get(entryUid, index);
719
+ }
720
+ function legacySource(db, entry) {
721
+ if (!entry.nextStepsSourceUid)
722
+ return entry;
723
+ const origin = getEntry(db, entry.nextStepsSourceUid);
724
+ assert.ok(origin);
725
+ assert.equal(origin.nextStepsSourceUid, undefined);
726
+ return origin;
727
+ }
728
+ function preservedNextWork(db, latest) {
729
+ if (latest.nextTodoUids !== undefined)
730
+ return { nextSteps: [], nextTodoUids: [...latest.nextTodoUids] };
731
+ const source = legacySource(db, latest);
732
+ const pending = source.nextSteps.some((_, index) => stepTodo(db, source.uid, index) === undefined);
733
+ if (pending)
734
+ return {
735
+ nextSteps: [...source.nextSteps],
736
+ nextStepsSourceUid: source.uid,
737
+ };
738
+ return {
739
+ nextSteps: [],
740
+ nextTodoUids: source.nextSteps.map((_, index) => {
741
+ const todo = stepTodo(db, source.uid, index);
742
+ assert.ok(todo);
743
+ return todo.uid;
744
+ }),
745
+ };
746
+ }
747
+ export function listTodos(db, project, options = {}) {
748
+ const clauses = [];
749
+ const params = [];
750
+ if (project) {
751
+ clauses.push("project = ?");
752
+ params.push(project);
753
+ }
754
+ if (options.status !== undefined) {
755
+ if (!["open", "completed", "cancelled"].includes(options.status))
756
+ throw new ValidationError("invalid todo status");
757
+ clauses.push("status = ?");
758
+ params.push(options.status);
759
+ }
760
+ const rows = db
551
761
  .prepare(todoSelect +
552
- (project ? " WHERE project = ?" : "") +
762
+ (clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "") +
553
763
  " ORDER BY created_at, uid")
554
- .all(...(project ? [project] : []));
764
+ .all(...params);
765
+ const pins = options.status === "open" && project
766
+ ? current(db, project)[0]?.nextTodoUids
767
+ : undefined;
768
+ if (pins?.length) {
769
+ const rank = new Map(pins.map((uid, index) => [uid, index]));
770
+ rows.sort((left, right) => {
771
+ const leftRank = rank.get(left.uid) ?? pins.length;
772
+ const rightRank = rank.get(right.uid) ?? pins.length;
773
+ return leftRank - rightRank;
774
+ });
775
+ }
776
+ return rows;
555
777
  }
556
778
  function insertTodo(db, todo) {
557
779
  db.prepare(`INSERT INTO todos(uid,title,project,entry_uid,step_index,status,created_at,completed_at)
@@ -638,15 +860,21 @@ export function updateTodo(db, uid, value) {
638
860
  }
639
861
  }
640
862
  if (input.project !== undefined) {
641
- if (todo.entryUid)
642
- throw new ValidationError("source-linked todo project cannot change");
643
- todo.project =
644
- input.project === null
645
- ? null
646
- : validateProjectMetadataInput({ project: input.project }).project;
647
- if (todo.project &&
648
- !db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(todo.project))
649
- throw new ValidationError("project not found");
863
+ const nextProject = input.project === null
864
+ ? null
865
+ : validateProjectMetadataInput({ project: input.project }).project;
866
+ if (nextProject !== todo.project) {
867
+ if (todo.entryUid || todoIsReferenced(db, todo.uid))
868
+ throw new ValidationError(todo.entryUid
869
+ ? "source-linked todo project cannot change"
870
+ : "referenced todo project cannot change");
871
+ if (nextProject &&
872
+ !db
873
+ .prepare("SELECT 1 FROM projects WHERE slug = ?")
874
+ .get(nextProject))
875
+ throw new ValidationError("project not found");
876
+ todo.project = nextProject;
877
+ }
650
878
  }
651
879
  db.prepare("UPDATE todos SET status=?, completed_at=?, project=? WHERE uid=?").run(todo.status, todo.completedAt, todo.project, uid);
652
880
  return todo;
@@ -684,11 +912,17 @@ function importTodo(db, value) {
684
912
  const entry = getEntry(db, validateUid(input.entryUid));
685
913
  if (!entry || entry.project !== input.project)
686
914
  throw new ValidationError("invalid todo source");
687
- if (input.stepIndex !== null &&
688
- (!Number.isInteger(input.stepIndex) ||
689
- Number(input.stepIndex) < 0 ||
690
- entry.nextSteps[Number(input.stepIndex)] !== input.title))
691
- throw new ValidationError("invalid next step index");
915
+ if (input.stepIndex !== null) {
916
+ if (!Number.isInteger(input.stepIndex) || Number(input.stepIndex) < 0)
917
+ throw new ValidationError("invalid next step index");
918
+ const index = Number(input.stepIndex);
919
+ if (entry.nextTodoUids !== undefined) {
920
+ if (entry.nextTodoUids[index] !== input.uid)
921
+ throw new ValidationError("invalid next step index");
922
+ }
923
+ else if (entry.nextSteps[index] !== input.title)
924
+ throw new ValidationError("invalid next step index");
925
+ }
692
926
  }
693
927
  else if (input.stepIndex !== null)
694
928
  throw new ValidationError("next step requires a source");
@@ -711,18 +945,18 @@ export function completeActivity(db, ref) {
711
945
  return entry;
712
946
  const latest = current(db, entry.project)[0];
713
947
  const occurredAt = new Date(Math.max(Date.now(), Date.parse(latest.occurredAt) + 1)).toISOString();
714
- // Preserve current context without generating another set of next-step Todos.
948
+ // Copy refs, or keep the full legacy string list until every index is converted.
949
+ const preserved = preservedNextWork(db, latest);
715
950
  const followUp = addValidatedStatus(db, validateStatusEntry({
716
951
  project: entry.project,
717
952
  status: latest.status,
718
953
  summary: `Completed activity #${entry.id}`,
719
954
  progress: latest.progress,
720
955
  blockers: latest.blockers,
721
- nextSteps: latest.nextSteps,
722
- nextStepsSourceUid: latest.nextStepsSourceUid ?? latest.uid,
723
956
  decisions: latest.decisions,
724
957
  tags: latest.tags,
725
958
  occurredAt,
959
+ ...preserved,
726
960
  }));
727
961
  changeCompletion(db, entry.uid, followUp.entry.uid);
728
962
  return getEntry(db, entry.uid);
package/dist/domain.d.ts CHANGED
@@ -18,6 +18,12 @@ export type ProjectMetadataInput = {
18
18
  description?: string | null;
19
19
  notes?: string | null;
20
20
  };
21
+ export type NextTodoRef = {
22
+ uid: string;
23
+ title: string;
24
+ status: "open" | "completed" | "cancelled";
25
+ project: string | null;
26
+ };
21
27
  export type StatusEntryInput = {
22
28
  uid?: string;
23
29
  project: string;
@@ -26,6 +32,7 @@ export type StatusEntryInput = {
26
32
  progress?: string;
27
33
  blockers?: string[];
28
34
  nextSteps?: string[];
35
+ nextTodoUids?: string[];
29
36
  nextStepsSourceUid?: string;
30
37
  decisions?: string[];
31
38
  tags?: string[];
@@ -33,8 +40,11 @@ export type StatusEntryInput = {
33
40
  source?: Source;
34
41
  externalRef?: string;
35
42
  };
36
- export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef" | "nextStepsSourceUid">> & {
43
+ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef" | "nextStepsSourceUid" | "nextTodoUids">> & {
37
44
  nextStepsSourceUid?: string;
45
+ nextTodoUids?: string[];
46
+ /** Live Todo rows for display. Not a historical as-of view. */
47
+ nextTodos?: NextTodoRef[];
38
48
  id: number;
39
49
  progress?: string;
40
50
  externalRef?: string;
@@ -49,12 +59,12 @@ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "external
49
59
  recordedAt: string;
50
60
  };
51
61
  };
52
- export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt" | "effectiveStatus" | "isCurrent" | "completedBy">;
62
+ export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt" | "effectiveStatus" | "isCurrent" | "completedBy" | "nextTodos">;
53
63
  export declare class ValidationError extends Error {
54
64
  constructor(message: string);
55
65
  }
56
66
  export declare function parseTime(value: unknown, field: string): string;
57
67
  export declare function validateProjectMetadataInput(value: unknown): ProjectMetadataInput;
58
- export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
68
+ export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source" | "nextSteps" | "blockers" | "decisions" | "tags">>;
59
69
  export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
60
70
  export declare function validateUid(value: unknown): string;
package/dist/domain.js CHANGED
@@ -16,6 +16,7 @@ const keys = new Set([
16
16
  "progress",
17
17
  "blockers",
18
18
  "nextSteps",
19
+ "nextTodoUids",
19
20
  "nextStepsSourceUid",
20
21
  "decisions",
21
22
  "tags",
@@ -67,6 +68,14 @@ function textList(value, field) {
67
68
  throw new ValidationError(`${field} must be an array of at most 100 strings`);
68
69
  return value.map((item) => text(item, field, true));
69
70
  }
71
+ function uidList(value, field) {
72
+ if (!Array.isArray(value) || value.length > 100)
73
+ throw new ValidationError(`${field} must be an array of at most 100 uuids`);
74
+ const uids = value.map((item) => validateUid(item));
75
+ if (new Set(uids).size !== uids.length)
76
+ throw new ValidationError(`${field} contains a duplicate uid`);
77
+ return uids;
78
+ }
70
79
  export function parseTime(value, field) {
71
80
  if (typeof value !== "string" ||
72
81
  !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
@@ -121,6 +130,18 @@ export function validateStatusEntry(value, defaultSource = "human") {
121
130
  const externalRef = text(input.externalRef, "externalRef");
122
131
  if (externalRef && externalRef.length > 200)
123
132
  throw new ValidationError("externalRef must be at most 200 characters");
133
+ const nextSteps = textList(input.nextSteps, "nextSteps");
134
+ const nextStepsSourceUid = input.nextStepsSourceUid === undefined
135
+ ? undefined
136
+ : validateUid(input.nextStepsSourceUid);
137
+ // null and omission both mean legacy rows: the refs field is absent, not [].
138
+ const nextTodoUids = input.nextTodoUids === undefined || input.nextTodoUids === null
139
+ ? undefined
140
+ : uidList(input.nextTodoUids, "nextTodoUids");
141
+ if (nextTodoUids !== undefined && nextSteps.length > 0)
142
+ throw new ValidationError("cannot combine nextSteps text with nextTodoUids");
143
+ if (nextTodoUids !== undefined && nextStepsSourceUid !== undefined)
144
+ throw new ValidationError("invalid next step provenance");
124
145
  return {
125
146
  ...(input.uid === undefined ? {} : { uid: validateUid(input.uid) }),
126
147
  project,
@@ -128,10 +149,9 @@ export function validateStatusEntry(value, defaultSource = "human") {
128
149
  summary: text(input.summary, "summary", true),
129
150
  progress: text(input.progress, "progress"),
130
151
  blockers: textList(input.blockers, "blockers"),
131
- nextSteps: textList(input.nextSteps, "nextSteps"),
132
- ...(input.nextStepsSourceUid === undefined
133
- ? {}
134
- : { nextStepsSourceUid: validateUid(input.nextStepsSourceUid) }),
152
+ nextSteps,
153
+ ...(nextTodoUids !== undefined ? { nextTodoUids } : {}),
154
+ ...(nextStepsSourceUid !== undefined ? { nextStepsSourceUid } : {}),
135
155
  decisions: textList(input.decisions, "decisions"),
136
156
  tags: textList(input.tags, "tags"),
137
157
  occurredAt: input.occurredAt === undefined
@@ -156,6 +176,8 @@ export function toPortableStatusEntry(entry) {
156
176
  };
157
177
  if (entry.nextStepsSourceUid !== undefined)
158
178
  result.nextStepsSourceUid = entry.nextStepsSourceUid;
179
+ if (entry.nextTodoUids !== undefined)
180
+ result.nextTodoUids = entry.nextTodoUids;
159
181
  if (entry.progress !== undefined)
160
182
  result.progress = entry.progress;
161
183
  if (entry.externalRef !== undefined)
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { exec } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { createServer, } from "node:http";
3
3
  import { URL } from "node:url";
4
4
  import { countEntries, createTodo, listTodos, updateTodo, convertNextStep, completeActivity, getEntry, history, listProjects, openLedger, } from "./db.js";
@@ -6,19 +6,18 @@ import { ValidationError } from "./domain.js";
6
6
  import { getWebUIHTML } from "./web/ui.js";
7
7
  export function openBrowser(url) {
8
8
  const platform = process.platform;
9
- let command = "";
10
- if (platform === "darwin") {
11
- command = `open "${url}"`;
12
- }
13
- else if (platform === "win32") {
14
- command = `start "" "${url}"`;
15
- }
16
- else {
17
- command = `xdg-open "${url}"`;
18
- }
19
- exec(command, () => {
20
- /* ignore background exec errors */
9
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
10
+ const args = platform === "darwin"
11
+ ? [url]
12
+ : platform === "win32"
13
+ ? ["/c", "start", "", url]
14
+ : [url];
15
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
16
+ child.on("error", () => {
17
+ /* ignore background launch errors */
21
18
  });
19
+ // Detach so a long-lived browser does not keep this process alive.
20
+ child.unref();
22
21
  }
23
22
  function parseQueryParams(url) {
24
23
  const params = {};
@@ -68,7 +67,17 @@ export function handleRequest(req, res, db) {
68
67
  return;
69
68
  }
70
69
  if (pathname === "/api/todos") {
71
- sendJson(res, 200, listTodos(db, query.project));
70
+ const status = query.status;
71
+ if (status !== undefined &&
72
+ status !== "open" &&
73
+ status !== "completed" &&
74
+ status !== "cancelled") {
75
+ sendJson(res, 400, { error: "invalid todo status" });
76
+ return;
77
+ }
78
+ sendJson(res, 200, listTodos(db, query.project, status === undefined
79
+ ? {}
80
+ : { status: status }));
72
81
  return;
73
82
  }
74
83
  if (pathname === "/api/projects") {
@@ -184,7 +184,16 @@ export function getWebUIScript(initialLang) {
184
184
  </div>
185
185
  \` : ''}
186
186
 
187
- \${item.nextSteps && item.nextSteps.length ? \`
187
+ \${pinnedTodos(item).length ? \`
188
+ <div class="section-box">
189
+ <div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
190
+ <ul class="bullet-list">
191
+ \${pinnedTodos(item).map(todo => \`<li><strong>\${escapeHtml(todo.title)}</strong> <span class="status-badge status-\${escapeHtml(todo.status)}">\${escapeHtml(t('todo_' + todo.status))}</span> <code>\${escapeHtml(todo.uid)}</code></li>\`).join('')}
192
+ </ul>
193
+ </div>
194
+ \` : ''}
195
+
196
+ \${!item.nextTodoUids && item.nextSteps && item.nextSteps.length ? \`
188
197
  <div class="section-box">
189
198
  <div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
190
199
  <ul class="bullet-list">
@@ -211,7 +220,7 @@ export function getWebUIScript(initialLang) {
211
220
  <div class="section-box">
212
221
  <button class="btn" data-action="add-todo" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('addTodo'))}</button>
213
222
  \${status !== 'completed' ? \`<button class="btn" data-action="complete-entry" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('complete'))}</button>\` : ''}
214
- \${todosData.filter(todo => todo.entryUid === item.uid || todo.entryUid === item.nextStepsSourceUid).map(renderTodo).join('')}
223
+ \${todosData.filter(todo => (todo.entryUid === item.uid || todo.entryUid === item.nextStepsSourceUid) && !(item.nextTodoUids || []).includes(todo.uid)).map(renderTodo).join('')}
215
224
  </div>
216
225
  </div>
217
226
  \`;
@@ -283,6 +292,7 @@ export function getWebUIScript(initialLang) {
283
292
  <div class="latest-summary">
284
293
  <div class="latest-summary-title">\${escapeHtml(t('latestUpdate'))} (\${formatTime(p.latestStatus.occurredAt)})</div>
285
294
  <div>\${escapeHtml(p.latestStatus.summary)}</div>
295
+ \${Array.isArray(p.latestStatus.nextTodos) && p.latestStatus.nextTodos.length ? \`<ul class="bullet-list">\${p.latestStatus.nextTodos.map(todo => \`<li>\${escapeHtml(todo.title)} <span class="status-badge status-\${escapeHtml(todo.status)}">\${escapeHtml(t('todo_' + todo.status))}</span></li>\`).join('')}</ul>\` : ''}
286
296
  </div>
287
297
  \` : ''}
288
298
  </div>
@@ -376,6 +386,15 @@ export function getWebUIScript(initialLang) {
376
386
  return '<option value="">' + escapeHtml(t('noProject')) + '</option>' + projectsData.map(p =>
377
387
  '<option value="' + escapeHtml(p.project) + '"' + (selected === p.project ? ' selected' : '') + '>' + escapeHtml(p.displayName) + '</option>').join('');
378
388
  }
389
+ function pinnedTodos(item) {
390
+ if (!Array.isArray(item.nextTodoUids)) return [];
391
+ const embedded = Array.isArray(item.nextTodos) ? item.nextTodos : [];
392
+ return item.nextTodoUids.map((uid) => {
393
+ const live = todosData.find((todo) => todo.uid === uid);
394
+ const snapshot = embedded.find((todo) => todo.uid === uid);
395
+ return live || snapshot || { uid, title: uid, status: 'open', project: null };
396
+ });
397
+ }
379
398
  function renderTodo(todo) {
380
399
  return '<div class="section-box"><strong>' + escapeHtml(todo.title) + '</strong> · ' + escapeHtml(t('todo_' + todo.status)) +
381
400
  (todo.project ? ' · ' + escapeHtml(todo.project) : '') +
@@ -1 +1 @@
1
- export declare const WEB_UI_STYLES = " :root {\n --bg: #0d1117;\n --card-bg: #161b22;\n --border: #30363d;\n --text: #c9d1d9;\n --text-muted: #8b949e;\n --heading: #f0f6fc;\n --accent: #58a6ff;\n --accent-hover: #79c0ff;\n --success: #3fb950;\n --warning: #d29922;\n --danger: #f85149;\n --purple: #bc8cff;\n --cyan: #39c5cf;\n --radius: 8px;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg);\n color: var(--text);\n line-height: 1.5;\n padding: 0;\n margin: 0;\n min-height: 100vh;\n }\n\n header {\n background-color: var(--card-bg);\n border-bottom: 1px solid var(--border);\n position: sticky;\n top: 0;\n z-index: 100;\n padding: 1rem 1.5rem;\n }\n\n .header-container {\n max-width: 1200px;\n margin: 0 auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n }\n\n .brand {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .logo-icon {\n width: 32px;\n height: 32px;\n background: linear-gradient(135deg, var(--accent), var(--purple));\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n font-weight: bold;\n font-size: 1.1rem;\n }\n\n h1 {\n font-size: 1.25rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .stats-bar {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .lang-switch {\n display: inline-flex;\n gap: 0.2rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .lang-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.25rem 0.65rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .lang-btn:hover {\n color: var(--heading);\n }\n\n .lang-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n .stat-pill {\n background: rgba(255, 255, 255, 0.05);\n border: 1px solid var(--border);\n border-radius: 20px;\n padding: 0.25rem 0.75rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n }\n\n .stat-val {\n font-weight: 600;\n color: var(--heading);\n }\n\n .controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n width: 100%;\n max-width: 1200px;\n margin: 1rem auto 0 auto;\n padding: 0 1.5rem;\n }\n\n .search-box {\n flex: 1;\n min-width: 240px;\n position: relative;\n }\n\n .search-input {\n width: 100%;\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.5rem 0.75rem 0.5rem 2.25rem;\n border-radius: var(--radius);\n font-size: 0.9rem;\n outline: none;\n transition: border-color 0.2s;\n }\n\n .search-input:focus {\n border-color: var(--accent);\n }\n\n .search-icon {\n position: absolute;\n left: 0.75rem;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n pointer-events: none;\n font-size: 0.85rem;\n }\n\n .nav-tabs {\n display: flex;\n gap: 0.5rem;\n background: var(--card-bg);\n padding: 0.25rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n }\n\n .tab-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.4rem 1rem;\n font-size: 0.875rem;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .tab-btn:hover {\n color: var(--heading);\n }\n\n .tab-btn.active {\n background: var(--accent);\n color: #ffffff;\n }\n\n .btn[hidden] { display: none !important; }\n .btn {\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.45rem 0.85rem;\n border-radius: var(--radius);\n font-size: 0.875rem;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n transition: all 0.2s;\n }\n\n .btn:hover {\n border-color: var(--text-muted);\n color: var(--heading);\n }\n\n .btn-sm {\n padding: 0.3rem 0.6rem;\n font-size: 0.8rem;\n }\n\n .btn-primary {\n background: var(--accent);\n color: #ffffff;\n border-color: var(--accent);\n margin-top: 0.75rem;\n }\n\n .btn-primary:hover {\n background: var(--accent-hover);\n color: #ffffff;\n }\n\n main {\n max-width: 1200px;\n margin: 1.5rem auto;\n padding: 0 1.5rem;\n }\n\n .view-panel {\n display: none;\n }\n\n .view-panel.active {\n display: block;\n }\n\n /* Activity Feed Toolbar & Grouping Toggle */\n .activity-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 0.75rem 1rem;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .date-picker-wrapper {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 0.9rem;\n color: var(--heading);\n font-weight: 500;\n flex-wrap: wrap;\n }\n\n .date-input {\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--heading);\n padding: 0.35rem 0.65rem;\n border-radius: 6px;\n font-size: 0.875rem;\n outline: none;\n font-family: inherit;\n }\n\n .date-input:focus {\n border-color: var(--accent);\n }\n\n .view-mode-toggle {\n display: flex;\n gap: 0.25rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .mode-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.3rem 0.75rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .mode-btn:hover {\n color: var(--heading);\n }\n\n .mode-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n /* Project Grouping Container */\n .project-group-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .project-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n border-bottom: 1px solid var(--border);\n padding-bottom: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .project-group-title {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-group-count {\n font-size: 0.8rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.06);\n padding: 0.15rem 0.55rem;\n border-radius: 12px;\n }\n\n /* Projects Grid */\n .projects-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));\n gap: 1.25rem;\n }\n\n .project-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n transition: transform 0.15s, border-color 0.15s;\n }\n\n .project-card:hover {\n border-color: var(--text-muted);\n transform: translateY(-2px);\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n }\n\n .project-title {\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-slug {\n font-size: 0.8rem;\n color: var(--text-muted);\n font-family: monospace;\n }\n\n .completion-evidence { margin: 12px 0; line-height: 1.7; }\n details > summary { cursor: pointer; margin: 12px 0; }\n .status-badge {\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n padding: 0.2rem 0.55rem;\n border-radius: 12px;\n display: inline-block;\n white-space: nowrap;\n }\n\n .status-active { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-planned { background: rgba(88, 166, 255, 0.15); color: var(--accent); border: 1px solid rgba(88, 166, 255, 0.3); }\n .status-idea { background: rgba(188, 140, 255, 0.15); color: var(--purple); border: 1px solid rgba(188, 140, 255, 0.3); }\n .status-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.3); }\n .status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }\n .status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }\n .status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n\n .card-body {\n margin-bottom: 1rem;\n font-size: 0.9rem;\n }\n\n .card-description {\n color: var(--text);\n margin-bottom: 0.5rem;\n }\n\n .card-notes {\n color: var(--text-muted);\n font-size: 0.85rem;\n font-style: italic;\n border-left: 2px solid var(--border);\n padding-left: 0.5rem;\n margin-top: 0.5rem;\n }\n\n .latest-summary {\n background: rgba(0, 0, 0, 0.2);\n padding: 0.6rem;\n border-radius: 6px;\n margin-top: 0.75rem;\n font-size: 0.85rem;\n }\n\n .latest-summary-title {\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 0.85rem;\n color: var(--text-muted);\n border-top: 1px solid var(--border);\n padding-top: 0.75rem;\n margin-top: auto;\n }\n\n .link-btn {\n color: var(--accent);\n text-decoration: none;\n cursor: pointer;\n font-weight: 500;\n }\n\n .link-btn:hover {\n text-decoration: underline;\n }\n\n /* Timeline View */\n .timeline {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n position: relative;\n }\n\n .timeline-item {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n position: relative;\n }\n\n .timeline-header {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n }\n\n .timeline-meta {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n }\n\n .project-tag {\n background: rgba(88, 166, 255, 0.1);\n color: var(--accent);\n border: 1px solid rgba(88, 166, 255, 0.2);\n padding: 0.15rem 0.5rem;\n border-radius: 4px;\n font-family: monospace;\n font-size: 0.85rem;\n cursor: pointer;\n }\n\n .time-stamp {\n font-size: 0.8rem;\n color: var(--text-muted);\n }\n\n .source-badge {\n font-size: 0.75rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.05);\n padding: 0.1rem 0.4rem;\n border-radius: 4px;\n }\n\n .entry-summary {\n font-size: 1rem;\n font-weight: 500;\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .entry-progress {\n font-size: 0.9rem;\n color: var(--text);\n margin-bottom: 0.75rem;\n }\n\n .section-box {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n }\n\n .section-title {\n font-weight: 600;\n font-size: 0.8rem;\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n display: flex;\n align-items: center;\n gap: 0.25rem;\n }\n\n .section-title.blockers { color: var(--danger); }\n .section-title.next-steps { color: var(--accent); }\n .section-title.decisions { color: var(--purple); }\n\n .bullet-list {\n list-style: disc inside;\n color: var(--text);\n padding-left: 0.25rem;\n }\n\n .bullet-list li {\n margin-bottom: 0.2rem;\n }\n\n .tags-container {\n display: flex;\n flex-wrap: wrap;\n gap: 0.35rem;\n margin-top: 0.75rem;\n }\n\n .pill-tag {\n background: rgba(255, 255, 255, 0.06);\n border: 1px solid var(--border);\n color: var(--text-muted);\n font-size: 0.75rem;\n padding: 0.1rem 0.45rem;\n border-radius: 12px;\n }\n\n .empty-state {\n text-align: center;\n padding: 3rem 1rem;\n color: var(--text-muted);\n }\n\n .empty-state h3 {\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .filter-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 1rem;\n font-size: 0.875rem;\n color: var(--text-muted);\n }\n\n .filter-chip {\n background: rgba(88, 166, 255, 0.15);\n color: var(--accent);\n padding: 0.2rem 0.6rem;\n border-radius: 12px;\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n }\n\n .filter-chip button {\n background: none;\n border: none;\n color: var(--accent);\n cursor: pointer;\n font-weight: bold;\n }";
1
+ export declare const WEB_UI_STYLES = " :root {\n --bg: #0d1117;\n --card-bg: #161b22;\n --border: #30363d;\n --text: #c9d1d9;\n --text-muted: #8b949e;\n --heading: #f0f6fc;\n --accent: #58a6ff;\n --accent-hover: #79c0ff;\n --success: #3fb950;\n --warning: #d29922;\n --danger: #f85149;\n --purple: #bc8cff;\n --cyan: #39c5cf;\n --radius: 8px;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg);\n color: var(--text);\n line-height: 1.5;\n padding: 0;\n margin: 0;\n min-height: 100vh;\n }\n\n header {\n background-color: var(--card-bg);\n border-bottom: 1px solid var(--border);\n position: sticky;\n top: 0;\n z-index: 100;\n padding: 1rem 1.5rem;\n }\n\n .header-container {\n max-width: 1200px;\n margin: 0 auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n }\n\n .brand {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .logo-icon {\n width: 32px;\n height: 32px;\n background: linear-gradient(135deg, var(--accent), var(--purple));\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n font-weight: bold;\n font-size: 1.1rem;\n }\n\n h1 {\n font-size: 1.25rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .stats-bar {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .lang-switch {\n display: inline-flex;\n gap: 0.2rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .lang-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.25rem 0.65rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .lang-btn:hover {\n color: var(--heading);\n }\n\n .lang-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n .stat-pill {\n background: rgba(255, 255, 255, 0.05);\n border: 1px solid var(--border);\n border-radius: 20px;\n padding: 0.25rem 0.75rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n }\n\n .stat-val {\n font-weight: 600;\n color: var(--heading);\n }\n\n .controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n width: 100%;\n max-width: 1200px;\n margin: 1rem auto 0 auto;\n padding: 0 1.5rem;\n }\n\n .search-box {\n flex: 1;\n min-width: 240px;\n position: relative;\n }\n\n .search-input {\n width: 100%;\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.5rem 0.75rem 0.5rem 2.25rem;\n border-radius: var(--radius);\n font-size: 0.9rem;\n outline: none;\n transition: border-color 0.2s;\n }\n\n .search-input:focus {\n border-color: var(--accent);\n }\n\n .search-icon {\n position: absolute;\n left: 0.75rem;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n pointer-events: none;\n font-size: 0.85rem;\n }\n\n .nav-tabs {\n display: flex;\n gap: 0.5rem;\n background: var(--card-bg);\n padding: 0.25rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n }\n\n .tab-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.4rem 1rem;\n font-size: 0.875rem;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .tab-btn:hover {\n color: var(--heading);\n }\n\n .tab-btn.active {\n background: var(--accent);\n color: #ffffff;\n }\n\n .btn[hidden] { display: none !important; }\n .btn {\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.45rem 0.85rem;\n border-radius: var(--radius);\n font-size: 0.875rem;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n transition: all 0.2s;\n }\n\n .btn:hover {\n border-color: var(--text-muted);\n color: var(--heading);\n }\n\n .btn-sm {\n padding: 0.3rem 0.6rem;\n font-size: 0.8rem;\n }\n\n .btn-primary {\n background: var(--accent);\n color: #ffffff;\n border-color: var(--accent);\n margin-top: 0.75rem;\n }\n\n .btn-primary:hover {\n background: var(--accent-hover);\n color: #ffffff;\n }\n\n main {\n max-width: 1200px;\n margin: 1.5rem auto;\n padding: 0 1.5rem;\n }\n\n .view-panel {\n display: none;\n }\n\n .view-panel.active {\n display: block;\n }\n\n /* Activity Feed Toolbar & Grouping Toggle */\n .activity-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 0.75rem 1rem;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .date-picker-wrapper {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 0.9rem;\n color: var(--heading);\n font-weight: 500;\n flex-wrap: wrap;\n }\n\n .date-input {\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--heading);\n padding: 0.35rem 0.65rem;\n border-radius: 6px;\n font-size: 0.875rem;\n outline: none;\n font-family: inherit;\n }\n\n .date-input:focus {\n border-color: var(--accent);\n }\n\n .view-mode-toggle {\n display: flex;\n gap: 0.25rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .mode-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.3rem 0.75rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .mode-btn:hover {\n color: var(--heading);\n }\n\n .mode-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n /* Project Grouping Container */\n .project-group-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .project-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n border-bottom: 1px solid var(--border);\n padding-bottom: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .project-group-title {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-group-count {\n font-size: 0.8rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.06);\n padding: 0.15rem 0.55rem;\n border-radius: 12px;\n }\n\n /* Projects Grid */\n .projects-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));\n gap: 1.25rem;\n }\n\n .project-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n transition: transform 0.15s, border-color 0.15s;\n }\n\n .project-card:hover {\n border-color: var(--text-muted);\n transform: translateY(-2px);\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n }\n\n .project-title {\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-slug {\n font-size: 0.8rem;\n color: var(--text-muted);\n font-family: monospace;\n }\n\n .completion-evidence { margin: 12px 0; line-height: 1.7; }\n details > summary { cursor: pointer; margin: 12px 0; }\n .status-badge {\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n padding: 0.2rem 0.55rem;\n border-radius: 12px;\n display: inline-block;\n white-space: nowrap;\n }\n\n .status-active { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-planned { background: rgba(88, 166, 255, 0.15); color: var(--accent); border: 1px solid rgba(88, 166, 255, 0.3); }\n .status-idea { background: rgba(188, 140, 255, 0.15); color: var(--purple); border: 1px solid rgba(188, 140, 255, 0.3); }\n .status-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.3); }\n .status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }\n .status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }\n .status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n .status-open { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-cancelled { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n\n .card-body {\n margin-bottom: 1rem;\n font-size: 0.9rem;\n }\n\n .card-description {\n color: var(--text);\n margin-bottom: 0.5rem;\n }\n\n .card-notes {\n color: var(--text-muted);\n font-size: 0.85rem;\n font-style: italic;\n border-left: 2px solid var(--border);\n padding-left: 0.5rem;\n margin-top: 0.5rem;\n }\n\n .latest-summary {\n background: rgba(0, 0, 0, 0.2);\n padding: 0.6rem;\n border-radius: 6px;\n margin-top: 0.75rem;\n font-size: 0.85rem;\n }\n\n .latest-summary-title {\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 0.85rem;\n color: var(--text-muted);\n border-top: 1px solid var(--border);\n padding-top: 0.75rem;\n margin-top: auto;\n }\n\n .link-btn {\n color: var(--accent);\n text-decoration: none;\n cursor: pointer;\n font-weight: 500;\n }\n\n .link-btn:hover {\n text-decoration: underline;\n }\n\n /* Timeline View */\n .timeline {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n position: relative;\n }\n\n .timeline-item {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n position: relative;\n }\n\n .timeline-header {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n }\n\n .timeline-meta {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n }\n\n .project-tag {\n background: rgba(88, 166, 255, 0.1);\n color: var(--accent);\n border: 1px solid rgba(88, 166, 255, 0.2);\n padding: 0.15rem 0.5rem;\n border-radius: 4px;\n font-family: monospace;\n font-size: 0.85rem;\n cursor: pointer;\n }\n\n .time-stamp {\n font-size: 0.8rem;\n color: var(--text-muted);\n }\n\n .source-badge {\n font-size: 0.75rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.05);\n padding: 0.1rem 0.4rem;\n border-radius: 4px;\n }\n\n .entry-summary {\n font-size: 1rem;\n font-weight: 500;\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .entry-progress {\n font-size: 0.9rem;\n color: var(--text);\n margin-bottom: 0.75rem;\n }\n\n .section-box {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n }\n\n .section-title {\n font-weight: 600;\n font-size: 0.8rem;\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n display: flex;\n align-items: center;\n gap: 0.25rem;\n }\n\n .section-title.blockers { color: var(--danger); }\n .section-title.next-steps { color: var(--accent); }\n .section-title.decisions { color: var(--purple); }\n\n .bullet-list {\n list-style: disc inside;\n color: var(--text);\n padding-left: 0.25rem;\n }\n\n .bullet-list li {\n margin-bottom: 0.2rem;\n }\n\n .tags-container {\n display: flex;\n flex-wrap: wrap;\n gap: 0.35rem;\n margin-top: 0.75rem;\n }\n\n .pill-tag {\n background: rgba(255, 255, 255, 0.06);\n border: 1px solid var(--border);\n color: var(--text-muted);\n font-size: 0.75rem;\n padding: 0.1rem 0.45rem;\n border-radius: 12px;\n }\n\n .empty-state {\n text-align: center;\n padding: 3rem 1rem;\n color: var(--text-muted);\n }\n\n .empty-state h3 {\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .filter-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 1rem;\n font-size: 0.875rem;\n color: var(--text-muted);\n }\n\n .filter-chip {\n background: rgba(88, 166, 255, 0.15);\n color: var(--accent);\n padding: 0.2rem 0.6rem;\n border-radius: 12px;\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n }\n\n .filter-chip button {\n background: none;\n border: none;\n color: var(--accent);\n cursor: pointer;\n font-weight: bold;\n }";
@@ -426,6 +426,8 @@ export const WEB_UI_STYLES = ` :root {
426
426
  .status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }
427
427
  .status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }
428
428
  .status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }
429
+ .status-open { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }
430
+ .status-cancelled { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }
429
431
 
430
432
  .card-body {
431
433
  margin-bottom: 1rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-mgr",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local-first append-only project status ledger",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,8 +64,10 @@ pmgr list --query "keyword" --json
64
64
  pmgr serve --port 3000 --no-open --json
65
65
 
66
66
  # Record a completed milestone while the project remains active.
67
+ # --next creates Todos and stores their UIDs on the snapshot. Reuse work with --next-todo.
67
68
  pmgr add --project my-project --status active --summary "Implemented the CLI" \
68
69
  --progress "Tests pass" --next "Publish the package" --source agent --json
70
+ pmgr todos --project my-project --status open --json
69
71
 
70
72
  # Same project, with a tag naming the sub-project this snapshot covers.
71
73
  pmgr add --project acme --status active --summary "Shipped the web dashboard" \
@@ -92,4 +94,4 @@ When this update resolves all commitments of earlier entries (including blockers
92
94
 
93
95
  Use `complete --entry <id-or-uid> --by <id-or-uid> --source agent` for an existing follow-up. Use `revoke-completion --entry <id-or-uid> --by <id-or-uid> --reason "..." --source agent` to correct a mistaken link. Links require the same project and strictly increasing occurrence times. Query the affected history afterward to verify effective completion. Do not bulk-close old entries without evidence.
94
96
 
95
- Export produces version-2 bundles (one bundle per line for JSONL), preserving entry/event identities and audit history; legacy imports remain supported. `at` has no completion annotations. Project current state still comes from the latest snapshot.
97
+ Export produces a version-2 bundle when there are no Todos or refs, version 3 when Todos exist without refs, and version 4 when snapshots store `nextTodoUids` (one bundle per line for JSONL). Legacy imports remain supported and do not create Todos. Open work is `todos --status open`; current `nextTodoUids` only order that project's open items. Do not combine `--next` with `--next-todo`. Repeating `--next` text on a later snapshot creates a new Todo; pass existing UIDs to keep the same work. Cancelling or completing a Todo is `todo-update`, not deleting text from a snapshot. `at` has no completion annotations and does not resolve live Todo status. Project current state still comes from the latest snapshot.