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 +12 -1
- package/README.md +53 -8
- package/README_CN.md +53 -8
- package/dist/cli.js +86 -6
- 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 +13 -3
- package/src/cli.ts +106 -5
- package/src/db.ts +137 -6
- package/src/domain.ts +69 -5
- package/test/core.test.ts +240 -4
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,13 +19,23 @@ 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
|
|
28
37
|
npx --yes --package project-mgr pmgr history --project my-project --limit 20 --json
|
|
38
|
+
npx --yes --package project-mgr pmgr history --limit 20 --json
|
|
29
39
|
npx --yes --package project-mgr pmgr at --project my-project --at 2026-08-03T12:00:00+08:00 --json
|
|
30
40
|
|
|
31
41
|
# Import validated JSON/JSONL, or export portable records.
|
package/src/cli.ts
CHANGED
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
current,
|
|
9
9
|
history,
|
|
10
10
|
importStatuses,
|
|
11
|
+
listProjects,
|
|
11
12
|
openLedger,
|
|
12
13
|
parseTime,
|
|
14
|
+
type ProjectOverview,
|
|
15
|
+
setProjectMetadata,
|
|
13
16
|
toPortableStatusEntry,
|
|
14
17
|
ValidationError,
|
|
15
18
|
} from "./index.js";
|
|
@@ -27,7 +30,11 @@ function parse(argv: string[]): { command: string[]; flags: Flags } {
|
|
|
27
30
|
continue;
|
|
28
31
|
}
|
|
29
32
|
const key = token.slice(2);
|
|
30
|
-
if (
|
|
33
|
+
if (
|
|
34
|
+
key === "json" ||
|
|
35
|
+
key === "clear-description" ||
|
|
36
|
+
key === "clear-notes"
|
|
37
|
+
) {
|
|
31
38
|
if (flags[key] !== undefined)
|
|
32
39
|
throw new ValidationError(`--${key} may only be supplied once`);
|
|
33
40
|
flags[key] = true;
|
|
@@ -52,6 +59,8 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
|
|
|
52
59
|
"project",
|
|
53
60
|
"status",
|
|
54
61
|
"summary",
|
|
62
|
+
"description",
|
|
63
|
+
"notes",
|
|
55
64
|
"progress",
|
|
56
65
|
"blocker",
|
|
57
66
|
"next",
|
|
@@ -65,6 +74,20 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
|
|
|
65
74
|
],
|
|
66
75
|
["import", new Set(["file", "json"])],
|
|
67
76
|
["current", new Set(["project", "json"])],
|
|
77
|
+
["projects", new Set(["query", "json"])],
|
|
78
|
+
["list", new Set(["query", "json"])],
|
|
79
|
+
[
|
|
80
|
+
"set-project",
|
|
81
|
+
new Set([
|
|
82
|
+
"project",
|
|
83
|
+
"display-name",
|
|
84
|
+
"description",
|
|
85
|
+
"notes",
|
|
86
|
+
"clear-description",
|
|
87
|
+
"clear-notes",
|
|
88
|
+
"json",
|
|
89
|
+
]),
|
|
90
|
+
],
|
|
68
91
|
[
|
|
69
92
|
"history",
|
|
70
93
|
new Set(["project", "since", "until", "limit", "offset", "json"]),
|
|
@@ -80,7 +103,7 @@ function validateInvocation(command: string[], flags: Flags): string {
|
|
|
80
103
|
typeof action === "string" ? allowedFlags.get(action) : undefined;
|
|
81
104
|
if (typeof action !== "string" || !allowed)
|
|
82
105
|
throw new ValidationError(
|
|
83
|
-
"usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)",
|
|
106
|
+
"usage: pmgr <add|import|current|projects|list|set-project|history|at|export> (or: pmgr status <action>)",
|
|
84
107
|
);
|
|
85
108
|
const positional = command.slice(statusCommand ? 2 : 1);
|
|
86
109
|
if (action === "import") {
|
|
@@ -139,6 +162,30 @@ function output(
|
|
|
139
162
|
} else write(`${JSON.stringify(value, null, 2)}\n`);
|
|
140
163
|
}
|
|
141
164
|
|
|
165
|
+
function formatProjectOverview(item: ProjectOverview): string {
|
|
166
|
+
const lines = [`${item.project}\t${item.displayName}`];
|
|
167
|
+
if (item.description) lines.push(` Description: ${item.description}`);
|
|
168
|
+
if (item.notes) lines.push(` Notes: ${item.notes}`);
|
|
169
|
+
if (item.latestStatus) {
|
|
170
|
+
lines.push(
|
|
171
|
+
` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`,
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
lines.push(` Latest Status: (none)`);
|
|
175
|
+
}
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function outputProjects(
|
|
180
|
+
items: ProjectOverview[],
|
|
181
|
+
json: boolean,
|
|
182
|
+
write: (text: string) => void,
|
|
183
|
+
): void {
|
|
184
|
+
if (json) write(`${JSON.stringify(items)}\n`);
|
|
185
|
+
else if (items.length === 0) write("No projects found.\n");
|
|
186
|
+
else write(`${items.map(formatProjectOverview).join("\n")}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
142
189
|
function imported(file: string): unknown[] {
|
|
143
190
|
if (!file) throw new ValidationError("import file is required");
|
|
144
191
|
const content = readFileSync(file, "utf8").trim();
|
|
@@ -172,10 +219,16 @@ export function run(
|
|
|
172
219
|
const json = flags.json === true;
|
|
173
220
|
try {
|
|
174
221
|
switch (action) {
|
|
175
|
-
case "add":
|
|
222
|
+
case "add": {
|
|
223
|
+
const project = stringFlag(flags, "project", true) as string;
|
|
224
|
+
const description = stringFlag(flags, "description");
|
|
225
|
+
const notes = stringFlag(flags, "notes");
|
|
226
|
+
if (description !== undefined || notes !== undefined) {
|
|
227
|
+
setProjectMetadata(db, { project, description, notes });
|
|
228
|
+
}
|
|
176
229
|
output(
|
|
177
230
|
addStatus(db, {
|
|
178
|
-
project
|
|
231
|
+
project,
|
|
179
232
|
status: stringFlag(flags, "status", true),
|
|
180
233
|
summary: stringFlag(flags, "summary", true),
|
|
181
234
|
progress: stringFlag(flags, "progress"),
|
|
@@ -191,6 +244,8 @@ export function run(
|
|
|
191
244
|
write,
|
|
192
245
|
);
|
|
193
246
|
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
194
249
|
case "import":
|
|
195
250
|
output(
|
|
196
251
|
importStatuses(
|
|
@@ -208,9 +263,55 @@ export function run(
|
|
|
208
263
|
case "current":
|
|
209
264
|
output(current(db, stringFlag(flags, "project")), json, write);
|
|
210
265
|
break;
|
|
266
|
+
case "projects":
|
|
267
|
+
case "list":
|
|
268
|
+
outputProjects(
|
|
269
|
+
listProjects(db, { query: stringFlag(flags, "query") }),
|
|
270
|
+
json,
|
|
271
|
+
write,
|
|
272
|
+
);
|
|
273
|
+
break;
|
|
274
|
+
case "set-project": {
|
|
275
|
+
const project = stringFlag(flags, "project", true) as string;
|
|
276
|
+
const displayName = stringFlag(flags, "display-name");
|
|
277
|
+
let description: string | null | undefined = undefined;
|
|
278
|
+
if (flags["clear-description"] === true) {
|
|
279
|
+
if (flags.description !== undefined)
|
|
280
|
+
throw new ValidationError(
|
|
281
|
+
"cannot specify both --description and --clear-description",
|
|
282
|
+
);
|
|
283
|
+
description = null;
|
|
284
|
+
} else if (flags.description !== undefined) {
|
|
285
|
+
description = stringFlag(flags, "description");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let notes: string | null | undefined = undefined;
|
|
289
|
+
if (flags["clear-notes"] === true) {
|
|
290
|
+
if (flags.notes !== undefined)
|
|
291
|
+
throw new ValidationError(
|
|
292
|
+
"cannot specify both --notes and --clear-notes",
|
|
293
|
+
);
|
|
294
|
+
notes = null;
|
|
295
|
+
} else if (flags.notes !== undefined) {
|
|
296
|
+
notes = stringFlag(flags, "notes");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
output(
|
|
300
|
+
setProjectMetadata(db, {
|
|
301
|
+
project,
|
|
302
|
+
displayName,
|
|
303
|
+
description,
|
|
304
|
+
notes,
|
|
305
|
+
}),
|
|
306
|
+
json,
|
|
307
|
+
write,
|
|
308
|
+
);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
211
312
|
case "history":
|
|
212
313
|
output(
|
|
213
|
-
history(db, stringFlag(flags, "project"
|
|
314
|
+
history(db, stringFlag(flags, "project"), {
|
|
214
315
|
since:
|
|
215
316
|
stringFlag(flags, "since") &&
|
|
216
317
|
parseTime(stringFlag(flags, "since"), "since"),
|
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,124 @@ 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
|
+
if (!query) return overviews;
|
|
211
|
+
|
|
212
|
+
return overviews.filter((overview) => {
|
|
213
|
+
if (overview.project.toLowerCase().includes(query)) return true;
|
|
214
|
+
if (overview.displayName.toLowerCase().includes(query)) return true;
|
|
215
|
+
if (overview.description?.toLowerCase().includes(query)) return true;
|
|
216
|
+
if (overview.notes?.toLowerCase().includes(query)) return true;
|
|
217
|
+
if (overview.latestStatus) {
|
|
218
|
+
if (overview.latestStatus.status.toLowerCase().includes(query))
|
|
219
|
+
return true;
|
|
220
|
+
if (overview.latestStatus.summary.toLowerCase().includes(query))
|
|
221
|
+
return true;
|
|
222
|
+
if (overview.latestStatus.progress?.toLowerCase().includes(query))
|
|
223
|
+
return true;
|
|
224
|
+
if (
|
|
225
|
+
overview.latestStatus.tags.some((t) => t.toLowerCase().includes(query))
|
|
226
|
+
)
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
101
233
|
export function addStatus(
|
|
102
234
|
db: Ledger,
|
|
103
235
|
value: unknown,
|
|
@@ -126,9 +258,8 @@ function addValidatedStatus(
|
|
|
126
258
|
}
|
|
127
259
|
}
|
|
128
260
|
const now = new Date().toISOString();
|
|
129
|
-
db.
|
|
130
|
-
|
|
131
|
-
).run(input.project, input.project, now);
|
|
261
|
+
upsertProjectMetadata(db, { project: input.project });
|
|
262
|
+
|
|
132
263
|
const project = db
|
|
133
264
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
134
265
|
.get(input.project) as { id: number };
|
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",
|
|
@@ -135,19 +200,18 @@ export function validateStatusEntry(
|
|
|
135
200
|
}
|
|
136
201
|
|
|
137
202
|
export function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry {
|
|
138
|
-
|
|
203
|
+
const result: PortableStatusEntry = {
|
|
139
204
|
project: entry.project,
|
|
140
205
|
status: entry.status,
|
|
141
206
|
summary: entry.summary,
|
|
142
|
-
...(entry.progress === undefined ? {} : { progress: entry.progress }),
|
|
143
207
|
blockers: entry.blockers,
|
|
144
208
|
nextSteps: entry.nextSteps,
|
|
145
209
|
decisions: entry.decisions,
|
|
146
210
|
tags: entry.tags,
|
|
147
211
|
occurredAt: entry.occurredAt,
|
|
148
212
|
source: entry.source,
|
|
149
|
-
...(entry.externalRef === undefined
|
|
150
|
-
? {}
|
|
151
|
-
: { externalRef: entry.externalRef }),
|
|
152
213
|
};
|
|
214
|
+
if (entry.progress !== undefined) result.progress = entry.progress;
|
|
215
|
+
if (entry.externalRef !== undefined) result.externalRef = entry.externalRef;
|
|
216
|
+
return result;
|
|
153
217
|
}
|