project-mgr 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-mgr",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first append-only project status ledger",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -13,15 +13,25 @@ Use `npx --yes --package project-mgr pmgr`. In a source checkout, use `pnpm pmgr
13
13
  - For exploratory or test work, set `PMGR_DB_PATH` to a dedicated absolute SQLite path for every command. Do not use `:memory:` when more than one CLI invocation must share data.
14
14
  - Query before writing. `add` appends a snapshot; it does not edit the previous one.
15
15
  - Set `--source agent` for agent-authored updates. Use `--external-ref` only when a stable idempotency key is available.
16
+ - If the project is not yet in the ledger database, initialize its metadata with `set-project`, backfill 3–5 historical major changes/milestones using `add` (setting appropriate historical `--at` timestamps), and then append the latest status snapshot.
16
17
 
17
18
  ## Operations
18
19
 
19
20
  Prefer `--json` for data that must be interpreted or reported programmatically.
20
21
 
21
22
  ```bash
22
- # Add an agent-authored status snapshot.
23
- 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
23
+ # Set, update, or clear project metadata (description, notes, display name).
24
+ npx --yes --package project-mgr pmgr set-project --project my-project --description "Project summary" --notes "Internal notes" --json
25
+ npx --yes --package project-mgr pmgr set-project --project my-project --clear-description --clear-notes --json
26
+
27
+
28
+ # List all projects or fuzzy search across project names, descriptions, notes, status summaries, and tags.
29
+ npx --yes --package project-mgr pmgr projects --json
30
+ npx --yes --package project-mgr pmgr list --query "keyword" --json
31
+
32
+ # Add an agent-authored status snapshot (status defaults to completed if omitted).
33
+ npx --yes --package project-mgr pmgr add --project my-project --summary "Implemented the CLI" \
34
+ --description "Project description" --progress "Tests pass" --next "Publish the package" --source agent --json
25
35
 
26
36
  # Query current state, history, or state at a time.
27
37
  npx --yes --package project-mgr pmgr current --project my-project --json
package/src/cli.ts CHANGED
@@ -8,8 +8,13 @@ import {
8
8
  current,
9
9
  history,
10
10
  importStatuses,
11
+ listProjects,
11
12
  openLedger,
12
13
  parseTime,
14
+ type ProjectOverview,
15
+ type ProjectStatus,
16
+ setProjectMetadata,
17
+ startServer,
13
18
  toPortableStatusEntry,
14
19
  ValidationError,
15
20
  } from "./index.js";
@@ -27,7 +32,13 @@ function parse(argv: string[]): { command: string[]; flags: Flags } {
27
32
  continue;
28
33
  }
29
34
  const key = token.slice(2);
30
- if (key === "json") {
35
+ if (
36
+ key === "json" ||
37
+ key === "clear-description" ||
38
+ key === "clear-notes" ||
39
+ key === "open" ||
40
+ key === "no-open"
41
+ ) {
31
42
  if (flags[key] !== undefined)
32
43
  throw new ValidationError(`--${key} may only be supplied once`);
33
44
  flags[key] = true;
@@ -52,6 +63,8 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
52
63
  "project",
53
64
  "status",
54
65
  "summary",
66
+ "description",
67
+ "notes",
55
68
  "progress",
56
69
  "blocker",
57
70
  "next",
@@ -65,12 +78,27 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
65
78
  ],
66
79
  ["import", new Set(["file", "json"])],
67
80
  ["current", new Set(["project", "json"])],
81
+ ["projects", new Set(["query", "json"])],
82
+ ["list", new Set(["query", "json"])],
83
+ [
84
+ "set-project",
85
+ new Set([
86
+ "project",
87
+ "display-name",
88
+ "description",
89
+ "notes",
90
+ "clear-description",
91
+ "clear-notes",
92
+ "json",
93
+ ]),
94
+ ],
68
95
  [
69
96
  "history",
70
97
  new Set(["project", "since", "until", "limit", "offset", "json"]),
71
98
  ],
72
99
  ["at", new Set(["project", "at", "json"])],
73
100
  ["export", new Set(["project", "format"])],
101
+ ["serve", new Set(["port", "host", "open", "no-open", "json"])],
74
102
  ]);
