project-mgr 0.1.5 → 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 CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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
+
10
+ ## 0.1.6 - 2026-08-03
11
+
12
+ ### Fixed
13
+
14
+ - Allowed `history` to query recent entries across all projects without `--project`.
4
15
 
5
16
  ## 0.1.5 - 2026-08-03
6
17
 
package/README.md CHANGED
@@ -45,7 +45,22 @@ The installer detects supported agents and asks where to install the skill. No s
45
45
 
46
46
  In Codex, write `$project-mgr` in the prompt. Alternatively, type `/pro` and select **project-mgr** from the picker. In other agents, use their explicit skill syntax or select `project-mgr` from the skills menu.
47
47
 
48
- For example: `Use $project-mgr to show this project's current status and recent history.`
48
+ Use normal language after the skill name; the agent translates the request into CLI commands. For example:
49
+
50
+ ```text
51
+ Use $project-mgr to show the current status and latest 10 updates for project-mgr.
52
+
53
+ Use $project-mgr to record this update for project-mgr: active; released the Agent Skill;
54
+ next, collect user feedback.
55
+
56
+ Use $project-mgr to record these updates: api is blocked by authentication; web is active
57
+ and the dashboard is complete.
58
+
59
+ Use $project-mgr with PMGR_DB_PATH=/tmp/project-mgr-test.sqlite to add a test update,
60
+ then show its history.
61
+ ```
62
+
63
+ The agent should query before writing and ask for missing required values such as the project name, status, or summary.
49
64
 
50
65
  The skill uses the same `PMGR_DB_PATH` behavior as the CLI: it defaults to `~/.local/share/project-mgr/project-mgr.sqlite`; set an absolute path to work in another ledger. For manual or exploratory work, use a dedicated database such as `PMGR_DB_PATH=/tmp/project-mgr-skill-test.sqlite` rather than the default ledger.
51
66
 
@@ -142,6 +157,9 @@ pnpm start history \
142
157
  --limit 20 \
143
158
  --offset 0
144
159
 
160
+ # Recent history across every project.
161
+ pnpm start history --limit 20 --json
162
+
145
163
  pnpm start at \
146
164
  --project project-mgr \
147
165
  --at 2026-08-03T12:00:00+08:00 \
@@ -158,6 +176,8 @@ pnpm start at \
158
176
 
159
177
  Optional arguments:
160
178
 
179
+ - `--description`: Project introduction or description (updates project metadata).
180
+ - `--notes`: Project notes/remarks (updates project metadata).
161
181
  - `--progress`: Work already completed.
162
182
  - `--blocker`, `--next`, `--decision`, and `--tag`: May be specified repeatedly.
163
183
  - `--at`: When the status actually occurred. It must be an ISO 8601 timestamp with a timezone; defaults to the current time.
@@ -165,6 +185,29 @@ Optional arguments:
165
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.
166
186
  - `--json`: Emit stable JSON.
167
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
+
168
211
  ## Bulk import
169
212
 
170
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.
@@ -195,14 +238,16 @@ Unknown fields, unknown CLI options, extra positional arguments, invalid statuse
195
238
 
196
239
  ## Query and export
197
240
 
