project-mgr 0.3.1 → 0.4.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-10-28
4
+
5
+ ### Added
6
+
7
+ - Add independent Todos with optional project/source links, per-item completion, cancellation, reopening, and CLI commands.
8
+ - Create Todos atomically from new next steps and explicitly convert legacy steps without rewriting history.
9
+ - Add dashboard activity completion controls and project-filtered Todo views.
10
+ - Preserve Todo state in version-3 exports and enforce same-origin local JSON writes.
11
+
12
+ ### Changed
13
+
14
+ - Guide agents to ask before recording multiple sub-projects, either under one project name or with snapshot tags.
15
+ - Sync the Chinese README with CLI help, activity completion, and Todo documentation.
16
+
17
+ ## 0.3.2 - 2026-10-16
18
+
19
+ - Add general and per-command CLI help, no-argument help, and package version aliases without opening the database.
20
+
3
21
  ## 0.3.1 - 2026-10-16
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -127,6 +127,20 @@ Delete `/tmp/project-mgr-manual-test.sqlite` when it is no longer needed.
127
127
 
128
128
  Automated tests use an in-memory database or SQLite files in the system temporary directory; they do not read or write the default database. `PMGR_DB_PATH=:memory:` also works for one command, but each CLI command runs in a separate process and its data disappears when the command exits, so it is not suitable for consecutive manual tests.
129
129
 
130
+ ## CLI help and version
131
+
132
+ ```bash
133
+ pmgr # Show general help
134
+ pmgr -h # Also: --help or help
135
+ pmgr add --help # Command usage and available options
136
+ pmgr help history # Equivalent to history -h
137
+ pmgr -v # Also: -V, --version, or version
138
+ ```
139
+
140
+ Help and version commands exit successfully without opening or creating a database.
141
+ Version output comes from the installed package metadata. The compatible
142
+ `pmgr status <command> --help` form is also supported.
143
+
130
144
  ## Quick start
131
145
 
132
146
  Append a project status:
@@ -319,3 +333,25 @@ Activity cards display Completed with a follow-up link and retain original detai
319
333
  Export now emits a version-2 bundle `{ "version": 2, "entries": [...], "completions": [...] }`. JSONL emits one complete bundle per line. Entries retain UUIDs and recording times; events retain UUIDs and per-target predecessor links, including revocations. Import accepts legacy entry objects/arrays/JSONL and version-2 bundles, checks identity conflicts, and applies each file atomically. Re-importing the same bundle is idempotent. Event order is significant: import events in exported order; divergent histories fail instead of silently merging. Older clients cannot import version-2 bundles. Migration adds UUIDs to existing snapshots without changing their content.
320
334
 
321
335
  Independently migrated copies of legacy data receive different UUIDs. Cross-import may fail on an existing `externalRef`; no implicit identity adoption occurs. Restore a bundle into a fresh database for a faithful migration. Older writers are rejected if they omit UUIDs. With an existing `externalRef`, `add` retains its existing snapshot and applies explicitly supplied `--completes` links to that snapshot; identical active links are no-ops. Use `complete` to make after-the-fact reconciliation explicit.
336
+
337
+ ### Todos and dashboard actions
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.
345
+
346
+ ```sh
347
+ pmgr todo-add --title "Review results" --project demo --json
348
+ pmgr todo-add --title "Unassigned idea" --json
349
+ pmgr todos --project demo --json
350
+ pmgr todo-update --uid <todo-uuid> --status completed --json
351
+ ```
352
+
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.
package/README_CN.md CHANGED
@@ -127,6 +127,18 @@ pnpm start history --project test-project
127
127
 
128
128
  自动化测试会使用内存数据库或系统临时目录中的 SQLite 文件,不会读写默认数据库。`PMGR_DB_PATH=:memory:` 也可用于单条命令,但每个 CLI 命令都是独立进程,命令结束后数据就会消失,不适合连续手工测试。
129
129
 
130
+ ## CLI 帮助与版本
131
+
132
+ ```bash
133
+ pmgr # 显示通用帮助
134
+ pmgr -h # 也可使用 --help 或 help
135
+ pmgr add --help # 命令用法与可用选项
136
+ pmgr help history # 等价于 history -h
137
+ pmgr -v # 也可使用 -V、--version 或 version
138
+ ```
139
+
140
+ 帮助与版本命令会成功退出,且不会打开或创建数据库。版本输出来自已安装包的元数据。也支持兼容形式 `pmgr status <command> --help`。
141
+
130
142
  ## 快速开始
131
143
 
132
144
  追加一条项目状态:
@@ -186,7 +198,7 @@ pnpm start at \
186
198
  - `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
187
199
  - `--json`:输出稳定 JSON。
188
200
 
189
- ## 项目元数据与模糊查询
201
+ ### 项目元数据与模糊查询
190
202
 
191
203
  可通过 `set-project` 设置或更新项目的名称、简介与备注,或使用 `--clear-description` / `--clear-notes` 清空:
192
204
 
@@ -298,3 +310,44 @@ pnpm build
298
310
  ```
299
311
 
300
312
  Statements、branches、functions、lines 的覆盖率门槛均为 100%。详细设计见[架构文档](docs/architecture.md)。