75
103
 
76
104
  function validateInvocation(command: string[], flags: Flags): string {
@@ -80,7 +108,7 @@ function validateInvocation(command: string[], flags: Flags): string {
80
108
  typeof action === "string" ? allowedFlags.get(action) : undefined;
81
109
  if (typeof action !== "string" || !allowed)
82
110
  throw new ValidationError(
83
- "usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)",
111
+ "usage: pmgr <add|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)",
84
112
  );
85
113
  const positional = command.slice(statusCommand ? 2 : 1);
86
114
  if (action === "import") {
@@ -139,6 +167,30 @@ function output(
139
167
  } else write(`${JSON.stringify(value, null, 2)}\n`);
140
168
  }
141
169
 
170
+ function formatProjectOverview(item: ProjectOverview): string {
171
+ const lines = [`${item.project}\t${item.displayName}`];
172
+ if (item.description) lines.push(` Description: ${item.description}`);
173
+ if (item.notes) lines.push(` Notes: ${item.notes}`);
174
+ if (item.latestStatus) {
175
+ lines.push(
176
+ ` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`,
177
+ );
178
+ } else {
179
+ lines.push(` Latest Status: (none)`);
180
+ }
181
+ return lines.join("\n");
182
+ }
183
+
184
+ function outputProjects(
185
+ items: ProjectOverview[],
186
+ json: boolean,
187
+ write: (text: string) => void,
188
+ ): void {
189
+ if (json) write(`${JSON.stringify(items)}\n`);
190
+ else if (items.length === 0) write("No projects found.\n");
191
+ else write(`${items.map(formatProjectOverview).join("\n")}\n`);
192
+ }
193
+
142
194
  function imported(file: string): unknown[] {
143
195
  if (!file) throw new ValidationError("import file is required");
144
196
  const content = readFileSync(file, "utf8").trim();
@@ -165,18 +217,24 @@ function imported(file: string): unknown[] {
165
217
  export function run(
166
218
  argv: string[],
167
219
  write = process.stdout.write.bind(process.stdout),
168
- ): void {
220
+ ): unknown {
169
221
  const { command, flags } = parse(argv);
170
222
  const action = validateInvocation(command, flags);
171
223
  const db = openLedger();
172
224
  const json = flags.json === true;
173
225
  try {
174
226
  switch (action) {
175
- case "add":
227
+ case "add": {
228
+ const project = stringFlag(flags, "project", true) as string;
229
+ const description = stringFlag(flags, "description");
230
+ const notes = stringFlag(flags, "notes");
231
+ if (description !== undefined || notes !== undefined) {
232
+ setProjectMetadata(db, { project, description, notes });
233
+ }
176
234
  output(
177
235
  addStatus(db, {
178
- project: stringFlag(flags, "project", true),
179
- status: stringFlag(flags, "status", true),
236
+ project,
237
+ status: stringFlag(flags, "status") as unknown as ProjectStatus,
180
238
  summary: stringFlag(flags, "summary", true),
181
239
  progress: stringFlag(flags, "progress"),
182
240
  blockers: listFlag(flags, "blocker"),
@@ -191,6 +249,8 @@ export function run(
191
249
  write,
192
250
  );
193
251
  break;
252
+ }
253
+
194
254
  case "import":
195
255
  output(
196
256
  importStatuses(
@@ -208,6 +268,52 @@ export function run(
208
268
  case "current":
209
269
  output(current(db, stringFlag(flags, "project")), json, write);
210
270
  break;
271
+ case "projects":
272
+ case "list":
273
+ outputProjects(
274
+ listProjects(db, { query: stringFlag(flags, "query") }),
275
+ json,
276
+ write,
277
+ );
278
+ break;
279
+ case "set-project": {
280
+ const project = stringFlag(flags, "project", true) as string;
281
+ const displayName = stringFlag(flags, "display-name");
282
+ let description: string | null | undefined = undefined;
283
+ if (flags["clear-description"] === true) {
284
+ if (flags.description !== undefined)
285
+ throw new ValidationError(
286
+ "cannot specify both --description and --clear-description",
287
+ );
288
+ description = null;
289
+ } else if (flags.description !== undefined) {
290
+ description = stringFlag(flags, "description");
291
+ }
292
+
293
+ let notes: string | null | undefined = undefined;
294
+ if (flags["clear-notes"] === true) {
295
+ if (flags.notes !== undefined)
296
+ throw new ValidationError(
297
+ "cannot specify both --notes and --clear-notes",
298
+ );
299
+ notes = null;
300
+ } else if (flags.notes !== undefined) {
301
+ notes = stringFlag(flags, "notes");
302
+ }
303
+
304
+ output(
305
+ setProjectMetadata(db, {
306
+ project,
307
+ displayName,
308
+ description,
309
+ notes,
310
+ }),
311
+ json,
312
+ write,
313
+ );
314
+ break;
315
+ }
316
+
211
317
  case "history":
212
318
  output(
213
319
  history(db, stringFlag(flags, "project"), {
@@ -251,9 +357,30 @@ export function run(
251
357
  else throw new ValidationError("--format must be json or jsonl");
252
358
  break;
253
359
  }
360
+
361
+ case "serve": {
362
+ const port =
363
+ flags.port !== undefined
364
+ ? numberFlag(flags, "port", 3000)
365
+ : undefined;
366
+ const host = stringFlag(flags, "host");
367
+ const open = flags["no-open"] === true ? false : true;
368
+ return startServer({ port, host, open, db }).then((instance) => {
369
+ if (json) {
370
+ write(
371
+ `${JSON.stringify({ url: instance.url, port: instance.port, host: instance.host })}\n`,
372
+ );
373
+ } else {
374
+ write(`Server running at ${instance.url}\n`);
375
+ }
376
+ return instance;
377
+ });
378
+ }
254
379
  }
255
380
  } finally {
256
- db.close();
381
+ if (action !== "serve") {
382
+ db.close();
383
+ }
257
384
  }
258
385
  }
259
386
 
@@ -263,7 +390,7 @@ if (
263
390
  import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
264
391
  ) {
265
392
  try {
266
- run(process.argv.slice(2));
393
+ await run(process.argv.slice(2));
267
394
  } catch (error) {
268
395
  console.error(error instanceof Error ? error.message : String(error));
269
396
  process.exitCode = 1;
package/src/db.ts CHANGED
@@ -3,9 +3,12 @@ import { mkdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import {
6
+ type ProjectMetadata,
7
+ type ProjectOverview,
6
8
  type Source,
7
9
  type StatusEntry,
8
10
  type StatusEntryInput,
11
+ validateProjectMetadataInput,
9
12
  validateStatusEntry,
10
13
  ValidationError,
11
14
  } from "./domain.js";
@@ -19,7 +22,7 @@ export type HistoryOptions = {
19
22
  offset?: number;
20
23
  };
21
24
 
22
- const migration = `
25
+ const migration1 = `
23
26
  CREATE TABLE IF NOT EXISTS projects (
24
27
  id INTEGER PRIMARY KEY,
25
28
  slug TEXT NOT NULL UNIQUE,
@@ -44,6 +47,11 @@ CREATE TABLE IF NOT EXISTS status_entries (
44
47
  CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
45
48
  `;
46
49
 
50
+ const migration2 = `
51
+ ALTER TABLE projects ADD COLUMN description TEXT;
52
+ ALTER TABLE projects ADD COLUMN notes TEXT;
53
+ `;
54
+
47
55
  export function databasePath(): string {
48
56
  return (
49
57
  process.env.PMGR_DB_PATH ||
@@ -62,9 +70,15 @@ export function openLedger(path = databasePath()): Ledger {
62
70
  if (
63
71
  !db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()
64
72
  ) {
65
- db.exec(migration);
73
+ db.exec(migration1);
66
74
  db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
67
75
  }
76
+ if (
77
+ !db.prepare("SELECT 1 FROM schema_migrations WHERE version = 2").get()
78
+ ) {
79
+ db.exec(migration2);
80
+ db.prepare("INSERT INTO schema_migrations(version) VALUES (2)").run();
81
+ }
68
82
  }).immediate();
69
83
  return db;
70
84
  }
@@ -82,7 +96,7 @@ function entryFromRow(row: Record<string, unknown>): StatusEntry {
82
96
  tags: JSON.parse(row.tags_json as string) as string[],
83
97
  occurredAt: row.occurred_at as string,
84
98
  recordedAt: row.recorded_at as string,
85
- source: row.source as StatusEntry["source"],
99
+ source: row.source as Source,
86
100
  ...(row.external_ref ? { externalRef: row.external_ref as string } : {}),
87
101
  };
88
102
  }
@@ -98,6 +112,130 @@ function one(
98
112
  return row && entryFromRow(row);
99
113
  }
100
114
 
115
+ function upsertProjectMetadata(
116
+ db: Ledger,
117
+ input: {
118
+ project: string;
119
+ displayName?: string;
120
+ description?: string | null;
121
+ notes?: string | null;
122
+ },
123
+ ): void {
124
+ const existing = db
125
+ .prepare("SELECT 1 FROM projects WHERE slug = ?")
126
+ .get(input.project);
127
+ if (!existing) {
128
+ const now = new Date().toISOString();
129
+ db.prepare(
130
+ "INSERT INTO projects(slug, display_name, description, notes, created_at) VALUES (?, ?, ?, ?, ?)",
131
+ ).run(
132
+ input.project,
133
+ input.displayName ?? input.project,
134
+ input.description ?? null,
135
+ input.notes ?? null,
136
+ now,
137
+ );
138
+ } else {
139
+ const updates: string[] = [];
140
+ const params: unknown[] = [];
141
+ if (input.displayName !== undefined) {
142
+ updates.push("display_name = ?");
143
+ params.push(input.displayName);
144
+ }
145
+ if (input.description !== undefined) {
146
+ updates.push("description = ?");
147
+ params.push(input.description);
148
+ }
149
+ if (input.notes !== undefined) {
150
+ updates.push("notes = ?");
151
+ params.push(input.notes);
152
+ }
153
+ if (updates.length > 0) {
154
+ params.push(input.project);
155
+ db.prepare(
156
+ `UPDATE projects SET ${updates.join(", ")} WHERE slug = ?`,
157
+ ).run(...params);
158
+ }
159
+ }
160
+ }
161
+
162
+ export function setProjectMetadata(
163
+ db: Ledger,
164
+ value: unknown,
165
+ ): ProjectMetadata {
166
+ const input = validateProjectMetadataInput(value);
167
+ return db.transaction(() => {
168
+ upsertProjectMetadata(db, input);
169
+ const row = db
170
+ .prepare("SELECT * FROM projects WHERE slug = ?")
171
+ .get(input.project) as Record<string, unknown>;
172
+ return {
173
+ project: row.slug as string,
174
+ displayName: row.display_name as string,
175
+ ...(row.description ? { description: row.description as string } : {}),
176
+ ...(row.notes ? { notes: row.notes as string } : {}),
177
+ createdAt: row.created_at as string,
178
+ };
179
+ })();
180
+ }
181
+
182
+ export function listProjects(
183
+ db: Ledger,
184
+ options: { query?: string } = {},
185
+ ): ProjectOverview[] {
186
+ const query = options.query?.trim().toLowerCase();
187
+ const projects = db
188
+ .prepare("SELECT * FROM projects ORDER BY slug ASC")
189
+ .all() as Record<string, unknown>[];
190
+
191
+ const latestStatuses = current(db);
192
+ const statusMap = new Map<string, StatusEntry>();
193
+ for (const entry of latestStatuses) {
194
+ statusMap.set(entry.project, entry);
195
+ }
196
+
197
+ const overviews: ProjectOverview[] = projects.map((row) => {
198
+ const slug = row.slug as string;
199
+ const latestStatus = statusMap.get(slug);
200
+ return {
201
+ project: slug,
202
+ displayName: row.display_name as string,
203
+ ...(row.description ? { description: row.description as string } : {}),
204
+ ...(row.notes ? { notes: row.notes as string } : {}),
205
+ createdAt: row.created_at as string,
206
+ ...(latestStatus ? { latestStatus } : {}),
207
+ };
208
+ });
209
+
210
+ overviews.sort((a, b) => {
211
+ const timeA = a.latestStatus?.occurredAt ?? a.createdAt;
212
+ const timeB = b.latestStatus?.occurredAt ?? b.createdAt;
213
+ if (timeA !== timeB) {
214
+ return timeB.localeCompare(timeA);
215
+ }
216
+ return a.project.localeCompare(b.project);
217
+ });
218
+
219
+ if (!query) return overviews;
220
+
221
+ return overviews.filter((overview) => {
222
+ if (overview.project.toLowerCase().includes(query)) return true;
223
+ if (overview.displayName.toLowerCase().includes(query)) return true;
224
+ if (overview.description?.toLowerCase().includes(query)) return true;
225
+ if (overview.notes?.toLowerCase().includes(query)) return true;
226
+ if (overview.latestStatus) {
227
+ const st = overview.latestStatus;
228
+ const match =
229
+ st.status.toLowerCase().includes(query) ||
230
+ st.summary.toLowerCase().includes(query) ||
231
+ (st.progress && st.progress.toLowerCase().includes(query)) ||
232
+ st.tags.some((t) => t.toLowerCase().includes(query));
233
+ if (match) return true;
234
+ }
235
+ return false;
236
+ });
237
+ }
238
+
101
239
  export function addStatus(
102
240
  db: Ledger,
103
241
  value: unknown,
@@ -126,9 +264,8 @@ function addValidatedStatus(
126
264
  }
127
265
  }
128
266
  const now = new Date().toISOString();
129
- db.prepare(
130
- "INSERT INTO projects(slug, display_name, created_at) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING",
131
- ).run(input.project, input.project, now);
267
+ upsertProjectMetadata(db, { project: input.project });
268
+
132
269
  const project = db
133
270
  .prepare("SELECT id FROM projects WHERE slug = ?")
134
271
  .get(input.project) as { id: number };
@@ -219,3 +356,18 @@ export function at(
219
356
  occurredAt,
220
357
  );
221
358
  }
359
+
360
+ export function countEntries(db: Ledger, project?: string): number {
361
+ if (project) {
362
+ const row = db
363
+ .prepare(
364
+ "SELECT COUNT(*) AS count FROM status_entries e JOIN projects p ON p.id = e.project_id WHERE p.slug = ?",
365
+ )
366
+ .get(project) as { count: number };
367
+ return row.count;
368
+ }
369
+ const row = db
370
+ .prepare("SELECT COUNT(*) AS count FROM status_entries")
371
+ .get() as { count: number };
372
+ return row.count;
373
+ }
package/src/domain.ts CHANGED
@@ -12,6 +12,25 @@ export const sources = ["human", "agent", "import"] as const;
12
12
  export type ProjectStatus = (typeof statuses)[number];
13
13
  export type Source = (typeof sources)[number];
14
14
 
15
+ export type ProjectMetadata = {
16
+ project: string;
17
+ displayName: string;
18
+ description?: string;
19
+ notes?: string;
20
+ createdAt: string;
21
+ };
22
+
23
+ export type ProjectOverview = ProjectMetadata & {
24
+ latestStatus?: StatusEntry;
25
+ };
26
+
27
+ export type ProjectMetadataInput = {
28
+ project: string;
29
+ displayName?: string;
30
+ description?: string | null;
31
+ notes?: string | null;
32
+ };
33
+
15
34
  export type StatusEntryInput = {
16
35
  project: string;
17
36
  status: ProjectStatus;
@@ -50,6 +69,12 @@ const keys = new Set([
50
69
  "source",
51
70
  "externalRef",
52
71
  ]);
72
+ const projectMetadataKeys = new Set([
73
+ "project",
74
+ "displayName",
75
+ "description",
76
+ "notes",
77
+ ]);
53
78
  const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
54
79
 
55
80
  export class ValidationError extends Error {
@@ -73,6 +98,21 @@ function text(
73
98
  return normalized;
74
99
  }
75
100
 
101
+ function nullableText(
102
+ value: unknown,
103
+ field: string,
104
+ ): string | null | undefined {
105
+ if (value === undefined) return undefined;
106
+ if (value === null) return null;
107
+ if (typeof value !== "string")
108
+ throw new ValidationError(`${field} must be a string or null`);
109
+ const normalized = value.trim();
110
+ if (normalized === "") return null;
111
+ if (normalized.length > 2_000)
112
+ throw new ValidationError(`${field} must be at most 2000 characters`);
113
+ return normalized;
114
+ }
115
+
76
116
  function textList(value: unknown, field: string): string[] {
77
117
  if (value === undefined) return [];
78
118
  if (!Array.isArray(value) || value.length > 100)
@@ -93,6 +133,31 @@ export function parseTime(value: unknown, field: string): string {
93
133
  return new Date(value).toISOString();
94
134
  }
95
135
 
136
+ export function validateProjectMetadataInput(
137
+ value: unknown,
138
+ ): ProjectMetadataInput {
139
+ if (!value || typeof value !== "object" || Array.isArray(value))
140
+ throw new ValidationError("project metadata must be an object");
141
+ const input = value as Record<string, unknown>;
142
+ for (const key of Object.keys(input))
143
+ if (!projectMetadataKeys.has(key))
144
+ throw new ValidationError(`unknown field: ${key}`);
145
+ const project = text(input.project, "project", true) as string;
146
+ if (!projectName.test(project))
147
+ throw new ValidationError(
148
+ "project must use letters, numbers, dots, underscores, or hyphens",
149
+ );
150
+ const displayName = text(input.displayName, "displayName");
151
+ const description = nullableText(input.description, "description");
152
+ const notes = nullableText(input.notes, "notes");
153
+ return {
154
+ project,
155
+ ...(displayName !== undefined ? { displayName } : {}),
156
+ ...(description !== undefined ? { description } : {}),
157
+ ...(notes !== undefined ? { notes } : {}),
158
+ };
159
+ }
160
+
96
161
  export function validateStatusEntry(
97
162
  value: unknown,
98
163
  defaultSource: Source = "human",
@@ -108,7 +173,16 @@ export function validateStatusEntry(
108
173
  throw new ValidationError(
109
174
  "project must use letters, numbers, dots, underscores, or hyphens",
110
175
  );
111
- if (!statuses.includes(input.status as ProjectStatus))
176
+ let statusInput = input.status;
177
+ if (statusInput === undefined || statusInput === null) {
178
+ statusInput = "completed";
179
+ } else if (
180
+ typeof statusInput === "string" &&
181
+ statusInput.trim().toLowerCase() === "done"
182
+ ) {
183
+ statusInput = "completed";
184
+ }
185
+ if (!statuses.includes(statusInput as ProjectStatus))
112
186
  throw new ValidationError("status is invalid");
113
187
  const source = input.source === undefined ? defaultSource : input.source;
114
188
  if (!sources.includes(source as Source))
@@ -118,7 +192,7 @@ export function validateStatusEntry(
118
192
  throw new ValidationError("externalRef must be at most 200 characters");
119
193
  return {
120
194
  project,
121
- status: input.status as ProjectStatus,
195
+ status: statusInput as ProjectStatus,
122
196
  summary: text(input.summary, "summary", true) as string,
123
197
  progress: text(input.progress, "progress"),
124
198
  blockers: textList(input.blockers, "blockers"),
@@ -135,19 +209,18 @@ export function validateStatusEntry(
135
209
  }
136
210
 
137
211
  export function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry {
138
- return {
212
+ const result: PortableStatusEntry = {
139
213
  project: entry.project,
140
214
  status: entry.status,
141
215
  summary: entry.summary,
142
- ...(entry.progress === undefined ? {} : { progress: entry.progress }),
143
216
  blockers: entry.blockers,
144
217
  nextSteps: entry.nextSteps,
145
218
  decisions: entry.decisions,
146
219
  tags: entry.tags,
147
220
  occurredAt: entry.occurredAt,
148
221
  source: entry.source,
149
- ...(entry.externalRef === undefined
150
- ? {}
151
- : { externalRef: entry.externalRef }),
152
222
  };
223
+ if (entry.progress !== undefined) result.progress = entry.progress;
224
+ if (entry.externalRef !== undefined) result.externalRef = entry.externalRef;
225
+ return result;
153
226
  }
package/src/index.ts CHANGED
@@ -1,2 +1,5 @@
1
+ export * from "./cli.js";
1
2
  export * from "./db.js";
2
3
  export * from "./domain.js";
4
+ export * from "./server.js";
5
+ export * from "./web/ui.js";