project-mgr 0.1.6 → 0.1.7
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 +7 -0
- package/README.md +34 -7
- package/README_CN.md +34 -7
- package/dist/cli.js +85 -5
- package/dist/db.d.ts +5 -1
- package/dist/db.js +103 -4
- package/dist/domain.d.ts +17 -0
- package/dist/domain.js +46 -5
- package/docs/architecture.md +3 -3
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +12 -3
- package/src/cli.ts +105 -4
- package/src/db.ts +137 -6
- package/src/domain.ts +69 -5
- package/test/core.test.ts +235 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added project metadata management (`displayName`, `description`, `notes`) and fuzzy search via `projects` / `list --query`.
|
|
8
|
+
- Added explicit field clearing for project metadata via `--clear-description` and `--clear-notes` flags in `pmgr set-project`.
|
|
9
|
+
|
|
3
10
|
## 0.1.6 - 2026-08-03
|
|
4
11
|
|
|
5
12
|
### Fixed
|
package/README.md
CHANGED
|
@@ -176,6 +176,8 @@ pnpm start at \
|
|
|
176
176
|
|
|
177
177
|
Optional arguments:
|
|
178
178
|
|
|
179
|
+
- `--description`: Project introduction or description (updates project metadata).
|
|
180
|
+
- `--notes`: Project notes/remarks (updates project metadata).
|
|
179
181
|
- `--progress`: Work already completed.
|
|
180
182
|
- `--blocker`, `--next`, `--decision`, and `--tag`: May be specified repeatedly.
|
|
181
183
|
- `--at`: When the status actually occurred. It must be an ISO 8601 timestamp with a timezone; defaults to the current time.
|
|
@@ -183,6 +185,29 @@ Optional arguments:
|
|
|
183
185
|
- `--external-ref`: A globally idempotent key. A duplicate write to the same project returns the existing record; use by another project is an error.
|
|
184
186
|
- `--json`: Emit stable JSON.
|
|
185
187
|
|
|
188
|
+
### Project Metadata & Fuzzy Search
|
|
189
|
+
|
|
190
|
+
Set or update a project's display name, description, and notes with `set-project`, or clear metadata using `--clear-description` / `--clear-notes`:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pnpm start set-project \
|
|
194
|
+
--project project-mgr \
|
|
195
|
+
--display-name "Project Manager" \
|
|
196
|
+
--description "Local-first append-only project status ledger" \
|
|
197
|
+
--notes "Core CLI and Agent Skill are published"
|
|
198
|
+
|
|
199
|
+
# Clear description or notes
|
|
200
|
+
pnpm start set-project --project project-mgr --clear-description --clear-notes
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
List projects or search projects using fuzzy matching across slug, display name, description, notes, latest status summary, and tags with `projects` (or `list`):
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
pnpm start projects
|
|
207
|
+
pnpm start list --query "ledger"
|
|
208
|
+
pnpm start projects --query "mvp" --json
|
|
209
|
+
```
|
|
210
|
+
|
|
186
211
|
## Bulk import
|
|
187
212
|
|
|
188
213
|
`import` accepts a JSON object, a JSON array, or a JSONL file containing one object per line. Every record is validated before the whole batch is written in one SQLite transaction; any failure rolls back the entire batch.
|
|
@@ -213,14 +238,16 @@ Unknown fields, unknown CLI options, extra positional arguments, invalid statuse
|
|
|
213
238
|
|
|
214
239
|
## Query and export
|
|
215
240
|
|
|
216
|
-
| Command
|
|
217
|
-
|
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
241
|
+
| Command | Purpose | Key options |
|
|
242
|
+
| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
243
|
+
| `projects` | List all projects or fuzzy search by query | `--query`, `--json` |
|
|
244
|
+
| `set-project` | Set/update project display name, description, & notes | `--project`, `--display-name`, `--description`, `--notes`, `--clear-description`, `--clear-notes`, `--json` |
|
|
245
|
+
| `current` | Current status for all projects or one project | `--project`, `--json` |
|
|
246
|
+
| `history` | History for all projects or one project, newest first | `--project`, `--since`, `--until`, `--limit`, `--offset`, `--json` |
|
|
247
|
+
| `at` | The latest status at or before a specified time | `--project`, `--at`, `--json` |
|
|
248
|
+
| `export` | Export all records or one project's records | `--project`, `--format json\|jsonl` |
|
|
222
249
|
|
|
223
|
-
All
|
|
250
|
+
All actions can also use the compatible `status <action>` form, for example `pmgr status projects`. Human-readable multi-record results list projects and their latest status, or `No entries found.` / `No projects found.` when empty. `add`, `import`, `current`, `projects`, `list`, `set-project`, `history`, and `at` support `--json`; `export` uses `--format` to select its format.
|
|
224
251
|
|
|
225
252
|
Exports omit the database-internal `id` and `recordedAt` fields and can be imported directly. Records are exported from oldest to newest so that restoring them preserves the insertion order of records with the same timestamp:
|
|
226
253
|
|
package/README_CN.md
CHANGED
|
@@ -176,6 +176,8 @@ pnpm start at \
|
|
|
176
176
|
|
|
177
177
|
可选参数:
|
|
178
178
|
|
|
179
|
+
- `--description`:项目简介/描述(更新项目元数据)。
|
|
180
|
+
- `--notes`:项目备注(更新项目元数据)。
|
|
179
181
|
- `--progress`:已经完成的工作。
|
|
180
182
|
- `--blocker`、`--next`、`--decision`、`--tag`:可以重复传入。
|
|
181
183
|
- `--at`:状态实际发生时间,必须是带时区的 ISO 8601 时间;缺省为当前时间。
|
|
@@ -183,6 +185,29 @@ pnpm start at \
|
|
|
183
185
|
- `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
|
|
184
186
|
- `--json`:输出稳定 JSON。
|
|
185
187
|
|
|
188
|
+
## 项目元数据与模糊查询
|
|
189
|
+
|
|
190
|
+
可通过 `set-project` 设置或更新项目的名称、简介与备注,或使用 `--clear-description` / `--clear-notes` 清空:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pnpm start set-project \
|
|
194
|
+
--project project-mgr \
|
|
195
|
+
--display-name "Project Manager" \
|
|
196
|
+
--description "本地优先、只追加的项目状态账本" \
|
|
197
|
+
--notes "核心 CLI 与 Agent Skill 已上线"
|
|
198
|
+
|
|
199
|
+
# 清空简介或备注
|
|
200
|
+
pnpm start set-project --project project-mgr --clear-description --clear-notes
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
可通过 `projects`(或 `list`)列出所有项目或使用 `--query` 进行模糊匹配搜索(匹配项目名称 slug、显示名称、简介、备注、最新状态摘要及标签):
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
pnpm start projects
|
|
207
|
+
pnpm start list --query "账本"
|
|
208
|
+
pnpm start projects --query "mvp" --json
|
|
209
|
+
```
|
|
210
|
+
|
|
186
211
|
## 批量导入
|
|
187
212
|
|
|
188
213
|
`import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。
|
|
@@ -213,14 +238,16 @@ pnpm start import --file statuses.jsonl --json
|
|
|
213
238
|
|
|
214
239
|
## 查询与导出
|
|
215
240
|
|
|
216
|
-
| 命令
|
|
217
|
-
|
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
241
|
+
| 命令 | 用途 | 主要选项 |
|
|
242
|
+
| ------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
243
|
+
| `projects` | 列出所有项目或按关键词模糊匹配搜索项目 | `--query`、`--json` |
|
|
244
|
+
| `set-project` | 设置/更新/清空项目的显示名称、简介与备注 | `--project`、`--display-name`、`--description`、`--notes`、`--clear-description`、`--clear-notes`、`--json` |
|
|
245
|
+
| `current` | 所有项目或单项目的当前状态 | `--project`、`--json` |
|
|
246
|
+
| `history` | 全部或单个项目的历史,最新记录在前 | `--project`、`--since`、`--until`、`--limit`、`--offset`、`--json` |
|
|
247
|
+
| `at` | 指定时间点及之前的最后状态 | `--project`、`--at`、`--json` |
|
|
248
|
+
| `export` | 导出全部或单项目记录 | `--project`、`--format json\|jsonl` |
|
|
222
249
|
|
|
223
|
-
|
|
250
|
+
全部 action 仍可使用 `status <action>` 的兼容形式,例如 `pmgr status projects`。人类可读的多条结果按项目及其最新状态输出;为空时输出 `No entries found.` 或 `No projects found.`。`add`、`import`、`current`、`projects`、`list`、`set-project`、`history`、`at` 支持 `--json`;`export` 由 `--format` 控制格式。
|
|
224
251
|
|
|
225
252
|
导出内容不包含数据库内部的 `id` 和 `recordedAt`,可以直接重新导入。记录按时间从旧到新导出,从而在恢复时保留相同时间戳记录的先后关系:
|
|
226
253
|
|
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, at, current, history, importStatuses, openLedger, parseTime, toPortableStatusEntry, ValidationError, } from "./index.js";
|
|
5
|
+
import { addStatus, at, current, history, importStatuses, listProjects, openLedger, parseTime, setProjectMetadata, toPortableStatusEntry, ValidationError, } from "./index.js";
|
|
6
6
|
const repeats = new Set(["next", "blocker", "decision", "tag"]);
|
|
7
7
|
function parse(argv) {
|
|
8
8
|
const command = [];
|
|
@@ -14,7 +14,9 @@ function parse(argv) {
|
|
|
14
14
|
continue;
|
|
15
15
|
}
|
|
16
16
|
const key = token.slice(2);
|
|
17
|
-
if (key === "json"
|
|
17
|
+
if (key === "json" ||
|
|
18
|
+
key === "clear-description" ||
|
|
19
|
+
key === "clear-notes") {
|
|
18
20
|
if (flags[key] !== undefined)
|
|
19
21
|
throw new ValidationError(`--${key} may only be supplied once`);
|
|
20
22
|
flags[key] = true;
|
|
@@ -39,6 +41,8 @@ const allowedFlags = new Map([
|
|
|
39
41
|
"project",
|
|
40
42
|
"status",
|
|
41
43
|
"summary",
|
|
44
|
+
"description",
|
|
45
|
+
"notes",
|
|
42
46
|
"progress",
|
|
43
47
|
"blocker",
|
|
44
48
|
"next",
|
|
@@ -52,6 +56,20 @@ const allowedFlags = new Map([
|
|
|
52
56
|
],
|
|
53
57
|
["import", new Set(["file", "json"])],
|
|
54
58
|
["current", new Set(["project", "json"])],
|
|
59
|
+
["projects", new Set(["query", "json"])],
|
|
60
|
+
["list", new Set(["query", "json"])],
|
|
61
|
+
[
|
|
62
|
+
"set-project",
|
|
63
|
+
new Set([
|
|
64
|
+
"project",
|
|
65
|
+
"display-name",
|
|
66
|
+
"description",
|
|
67
|
+
"notes",
|
|
68
|
+
"clear-description",
|
|
69
|
+
"clear-notes",
|
|
70
|
+
"json",
|
|
71
|
+
]),
|
|
72
|
+
],
|
|
55
73
|
[
|
|
56
74
|
"history",
|
|
57
75
|
new Set(["project", "since", "until", "limit", "offset", "json"]),
|
|
@@ -64,7 +82,7 @@ function validateInvocation(command, flags) {
|
|
|
64
82
|
const action = command[statusCommand ? 1 : 0];
|
|
65
83
|
const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
|
|
66
84
|
if (typeof action !== "string" || !allowed)
|
|
67
|
-
throw new ValidationError("usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)");
|
|
85
|
+
throw new ValidationError("usage: pmgr <add|import|current|projects|list|set-project|history|at|export> (or: pmgr status <action>)");
|
|
68
86
|
const positional = command.slice(statusCommand ? 2 : 1);
|
|
69
87
|
if (action === "import") {
|
|
70
88
|
if (positional.length > 1)
|
|
@@ -110,6 +128,28 @@ function output(value, json, write) {
|
|
|
110
128
|
else
|
|
111
129
|
write(`${JSON.stringify(value, null, 2)}\n`);
|
|
112
130
|
}
|
|
131
|
+
function formatProjectOverview(item) {
|
|
132
|
+
const lines = [`${item.project}\t${item.displayName}`];
|
|
133
|
+
if (item.description)
|
|
134
|
+
lines.push(` Description: ${item.description}`);
|
|
135
|
+
if (item.notes)
|
|
136
|
+
lines.push(` Notes: ${item.notes}`);
|
|
137
|
+
if (item.latestStatus) {
|
|
138
|
+
lines.push(` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
lines.push(` Latest Status: (none)`);
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
function outputProjects(items, json, write) {
|
|
146
|
+
if (json)
|
|
147
|
+
write(`${JSON.stringify(items)}\n`);
|
|
148
|
+
else if (items.length === 0)
|
|
149
|
+
write("No projects found.\n");
|
|
150
|
+
else
|
|
151
|
+
write(`${items.map(formatProjectOverview).join("\n")}\n`);
|
|
152
|
+
}
|
|
113
153
|
function imported(file) {
|
|
114
154
|
if (!file)
|
|
115
155
|
throw new ValidationError("import file is required");
|
|
@@ -141,9 +181,15 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
141
181
|
const json = flags.json === true;
|
|
142
182
|
try {
|
|
143
183
|
switch (action) {
|
|
144
|
-
case "add":
|
|
184
|
+
case "add": {
|
|
185
|
+
const project = stringFlag(flags, "project", true);
|
|
186
|
+
const description = stringFlag(flags, "description");
|
|
187
|
+
const notes = stringFlag(flags, "notes");
|
|
188
|
+
if (description !== undefined || notes !== undefined) {
|
|
189
|
+
setProjectMetadata(db, { project, description, notes });
|
|
190
|
+
}
|
|
145
191
|
output(addStatus(db, {
|
|
146
|
-
project
|
|
192
|
+
project,
|
|
147
193
|
status: stringFlag(flags, "status", true),
|
|
148
194
|
summary: stringFlag(flags, "summary", true),
|
|
149
195
|
progress: stringFlag(flags, "progress"),
|
|
@@ -156,6 +202,7 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
156
202
|
externalRef: stringFlag(flags, "external-ref"),
|
|
157
203
|
}).entry, json, write);
|
|
158
204
|
break;
|
|
205
|
+
}
|
|
159
206
|
case "import":
|
|
160
207
|
output(importStatuses(db, imported(stringFlag(flags, "file") ??
|
|
161
208
|
command[command[0] === "status" ? 2 : 1] ??
|
|
@@ -164,6 +211,39 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
164
211
|
case "current":
|
|
165
212
|
output(current(db, stringFlag(flags, "project")), json, write);
|
|
166
213
|
break;
|
|
214
|
+
case "projects":
|
|
215
|
+
case "list":
|
|
216
|
+
outputProjects(listProjects(db, { query: stringFlag(flags, "query") }), json, write);
|
|
217
|
+
break;
|
|
218
|
+
case "set-project": {
|
|
219
|
+
const project = stringFlag(flags, "project", true);
|
|
220
|
+
const displayName = stringFlag(flags, "display-name");
|
|
221
|
+
let description = undefined;
|
|
222
|
+
if (flags["clear-description"] === true) {
|
|
223
|
+
if (flags.description !== undefined)
|
|
224
|
+
throw new ValidationError("cannot specify both --description and --clear-description");
|
|
225
|
+
description = null;
|
|
226
|
+
}
|
|
227
|
+
else if (flags.description !== undefined) {
|
|
228
|
+
description = stringFlag(flags, "description");
|
|
229
|
+
}
|
|
230
|
+
let notes = undefined;
|
|
231
|
+
if (flags["clear-notes"] === true) {
|
|
232
|
+
if (flags.notes !== undefined)
|
|
233
|
+
throw new ValidationError("cannot specify both --notes and --clear-notes");
|
|
234
|
+
notes = null;
|
|
235
|
+
}
|
|
236
|
+
else if (flags.notes !== undefined) {
|
|
237
|
+
notes = stringFlag(flags, "notes");
|
|
238
|
+
}
|
|
239
|
+
output(setProjectMetadata(db, {
|
|
240
|
+
project,
|
|
241
|
+
displayName,
|
|
242
|
+
description,
|
|
243
|
+
notes,
|
|
244
|
+
}), json, write);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
167
247
|
case "history":
|
|
168
248
|
output(history(db, stringFlag(flags, "project"), {
|
|
169
249
|
since: stringFlag(flags, "since") &&
|
package/dist/db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { type Source, type StatusEntry } from "./domain.js";
|
|
2
|
+
import { type ProjectMetadata, type ProjectOverview, type Source, type StatusEntry } from "./domain.js";
|
|
3
3
|
export type Ledger = Database.Database;
|
|
4
4
|
export type InsertResult = {
|
|
5
5
|
entry: StatusEntry;
|
|
@@ -13,6 +13,10 @@ export type HistoryOptions = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function databasePath(): string;
|
|
15
15
|
export declare function openLedger(path?: string): Ledger;
|
|
16
|
+
export declare function setProjectMetadata(db: Ledger, value: unknown): ProjectMetadata;
|
|
17
|
+
export declare function listProjects(db: Ledger, options?: {
|
|
18
|
+
query?: string;
|
|
19
|
+
}): ProjectOverview[];
|
|
16
20
|
export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source): InsertResult;
|
|
17
21
|
export declare function importStatuses(db: Ledger, values: unknown[]): InsertResult[];
|
|
18
22
|
export declare function current(db: Ledger, project?: string): StatusEntry[];
|
package/dist/db.js
CHANGED
|
@@ -2,8 +2,8 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { validateStatusEntry, ValidationError, } from "./domain.js";
|
|
6
|
-
const
|
|
5
|
+
import { validateProjectMetadataInput, validateStatusEntry, ValidationError, } from "./domain.js";
|
|
6
|
+
const migration1 = `
|
|
7
7
|
CREATE TABLE IF NOT EXISTS projects (
|
|
8
8
|
id INTEGER PRIMARY KEY,
|
|
9
9
|
slug TEXT NOT NULL UNIQUE,
|
|
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS status_entries (
|
|
|
27
27
|
);
|
|
28
28
|
CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
|
|
29
29
|
`;
|
|
30
|
+
const migration2 = `
|
|
31
|
+
ALTER TABLE projects ADD COLUMN description TEXT;
|
|
32
|
+
ALTER TABLE projects ADD COLUMN notes TEXT;
|
|
33
|
+
`;
|
|
30
34
|
export function databasePath() {
|
|
31
35
|
return (process.env.PMGR_DB_PATH ||
|
|
32
36
|
resolve(homedir(), ".local", "share", "project-mgr", "project-mgr.sqlite"));
|
|
@@ -39,9 +43,13 @@ export function openLedger(path = databasePath()) {
|
|
|
39
43
|
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)");
|
|
40
44
|
db.transaction(() => {
|
|
41
45
|
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()) {
|
|
42
|
-
db.exec(
|
|
46
|
+
db.exec(migration1);
|
|
43
47
|
db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
|
|
44
48
|
}
|
|
49
|
+
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 2").get()) {
|
|
50
|
+
db.exec(migration2);
|
|
51
|
+
db.prepare("INSERT INTO schema_migrations(version) VALUES (2)").run();
|
|
52
|
+
}
|
|
45
53
|
}).immediate();
|
|
46
54
|
return db;
|
|
47
55
|
}
|
|
@@ -67,6 +75,97 @@ function one(db, sql, ...params) {
|
|
|
67
75
|
const row = db.prepare(sql).get(...params);
|
|
68
76
|
return row && entryFromRow(row);
|
|
69
77
|
}
|
|
78
|
+
function upsertProjectMetadata(db, input) {
|
|
79
|
+
const existing = db
|
|
80
|
+
.prepare("SELECT 1 FROM projects WHERE slug = ?")
|
|
81
|
+
.get(input.project);
|
|
82
|
+
if (!existing) {
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
db.prepare("INSERT INTO projects(slug, display_name, description, notes, created_at) VALUES (?, ?, ?, ?, ?)").run(input.project, input.displayName ?? input.project, input.description ?? null, input.notes ?? null, now);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const updates = [];
|
|
88
|
+
const params = [];
|
|
89
|
+
if (input.displayName !== undefined) {
|
|
90
|
+
updates.push("display_name = ?");
|
|
91
|
+
params.push(input.displayName);
|
|
92
|
+
}
|
|
93
|
+
if (input.description !== undefined) {
|
|
94
|
+
updates.push("description = ?");
|
|
95
|
+
params.push(input.description);
|
|
96
|
+
}
|
|
97
|
+
if (input.notes !== undefined) {
|
|
98
|
+
updates.push("notes = ?");
|
|
99
|
+
params.push(input.notes);
|
|
100
|
+
}
|
|
101
|
+
if (updates.length > 0) {
|
|
102
|
+
params.push(input.project);
|
|
103
|
+
db.prepare(`UPDATE projects SET ${updates.join(", ")} WHERE slug = ?`).run(...params);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function setProjectMetadata(db, value) {
|
|
108
|
+
const input = validateProjectMetadataInput(value);
|
|
109
|
+
return db.transaction(() => {
|
|
110
|
+
upsertProjectMetadata(db, input);
|
|
111
|
+
const row = db
|
|
112
|
+
.prepare("SELECT * FROM projects WHERE slug = ?")
|
|
113
|
+
.get(input.project);
|
|
114
|
+
return {
|
|
115
|
+
project: row.slug,
|
|
116
|
+
displayName: row.display_name,
|
|
117
|
+
...(row.description ? { description: row.description } : {}),
|
|
118
|
+
...(row.notes ? { notes: row.notes } : {}),
|
|
119
|
+
createdAt: row.created_at,
|
|
120
|
+
};
|
|
121
|
+
})();
|
|
122
|
+
}
|
|
123
|
+
export function listProjects(db, options = {}) {
|
|
124
|
+
const query = options.query?.trim().toLowerCase();
|
|
125
|
+
const projects = db
|
|
126
|
+
.prepare("SELECT * FROM projects ORDER BY slug ASC")
|
|
127
|
+
.all();
|
|
128
|
+
const latestStatuses = current(db);
|
|
129
|
+
const statusMap = new Map();
|
|
130
|
+
for (const entry of latestStatuses) {
|
|
131
|
+
statusMap.set(entry.project, entry);
|
|
132
|
+
}
|
|
133
|
+
const overviews = projects.map((row) => {
|
|
134
|
+
const slug = row.slug;
|
|
135
|
+
const latestStatus = statusMap.get(slug);
|
|
136
|
+
return {
|
|
137
|
+
project: slug,
|
|
138
|
+
displayName: row.display_name,
|
|
139
|
+
...(row.description ? { description: row.description } : {}),
|
|
140
|
+
...(row.notes ? { notes: row.notes } : {}),
|
|
141
|
+
createdAt: row.created_at,
|
|
142
|
+
...(latestStatus ? { latestStatus } : {}),
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
if (!query)
|
|
146
|
+
return overviews;
|
|
147
|
+
return overviews.filter((overview) => {
|
|
148
|
+
if (overview.project.toLowerCase().includes(query))
|
|
149
|
+
return true;
|
|
150
|
+
if (overview.displayName.toLowerCase().includes(query))
|
|
151
|
+
return true;
|
|
152
|
+
if (overview.description?.toLowerCase().includes(query))
|
|
153
|
+
return true;
|
|
154
|
+
if (overview.notes?.toLowerCase().includes(query))
|
|
155
|
+
return true;
|
|
156
|
+
if (overview.latestStatus) {
|
|
157
|
+
if (overview.latestStatus.status.toLowerCase().includes(query))
|
|
158
|
+
return true;
|
|
159
|
+
if (overview.latestStatus.summary.toLowerCase().includes(query))
|
|
160
|
+
return true;
|
|
161
|
+
if (overview.latestStatus.progress?.toLowerCase().includes(query))
|
|
162
|
+
return true;
|
|
163
|
+
if (overview.latestStatus.tags.some((t) => t.toLowerCase().includes(query)))
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
70
169
|
export function addStatus(db, value, defaultSource = "human") {
|
|
71
170
|
const input = validateStatusEntry(value, defaultSource);
|
|
72
171
|
return db.transaction(() => addValidatedStatus(db, input))();
|
|
@@ -81,7 +180,7 @@ function addValidatedStatus(db, input) {
|
|
|
81
180
|
}
|
|
82
181
|
}
|
|
83
182
|
const now = new Date().toISOString();
|
|
84
|
-
db
|
|
183
|
+
upsertProjectMetadata(db, { project: input.project });
|
|
85
184
|
const project = db
|
|
86
185
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
87
186
|
.get(input.project);
|
package/dist/domain.d.ts
CHANGED
|
@@ -2,6 +2,22 @@ export declare const statuses: readonly ["idea", "planned", "active", "blocked",
|
|
|
2
2
|
export declare const sources: readonly ["human", "agent", "import"];
|
|
3
3
|
export type ProjectStatus = (typeof statuses)[number];
|
|
4
4
|
export type Source = (typeof sources)[number];
|
|
5
|
+
export type ProjectMetadata = {
|
|
6
|
+
project: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
notes?: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
};
|
|
12
|
+
export type ProjectOverview = ProjectMetadata & {
|
|
13
|
+
latestStatus?: StatusEntry;
|
|
14
|
+
};
|
|
15
|
+
export type ProjectMetadataInput = {
|
|
16
|
+
project: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
notes?: string | null;
|
|
20
|
+
};
|
|
5
21
|
export type StatusEntryInput = {
|
|
6
22
|
project: string;
|
|
7
23
|
status: ProjectStatus;
|
|
@@ -26,5 +42,6 @@ export declare class ValidationError extends Error {
|
|
|
26
42
|
constructor(message: string);
|
|
27
43
|
}
|
|
28
44
|
export declare function parseTime(value: unknown, field: string): string;
|
|
45
|
+
export declare function validateProjectMetadataInput(value: unknown): ProjectMetadataInput;
|
|
29
46
|
export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
|
|
30
47
|
export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
|
package/dist/domain.js
CHANGED
|
@@ -21,6 +21,12 @@ const keys = new Set([
|
|
|
21
21
|
"source",
|
|
22
22
|
"externalRef",
|
|
23
23
|
]);
|
|
24
|
+
const projectMetadataKeys = new Set([
|
|
25
|
+
"project",
|
|
26
|
+
"displayName",
|
|
27
|
+
"description",
|
|
28
|
+
"notes",
|
|
29
|
+
]);
|
|
24
30
|
const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
25
31
|
export class ValidationError extends Error {
|
|
26
32
|
constructor(message) {
|
|
@@ -38,6 +44,20 @@ function text(value, field, required = false) {
|
|
|
38
44
|
throw new ValidationError(`${field} must be at most 2000 characters`);
|
|
39
45
|
return normalized;
|
|
40
46
|
}
|
|
47
|
+
function nullableText(value, field) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (value === null)
|
|
51
|
+
return null;
|
|
52
|
+
if (typeof value !== "string")
|
|
53
|
+
throw new ValidationError(`${field} must be a string or null`);
|
|
54
|
+
const normalized = value.trim();
|
|
55
|
+
if (normalized === "")
|
|
56
|
+
return null;
|
|
57
|
+
if (normalized.length > 2_000)
|
|
58
|
+
throw new ValidationError(`${field} must be at most 2000 characters`);
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
41
61
|
function textList(value, field) {
|
|
42
62
|
if (value === undefined)
|
|
43
63
|
return [];
|
|
@@ -53,6 +73,26 @@ export function parseTime(value, field) {
|
|
|
53
73
|
}
|
|
54
74
|
return new Date(value).toISOString();
|
|
55
75
|
}
|
|
76
|
+
export function validateProjectMetadataInput(value) {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
78
|
+
throw new ValidationError("project metadata must be an object");
|
|
79
|
+
const input = value;
|
|
80
|
+
for (const key of Object.keys(input))
|
|
81
|
+
if (!projectMetadataKeys.has(key))
|
|
82
|
+
throw new ValidationError(`unknown field: ${key}`);
|
|
83
|
+
const project = text(input.project, "project", true);
|
|
84
|
+
if (!projectName.test(project))
|
|
85
|
+
throw new ValidationError("project must use letters, numbers, dots, underscores, or hyphens");
|
|
86
|
+
const displayName = text(input.displayName, "displayName");
|
|
87
|
+
const description = nullableText(input.description, "description");
|
|
88
|
+
const notes = nullableText(input.notes, "notes");
|
|
89
|
+
return {
|
|
90
|
+
project,
|
|
91
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
92
|
+
...(description !== undefined ? { description } : {}),
|
|
93
|
+
...(notes !== undefined ? { notes } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
56
96
|
export function validateStatusEntry(value, defaultSource = "human") {
|
|
57
97
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
58
98
|
throw new ValidationError("entry must be an object");
|
|
@@ -88,19 +128,20 @@ export function validateStatusEntry(value, defaultSource = "human") {
|
|
|
88
128
|
};
|
|
89
129
|
}
|
|
90
130
|
export function toPortableStatusEntry(entry) {
|
|
91
|
-
|
|
131
|
+
const result = {
|
|
92
132
|
project: entry.project,
|
|
93
133
|
status: entry.status,
|
|
94
134
|
summary: entry.summary,
|
|
95
|
-
...(entry.progress === undefined ? {} : { progress: entry.progress }),
|
|
96
135
|
blockers: entry.blockers,
|
|
97
136
|
nextSteps: entry.nextSteps,
|
|
98
137
|
decisions: entry.decisions,
|
|
99
138
|
tags: entry.tags,
|
|
100
139
|
occurredAt: entry.occurredAt,
|
|
101
140
|
source: entry.source,
|
|
102
|
-
...(entry.externalRef === undefined
|
|
103
|
-
? {}
|
|
104
|
-
: { externalRef: entry.externalRef }),
|
|
105
141
|
};
|
|
142
|
+
if (entry.progress !== undefined)
|
|
143
|
+
result.progress = entry.progress;
|
|
144
|
+
if (entry.externalRef !== undefined)
|
|
145
|
+
result.externalRef = entry.externalRef;
|
|
146
|
+
return result;
|
|
106
147
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -36,8 +36,8 @@ The CLI accepts `pmgr <action>` for `add`, `import`, `current`, `history`, `at`,
|
|
|
36
36
|
|
|
37
37
|
The database contains three structures:
|
|
38
38
|
|
|
39
|
-
- `schema_migrations`: Applied migration versions.
|
|
40
|
-
- `projects`: Project slug, display name, and creation time.
|
|
39
|
+
- `schema_migrations`: Applied migration versions (1: base tables, 2: project description & notes columns).
|
|
40
|
+
- `projects`: Project slug, display name, description, notes, and creation time.
|
|
41
41
|
- `status_entries`: Immutable status snapshots and their project foreign keys.
|
|
42
42
|
|
|
43
43
|
Key `status_entries` fields:
|
|
@@ -55,7 +55,7 @@ The `project_id, occurred_at DESC, id DESC` index supports current-status and hi
|
|
|
55
55
|
|
|
56
56
|
## Initialization and migrations
|
|
57
57
|
|
|
58
|
-
After opening the database, `openLedger()` enables foreign keys, creates the migration table, and checks for and applies
|
|
58
|
+
After opening the database, `openLedger()` enables foreign keys, creates the migration table, and checks for and applies migrations 1 and 2 within a `BEGIN IMMEDIATE` transaction. The immediate transaction prevents migration checks and writes from interleaving when several local processes open the database for the first time at once.
|
|
59
59
|
|
|
60
60
|
The database path is read from `PMGR_DB_PATH` first; otherwise, it uses the absolute path `~/.local/share/project-mgr/project-mgr.sqlite`, resolved from the current user's home directory. Non-memory databases create their parent directory automatically.
|
|
61
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, 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, import status records, query current or historical project state, or export a status ledger.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# project-mgr
|
|
@@ -19,9 +19,18 @@ Use `npx --yes --package project-mgr pmgr`. In a source checkout, use `pnpm pmgr
|
|
|
19
19
|
Prefer `--json` for data that must be interpreted or reported programmatically.
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
#
|
|
22
|
+
# Set, update, or clear project metadata (description, notes, display name).
|
|
23
|
+
npx --yes --package project-mgr pmgr set-project --project my-project --description "Project summary" --notes "Internal notes" --json
|
|
24
|
+
npx --yes --package project-mgr pmgr set-project --project my-project --clear-description --clear-notes --json
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# List all projects or fuzzy search across project names, descriptions, notes, status summaries, and tags.
|
|
28
|
+
npx --yes --package project-mgr pmgr projects --json
|
|
29
|
+
npx --yes --package project-mgr pmgr list --query "keyword" --json
|
|
30
|
+
|
|
31
|
+
# Add an agent-authored status snapshot (optionally with project description or notes).
|
|
23
32
|
npx --yes --package project-mgr pmgr add --project my-project --status active --summary "Implemented the CLI" \
|
|
24
|
-
--progress "Tests pass" --next "Publish the package" --source agent --json
|
|
33
|
+
--description "Project description" --progress "Tests pass" --next "Publish the package" --source agent --json
|
|
25
34
|
|
|
26
35
|
# Query current state, history, or state at a time.
|
|
27
36
|
npx --yes --package project-mgr pmgr current --project my-project --json
|