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.
- package/.claude/settings.local.json +5 -0
- package/CHANGELOG.md +19 -0
- package/README.md +64 -9
- package/README_CN.md +67 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +109 -8
- package/dist/db.d.ts +6 -1
- package/dist/db.js +122 -4
- package/dist/domain.d.ts +17 -0
- package/dist/domain.js +56 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +147 -0
- package/dist/web/ui.d.ts +1 -0
- package/dist/web/ui.js +977 -0
- package/docs/architecture.md +3 -3
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +14 -4
- package/src/cli.ts +135 -8
- package/src/db.ts +158 -6
- package/src/domain.ts +80 -7
- package/src/index.ts +3 -0
- package/src/server.ts +202 -0
- package/src/web/ui.ts +977 -0
- package/test/core.test.ts +274 -4
- package/test/server.test.ts +354 -0
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
|
|
@@ -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
|
-
#
|
|
23
|
-
npx --yes --package project-mgr pmgr
|
|
24
|
-
|
|
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 (
|
|
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
|
-
):
|
|
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
|
|
179
|
-
status: stringFlag(flags, "status"
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
130
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|