198
- | Command | Purpose | Key options |
199
- | --------- | ----------------------------------------------- | ------------------------------------------------------------------ |
200
- | `current` | Current status for all projects or one project | `--project`, `--json` |
201
- | `history` | One project's history, newest first | `--project`, `--since`, `--until`, `--limit`, `--offset`, `--json` |
202
- | `at` | The latest status at or before a specified time | `--project`, `--at`, `--json` |
203
- | `export` | Export all records or one project's records | `--project`, `--format json\|jsonl` |
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` |
204
249
 
205
- All six actions can also use the compatible `status <action>` form, for example `pmgr status export`. Human-readable multi-record results list project, status, occurrence time, and summary, or `No entries found.` when empty. `add`, `import`, `current`, `history`, and `at` support `--json`; `export` uses `--format` to select its format.
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.
206
251
 
207
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:
208
253
 
package/README_CN.md CHANGED
@@ -45,7 +45,22 @@ npx skills add deanplus/project-manager
45
45
 
46
46
  在 Codex 的提示词中直接写 `$project-mgr`。也可以输入 `/pro`,然后在选择器中选择 **project-mgr**。其他 Agent 请使用其显式 Skill 语法,或从 Skills 菜单选择 `project-mgr`。
47
47
 
48
- 例如:`使用 $project-mgr 查询当前项目状态和最近历史。`
48
+ Skill 名称后直接使用自然语言即可,Agent 会把请求转换成 CLI 命令。例如:
49
+
50
+ ```text
51
+ 使用 $project-mgr 查询 project-mgr 的当前状态和最近 10 条更新。
52
+
53
+ 使用 $project-mgr 记录 project-mgr 的状态:active;已发布 Agent Skill;
54
+ 下一步收集用户反馈。
55
+
56
+ 使用 $project-mgr 批量记录状态:api 因认证问题 blocked;web 为 active,
57
+ dashboard 已完成。
58
+
59
+ 使用 $project-mgr 和 PMGR_DB_PATH=/tmp/project-mgr-test.sqlite 添加一条测试状态,
60
+ 然后查询其历史。
61
+ ```
62
+
63
+ Agent 应在写入前先查询;如果缺少项目名、状态或摘要等必填信息,它会向用户确认。
49
64
 
50
65
  该 Skill 与 CLI 使用相同的 `PMGR_DB_PATH` 规则:默认使用 `~/.local/share/project-mgr/project-mgr.sqlite`;如需使用其他账本,请设置绝对路径。手工或探索性操作请使用独立数据库,例如 `PMGR_DB_PATH=/tmp/project-mgr-skill-test.sqlite`,避免写入默认账本。
51
66
 
@@ -142,6 +157,9 @@ pnpm start history \
142
157
  --limit 20 \
143
158
  --offset 0
144
159
 
160
+ # 查询全部项目的近期历史。
161
+ pnpm start history --limit 20 --json
162
+
145
163
  pnpm start at \
146
164
  --project project-mgr \
147
165
  --at 2026-08-03T12:00:00+08:00 \
@@ -158,6 +176,8 @@ pnpm start at \
158
176
 
159
177
  可选参数:
160
178
 
179
+ - `--description`:项目简介/描述(更新项目元数据)。
180
+ - `--notes`:项目备注(更新项目元数据)。
161
181
  - `--progress`:已经完成的工作。
162
182
  - `--blocker`、`--next`、`--decision`、`--tag`:可以重复传入。
163
183
  - `--at`:状态实际发生时间,必须是带时区的 ISO 8601 时间;缺省为当前时间。
@@ -165,6 +185,29 @@ pnpm start at \
165
185
  - `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
166
186
  - `--json`:输出稳定 JSON。
167
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
+
168
211
  ## 批量导入
169
212
 
170
213
  `import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。
@@ -195,14 +238,16 @@ pnpm start import --file statuses.jsonl --json
195
238
 
196
239
  ## 查询与导出
197
240
 
198
- | 命令 | 用途 | 主要选项 |
199
- | --------- | -------------------------- | ------------------------------------------------------------------ |
200
- | `current` | 所有项目或单项目的当前状态 | `--project`、`--json` |
201
- | `history` | 单项目历史,最新记录在前 | `--project`、`--since`、`--until`、`--limit`、`--offset`、`--json` |
202
- | `at` | 指定时间点及之前的最后状态 | `--project`、`--at`、`--json` |
203
- | `export` | 导出全部或单项目记录 | `--project`、`--format json\|jsonl` |
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` |
204
249
 
205
- 全部六个 action 仍可使用 `status <action>` 的兼容形式,例如 `pmgr status export`。人类可读的多条结果按“项目、状态、发生时间、摘要”输出;为空时输出 `No entries found.`。`add`、`import`、`current`、`history`、`at` 支持 `--json`;`export` 由 `--format` 控制格式。
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` 控制格式。
206
251
 
207
252
  导出内容不包含数据库内部的 `id` 和 `recordedAt`,可以直接重新导入。记录按时间从旧到新导出,从而在恢复时保留相同时间戳记录的先后关系:
208
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: stringFlag(flags, "project", true),
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,8 +211,41 @@ 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
- output(history(db, stringFlag(flags, "project", true), {
248
+ output(history(db, stringFlag(flags, "project"), {
169
249
  since: stringFlag(flags, "since") &&
170
250
  parseTime(stringFlag(flags, "since"), "since"),
171
251
  until: stringFlag(flags, "until") &&
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 migration = `
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(migration);
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.prepare("INSERT INTO projects(slug, display_name, created_at) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING").run(input.project, input.project, now);
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
- return {
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
  }
@@ -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 migration 1 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.
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