project-mgr 0.5.0 → 0.5.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-11-02
4
+
5
+ ### Added
6
+
7
+ - Export `openWork(db, project?)` and expose it through `pmgr next` / `pmgr status next`, combining open pins, unpinned Todos, and unconverted legacy steps.
8
+ - Add live `openWork` to CLI `current --json` and show open work with pin markers in text mode. Historical reads retain their previous shape.
9
+ - Add repeatable `add --done-todo` / `--cancel-todo` for atomic closures and `todo-convert` by entry/index, project, or all current legacy sources.
10
+
11
+ ### Changed
12
+
13
+ - **CLI behavior change:** plain `add` now keeps open pins; `--next` and `--next-todo` append (first-occurrence uid order). Use `--replace-next` to replace or clear pins without changing Todo status. Domain `addStatus` still defaults to replace; imports/exports stay unchanged. Fixed-uid re-pin of an already-carried pin remains a documented L2 limitation for direct API callers.
14
+ - Reuse prior open Todos for exact trimmed `--next` titles, including unassigned Todos already pinned on the project, preferring pinned work, then the oldest Todo, while preserving provenance. Duplicate new titles in one write still create separate Todos.
15
+ - Make `pmgr next --json` the daily checklist and guide agents to keep, finish, or explicitly retire work before adding new actions. Legacy carry preserves unconverted strings until explicit conversion. No automatic cancellation.
16
+ - CLI `current` builds open work in one scan and groups it per project instead of re-querying each project.
17
+ - Translate the new Todo/open-work section of `README_CN.md` into Chinese and document the L2 fixed-UID retry limitation in the architecture notes.
18
+
3
19
  ## 0.5.0 - 2026-10-29
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -337,9 +337,12 @@ Independently migrated copies of legacy data receive different UUIDs. Cross-impo
337
337
  ### Todos and dashboard actions
338
338
 
339
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.
340
+ and does not persist next-step prose. CLI `add` now carries open pins by default;
341
+ plain `add` no longer clears them, and `--next` appends new work. An exact,
342
+ case-sensitive title (after trimming) reuses a prior open Todo in that project
343
+ or an unassigned Todo already pinned on it, preferring a current pin, then the oldest Todo. Provenance stays unchanged.
344
+ Duplicate new titles within one write still create separate Todos.
345
+ Use `--next-todo` to pin an existing UID explicitly.
343
346
  Do not combine `--next` with `--next-todo`. Duplicate UIDs in one list are rejected.
344
347
 
345
348
  ```sh
@@ -348,28 +351,48 @@ pmgr add --project demo --status active --summary "Review queued" \
348
351
  pmgr todo-add --title "Unassigned idea" --json
349
352
  pmgr add --project demo --status active --summary "Pin the idea" \
350
353
  --next-todo <todo-uuid> --source agent --json
351
- pmgr todos --project demo --status open --json
354
+ pmgr next --project demo --json
352
355
  pmgr todo-update --uid <todo-uuid> --status completed --json
353
356
  pmgr todo-update --uid <todo-uuid> --status cancelled --json
354
357
  ```
355
358
 
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.
359
+ The canonical daily checklist is `pmgr next --json` (also `pmgr status next`).
360
+ Do not combine `nextSteps`, `nextTodos`, and `todos` by hand. Before every `add`,
361
+ run `pmgr next --project P --json` and decide each item: keep it (do nothing;
362
+ existing pins carry), finish it with `--done-todo <uid>`, or retire superseded
363
+ work with `--cancel-todo <uid>`. Both close flags are repeatable and run in the
364
+ same transaction as the snapshot. Unpinned work remains visible in the backlog.
365
+ Use `--next` only for new actions, never status descriptions; put blockers in
366
+ `--blocker`. No Todo is automatically cancelled by age or similarity.
367
+
368
+ `--replace-next` restores replacement behavior: without next work it unpins
369
+ everything; with next work it pins only those UIDs. It never closes Todos.
370
+ The domain `addStatus` API still defaults to `nextMode: "replace"`; CLI `add`
371
+ passes `"carry"` unless `--replace-next` is present. Imports stay strict.
372
+
373
+ Legacy snapshots retain strings until explicitly converted. A plain carry-mode
374
+ `add` preserves the full legacy list and `nextStepsSourceUid` while any index is
375
+ unconverted; adding new next work then requires conversion or `--replace-next`.
376
+ Conversion is idempotent and appends no snapshot:
377
+
378
+ ```sh
379
+ pmgr todo-convert --entry <id-or-uid> --index 0 --json
380
+ pmgr todo-convert --project demo --json
381
+ # Back up before an explicit, user-run conversion of every current legacy source.
382
+ pmgr export --format json > backup.json
383
+ pmgr todo-convert --all --json
384
+ ```
385
+
386
+ Once all source indices have Todos, the next carry-mode `add` materializes refs
387
+ in index order, filtering out closed Todos. Historical snapshots stay unchanged.
388
+ `next` combines converted Todos and unconverted strings without duplicates.
389
+
390
+ `current --json` adds live `openWork` for each project's checklist, while its
391
+ stored `nextSteps`, `nextTodoUids`, and live `nextTodos` retain their previous
392
+ meaning, including closed pins. Text `current` prints open work with `[pinned]`
393
+ markers. `history`, `at`, and `getEntry` do not gain `openWork`; `history` text
394
+ and `completeActivity` follow-up copying stay unchanged. `at` returns stored
395
+ snapshots without live Todo resolution.
373
396
 
