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 +16 -0
- package/README.md +44 -21
- package/README_CN.md +30 -14
- package/dist/cli.js +82 -6
- package/dist/db.d.ts +32 -1
- package/dist/db.js +207 -17
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +38 -3
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
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
`
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
339
|
-
|
|
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
|
|
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
|
-
`
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
265
|
-
if (existing.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
394
|
+
newTodos.forEach(({ uid, title }) => {
|
|
309
395
|
insertTodo(db, {
|
|
310
|
-
uid
|
|
396
|
+
uid,
|
|
311
397
|
title,
|
|
312
398
|
project: result.entry.project,
|
|
313
399
|
entryUid: result.entry.uid,
|
|
314
|
-
|
|
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
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|