313
+
314
+ ## 补全更早的动态
315
+
316
+ 项目状态和动态是否完成是两回事。关联条目前,先查看 `pmgr history --project demo --json`。`status` 是原始快照;`effectiveStatus` 计入显式的后续完成;`isCurrent` 标识该项目的最新快照。缺少 `completedBy` 并不表示这条动态尚未完成。
317
+
318
+ ```bash
319
+ # 一次更新可完成多条更早的记录(本地数字 ID 或可移植 UUID)。
320
+ pmgr add --project demo --status active --summary "部署已验收" \
321
+ --completes 12 --completes 15 --source agent --json
322
+ # 关联一条已有的后续记录,或不删除历史地纠正错误关联。
323
+ pmgr complete --entry 12 --by 18 --source agent --json
324
+ pmgr revoke-completion --entry 12 --by 18 --reason "验收仍未完成" --source agent --json
325
+ ```
326
+
327
+ 只关联那些全部未完成工作(包括阻塞和下一步)都已解决的条目。同一项目内的关联要求后续记录的发生时间严格更晚;发生时间相同的关联会被拒绝。每个目标只能有一个有效的完成来源。重复同一关联是空操作;要替换时需先撤销。`add --completes` 会原子写入快照、元数据和关联。已有历史不会自动对账。
328
+
329
+ 动态卡片会显示已完成,并链到后续动态;原始详情保留在可展开的「原始记录」中。「当前状态」和「已有后续更新」只表示快照新旧,与是否完成相互独立。`/api/entries/<id-or-uid>` 和 `/?entry=<uid>` 即使不在初始动态页,也能打开一条记录。日期筛选选择的是快照;完成标注反映的是当前结果。`at` 仍然返回不含完成信息的历史项目快照;按历史时点查询完成状态暂不支持。
330
+
331
+ 导出现在会输出 version 2 包 `{ "version": 2, "entries": [...], "completions": [...] }`。JSONL 每行输出一个完整包。条目保留 UUID 和记录时间;事件保留 UUID,以及每个目标的前驱链接(含撤销)。导入同时接受旧版条目对象、数组、JSONL 和 version 2 包,会检查身份冲突,并按文件原子应用。重复导入同一包是幂等的。事件顺序有意义:须按导出顺序导入;历史不一致时导入失败,不会静默合并。旧客户端无法导入 version 2 包。迁移会给已有快照补上 UUID,不改变其内容。
332
+
333
+ 各自独立迁移的旧数据副本会得到不同的 UUID。跨库导入可能因已存在的 `externalRef` 失败,不会隐式沿用对方身份。要忠实迁移,请把包恢复到一个全新数据库。省略 UUID 的旧写入会被拒绝。若 `externalRef` 已存在,`add` 会保留原快照,并把显式传入的 `--completes` 关联应用到该快照;相同且仍然有效的关联是空操作。事后补关联请使用 `complete`,让对账显式发生。
334
+
335
+ ### 待办与仪表板操作
336
+
337
+ 新的 `add --next` 会创建独立待办,并关联到这条新动态。
338
+ 历史中的下一步仍只是文本,直到在仪表板里显式转为待办。
339
+ 待办页支持单独创建、可选关联项目、查看来源动态、标为已完成、取消和重新打开。项目卡片可以筛选这份列表。
340
+ 动态卡片提供 **标为已完成**,会用当前项目状态记一条后续动态;这既不会把项目标成完成,也不会完成它的待办。
341
+
342
+ ```sh
343
+ pmgr todo-add --title "复查结果" --project demo --json
344
+ pmgr todo-add --title "未归属的想法" --json
345
+ pmgr todos --project demo --json
346
+ pmgr todo-update --uid <todo-uuid> --status completed --json
347
+ ```
348
+
349
+ 带待办的导出使用 version 3,并保留待办的身份与状态;
350
+ version 2 和旧版导入仍然可用,且不会把旧的下一步文本自动变成新待办。
351
+ 冲突的待办快照会在导入时整批拒绝。
352
+ 仪表板写入仅接受同源的 localhost JSON 请求;
353
+ 绑定到远程主机时,仪表板保持只读。
package/dist/cli.js CHANGED
@@ -2,23 +2,33 @@
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { addStatus, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
5
+ import { addStatus, createTodo, listTodos, updateTodo, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
6
+ const booleanFlags = new Set([
7
+ "json",
8
+ "clear-description",
9
+ "clear-notes",
10
+ "open",
11
+ "no-open",
12
+ "help",
13
+ "version",
14
+ ]);
6
15
  const repeats = new Set(["next", "blocker", "decision", "tag", "completes"]);
7
16
  function parse(argv) {
8
17
  const command = [];
9
18
  const flags = Object.create(null);
10
19
  for (let index = 0; index < argv.length; index += 1) {
11
- const token = argv[index];
20
+ const raw = argv[index];
21
+ const token = raw === "-h"
22
+ ? "--help"
23
+ : raw === "-v" || raw === "-V"
24
+ ? "--version"
25
+ : raw;
12
26
  if (!token.startsWith("--")) {
13
27
  command.push(token);
14
28
  continue;
15
29
  }
16
30
  const key = token.slice(2);
17
- if (key === "json" ||
18
- key === "clear-description" ||
19
- key === "clear-notes" ||
20
- key === "open" ||
21
- key === "no-open") {
31
+ if (booleanFlags.has(key)) {
22
32
  if (flags[key] !== undefined)
23
33
  throw new ValidationError(`--${key} may only be supplied once`);
24
34
  flags[key] = true;
@@ -57,6 +67,9 @@ const allowedFlags = new Map([
57
67
  "json",
58
68
  ]),
59
69
  ],
70
+ ["todo-add", new Set(["title", "project", "entry", "json"])],
71
+ ["todos", new Set(["project", "json"])],
72
+ ["todo-update", new Set(["uid", "status", "project", "json"])],
60
73
  ["complete", new Set(["entry", "by", "reason", "source", "json"])],
61
74
  ["revoke-completion", new Set(["entry", "by", "reason", "source", "json"])],
62
75
  ["import", new Set(["file", "json"])],
@@ -83,6 +96,87 @@ const allowedFlags = new Map([
83
96
  ["export", new Set(["project", "format"])],
84
97
  ["serve", new Set(["port", "host", "open", "no-open", "json"])],
85
98
  ]);
99
+ const commandHelp = {
100
+ "todo-add": [
101
+ "Create an independent or linked Todo",
102
+ "--title <text> [--project <name>] [--entry <uid>] [--json]",
103
+ ],
104
+ todos: ["List Todos", "[--project <name>] [--json]"],
105
+ "todo-update": [
106
+ "Change Todo status or assign a project",
107
+ "--uid <uuid> [--status open|completed|cancelled] [--project <name>] [--json]",
108
+ ],
109
+ add: [
110
+ "Append a status snapshot",
111
+ "--project <name> --summary <text> [options]",
112
+ ],
113
+ complete: [
114
+ "Link a completed activity to its follow-up",
115
+ "--entry <id-or-uid> --by <id-or-uid> [options]",
116
+ ],
117
+ "revoke-completion": [
118
+ "Revoke an activity completion link",
119
+ "--entry <id-or-uid> --by <id-or-uid> [options]",
120
+ ],
121
+ import: [
122
+ "Import a JSON or JSONL ledger",
123
+ "<file> [--json] | --file <path> [--json]",
124
+ ],
125
+ current: [
126
+ "Show the latest status of each project",
127
+ "[--project <name>] [--json]",
128
+ ],
129
+ projects: ["List or search projects", "[--query <text>] [--json]"],
130
+ list: ["Alias for projects", "[--query <text>] [--json]"],
131
+ "set-project": ["Update project metadata", "--project <name> [options]"],
132
+ history: [
133
+ "Show status history, newest first",
134
+ "[--project <name>] [options]",
135
+ ],
136
+ at: [
137
+ "Show a project's status at a time",
138
+ "--project <name> --at <ISO-timestamp> [--json]",
139
+ ],
140
+ export: [
141
+ "Export a portable ledger",
142
+ "[--project <name>] [--format json|jsonl]",
143
+ ],
144
+ serve: [
145
+ "Start the local web dashboard",
146
+ "[--port <number>] [--host <address>] [--no-open] [--json]",
147
+ ],
148
+ };
149
+ function help(action) {
150
+ if (action) {
151
+ const [description, usage] = commandHelp[action];
152
+ const options = [...allowedFlags.get(action)].map((key) => ` --${key}${booleanFlags.has(key) ? "" : " <value>"}${repeats.has(key) ? " (repeatable)" : ""}`);
153
+ return `Usage: pmgr ${action} ${usage}\n\n${description}\n\nOptions:\n${options.join("\n")}\n -h, --help Show this help\n`;
154
+ }
155
+ return `Usage: pmgr <command> [options]
156
+ pmgr status <command> [options]
157
+
158
+ Local-first, append-only project status ledger.
159
+
160
+ Commands:
161
+ ${Object.entries(commandHelp)
162
+ .map(([name, [description]]) => ` ${name.padEnd(20)}${description}`)
163
+ .join("\n")}
164
+ help [command] Show general or command help
165
+ version Show the installed package version
166
+
167
+ Options:
168
+ -h, --help Show help (also supported after a command)
169
+ -v, -V, --version Show the installed package version
170
+
171
+ Environment:
172
+ PMGR_DB_PATH SQLite path (default: ~/.local/share/project-mgr/project-mgr.sqlite)
173
+
174
+ Examples:
175
+ pmgr current --json
176
+ pmgr add --project demo --status active --summary "Work started"
177
+ pmgr help history
178
+ `;
179
+ }
86
180
  function validateInvocation(command, flags) {
87
181
  const statusCommand = command[0] === "status";
88
182
  const action = command[statusCommand ? 1 : 0];
@@ -182,11 +276,54 @@ function imported(file) {
182
276
  }
183
277
  export function run(argv, write = process.stdout.write.bind(process.stdout)) {
184
278
  const { command, flags } = parse(argv);
279
+ if (command[0] === "help") {
280
+ command.shift();
281
+ flags.help = true;
282
+ }
283
+ if (argv.length === 0 || flags.help === true) {
284
+ delete flags.help;
285
+ const action = command.length || Object.keys(flags).length
286
+ ? validateInvocation(command, flags)
287
+ : undefined;
288
+ write(help(action));
289
+ return;
290
+ }
291
+ if (flags.version === true || command[0] === "version") {
292
+ if (Object.keys(flags).some((key) => key !== "version") ||
293
+ (command.length > 0 && (command.length !== 1 || command[0] !== "version")))
294
+ throw new ValidationError("usage: pmgr --version (or: pmgr version)");
295
+ const metadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
296
+ write(`${metadata.version}\n`);
297
+ return;
298
+ }
185
299
  const action = validateInvocation(command, flags);
186
300
  const db = openLedger();
187
301
  const json = flags.json === true;
188
302
  try {
189
303
  switch (action) {
304
+ case "todo-add":
305
+ output(createTodo(db, {
306
+ title: stringFlag(flags, "title", true),
307
+ project: stringFlag(flags, "project"),
308
+ entryUid: stringFlag(flags, "entry"),
309
+ }), json, write);
310
+ break;
311
+ case "todos": {
312
+ const todos = listTodos(db, stringFlag(flags, "project"));
313
+ if (json)
314
+ output(todos, true, write);
315
+ else
316
+ write(todos
317
+ .map((t) => `${t.uid}\t${t.status}\t${t.project ?? "-"}\t${t.title}`)
318
+ .join("\n") + "\n");
319
+ break;
320
+ }
321
+ case "todo-update":
322
+ output(updateTodo(db, stringFlag(flags, "uid", true), {
323
+ status: stringFlag(flags, "status"),
324
+ project: stringFlag(flags, "project"),
325
+ }), json, write);
326
+ break;
190
327
  case "add": {
191
328
  const project = stringFlag(flags, "project", true);
192
329
  const description = stringFlag(flags, "description");
package/dist/db.d.ts CHANGED
@@ -36,11 +36,13 @@ export type CompletionEvent = {
36
36
  export declare function getEntry(db: Ledger, ref: string): StatusEntry | undefined;
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
+ todos?: Todo[] | undefined;
39
40
  version: number;
40
41
  entries: {
41
42
  recordedAt: string;
42
43
  progress?: string | undefined;
43
44
  externalRef?: string | undefined;
45
+ nextStepsSourceUid?: string | undefined;
44
46
  uid: string;
45
47
  project: string;
46
48
  status: import("./domain.js").ProjectStatus;
@@ -55,3 +57,18 @@ export declare function exportLedger(db: Ledger, project?: string): {
55
57
  completions: CompletionEvent[];
56
58
  };
57
59
  export declare function importLedger(db: Ledger, values: unknown[]): InsertResult[];
60
+ export type Todo = {
61
+ uid: string;
62
+ title: string;
63
+ project: string | null;
64
+ entryUid: string | null;
65
+ stepIndex: number | null;
66
+ status: "open" | "completed" | "cancelled";
67
+ createdAt: string;
68
+ completedAt: string | null;
69
+ };
70
+ export declare function listTodos(db: Ledger, project?: string): Todo[];
71
+ export declare function createTodo(db: Ledger, value: unknown): Todo;
72
+ export declare function convertNextStep(db: Ledger, ref: string, index: number): Todo;
73
+ export declare function updateTodo(db: Ledger, uid: string, value: unknown): Todo;
74
+ export declare function completeActivity(db: Ledger, ref: string): StatusEntry;
package/dist/db.js CHANGED
@@ -75,6 +75,19 @@ export function openLedger(path = databasePath()) {
75
75
  BEGIN SELECT RAISE(ABORT, 'entry uid required; upgrade the writer'); END;`);
76
76
  db.prepare("INSERT INTO schema_migrations(version) VALUES (3)").run();
77
77
  }
78
+ if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 4").get()) {
79
+ db.exec(`ALTER TABLE status_entries ADD COLUMN next_steps_source_uid TEXT REFERENCES status_entries(uid);
80
+ CREATE TABLE todos (
81
+ uid TEXT PRIMARY KEY, title TEXT NOT NULL,
82
+ project TEXT REFERENCES projects(slug),
83
+ entry_uid TEXT REFERENCES status_entries(uid),
84
+ step_index INTEGER, status TEXT NOT NULL CHECK(status IN ('open','completed','cancelled')),
85
+ created_at TEXT NOT NULL, completed_at TEXT,
86
+ UNIQUE(entry_uid, step_index)
87
+ );
88
+ CREATE INDEX idx_todos_project ON todos(project);
89
+ INSERT INTO schema_migrations(version) VALUES (4);`);
90
+ }
78
91
  }).immediate();
79
92
  return db;
80
93
  }
@@ -88,6 +101,9 @@ function entryFromRow(row) {
88
101
  ...(row.progress ? { progress: row.progress } : {}),
89
102
  blockers: JSON.parse(row.blockers_json),
90
103
  nextSteps: JSON.parse(row.next_steps_json),
104
+ ...(row.next_steps_source_uid
105
+ ? { nextStepsSourceUid: row.next_steps_source_uid }
106
+ : {}),
91
107
  decisions: JSON.parse(row.decisions_json),
92
108
  tags: JSON.parse(row.tags_json),
93
109
  occurredAt: row.occurred_at,
@@ -204,6 +220,8 @@ export function addStatus(db, value, defaultSource = "human", completes = []) {
204
220
  return db
205
221
  .transaction(() => {
206
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));
207
225
  for (const target of completes)
208
226
  changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
209
227
  return result;
@@ -235,15 +253,24 @@ function addValidatedStatus(db, input, recordedAt) {
235
253
  return { entry: existing, inserted: false };
236
254
  }
237
255
  }
256
+ if (input.nextStepsSourceUid) {
257
+ const origin = getEntry(db, input.nextStepsSourceUid);
258
+ if (!origin ||
259
+ origin.nextStepsSourceUid !== undefined ||
260
+ origin.project !== input.project ||
261
+ origin.occurredAt >= input.occurredAt ||
262
+ !isDeepStrictEqual(origin.nextSteps, input.nextSteps))
263
+ throw new ValidationError("invalid next step provenance");
264
+ }
238
265
  const now = recordedAt ?? new Date().toISOString();
239
266
  upsertProjectMetadata(db, { project: input.project });
240
267
  const project = db
241
268
  .prepare("SELECT id FROM projects WHERE slug = ?")
242
269
  .get(input.project);
243
270
  const result = db
244
- .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)
245
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
246
- .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());
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);
247
274
  return {
248
275
  entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
249
276
  inserted: true,
@@ -423,7 +450,17 @@ export function exportLedger(db, project) {
423
450
  const rows = db
424
451
  .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`)
425
452
  .all(...(project ? [project] : []));
426
- return { version: 2, entries, completions: rows.map(completionFromRow) };
453
+ const todos = listTodos(db, project);
454
+ return {
455
+ version: todos.length || entries.some((entry) => entry.nextStepsSourceUid)
456
+ ? 3
457
+ : 2,
458
+ entries,
459
+ completions: rows.map(completionFromRow),
460
+ ...(todos.length || entries.some((entry) => entry.nextStepsSourceUid)
461
+ ? { todos }
462
+ : {}),
463
+ };
427
464
  })();
428
465
  }
429
466
  function record(value, keys) {
@@ -475,8 +512,15 @@ export function importLedger(db, values) {
475
512
  const results = [];
476
513
  for (const value of values) {
477
514
  if (value && typeof value === "object" && "version" in value) {
478
- const bundle = record(value, ["version", "entries", "completions"]);
479
- if (bundle.version !== 2 ||
515
+ const bundle = record(value, [
516
+ "version",
517
+ "entries",
518
+ "completions",
519
+ "todos",
520
+ ]);
521
+ if ((bundle.version !== 2 && bundle.version !== 3) ||
522
+ (bundle.version === 3 && !Array.isArray(bundle.todos)) ||
523
+ (bundle.version === 2 && bundle.todos !== undefined) ||
480
524
  !Array.isArray(bundle.entries) ||
481
525
  !Array.isArray(bundle.completions))
482
526
  throw new ValidationError("invalid ledger bundle");
@@ -489,6 +533,9 @@ export function importLedger(db, values) {
489
533
  }
490
534
  for (const event of bundle.completions)
491
535
  insertCompletion(db, validateCompletionEvent(event));
536
+ if (bundle.version === 3)
537
+ for (const todo of bundle.todos)
538
+ importTodo(db, todo);
492
539
  }
493
540
  else
494
541
  results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
@@ -497,3 +544,188 @@ export function importLedger(db, values) {
497
544
  })
498
545
  .immediate();
499
546
  }
547
+ const todoSelect = `SELECT uid, title, project, entry_uid AS entryUid,
548
+ step_index AS stepIndex, status, created_at AS createdAt, completed_at AS completedAt FROM todos`;
549
+ export function listTodos(db, project) {
550
+ return db
551
+ .prepare(todoSelect +
552
+ (project ? " WHERE project = ?" : "") +
553
+ " ORDER BY created_at, uid")
554
+ .all(...(project ? [project] : []));
555
+ }
556
+ function insertTodo(db, todo) {
557
+ db.prepare(`INSERT INTO todos(uid,title,project,entry_uid,step_index,status,created_at,completed_at)
558
+ VALUES (?,?,?,?,?,?,?,?)`).run(todo.uid, todo.title, todo.project, todo.entryUid, todo.stepIndex, todo.status, todo.createdAt, todo.completedAt);
559
+ return todo;
560
+ }
561
+ export function createTodo(db, value) {
562
+ const input = record(value, ["title", "project", "entryUid"]);
563
+ if (typeof input.title !== "string" ||
564
+ !input.title.trim() ||
565
+ input.title.trim().length > 2000)
566
+ throw new ValidationError("title must contain 1 to 2000 characters");
567
+ let project = null;
568
+ let entryUid = null;
569
+ if (input.project != null) {
570
+ project = validateProjectMetadataInput({ project: input.project }).project;
571
+ if (!db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(project))
572
+ throw new ValidationError("project not found");
573
+ }
574
+ if (input.entryUid != null) {
575
+ const entry = getEntry(db, validateUid(input.entryUid));
576
+ if (!entry)
577
+ throw new ValidationError("entry not found");
578
+ if (project && project !== entry.project)
579
+ throw new ValidationError("source project mismatch");
580
+ project = entry.project;
581
+ entryUid = entry.uid;
582
+ }
583
+ return insertTodo(db, {
584
+ uid: randomUUID(),
585
+ title: input.title.trim(),
586
+ project,
587
+ entryUid,
588
+ stepIndex: null,
589
+ status: "open",
590
+ createdAt: new Date().toISOString(),
591
+ completedAt: null,
592
+ });
593
+ }
594
+ export function convertNextStep(db, ref, index) {
595
+ return db
596
+ .transaction(() => {
597
+ const entry = getEntry(db, ref);
598
+ if (!entry ||
599
+ !Number.isInteger(index) ||
600
+ index < 0 ||
601
+ index >= entry.nextSteps.length)
602
+ throw new ValidationError("next step not found");
603
+ if (entry.nextStepsSourceUid)
604
+ return convertNextStep(db, entry.nextStepsSourceUid, index);
605
+ const existing = db
606
+ .prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
607
+ .get(entry.uid, index);
608
+ if (existing)
609
+ return existing;
610
+ return insertTodo(db, {
611
+ uid: randomUUID(),
612
+ title: entry.nextSteps[index],
613
+ project: entry.project,
614
+ entryUid: entry.uid,
615
+ stepIndex: index,
616
+ status: "open",
617
+ createdAt: new Date().toISOString(),
618
+ completedAt: null,
619
+ });
620
+ })
621
+ .immediate();
622
+ }
623
+ export function updateTodo(db, uid, value) {
624
+ validateUid(uid);
625
+ const input = record(value, ["status", "project"]);
626
+ return db
627
+ .transaction(() => {
628
+ const todo = db.prepare(todoSelect + " WHERE uid = ?").get(uid);
629
+ if (!todo)
630
+ throw new ValidationError("todo not found");
631
+ if (input.status !== undefined) {
632
+ if (!["open", "completed", "cancelled"].includes(input.status))
633
+ throw new ValidationError("invalid todo status");
634
+ if (todo.status !== input.status) {
635
+ todo.status = input.status;
636
+ todo.completedAt =
637
+ todo.status === "completed" ? new Date().toISOString() : null;
638
+ }
639
+ }
640
+ 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");
650
+ }
651
+ db.prepare("UPDATE todos SET status=?, completed_at=?, project=? WHERE uid=?").run(todo.status, todo.completedAt, todo.project, uid);
652
+ return todo;
653
+ })
654
+ .immediate();
655
+ }
656
+ function importTodo(db, value) {
657
+ const input = record(value, [
658
+ "uid",
659
+ "title",
660
+ "project",
661
+ "entryUid",
662
+ "stepIndex",
663
+ "status",
664
+ "createdAt",
665
+ "completedAt",
666
+ ]);
667
+ validateUid(input.uid);
668
+ if (typeof input.title !== "string" ||
669
+ !input.title.trim() ||
670
+ input.title.length > 2000)
671
+ throw new ValidationError("invalid todo title");
672
+ if (!["open", "completed", "cancelled"].includes(input.status))
673
+ throw new ValidationError("invalid todo status");
674
+ parseTime(input.createdAt, "createdAt");
675
+ if (input.status === "completed")
676
+ parseTime(input.completedAt, "completedAt");
677
+ else if (input.completedAt !== null)
678
+ throw new ValidationError("invalid completion timestamp");
679
+ if (input.project !== null) {
680
+ validateProjectMetadataInput({ project: input.project });
681
+ upsertProjectMetadata(db, { project: input.project });
682
+ }
683
+ if (input.entryUid !== null) {
684
+ const entry = getEntry(db, validateUid(input.entryUid));
685
+ if (!entry || entry.project !== input.project)
686
+ 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");
692
+ }
693
+ else if (input.stepIndex !== null)
694
+ throw new ValidationError("next step requires a source");
695
+ const todo = input;
696
+ const existing = db.prepare(todoSelect + " WHERE uid = ?").get(todo.uid);
697
+ if (existing) {
698
+ if (!isDeepStrictEqual(existing, todo))
699
+ throw new ValidationError("conflicting todo uid");
700
+ }
701
+ else
702
+ insertTodo(db, todo);
703
+ }
704
+ export function completeActivity(db, ref) {
705
+ return db
706
+ .transaction(() => {
707
+ const entry = getEntry(db, ref);
708
+ if (!entry)
709
+ throw new ValidationError("entry not found");
710
+ if (entry.effectiveStatus === "completed")
711
+ return entry;
712
+ const latest = current(db, entry.project)[0];
713
+ 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.
715
+ const followUp = addValidatedStatus(db, validateStatusEntry({
716
+ project: entry.project,
717
+ status: latest.status,
718
+ summary: `Completed activity #${entry.id}`,
719
+ progress: latest.progress,
720
+ blockers: latest.blockers,
721
+ nextSteps: latest.nextSteps,
722
+ nextStepsSourceUid: latest.nextStepsSourceUid ?? latest.uid,
723
+ decisions: latest.decisions,
724
+ tags: latest.tags,
725
+ occurredAt,
726
+ }));
727
+ changeCompletion(db, entry.uid, followUp.entry.uid);
728
+ return getEntry(db, entry.uid);
729
+ })
730
+ .immediate();
731
+ }
package/dist/domain.d.ts CHANGED
@@ -26,13 +26,15 @@ export type StatusEntryInput = {
26
26
  progress?: string;
27
27
  blockers?: string[];
28
28
  nextSteps?: string[];
29
+ nextStepsSourceUid?: string;
29
30
  decisions?: string[];
30
31
  tags?: string[];
31
32
  occurredAt?: string;
32
33
  source?: Source;
33
34
  externalRef?: string;
34
35
  };
35
- export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef">> & {
36
+ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef" | "nextStepsSourceUid">> & {
37
+ nextStepsSourceUid?: string;
36
38
  id: number;
37
39
  progress?: string;
38
40
  externalRef?: string;
package/dist/domain.js CHANGED
@@ -16,6 +16,7 @@ const keys = new Set([
16
16
  "progress",
17
17
  "blockers",
18
18
  "nextSteps",
19
+ "nextStepsSourceUid",
19
20
  "decisions",
20
21
  "tags",
21
22
  "occurredAt",
@@ -128,6 +129,9 @@ export function validateStatusEntry(value, defaultSource = "human") {
128
129
  progress: text(input.progress, "progress"),
129
130
  blockers: textList(input.blockers, "blockers"),
130
131
  nextSteps: textList(input.nextSteps, "nextSteps"),
132
+ ...(input.nextStepsSourceUid === undefined
133
+ ? {}
134
+ : { nextStepsSourceUid: validateUid(input.nextStepsSourceUid) }),
131
135
  decisions: textList(input.decisions, "decisions"),
132
136
  tags: textList(input.tags, "tags"),
133
137
  occurredAt: input.occurredAt === undefined
@@ -150,6 +154,8 @@ export function toPortableStatusEntry(entry) {
150
154
  occurredAt: entry.occurredAt,
151
155
  source: entry.source,
152
156
  };
157
+ if (entry.nextStepsSourceUid !== undefined)
158
+ result.nextStepsSourceUid = entry.nextStepsSourceUid;
153
159
  if (entry.progress !== undefined)
154
160
  result.progress = entry.progress;
155
161
  if (entry.externalRef !== undefined)
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { createServer, } from "node:http";
3
3
  import { URL } from "node:url";
4
- import { countEntries, getEntry, history, listProjects, openLedger, } from "./db.js";
4
+ import { countEntries, createTodo, listTodos, updateTodo, convertNextStep, completeActivity, getEntry, history, listProjects, openLedger, } from "./db.js";
5
+ import { ValidationError } from "./domain.js";
5
6
  import { getWebUIHTML } from "./web/ui.js";
6
7
  export function openBrowser(url) {
7
8
  const platform = process.platform;
@@ -34,6 +35,11 @@ function sendJson(res, statusCode, data) {
34
35
  res.end(JSON.stringify(data));
35
36
  }
36
37
  export function handleRequest(req, res, db) {
38
+ if (req.method === "POST" &&
39
+ /^\/api\/(todos(?:\/[^/]+)?|entries\/[^/]+\/(complete|todos))$/.test((req.url || "").split("?")[0])) {
40
+ void handleMutation(req, res, db);
41
+ return;
42
+ }
37
43
  if (req.method !== "GET" && req.method !== "HEAD") {
38
44
  sendJson(res, 405, { error: "Method not allowed" });
39
45
  return;
@@ -61,6 +67,10 @@ export function handleRequest(req, res, db) {
61
67
  res.end(getWebUIHTML());
62
68
  return;
63
69
  }
70
+ if (pathname === "/api/todos") {
71
+ sendJson(res, 200, listTodos(db, query.project));
72
+ return;
73
+ }
64
74
  if (pathname === "/api/projects") {
65
75
  const projects = listProjects(db, { query: query.query });
66
76
  sendJson(res, 200, projects);
@@ -162,3 +172,68 @@ export function startServer(options = {}) {
162
172
  });
163
173
  });
164
174
  }
175
+ async function handleMutation(req, res, db) {
176
+ try {
177
+ const host = req.headers.host;
178
+ if (!host ||
179
+ !/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) ||
180
+ (req.headers.origin !== undefined &&
181
+ req.headers.origin !== `http://${host}`) ||
182
+ (req.headers["sec-fetch-site"] !== undefined &&
183
+ !["same-origin", "none"].includes(String(req.headers["sec-fetch-site"])))) {
184
+ sendJson(res, 403, {
185
+ error: "Only same-origin local writes are allowed",
186
+ });
187
+ return;
188
+ }
189
+ if (req.headers["content-type"] !== "application/json") {
190
+ sendJson(res, 415, { error: "JSON required" });
191
+ return;
192
+ }
193
+ const chunks = [];
194
+ let size = 0;
195
+ for await (const chunk of req) {
196
+ const bytes = Buffer.from(chunk);
197
+ size += bytes.length;
198
+ if (size > 32768) {
199
+ sendJson(res, 413, { error: "Request too large" });
200
+ return;
201
+ }
202
+ chunks.push(bytes);
203
+ }
204
+ const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
205
+ const parts = new URL(req.url, `http://${host}`).pathname
206
+ .split("/")
207
+ .map(decodeURIComponent);
208
+ let result;
209
+ if (parts[2] === "todos")
210
+ result = parts[3]
211
+ ? updateTodo(db, parts[3], input)
212
+ : createTodo(db, input);
213
+ else {
214
+ if (!input || typeof input !== "object" || Array.isArray(input))
215
+ throw new ValidationError("expected an object");
216
+ const data = input;
217
+ if (parts[4] === "complete") {
218
+ if (Object.keys(data).length)
219
+ throw new ValidationError("unexpected fields");
220
+ result = completeActivity(db, parts[3]);
221
+ }
222
+ else if (Object.keys(data).length === 1 && "index" in data)
223
+ result = convertNextStep(db, parts[3], data.index);
224
+ else {
225
+ if (Object.keys(data).some((key) => key !== "title"))
226
+ throw new ValidationError("unexpected fields");
227
+ result = createTodo(db, { title: data.title, entryUid: parts[3] });
228
+ }
229
+ }
230
+ sendJson(res, 200, result);
231
+ }
232
+ catch (error) {
233
+ sendJson(res, error instanceof ValidationError ||
234
+ error instanceof SyntaxError ||
235
+ error instanceof URIError
236
+ ? 400
237
+ : 500, { error: error instanceof Error ? error.message : "Request failed" });
238
+ }
239
+ }
@@ -1,4 +1,20 @@
1
1
  export const en = {
2
+ savedRefreshError: "Saved, but the list could not refresh. Refresh before retrying.",
3
+ todos: "Todos",
4
+ todoTitle: "Todo title",
5
+ addTodo: "Add Todo",
6
+ complete: "Mark completed",
7
+ convertTodo: "Convert to Todo",
8
+ noTodos: "No Todos",
9
+ noProject: "No project",
10
+ sourceEntry: "Source activity",
11
+ reopen: "Reopen",
12
+ cancelTodo: "Cancel Todo",
13
+ todo_open: "Open",
14
+ todo_completed: "Completed",
15
+ todo_cancelled: "Cancelled",
16
+ viewTodos: "View Todos",
17
+ chooseProject: "Assign project",
2
18
  htmlLang: "en",
3
19
  pageTitle: "Project Manager - Projects & Activity Feed",
4
20
  appTitle: "Project Manager",
@@ -1,4 +1,20 @@
1
1
  export declare const zh: {
2
+ readonly savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。";
3
+ readonly todos: "待办";
4
+ readonly todoTitle: "待办内容";
5
+ readonly addTodo: "新增待办";
6
+ readonly complete: "标为已完成";
7
+ readonly convertTodo: "转为待办";
8
+ readonly noTodos: "暂无待办";
9
+ readonly noProject: "不关联项目";
10
+ readonly sourceEntry: "来源动态";
11
+ readonly reopen: "重新打开";
12
+ readonly cancelTodo: "取消待办";
13
+ readonly todo_open: "待办";
14
+ readonly todo_completed: "已完成";
15
+ readonly todo_cancelled: "已取消";
16
+ readonly viewTodos: "查看待办";
17
+ readonly chooseProject: "关联项目";
2
18
  readonly htmlLang: "zh-CN";
3
19
  readonly pageTitle: "项目管理 - 项目与动态概览";
4
20
  readonly appTitle: "项目管理";
@@ -1,4 +1,20 @@
1
1
  export const zh = {
2
+ savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。",
3
+ todos: "待办",
4
+ todoTitle: "待办内容",
5
+ addTodo: "新增待办",
6
+ complete: "标为已完成",
7
+ convertTodo: "转为待办",
8
+ noTodos: "暂无待办",
9
+ noProject: "不关联项目",
10
+ sourceEntry: "来源动态",
11
+ reopen: "重新打开",
12
+ cancelTodo: "取消待办",
13
+ todo_open: "待办",
14
+ todo_completed: "已完成",
15
+ todo_cancelled: "已取消",
16
+ viewTodos: "查看待办",
17
+ chooseProject: "关联项目",
2
18
  htmlLang: "zh-CN",
3
19
  pageTitle: "项目管理 - 项目与动态概览",
4
20
  appTitle: "项目管理",
@@ -2,11 +2,13 @@ import { getClientI18nScript } from "./i18n.js";
2
2
  export function getWebUIScript(initialLang) {
3
3
  return `${getClientI18nScript(initialLang)}
4
4
 
5
+ let todosData = [];
5
6
  let projectsData = [];
6
7
  let historyData = [];
7
8
  let focusedEntry = null;
8
9
  let activeProjectFilter = null;
9
10
  let activeGroupMode = 'grouped';
11
+ let initialNavigation = true;
10
12
 
11
13
  function getTodayDateString() {
12
14
  const now = new Date();
@@ -32,13 +34,15 @@ export function getWebUIScript(initialLang) {
32
34
 
33
35
  async function fetchData() {
34
36
  try {
35
- const [projRes, histRes, statsRes] = await Promise.all([
37
+ const [projRes, histRes, statsRes, todosRes] = await Promise.all([
36
38
  fetch('/api/projects'),
37
39
  fetch('/api/history?limit=1000'),
38
- fetch('/api/stats')
40
+ fetch('/api/stats'),
41
+ fetch('/api/todos')
39
42
  ]);
40
43
 
41
- if (projRes.ok) projectsData = await projRes.json();
44
+ if (![projRes, histRes, statsRes, todosRes].every(response => response.ok)) throw new Error(t('entryLoadError'));
45
+ projectsData = await projRes.json();
42
46
  if (histRes.ok) historyData = await histRes.json();
43
47
  if (statsRes.ok) {
44
48
  const stats = await statsRes.json();
@@ -47,24 +51,32 @@ export function getWebUIScript(initialLang) {
47
51
  document.getElementById('statUpdates').textContent = stats.totalEntries;
48
52
  }
49
53
 
54
+ if (!todosRes.ok) throw new Error('Todos unavailable');
55
+ todosData = await todosRes.json();
50
56
  render();
51
57
  if (typeof location !== 'undefined') {
52
58
  const ref = new URLSearchParams(location.search).get('entry');
53
59
  if (ref) {
54
- switchTab('activity');
60
+ if (initialNavigation) switchTab('activity');
55
61
  const response = await fetch('/api/entries/' + encodeURIComponent(ref));
56
62
  if (!response.ok) throw new Error('Entry unavailable');
57
63
  focusedEntry = await response.json();
58
64
  render();
59
65
  }
60
66
  }
67
+ initialNavigation = false;
68
+ return true;
61
69
  } catch (err) {
70
+ document.getElementById('actionError').textContent = t('entryLoadError');
62
71
  console.error('Failed to load data', err);
63
72
  document.getElementById('timelineContainer').textContent = t('entryLoadError');
73
+ return false;
64
74
  }
65
75
  }
66
76
 
67
77
  function switchTab(tab) {
78
+ document.getElementById('tabTodos').classList.toggle('active', tab === 'todos');
79
+ document.getElementById('viewTodos').classList.toggle('active', tab === 'todos');
68
80
  document.getElementById('tabProjects').classList.toggle('active', tab === 'projects');
69
81
  document.getElementById('tabActivity').classList.toggle('active', tab === 'activity');
70
82
  document.getElementById('viewProjects').classList.toggle('active', tab === 'projects');
@@ -176,7 +188,7 @@ export function getWebUIScript(initialLang) {
176
188
  <div class="section-box">
177
189
  <div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
178
190
  <ul class="bullet-list">
179
- \${item.nextSteps.map(n => \`<li>\${escapeHtml(n)}</li>\`).join('')}
191
+ \${item.nextSteps.map((n, index) => todosData.some(todo => todo.entryUid === (item.nextStepsSourceUid || item.uid) && todo.stepIndex === index) ? '' : \`<li>\${escapeHtml(n)} <button class="btn btn-sm" data-action="convert-todo" data-entry="\${escapeHtml(item.uid)}" data-index="\${index}">\${escapeHtml(t('convertTodo'))}</button></li>\`).join('')}
180
192
  </ul>
181
193
  </div>
182
194
  \` : ''}
@@ -196,6 +208,11 @@ export function getWebUIScript(initialLang) {
196
208
  </div>
197
209
  \` : ''}
198
210
  \${item.completedBy ? '</details>' : ''}
211
+ <div class="section-box">
212
+ <button class="btn" data-action="add-todo" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('addTodo'))}</button>
213
+ \${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('')}
215
+ </div>
199
216
  </div>
200
217
  \`;
201
218
  }
@@ -214,6 +231,7 @@ export function getWebUIScript(initialLang) {
214
231
  const query = document.getElementById('searchInput').value.trim().toLowerCase();
215
232
  const selectedDate = document.getElementById('activityDateInput').value;
216
233
 
234
+ renderTodos(query);
217
235
  // Render Active Filter Chip
218
236
  const filterBar = document.getElementById('filterBarContainer');
219
237
  const chip = document.getElementById('activeFilterChip');
@@ -271,6 +289,7 @@ export function getWebUIScript(initialLang) {
271
289
  </div>
272
290
  <div class="card-footer">
273
291
  <span>\${escapeHtml(t('createdLabel'))} \${formatTime(p.createdAt)}</span>
292
+ <button class="btn" data-action="project-todos" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewTodos'))}</button>
274
293
  <a class="link-btn" data-action="filter-project" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewActivity'))}</a>
275
294
  </div>
276
295
  </div>
@@ -352,5 +371,70 @@ export function getWebUIScript(initialLang) {
352
371
  .replace(/'/g, '&#039;');
353
372
  }
354
373
 
374
+
375
+ function projectOptions(selected) {
376
+ return '<option value="">' + escapeHtml(t('noProject')) + '</option>' + projectsData.map(p =>
377
+ '<option value="' + escapeHtml(p.project) + '"' + (selected === p.project ? ' selected' : '') + '>' + escapeHtml(p.displayName) + '</option>').join('');
378
+ }
379
+ function renderTodo(todo) {
380
+ return '<div class="section-box"><strong>' + escapeHtml(todo.title) + '</strong> · ' + escapeHtml(t('todo_' + todo.status)) +
381
+ (todo.project ? ' · ' + escapeHtml(todo.project) : '') +
382
+ (todo.entryUid ? ' <a class="link-btn" href="?entry=' + encodeURIComponent(todo.entryUid) + '">' + escapeHtml(t('sourceEntry')) + '</a>' :
383
+ ' <label>' + escapeHtml(t('chooseProject')) + ' <select data-todo-project="' + escapeHtml(todo.uid) + '">' + projectOptions(todo.project) + '</select></label>') +
384
+ ' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="' + (todo.status === 'open' ? 'completed' : 'open') + '">' + escapeHtml(t(todo.status === 'open' ? 'complete' : 'reopen')) + '</button>' +
385
+ (todo.status === 'open' ? ' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="cancelled">' + escapeHtml(t('cancelTodo')) + '</button>' : '') + '</div>';
386
+ }
387
+ function renderTodos(query) {
388
+ const select = document.getElementById('todoProject');
389
+ select.innerHTML = projectOptions(select.value);
390
+ const todos = todosData.filter(todo => (!activeProjectFilter || todo.project === activeProjectFilter) &&
391
+ (!query || (todo.title + ' ' + (todo.project || '')).toLowerCase().includes(query)));
392
+ document.getElementById('todosContainer').innerHTML = todos.length ? todos.map(renderTodo).join('') : '<p>' + escapeHtml(t('noTodos')) + '</p>';
393
+ }
394
+ async function mutate(path, data) {
395
+ const response = await fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)});
396
+ const result = await response.json();
397
+ if (!response.ok) throw new Error(result.error || 'Request failed');
398
+ if (!await fetchData()) { const error = new Error(t('savedRefreshError')); error.saved = true; throw error; }
399
+ }
400
+ async function runAction(element, action) {
401
+ if (element.disabled) return;
402
+ element.disabled = true;
403
+ document.getElementById('actionError').textContent = '';
404
+ try { await action(); }
405
+ catch (error) { document.getElementById('actionError').textContent = error.message; }
406
+ finally { element.disabled = false; }
407
+ }
408
+ document.getElementById('todoForm').addEventListener('submit', function(event) {
409
+ event.preventDefault();
410
+ const button = this.querySelector('button');
411
+ runAction(button, async () => {
412
+ try {
413
+ await mutate('/api/todos', {title: document.getElementById('todoTitle').value, project: document.getElementById('todoProject').value || null});
414
+ document.getElementById('todoTitle').value = '';
415
+ } catch (error) {
416
+ if (error.saved) document.getElementById('todoTitle').value = '';
417
+ throw error;
418
+ }
419
+ });
420
+ });
421
+ document.addEventListener('change', function(event) {
422
+ const select = event.target.closest('[data-todo-project]');
423
+ if (select) runAction(select, () => mutate('/api/todos/' + select.dataset.todoProject, {project: select.value || null}));
424
+ });
425
+ document.addEventListener('click', function(event) {
426
+ const button = event.target.closest('[data-action]');
427
+ if (!button) return;
428
+ const data = button.dataset;
429
+ if (data.action === 'project-todos') { activeProjectFilter = data.project; switchTab('todos'); render(); document.getElementById('todoProject').value = data.project; return; }
430
+ if (data.action === 'complete-entry') runAction(button, () => mutate('/api/entries/' + data.entry + '/complete', {}));
431
+ if (data.action === 'convert-todo') runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {index: Number(data.index)}));
432
+ if (data.action === 'todo-status') runAction(button, () => mutate('/api/todos/' + data.uid, {status: data.status}));
433
+ if (data.action === 'add-todo') {
434
+ const title = window.prompt(t('todoTitle'));
435
+ if (title && title.trim()) runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {title}));
436
+ }
437
+ });
438
+
355
439
  fetchData();`;
356
440
  }
package/dist/web/ui.js CHANGED
@@ -45,6 +45,7 @@ ${WEB_UI_STYLES}
45
45
  <div class="nav-tabs">
46
46
  <button class="tab-btn active" id="tabProjects" data-i18n="projects" onclick="switchTab('projects')">${dict.projects}</button>
47
47
  <button class="tab-btn" id="tabActivity" data-i18n="activityFeed" onclick="switchTab('activity')">${dict.activityFeed}</button>
48
+ <button class="tab-btn" id="tabTodos" data-i18n="todos" onclick="switchTab('todos')">${dict.todos}</button>
48
49
  </div>
49
50
  <button class="btn" id="btnRefresh" data-i18n="refresh" onclick="refreshData()">${dict.refresh}</button>
50
51
  </div>
@@ -54,6 +55,17 @@ ${WEB_UI_STYLES}
54
55
  <span id="labelFilterByProject" data-i18n="filteringByProject">${dict.filteringByProject}</span> <span id="activeFilterChip" class="filter-chip"></span>
55
56
  </div>
56
57
 
58
+ <div id="actionError" role="alert"></div>
59
+ <div id="viewTodos" class="view-panel">
60
+ <form id="todoForm" class="section-box">
61
+ <label for="todoTitle" data-i18n="todoTitle">${dict.todoTitle}</label>
62
+ <input id="todoTitle" class="search-input" required maxlength="2000" />
63
+ <label for="todoProject" data-i18n="projects">${dict.projects}</label>
64
+ <select id="todoProject" class="date-input"></select>
65
+ <button class="btn" type="submit" data-i18n="addTodo">${dict.addTodo}</button>
66
+ </form>
67
+ <div id="todosContainer"></div>
68
+ </div>
57
69
  <!-- Projects View -->
58
70
  <div id="viewProjects" class="view-panel active">
59
71
  <div id="projectsContainer" class="projects-grid"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-mgr",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first append-only project status ledger",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: project-mgr
3
- description: Manage project status with the local-first project-mgr CLI. Use when an AI agent needs to add an append-only project update, set project descriptions/notes, list or fuzzy-search projects, start the local Web Dashboard, import status records, query current or historical project state, or export a status ledger.
3
+ description: Manage project status with the local-first project-mgr CLI. Use when an AI agent needs to add an append-only project update, set project descriptions/notes, list or fuzzy-search projects, start the local Web Dashboard, import status records, query current or historical project state, or export a status ledger. When one effort contains multiple sub-projects, ask whether they share one project name or should be distinguished with tags.
4
4
  ---
5
5
 
6
6
  # project-mgr
7
7
 
8
8
  ## CLI selection
9
9
 
10
- Prefer an installed `pmgr`: locate it with `command -v pmgr` and check the installed package metadata/documentation for the required version and capabilities. The current CLI does not implement `--help` or `--version`; do not use those flags as health checks. In a source checkout, use `pnpm pmgr` only when the local dependencies are already installed and the package manager is available without downloading it.
10
+ Prefer an installed `pmgr`: locate it with `command -v pmgr` and check the installed package metadata/documentation for the required version and capabilities. This source revision supports `-h` / `--help`, `help [command]`, and `-v` / `-V` / `--version` / `version` without opening the database. Older installed releases may not support these flags; inspect their package metadata instead. In a source checkout, use `pnpm pmgr` only when the local dependencies are already installed and the package manager is available without downloading it.
11
11
 
12
12
  Do not automatically install or upgrade dependencies, or fall back to `npx` / `npm exec`. If no usable CLI is available, report the missing executable, dependency, or capability and the setup needed. Perform installation or upgrades only when the user authorizes that setup.
13
13
 
@@ -23,6 +23,7 @@ Completion links and version-2 exports require project-mgr 0.3.0 or later. This
23
23
  - Set `--source agent` for agent-authored updates. Use `--external-ref` only when a stable idempotency key is available.
24
24
  - Explicitly pass `--status` on every `add`, and include `status` in every agent-authored import entry. The CLI defaults to `completed`; do not rely on that default. Choose the status from the evidence and inspect the current snapshot when unclear. Completing one activity does not necessarily complete the project; use `active` when project work continues. If the status remains uncertain, clarify before writing.
25
25
  - If the project is not yet in the ledger database, initialize its metadata with `set-project`. Backfill only milestones supported by verifiable Git commits, dated documents, or historical records, and include the evidence reference in `--progress`. Use historical `--at` timestamps only when supported by that evidence; a commit timestamp proves a commit, not a deployment. There is no required number of entries. Never invent milestones or dates; when evidence is unavailable, skip backfill and append only the current known status.
26
+ - If one effort contains multiple sub-projects and the user has not already chosen how to identify them, ask before writing. See "Multiple sub-projects".
26
27
 
27
28
  ## Metadata vs. status entries
28
29
 
@@ -31,7 +32,20 @@ Completion links and version-2 exports require project-mgr 0.3.0 or later. This
31
32
  - **Do NOT** put transient progress, session logs, or one-off status messages into `--notes`.
32
33
  - Avoid passing `--notes` during routine `add` commands to prevent accidentally overwriting long-term project notes.
33
34
  - **Project description (`--description`)**: A high-level, stable summary of the project's purpose.
34
- - **Status snapshots (`add`)**: Use `--summary` (concise milestone headline), `--progress` (detailed work accomplished), `--next` (immediate next steps), `--blocker` (current obstacles), and `--decision` for transactional, append-only updates.
35
+ - **Status snapshots (`add`)**: Use `--summary` (concise milestone headline), `--progress` (detailed work accomplished), `--next` (immediate next steps), `--blocker` (current obstacles), `--decision`, and repeatable `--tag` for transactional, append-only updates.
36
+
37
+ ## Multiple sub-projects
38
+
39
+ One repository or effort may contain several sub-projects, such as separate packages, apps, services, or contracts. `--project` is the ledger identity: current status, history, and completion links all belong to that slug. `--tag` is a repeatable label on a single status snapshot. Tags do not create a project, a separate current status, or a separate completion history. `projects` / `list --query` can match tags on the latest snapshot.
40
+
41
+ When several sub-projects are in scope and the user has not already chosen how to identify them, ask before the first write. Do not decide silently. Do not create a separate project slug per sub-project unless the user explicitly asks for separate ledger projects.
42
+
43
+ Offer these two choices:
44
+
45
+ 1. **Share one project name.** Append every sub-project to the same `--project`. Use this when the user wants one current status and one history for the whole effort. Do not add sub-project tags unless the user also asks for them.
46
+ 2. **Distinguish sub-projects with tags.** Keep that same `--project`, and pass repeatable `--tag` values naming the sub-project this snapshot covers, for example `--project acme --tag web`. Reuse the same tag on later updates for that sub-project. A tag describes that snapshot only; older snapshots keep the tags they were written with. Search with `pmgr list --query web --json`. Completion links still apply to the whole project, so link an earlier entry only when this update resolves all of that entry's commitments.
47
+
48
+ Follow a choice the user already recorded. Do not store the choice in `--notes` unless the user asks; notes are long-term project context, and `set-project --notes` replaces them.
35
49
 
36
50
  ## Operations
37
51
 
@@ -53,6 +67,10 @@ pmgr serve --port 3000 --no-open --json
53
67
  pmgr add --project my-project --status active --summary "Implemented the CLI" \
54
68
  --progress "Tests pass" --next "Publish the package" --source agent --json
55
69
 
70
+ # Same project, with a tag naming the sub-project this snapshot covers.
71
+ pmgr add --project acme --status active --summary "Shipped the web dashboard" \
72
+ --tag web --source agent --json
73
+
56
74
  # Query current state, history, or state at a time.
57
75
  pmgr current --project my-project --json
58
76
  pmgr history --project my-project --limit 20 --json