374
397
  A snapshot may reference Todos that are unassigned or already in that project.
375
398
  If a Todo appears in any `nextTodoUids`, or is linked to a source entry, its
package/README_CN.md CHANGED
@@ -335,8 +335,9 @@ pmgr revoke-completion --entry 12 --by 18 --reason "验收仍未完成" --source
335
335
  ### 待办与仪表板操作
336
336
 
337
337
  待办是具体工作的来源。新的状态快照按顺序存储 `nextTodoUids`,不再把下一步原文写入快照。
338
- `add --next` 会为每条标题创建一个待办,并在同一事务里写入这些 UID。
339
- 之后的快照如果再次输入相同文本,会创建新的待办;要沿用已有工作,请传 `--next-todo`。
338
+ CLI `add` 现在默认沿用仍处于 open 的钉住项:不带下一步的 `add` 不再清空它们,`--next` 会追加新工作。
339
+ 去掉首尾空白后,标题与该项目已有的 open 待办(或已钉在该项目上的未归属待办)完全一致(区分大小写)时,会复用那个待办:优先已钉住的,其次最早创建的。来源信息保持不变。
340
+ 同一次写入里重复的新标题仍会各自创建待办。要显式钉住已有 UID,请传 `--next-todo`。
340
341
  不要把 `--next` 和 `--next-todo` 写在同一次 `add` 里。同一列表里的重复 UID 会被拒绝。
341
342
 
342
343
  ```sh
@@ -345,24 +346,39 @@ pmgr add --project demo --status active --summary "已排队复查" \
345
346
  pmgr todo-add --title "未归属的想法" --json
346
347
  pmgr add --project demo --status active --summary "钉住这个想法" \
347
348
  --next-todo <todo-uuid> --source agent --json
348
- pmgr todos --project demo --status open --json
349
+ pmgr next --project demo --json
349
350
  pmgr todo-update --uid <todo-uuid> --status completed --json
350
351
  pmgr todo-update --uid <todo-uuid> --status cancelled --json
351
352
  ```
352
353
 
353
- `todos --status open` 从待办而不是字符串下一步回答「还有哪些未完成工作」。
354
- 加上 `--project` 时,当前快照钉住的 UID 会排在前面。
355
- 完成或取消待办会立刻反映在所有解析视图上,且不会改写历史快照。
356
- 新快照不再列入某个 UID,只表示它不再是当前下一步,不会改变该待办的状态。
354
+ 每日清单统一使用 `pmgr next --json`(也可写作 `pmgr status next`),不要手动拼接 `nextSteps`、`nextTodos` 和 `todos`。
355
+ 每次 `add` 之前先运行 `pmgr next --project P --json`,逐条决定:保留(什么都不做,已钉住的会自动沿用)、用 `--done-todo <uid>` 完成,或用 `--cancel-todo <uid>` 退役被取代的工作。
356
+ 这两个关闭参数可重复,并与快照在同一事务中执行。未钉住的工作仍会作为积压项显示。
357
+ `--next` 只写新的行动,不写状态描述;阻塞写进 `--blocker`。系统不会按时间或相似度自动取消任何待办。
357
358
 
358
- 旧快照保留字符串 `nextSteps`,并省略 `nextTodoUids`。转换仍按来源下标显式进行,且不会追加快照。
359
- 只要还有来源下标未转换,完成活动时的后续快照会复制完整字符串列表和 `nextStepsSourceUid`。
360
- 每个下标都已有待办之后,后续快照才按原下标顺序写入 `nextTodoUids`,不新建待办,也不改变状态。
361
- 一条快照不能同时保存原文和引用。
359
+ `--replace-next` 恢复替换语义:不带下一步时取消全部钉住,带下一步时只钉住这些 UID。它不会关闭待办。
360
+ 领域层 `addStatus` 默认仍是 `nextMode: "replace"`;CLI `add` 除非带 `--replace-next`,否则传 `"carry"`。导入仍保持严格校验。
361
+ 已知限制:领域 API 用固定 UID 重新钉住一个已沿用的待办时,原样重试会报 `conflicting entry uid`。CLI 不传固定 UID,`--external-ref` 重放不受影响。
362
362
 
