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 +20 -0
- package/README.md +41 -14
- package/README_CN.md +36 -12
- package/dist/cli.js +30 -6
- package/dist/db.d.ts +5 -2
- package/dist/db.js +286 -52
- package/dist/domain.d.ts +13 -3
- package/dist/domain.js +26 -4
- package/dist/server.js +23 -14
- package/dist/web/script.js +21 -2
- package/dist/web/styles.d.ts +1 -1
- package/dist/web/styles.js +2 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +3 -1
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
|
343
|
+
pmgr add --project demo --status active --summary "已排队复查" \
|
|
344
|
+
--next "收集日志" --next "写说明" --source agent --json
|
|
344
345
|
pmgr todo-add --title "未归属的想法" --json
|
|
345
|
-
pmgr
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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([
|
|
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: [
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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 =
|
|
539
|
+
const todos = project ? todosVisibleToProject(db, project) : listTodos(db);
|
|
540
|
+
const version = ledgerVersion(entries, todos);
|
|
454
541
|
return {
|
|
455
|
-
version
|
|
456
|
-
? 3
|
|
457
|
-
: 2,
|
|
542
|
+
version,
|
|
458
543
|
entries,
|
|
459
544
|
completions: rows.map(completionFromRow),
|
|
460
|
-
...(todos
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
(
|
|
762
|
+
(clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "") +
|
|
553
763
|
" ORDER BY created_at, uid")
|
|
554
|
-
.all(...
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
//
|
|
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
|
|
132
|
-
...(
|
|
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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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") {
|
package/dist/web/script.js
CHANGED
|
@@ -184,7 +184,16 @@ export function getWebUIScript(initialLang) {
|
|
|
184
184
|
</div>
|
|
185
185
|
\` : ''}
|
|
186
186
|
|
|
187
|
-
\${item.
|
|
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) : '') +
|
package/dist/web/styles.d.ts
CHANGED
|
@@ -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 }";
|
package/dist/web/styles.js
CHANGED
|
@@ -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
|
@@ -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
|
|
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.
|