363
- `current` 和 `history` 会带上存储的 `nextTodoUids`,以及实时的 `nextTodos`(`uid`、`title`、`status`、`project`)。
364
- 这个视图是待办的当前状态,不是快照发生时的历史状态。
365
- `at` 返回当时的快照;如果存了引用就包含引用,但不会附加实时视图。
363
+ 旧快照保留字符串 `nextSteps`,并省略 `nextTodoUids`,直到显式转换。
364
+ 只要还有来源下标未转换,沿用模式的 `add`(以及完成活动时的后续快照)会复制完整字符串列表和 `nextStepsSourceUid`;此时要写入新的下一步,需先转换或使用 `--replace-next`。
365
+ 转换是幂等的,不会追加快照:
366
+
367
+ ```sh
368
+ pmgr todo-convert --entry <id-or-uid> --index 0 --json
369
+ pmgr todo-convert --project demo --json
370
+ # 对所有当前旧快照做显式转换前,先备份;仅在用户要求时执行。
371
+ pmgr export --format json > backup.json
372
+ pmgr todo-convert --all --json
373
+ ```
374
+
375
+ 每个下标都已有待办之后,下一次沿用模式的 `add` 会按原下标顺序写入引用,并过滤掉已关闭的待办。历史快照保持不变。
376
+ `next` 会合并已转换的待办和未转换的字符串,不会重复。
377
+
378
+ `current --json` 为每个项目增加实时的 `openWork` 清单;存储的 `nextSteps`、`nextTodoUids` 以及实时的 `nextTodos` 含义不变,仍包含已关闭的钉住项。
379
+ 文本模式的 `current` 会列出未完成工作,并用 `[pinned]` 标出钉住项。
380
+ `history`、`at` 和 `getEntry` 不带 `openWork`;`history` 文本输出和完成活动的后续快照复制规则不变。
381
+ `at` 返回当时存储的快照,不附加实时待办状态。
366
382
 
367
383
  快照只能引用未归属的待办,或已经属于该项目的待办。
368
384
  待办一旦出现在任意 `nextTodoUids` 中,或关联了来源动态,就不能再改项目。
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
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, createTodo, listTodos, updateTodo, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
5
+ import { addStatus, createTodo, convertNextStep, getEntry, openWork, openWorkByProject, listTodos, updateTodo, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
6
6
  const booleanFlags = new Set([
7
7
  "json",
8
8
  "clear-description",
@@ -11,6 +11,8 @@ const booleanFlags = new Set([
11
11
  "no-open",
12
12
  "help",
13
13
  "version",
14
+ "replace-next",
15
+ "all",
14
16
  ]);
15
17
  const repeats = new Set([
16
18
  "next",
@@ -19,6 +21,8 @@ const repeats = new Set([
19
21
  "decision",
20
22
  "tag",
21
23
  "completes",
24
+ "done-todo",
25
+ "cancel-todo",
22
26
  ]);
23
27
  function parse(argv) {
24
28
  const command = [];
@@ -66,6 +70,9 @@ const allowedFlags = new Map([
66
70
  "blocker",
67
71
  "next",
68
72
  "next-todo",
73
+ "replace-next",
74
+ "done-todo",
75
+ "cancel-todo",
69
76
  "decision",
70
77
  "tag",
71
78
  "at",
@@ -77,6 +84,8 @@ const allowedFlags = new Map([
77
84
  ],
78
85
  ["todo-add", new Set(["title", "project", "entry", "json"])],
79
86
  ["todos", new Set(["project", "status", "json"])],
87
+ ["next", new Set(["project", "json"])],
88
+ ["todo-convert", new Set(["entry", "index", "project", "all", "json"])],
80
89
  ["todo-update", new Set(["uid", "status", "project", "json"])],
81
90
  ["complete", new Set(["entry", "by", "reason", "source", "json"])],
82
91
  ["revoke-completion", new Set(["entry", "by", "reason", "source", "json"])],
@@ -105,6 +114,11 @@ const allowedFlags = new Map([
105
114
  ["serve", new Set(["port", "host", "open", "no-open", "json"])],
106
115
  ]);
107
116
  const commandHelp = {
117
+ next: ["List all open work, pinned first", "[--project <name>] [--json]"],
118
+ "todo-convert": [
119
+ "Convert legacy next steps without appending a snapshot",
120
+ "--entry <id-or-uid> --index <n> | --project <name> | --all [--json]",
121
+ ],
108
122
  "todo-add": [
109
123
  "Create an independent or linked Todo",
110
124
  "--title <text> [--project <name>] [--entry <uid>] [--json]",
@@ -119,7 +133,7 @@ const commandHelp = {
119
133
  ],
120
134
  add: [
121
135
  "Append a status snapshot",
122
- "--project <name> --summary <text> [--next <text> | --next-todo <uid>] [options]",
136
+ "--project <name> --summary <text> [--next <text> | --next-todo <uid>] [--replace-next] [--done-todo <uid>] [--cancel-todo <uid>] [options]",
123
137
  ],
124
138
  complete: [
125
139
  "Link a completed activity to its follow-up",
@@ -193,7 +207,7 @@ function validateInvocation(command, flags) {
193
207
  const action = command[statusCommand ? 1 : 0];
194
208
  const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
195
209
  if (typeof action !== "string" || !allowed)
196
- throw new ValidationError("usage: pmgr <add|complete|revoke-completion|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)");
210
+ throw new ValidationError("usage: pmgr <add|next|todo-add|todos|todo-update|todo-convert|complete|revoke-completion|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)");
197
211
  const positional = command.slice(statusCommand ? 2 : 1);
198
212
  if (action === "import") {
199
213
  if (positional.length > 1)
@@ -232,7 +246,7 @@ function output(value, json, write) {
232
246
  write(`${JSON.stringify(value)}\n`);
233
247
  else if (Array.isArray(value)) {
234
248
  const rows = value
235
- .map((entry) => `${entry.project}\t${entry.effectiveStatus ?? entry.status}\t${entry.occurredAt}\t${entry.summary}${entry.completedBy ? ` (completed by #${entry.completedBy.id}; original: ${entry.status})` : ""}`)
249
+ .map((entry) => `${entry.project}\t${entry.effectiveStatus ?? entry.status}\t${entry.occurredAt}\t${entry.summary}${entry.completedBy ? ` (completed by #${entry.completedBy.id}; original: ${entry.status})` : ""}${entry.openWork ? entry.openWork.map((item) => `\n - ${item.title}${item.pinned ? " [pinned]" : ""}`).join("") : ""}`)
236
250
  .join("\n");
237
251
  write(`${rows || "No entries found."}\n`);
238
252
  }
@@ -312,6 +326,50 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
312
326
  const json = flags.json === true;
313
327
  try {
314
328
  switch (action) {
329
+ case "next": {
330
+ const items = openWork(db, stringFlag(flags, "project"));
331
+ if (json)
332
+ output(items, true, write);
333
+ else
334
+ write(items.length
335
+ ? items
336
+ .map((item) => `${item.project ?? "-"}\t${item.kind === "todo" ? item.uid : `${item.entryUid}#${item.stepIndex}`}\t${item.pinned ? "pinned" : "-"}\t${item.title}`)
337
+ .join("\n") + "\n"
338
+ : "No open work.\n");
339
+ break;
340
+ }
341
+ case "todo-convert": {
342
+ const ref = stringFlag(flags, "entry");
343
+ const project = stringFlag(flags, "project");
344
+ if (Number(ref !== undefined) +
345
+ Number(project !== undefined) +
346
+ Number(flags.all === true) !==
347
+ 1)
348
+ throw new ValidationError("use exactly one of --entry, --project, or --all");
349
+ if ((ref === undefined) !== (flags.index === undefined))
350
+ throw new ValidationError("--entry requires --index; --index requires --entry");
351
+ const todos = db
352
+ .transaction(() => {
353
+ if (ref !== undefined)
354
+ return [convertNextStep(db, ref, numberFlag(flags, "index", 0))];
355
+ return current(db, project).flatMap((entry) => {
356
+ if (entry.nextTodoUids !== undefined)
357
+ return [];
358
+ const source = entry.nextStepsSourceUid
359
+ ? getEntry(db, entry.nextStepsSourceUid)
360
+ : entry;
361
+ return source.nextSteps.map((_, index) => convertNextStep(db, source.uid, index));
362
+ });
363
+ })
364
+ .immediate();
365
+ if (json)
366
+ output(todos, true, write);
367
+ else
368
+ write(todos
369
+ .map((todo) => `${todo.uid}\t${todo.status}\t${todo.project}\t${todo.title}`)
370
+ .join("\n") + "\n");
371
+ break;
372
+ }
315
373
  case "todo-add":
316
374
  output(createTodo(db, {
317
375
  title: stringFlag(flags, "title", true),
@@ -363,6 +421,17 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
363
421
  progress: stringFlag(flags, "progress"),
364
422
  blockers: listFlag(flags, "blocker"),
365
423
  nextSteps: next,
424
+ nextMode: flags["replace-next"] === true ? "replace" : "carry",
425
+ closeTodos: [
426
+ ...listFlag(flags, "done-todo").map((uid) => ({
427
+ uid,
428
+ status: "completed",
429
+ })),
430
+ ...listFlag(flags, "cancel-todo").map((uid) => ({
431
+ uid,
432
+ status: "cancelled",
433
+ })),
434
+ ],
366
435
  ...(nextTodo.length > 0 ? { nextTodoUids: nextTodo } : {}),
367
436
  decisions: listFlag(flags, "decision"),
368
437
  tags: listFlag(flags, "tag"),
@@ -384,9 +453,16 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
384
453
  command[command[0] === "status" ? 2 : 1] ??
385
454
  "")).map((result) => result.entry), json, write);
386
455
  break;
387
- case "current":
388
- output(current(db, stringFlag(flags, "project")), json, write);
456
+ case "current": {
457
+ const project = stringFlag(flags, "project");
458
+ // One open-work scan for all projects, grouped per project.
459
+ const { groups } = openWorkByProject(db, project);
460
+ output(current(db, project).map((entry) => ({
461
+ ...entry,
462
+ openWork: groups.get(entry.project),
463
+ })), json, write);
389
464
  break;
465
+ }
390
466
  case "projects":
391
467
  case "list":
392
468
  outputProjects(listProjects(db, { query: stringFlag(flags, "query") }), json, write);
package/dist/db.d.ts CHANGED
@@ -1,10 +1,35 @@
1
1
  import Database from "better-sqlite3";
2
- import { type ProjectMetadata, type ProjectOverview, type Source, type StatusEntry } from "./domain.js";
2
+ import { type ProjectMetadata, type ProjectOverview, type Source, type StatusEntry, type StatusEntryInput } from "./domain.js";
3
3
  export type Ledger = Database.Database;
4
4
  export type InsertResult = {
5
5
  entry: StatusEntry;
6
6
  inserted: boolean;
7
7
  };
8
+ export type AddStatusInput = StatusEntryInput & {
9
+ nextMode?: "carry" | "replace";
10
+ closeTodos?: {
11
+ uid: string;
12
+ status: "completed" | "cancelled";
13
+ }[];
14
+ };
15
+ export type OpenWorkItem = {
16
+ kind: "todo";
17
+ uid: string;
18
+ title: string;
19
+ project: string | null;
20
+ pinned: boolean;
21
+ pinIndex: number | null;
22
+ createdAt: string;
23
+ entryUid: string | null;
24
+ } | {
25
+ kind: "legacy-step";
26
+ project: string;
27
+ entryUid: string;
28
+ stepIndex: number;
29
+ title: string;
30
+ pinned: true;
31
+ pinIndex: number;
32
+ };
8
33
  export type HistoryOptions = {
9
34
  since?: string;
10
35
  until?: string;
@@ -20,6 +45,12 @@ export declare function listProjects(db: Ledger, options?: {
20
45
  export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source, completes?: string[]): InsertResult;
21
46
  export declare function importStatuses(db: Ledger, values: unknown[]): InsertResult[];
22
47
  export declare function current(db: Ledger, project?: string): StatusEntry[];
48
+ /** Per-project open work from one current() and one Todo scan; no cross-project dedupe. */
49
+ export declare function openWorkByProject(db: Ledger, project?: string): {
50
+ groups: Map<string, OpenWorkItem[]>;
51
+ unassigned: Todo[];
52
+ };
53
+ export declare function openWork(db: Ledger, project?: string): OpenWorkItem[];
23
54
  export declare function history(db: Ledger, project?: string, options?: HistoryOptions): StatusEntry[];
24
55
  export declare function at(db: Ledger, project: string, occurredAt: string): StatusEntry | undefined;
25
56
  export declare function countEntries(db: Ledger, project?: string): number;
package/dist/db.js CHANGED
@@ -230,7 +230,7 @@ function linkCompletions(db, targets, byUid, source) {
230
230
  for (const target of targets)
231
231
  changeCompletion(db, target, byUid, "complete", undefined, source);
232
232
  }
233
- function existingIdentity(db, input, recordedAt, textRetry = false) {
233
+ function existingIdentity(db, input, recordedAt, textRetry = false, carry = false) {
234
234
  if (input.uid) {
235
235
  const existing = one(db, `${select} WHERE e.uid = ?`, input.uid);
236
236
  if (existing) {
@@ -241,7 +241,7 @@ function existingIdentity(db, input, recordedAt, textRetry = false) {
241
241
  });
242
242
  // Text/ref equivalence is only for addStatus sugar retries, not imports.
243
243
  const sameWrite = isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
244
- (textRetry && textRefEquivalent(db, existing, input));
244
+ (textRetry && textRefEquivalent(db, existing, input, carry));
245
245
  if (!sameWrite ||
246
246
  (recordedAt !== undefined && recordedAt !== existing.recordedAt))
247
247
  throw new ValidationError("conflicting entry uid");
@@ -260,13 +260,25 @@ function existingIdentity(db, input, recordedAt, textRetry = false) {
260
260
  }
261
261
  return undefined;
262
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)
263
+ function textRefEquivalent(db, existing, input, carry = false) {
264
+ // Retry against stored pins, never today's open status or latest snapshot.
265
+ if (existing.nextTodoUids === undefined) {
266
+ if (!carry || input.nextTodoUids !== undefined || input.nextSteps?.length)
267
+ return false;
268
+ return isDeepStrictEqual(toPortableStatusEntry(existing), toPortableStatusEntry({
269
+ ...input,
270
+ nextSteps: existing.nextSteps,
271
+ nextStepsSourceUid: existing.nextStepsSourceUid,
272
+ }));
273
+ }
274
+ if (!carry && input.nextTodoUids !== undefined)
266
275
  return false;
267
276
  assert.ok(input.nextSteps);
268
277
  const titles = input.nextSteps;
269
- if (titles.length !== existing.nextTodoUids.length)
278
+ const size = input.nextTodoUids?.length ?? titles.length;
279
+ if (carry
280
+ ? size > existing.nextTodoUids.length
281
+ : size !== existing.nextTodoUids.length)
270
282
  return false;
271
283
  const candidate = toPortableStatusEntry({
272
284
  ...input,
@@ -277,41 +289,116 @@ function textRefEquivalent(db, existing, input) {
277
289
  });
278
290
  if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate))
279
291
  return false;
280
- return existing.nextTodoUids.every((uid, index) => {
292
+ const suffix = existing.nextTodoUids.slice(existing.nextTodoUids.length - size);
293
+ return suffix.every((uid, index) => {
294
+ if (input.nextTodoUids !== undefined)
295
+ return uid === input.nextTodoUids[index];
281
296
  const todo = todoByUid(db, uid);
282
297
  return todo !== undefined && todo.title === titles[index];
283
298
  });
284
299
  }
285
300
  export function addStatus(db, value, defaultSource = "human", completes = []) {
286
- const input = validateStatusEntry(value, defaultSource);
301
+ if (!value || typeof value !== "object" || Array.isArray(value))
302
+ throw new ValidationError("entry must be an object");
303
+ const { nextMode = "replace", closeTodos = [], ...fields } = value;
304
+ if (nextMode !== "carry" && nextMode !== "replace")
305
+ throw new ValidationError("invalid nextMode");
306
+ if (!Array.isArray(closeTodos))
307
+ throw new ValidationError("closeTodos must be an array");
308
+ const input = validateStatusEntry(fields, defaultSource);
287
309
  if (input.nextStepsSourceUid)
288
310
  throw new ValidationError("new snapshots cannot set nextStepsSourceUid");
289
311
  return db
290
312
  .transaction(() => {
291
- const replay = existingIdentity(db, input, undefined, true);
313
+ const replay = existingIdentity(db, input, undefined, true, nextMode === "carry");
292
314
  if (replay) {
293
315
  linkCompletions(db, completes, replay.entry.uid, input.source);
294
316
  return replay;
295
317
  }
296
- if (input.nextTodoUids !== undefined) {
297
- assertSnapshotRefs(db, input.project, input.nextTodoUids);
298
- const result = addValidatedStatus(db, { ...input, nextSteps: [] });
318
+ const latest = current(db, input.project)[0];
319
+ const preserved = latest
320
+ ? preservedNextWork(db, latest)
321
+ : { nextSteps: [], nextTodoUids: [] };
322
+ // Without refs, latest is a pending legacy snapshot: pin its converted indices.
323
+ const legacy = preserved.nextTodoUids === undefined
324
+ ? legacySource(db, latest)
325
+ : undefined;
326
+ const pins = preserved.nextTodoUids ??
327
+ legacy.nextSteps.flatMap((_, index) => {
328
+ const todo = stepTodo(db, legacy.uid, index);
329
+ return todo ? [todo.uid] : [];
330
+ });
331
+ const closing = new Map();
332
+ for (const value of closeTodos) {
333
+ const close = record(value, ["uid", "status"]);
334
+ const uid = validateUid(close.uid);
335
+ if (close.status !== "completed" && close.status !== "cancelled")
336
+ throw new ValidationError("invalid closeTodos status");
337
+ if ((closing.has(uid) && closing.get(uid) !== close.status) ||
338
+ input.nextTodoUids?.includes(uid))
339
+ throw new ValidationError("cannot close a Todo in both statuses or also pin it");
340
+ const todo = todoByUid(db, uid);
341
+ if (!todo || (todo.project !== input.project && !pins.includes(uid)))
342
+ throw new ValidationError(`todo ${uid} does not belong to project ${input.project} or its current pins`);
343
+ closing.set(uid, close.status);
344
+ }
345
+ for (const [uid, status] of closing)
346
+ updateTodo(db, uid, { status });
347
+ if (nextMode === "carry" && preserved.nextTodoUids === undefined) {
348
+ if (input.nextSteps.length || input.nextTodoUids !== undefined)
349
+ throw new ValidationError(`project ${input.project} has unconverted legacy next steps; run pmgr todo-convert --project ${input.project} or pass --replace-next`);
350
+ const result = addValidatedStatus(db, { ...input, ...preserved });
299
351
  linkCompletions(db, completes, result.entry.uid, input.source);
300
352
  return result;
301
353
  }
302
- // Text --next sugar: one new Todo per title, stored only as refs.
354
+ if (input.nextTodoUids !== undefined)
355
+ assertSnapshotRefs(db, input.project, input.nextTodoUids);
356
+ // Freeze candidates before creating this write's Todos: repeated new titles stay separate.
357
+ // Same-project open Todos plus unassigned open Todos already pinned here.
358
+ const candidates = [
359
+ ...listTodos(db, input.project, { status: "open" }),
360
+ ...pins.flatMap((pin) => {
361
+ const todo = todoByUid(db, pin);
362
+ return todo?.project === null && todo.status === "open" ? [todo] : [];
363
+ }),
364
+ ];
365
+ candidates.sort((a, b) => Number(pins.includes(b.uid)) - Number(pins.includes(a.uid)) ||
366
+ a.createdAt.localeCompare(b.createdAt) ||
367
+ a.uid.localeCompare(b.uid));
303
368
  const uid = input.uid ?? randomUUID();
304
- const nextTodoUids = input.nextSteps.map(() => randomUUID());
369
+ const newTodos = [];
370
+ const resolved = input.nextTodoUids ??
371
+ input.nextSteps.map((title, index) => {
372
+ const match = input.nextSteps.indexOf(title) === index
373
+ ? candidates.find((todo) => todo.title === title)
374
+ : undefined;
375
+ if (match)
376
+ return match.uid;
377
+ const uid = randomUUID();
378
+ newTodos.push({ uid, title });
379
+ return uid;
380
+ });
381
+ const nextTodoUids = [
382
+ ...new Set([
383
+ ...(nextMode === "carry"
384
+ ? pins.filter((uid) => todoByUid(db, uid)?.status === "open")
385
+ : []),
386
+ ...resolved,
387
+ ]),
388
+ ];
389
+ if (nextTodoUids.length > 100)
390
+ throw new ValidationError("nextTodoUids must be an array of at most 100 uuids");
305
391
  const recordedAt = new Date().toISOString();
306
392
  const result = addValidatedStatus(db, { ...input, uid, nextSteps: [], nextTodoUids }, recordedAt);
307
393
  assert.equal(result.inserted, true);
308
- input.nextSteps.forEach((title, index) => {
394
+ newTodos.forEach(({ uid, title }) => {
309
395
  insertTodo(db, {
310
- uid: nextTodoUids[index],
396
+ uid,
311
397
  title,
312
398
  project: result.entry.project,
313
399
  entryUid: result.entry.uid,
314
- stepIndex: index,
400
+ // Provenance indexes the final snapshot, including carried/reused pins.
401
+ stepIndex: nextTodoUids.indexOf(uid),
315
402
  status: "open",
316
403
  createdAt: recordedAt,
317
404
  completedAt: null,
@@ -369,6 +456,109 @@ export function current(db, project) {
369
456
  .all(...(project ? [project] : []));
370
457
  return rows.map((row) => annotateEntry(db, entryFromRow(row)));
371
458
  }
459
+ /** Per-project open work from one current() and one Todo scan; no cross-project dedupe. */
460
+ export function openWorkByProject(db, project) {
461
+ const latest = current(db, project);
462
+ const byProject = new Map();
463
+ const unassigned = [];
464
+ for (const todo of listTodos(db, project)) {
465
+ if (todo.project === null)
466
+ unassigned.push(todo);
467
+ else
468
+ byProject.set(todo.project, [
469
+ ...(byProject.get(todo.project) ?? []),
470
+ todo,
471
+ ]);
472
+ }
473
+ const names = project !== undefined
474
+ ? [project]
475
+ : [
476
+ ...latest.map((entry) => entry.project),
477
+ ...[...byProject.keys()]
478
+ .filter((name) => !latest.some((entry) => entry.project === name))
479
+ .sort(),
480
+ ];
481
+ const groups = new Map();
482
+ for (const name of names) {
483
+ const items = [];
484
+ const seen = new Set();
485
+ const add = (todo, pinIndex) => {
486
+ if (todo.status !== "open" || seen.has(todo.uid))
487
+ return;
488
+ seen.add(todo.uid);
489
+ items.push({
490
+ kind: "todo",
491
+ uid: todo.uid,
492
+ title: todo.title,
493
+ project: name,
494
+ pinned: pinIndex !== null,
495
+ pinIndex,
496
+ createdAt: todo.createdAt,
497
+ entryUid: todo.entryUid,
498
+ });
499
+ };
500
+ const entry = latest.find((entry) => entry.project === name);
501
+ if (entry?.nextTodoUids !== undefined) {
502
+ entry.nextTodoUids.forEach((uid, index) => {
503
+ const todo = todoByUid(db, uid);
504
+ if (todo)
505
+ add(todo, index);
506
+ });
507
+ }
508
+ else if (entry) {
509
+ const source = legacySource(db, entry);
510
+ source.nextSteps.forEach((title, index) => {
511
+ const todo = stepTodo(db, source.uid, index);
512
+ if (todo)
513
+ add(todo, index);
514
+ else
515
+ items.push({
516
+ kind: "legacy-step",
517
+ project: name,
518
+ entryUid: source.uid,
519
+ stepIndex: index,
520
+ title,
521
+ pinned: true,
522
+ pinIndex: index,
523
+ });
524
+ });
525
+ }
526
+ for (const todo of byProject.get(name) ?? [])
527
+ add(todo, null);
528
+ groups.set(name, items);
529
+ }
530
+ return { groups, unassigned };
531
+ }
532
+ export function openWork(db, project) {
533
+ const { groups, unassigned } = openWorkByProject(db, project);
534
+ if (project !== undefined)
535
+ return groups.get(project);
536
+ // Globally, a Todo pinned by several projects appears only under the first.
537
+ const items = [];
538
+ const seen = new Set();
539
+ for (const group of groups.values())
540
+ for (const item of group) {
541
+ if (item.kind === "todo") {
542
+ if (seen.has(item.uid))
543
+ continue;
544
+ seen.add(item.uid);
545
+ }
546
+ items.push(item);
547
+ }
548
+ for (const todo of unassigned)
549
+ if (todo.status === "open" && !seen.has(todo.uid))
550
+ items.push({
551
+ kind: "todo",
552
+ uid: todo.uid,
553
+ title: todo.title,
554
+ project: null,
555
+ pinned: false,
556
+ pinIndex: null,
557
+ createdAt: todo.createdAt,
558
+ entryUid: todo.entryUid,
559
+ });
560
+ return items;
561
+ }
372
562
  export function history(db, project, options = {}) {
373
563
  const clauses = [];
374
564
  const params = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-mgr",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local-first append-only project status ledger",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,10 +64,12 @@ 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
+ # Inspect open work before every add. Existing open pins carry automatically.
68
+ pmgr next --project my-project --json
69
+ # Use --next for new actions; exact prior open titles reuse their Todo UID.
68
70
  pmgr add --project my-project --status active --summary "Implemented the CLI" \
69
71
  --progress "Tests pass" --next "Publish the package" --source agent --json
70
- pmgr todos --project my-project --status open --json
72
+ pmgr next --project my-project --json
71
73
 
72
74
  # Same project, with a tag naming the sub-project this snapshot covers.
73
75
  pmgr add --project acme --status active --summary "Shipped the web dashboard" \
@@ -94,4 +96,37 @@ When this update resolves all commitments of earlier entries (including blockers
94
96
 
95
97
  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.
96
98
 
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.
99
+ ## Daily open-work checklist
100
+
101
+ Use `pmgr next --json` as the canonical daily checklist. Do not combine
102
+ `nextSteps`, `nextTodos`, and `todos` by hand. Before every `add`, run
103
+ `pmgr next --project P --json`. Decide each item from evidence: keep it (do
104
+ nothing; open pins carry), finish it (`--done-todo <uid>`), or retire superseded
105
+ work (`--cancel-todo <uid>`). Close flags are repeatable and atomic with `add`.
106
+ Unpinned Todos remain visible as backlog; pin them explicitly with `--next-todo`.
107
+ Use `--next` only for new actions, not status descriptions. Put blockers in
108
+ `--blocker`. Never cancel older or similar work automatically.
109
+
110
+ CLI behavior change: plain `add` keeps open pins instead of clearing them;
111
+ `--next` appends. Exact trimmed, case-sensitive titles reuse an existing open
112
+ Todo of the same project, or an unassigned Todo already pinned on it, preferring
113
+ a pin, then the oldest by creation time and UID. Its provenance stays unchanged. Duplicate new titles in one write still
114
+ create separate Todos. Do not combine `--next` and `--next-todo`.
115
+ `--replace-next` only replaces pins (or clears them with no next work); it does
116
+ not close Todos. The domain API defaults to replace; imports remain strict.
117
+
118
+ Legacy strings appear as `legacy-step` items until explicitly converted with
119
+ `pmgr todo-convert --project P --json`, or `--entry <id-or-uid> --index <n>`.
120
+ `--all` converts every current legacy source only when the user requests it;
121
+ back up first with `pmgr export --format json > backup.json`. Conversion is
122
+ idempotent and appends no snapshot. A plain `add` preserves partially converted
123
+ legacy strings; new next work requires conversion or `--replace-next`.
124
+ Once fully converted, the next carry-mode `add` pins the open converted Todos
125
+ in index order.
126
+
127
+ `current --json` adds live `openWork`, and text `current` marks pinned work.
128
+ Raw `nextSteps`, `nextTodoUids`, and `nextTodos` retain their existing meaning.
129
+ `history`, `at`, and `getEntry` do not gain `openWork`; `at` does not resolve
130
+ live Todo status. Project current state still comes from the latest snapshot.
131
+ Export emits version 2 without Todos/refs, version 3 with Todos but no refs,
132
+ and version 4 with refs. Import/export never carries, reuses, or closes work.