project-mgr 0.2.1 → 0.3.1

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,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 2026-10-16
4
+
5
+ ### Changed
6
+
7
+ - Prefer an installed `pmgr` in the Skill and remove automatic `npx @latest` execution from examples.
8
+ - Require explicit status values and evidence-backed historical backfills without a minimum entry count.
9
+ - Document database permission handling, stable ledger paths, and read-back verification after writes.
10
+
11
+ This release updates Skill guidance only; CLI behavior and storage architecture are unchanged.
12
+
13
+ ## 0.3.0 - 2026-10-16
14
+
15
+ ### Added
16
+
17
+ - Explicit completion of multiple predecessor activities, append-only revocation, and completion-source links in the activity feed.
18
+ - Independent Current/Superseded markers, preserved original snapshot details, and a direct entry route with a return-to-list control.
19
+ - Migration 3 adds portable entry and event identities and rejects obsolete writers that omit entry UUIDs.
20
+
21
+ ### Changed
22
+
23
+ - Export is now a version-2 bundle rather than a JSON array or entry-per-line JSONL. JSONL emits one complete bundle per line. Existing consumers must adapt; legacy imports remain supported.
24
+ - Imports preserve completion history and recording times, reject conflicting identities atomically, and deduplicate version-2 re-imports. Independently migrated legacy databases may require restoring into a fresh database due to differing UUIDs.
25
+
26
+ ### Fixed
27
+
28
+ - Invalid percent-encoding in entry/project detail routes returns 400 instead of 500.
29
+
3
30
  ## 0.2.0 - 2026-08-07
4
31
 
5
32
  ### Added
package/README.md CHANGED
@@ -298,3 +298,24 @@ pnpm build
298
298
  ```
299
299
 
300
300
  Coverage thresholds for statements, branches, functions, and lines are all 100%. See the [architecture document](docs/architecture.md) for the detailed design.
301
+
302
+ ## Completing earlier activities
303
+
304
+ Project status and activity completion are separate. Inspect `pmgr history --project demo --json` before linking entries. `status` is the original snapshot; `effectiveStatus` includes explicit follow-up completion, and `isCurrent` identifies the latest project snapshot. Missing `completedBy` does not mean an entry is unfinished.
305
+
306
+ ```bash
307
+ # One update completes multiple older entries (local numeric IDs or portable UUIDs).
308
+ pmgr add --project demo --status active --summary "Deployment accepted" \
309
+ --completes 12 --completes 15 --source agent --json
310
+ # Link an existing follow-up, or correct an erroneous link without deleting history.
311
+ pmgr complete --entry 12 --by 18 --source agent --json
312
+ pmgr revoke-completion --entry 12 --by 18 --reason "Acceptance still pending" --source agent --json
313
+ ```
314
+
315
+ Only link entries whose entire outstanding work is resolved, including blockers and next steps. Same-project links require a strictly later follow-up occurrence time; equal-time links are rejected. A target has one active completion source. Repeating that link is a no-op; revoke before replacing it. `add --completes` writes the snapshot, metadata, and links atomically. Existing history is not automatically reconciled.
316
+
317
+ Activity cards display Completed with a follow-up link and retain original details in an expandable section. Current/Superseded identifies snapshot recency independently of completion. `/api/entries/<id-or-uid>` and `/?entry=<uid>` open an entry even outside the initial activity page. Date filters select snapshots; completion annotations reflect today's result. `at` continues returning the historical project snapshot without completion evidence; historical completion queries are deferred.
318
+
319
+ Export now emits a version-2 bundle `{ "version": 2, "entries": [...], "completions": [...] }`. JSONL emits one complete bundle per line. Entries retain UUIDs and recording times; events retain UUIDs and per-target predecessor links, including revocations. Import accepts legacy entry objects/arrays/JSONL and version-2 bundles, checks identity conflicts, and applies each file atomically. Re-importing the same bundle is idempotent. Event order is significant: import events in exported order; divergent histories fail instead of silently merging. Older clients cannot import version-2 bundles. Migration adds UUIDs to existing snapshots without changing their content.
320
+
321
+ Independently migrated copies of legacy data receive different UUIDs. Cross-import may fail on an existing `externalRef`; no implicit identity adoption occurs. Restore a bundle into a fresh database for a faithful migration. Older writers are rejected if they omit UUIDs. With an existing `externalRef`, `add` retains its existing snapshot and applies explicitly supplied `--completes` links to that snapshot; identical active links are no-ops. Use `complete` to make after-the-fact reconciliation explicit.
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
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, listProjects, openLedger, parseTime, setProjectMetadata, startServer, toPortableStatusEntry, ValidationError, } from "./index.js";
6
- const repeats = new Set(["next", "blocker", "decision", "tag"]);
5
+ import { addStatus, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
6
+ const repeats = new Set(["next", "blocker", "decision", "tag", "completes"]);
7
7
  function parse(argv) {
8
8
  const command = [];
9
9
  const flags = Object.create(null);
@@ -53,9 +53,12 @@ const allowedFlags = new Map([
53
53
  "at",
54
54
  "source",
55
55
  "external-ref",
56
+ "completes",
56
57
  "json",
57
58
  ]),
58
59
  ],
60
+ ["complete", new Set(["entry", "by", "reason", "source", "json"])],
61
+ ["revoke-completion", new Set(["entry", "by", "reason", "source", "json"])],
59
62
  ["import", new Set(["file", "json"])],
60
63
  ["current", new Set(["project", "json"])],
61
64
  ["projects", new Set(["query", "json"])],
@@ -85,7 +88,7 @@ function validateInvocation(command, flags) {
85
88
  const action = command[statusCommand ? 1 : 0];
86
89
  const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
87
90
  if (typeof action !== "string" || !allowed)
88
- throw new ValidationError("usage: pmgr <add|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)");
91
+ throw new ValidationError("usage: pmgr <add|complete|revoke-completion|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)");
89
92
  const positional = command.slice(statusCommand ? 2 : 1);
90
93
  if (action === "import") {
91
94
  if (positional.length > 1)
@@ -124,7 +127,7 @@ function output(value, json, write) {
124
127
  write(`${JSON.stringify(value)}\n`);
125
128
  else if (Array.isArray(value)) {
126
129
  const rows = value
127
- .map((entry) => `${entry.project}\t${entry.status}\t${entry.occurredAt}\t${entry.summary}`)
130
+ .map((entry) => `${entry.project}\t${entry.effectiveStatus ?? entry.status}\t${entry.occurredAt}\t${entry.summary}${entry.completedBy ? ` (completed by #${entry.completedBy.id}; original: ${entry.status})` : ""}`)
128
131
  .join("\n");
129
132
  write(`${rows || "No entries found."}\n`);
130
133
  }
@@ -188,26 +191,35 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
188
191
  const project = stringFlag(flags, "project", true);
189
192
  const description = stringFlag(flags, "description");
190
193
  const notes = stringFlag(flags, "notes");
191
- if (description !== undefined || notes !== undefined) {
192
- setProjectMetadata(db, { project, description, notes });
193
- }
194
- output(addStatus(db, {
195
- project,
196
- status: stringFlag(flags, "status"),
197
- summary: stringFlag(flags, "summary", true),
198
- progress: stringFlag(flags, "progress"),
199
- blockers: listFlag(flags, "blocker"),
200
- nextSteps: listFlag(flags, "next"),
201
- decisions: listFlag(flags, "decision"),
202
- tags: listFlag(flags, "tag"),
203
- occurredAt: stringFlag(flags, "at"),
204
- source: stringFlag(flags, "source"),
205
- externalRef: stringFlag(flags, "external-ref"),
206
- }).entry, json, write);
194
+ const result = db
195
+ .transaction(() => {
196
+ if (description !== undefined || notes !== undefined) {
197
+ setProjectMetadata(db, { project, description, notes });
198
+ }
199
+ return addStatus(db, {
200
+ project,
201
+ status: stringFlag(flags, "status"),
202
+ summary: stringFlag(flags, "summary", true),
203
+ progress: stringFlag(flags, "progress"),
204
+ blockers: listFlag(flags, "blocker"),
205
+ nextSteps: listFlag(flags, "next"),
206
+ decisions: listFlag(flags, "decision"),
207
+ tags: listFlag(flags, "tag"),
208
+ occurredAt: stringFlag(flags, "at"),
209
+ source: stringFlag(flags, "source"),
210
+ externalRef: stringFlag(flags, "external-ref"),
211
+ }, "human", listFlag(flags, "completes")).entry;
212
+ })
213
+ .immediate();
214
+ output(result, json, write);
207
215
  break;
208
216
  }
217
+ case "complete":
218
+ case "revoke-completion":
219
+ output(changeCompletion(db, stringFlag(flags, "entry", true), stringFlag(flags, "by", true), action === "complete" ? "complete" : "revoke", stringFlag(flags, "reason"), (stringFlag(flags, "source") ?? "human")), json, write);
220
+ break;
209
221
  case "import":
210
- output(importStatuses(db, imported(stringFlag(flags, "file") ??
222
+ output(importLedger(db, imported(stringFlag(flags, "file") ??
211
223
  command[command[0] === "status" ? 2 : 1] ??
212
224
  "")).map((result) => result.entry), json, write);
213
225
  break;
@@ -261,19 +273,12 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
261
273
  output(at(db, stringFlag(flags, "project", true), parseTime(stringFlag(flags, "at", true), "at")) ?? null, json, write);
262
274
  break;
263
275
  case "export": {
264
- const entries = history(db, stringFlag(flags, "project"), {
265
- limit: -1,
266
- })
267
- .reverse()
268
- .map(toPortableStatusEntry);
276
+ const bundle = exportLedger(db, stringFlag(flags, "project"));
269
277
  const format = stringFlag(flags, "format") ?? "json";
270
- if (format === "jsonl")
271
- write(entries.map((entry) => JSON.stringify(entry)).join("\n") +
272
- (entries.length ? "\n" : ""));
273
- else if (format === "json")
274
- output(entries, true, write);
275
- else
278
+ if (format !== "json" && format !== "jsonl")
276
279
  throw new ValidationError("--format must be json or jsonl");
280
+ // JSONL contains one versioned bundle per line, preserving event order.
281
+ output(bundle, true, write);
277
282
  break;
278
283
  }
279
284
  case "serve": {
package/dist/db.d.ts CHANGED
@@ -17,9 +17,41 @@ export declare function setProjectMetadata(db: Ledger, value: unknown): ProjectM
17
17
  export declare function listProjects(db: Ledger, options?: {
18
18
  query?: string;
19
19
  }): ProjectOverview[];
20
- export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source): InsertResult;
20
+ export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source, completes?: string[]): InsertResult;
21
21
  export declare function importStatuses(db: Ledger, values: unknown[]): InsertResult[];
22
22
  export declare function current(db: Ledger, project?: string): StatusEntry[];
23
23
  export declare function history(db: Ledger, project?: string, options?: HistoryOptions): StatusEntry[];
24
24
  export declare function at(db: Ledger, project: string, occurredAt: string): StatusEntry | undefined;
25
25
  export declare function countEntries(db: Ledger, project?: string): number;
26
+ export type CompletionEvent = {
27
+ uid: string;
28
+ targetUid: string;
29
+ byUid: string;
30
+ previousUid: string | null;
31
+ action: "complete" | "revoke";
32
+ reason?: string;
33
+ recordedAt: string;
34
+ source: Source;
35
+ };
36
+ export declare function getEntry(db: Ledger, ref: string): StatusEntry | undefined;
37
+ export declare function changeCompletion(db: Ledger, targetRef: string, byRef: string, action?: CompletionEvent["action"], reason?: string, source?: Source): CompletionEvent;
38
+ export declare function exportLedger(db: Ledger, project?: string): {
39
+ version: number;
40
+ entries: {
41
+ recordedAt: string;
42
+ progress?: string | undefined;
43
+ externalRef?: string | undefined;
44
+ uid: string;
45
+ project: string;
46
+ status: import("./domain.js").ProjectStatus;
47
+ summary: string;
48
+ blockers: string[];
49
+ nextSteps: string[];
50
+ decisions: string[];
51
+ tags: string[];
52
+ occurredAt: string;
53
+ source: Source;
54
+ }[];
55
+ completions: CompletionEvent[];
56
+ };
57
+ export declare function importLedger(db: Ledger, values: unknown[]): InsertResult[];
package/dist/db.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
1
3
  import Database from "better-sqlite3";
2
4
  import { mkdirSync } from "node:fs";
3
5
  import { homedir } from "node:os";
4
6
  import { dirname, resolve } from "node:path";
5
- import { validateProjectMetadataInput, validateStatusEntry, ValidationError, } from "./domain.js";
7
+ import { validateProjectMetadataInput, validateStatusEntry, ValidationError, toPortableStatusEntry, validateUid, parseTime, sources, } from "./domain.js";
6
8
  const migration1 = `
7
9
  CREATE TABLE IF NOT EXISTS projects (
8
10
  id INTEGER PRIMARY KEY,
@@ -50,12 +52,36 @@ export function openLedger(path = databasePath()) {
50
52
  db.exec(migration2);
51
53
  db.prepare("INSERT INTO schema_migrations(version) VALUES (2)").run();
52
54
  }
55
+ if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 3").get()) {
56
+ db.exec(`ALTER TABLE status_entries ADD COLUMN uid TEXT;
57
+ CREATE UNIQUE INDEX idx_entry_uid ON status_entries(uid);
58
+ CREATE TABLE completion_events (
59
+ id INTEGER PRIMARY KEY,
60
+ uid TEXT NOT NULL UNIQUE,
61
+ target_uid TEXT NOT NULL REFERENCES status_entries(uid),
62
+ by_uid TEXT NOT NULL REFERENCES status_entries(uid),
63
+ previous_uid TEXT REFERENCES completion_events(uid),
64
+ action TEXT NOT NULL CHECK(action IN ('complete', 'revoke')),
65
+ reason TEXT,
66
+ recorded_at TEXT NOT NULL,
67
+ source TEXT NOT NULL
68
+ );
69
+ CREATE INDEX idx_completion_target ON completion_events(target_uid, id DESC);`);
70
+ for (const row of db.prepare("SELECT id FROM status_entries").all()) {
71
+ db.prepare("UPDATE status_entries SET uid = ? WHERE id = ?").run(randomUUID(), row.id);
72
+ }
73
+ db.exec(`CREATE TRIGGER require_entry_uid BEFORE INSERT ON status_entries
74
+ WHEN NEW.uid IS NULL
75
+ BEGIN SELECT RAISE(ABORT, 'entry uid required; upgrade the writer'); END;`);
76
+ db.prepare("INSERT INTO schema_migrations(version) VALUES (3)").run();
77
+ }
53
78
  }).immediate();
54
79
  return db;
55
80
  }
56
81
  function entryFromRow(row) {
57
82
  return {
58
83
  id: row.id,
84
+ uid: row.uid,
59
85
  project: row.project,
60
86
  status: row.status,
61
87
  summary: row.summary,
@@ -173,28 +199,51 @@ export function listProjects(db, options = {}) {
173
199
  return false;
174
200
  });
175
201
  }
176
- export function addStatus(db, value, defaultSource = "human") {
202
+ export function addStatus(db, value, defaultSource = "human", completes = []) {
177
203
  const input = validateStatusEntry(value, defaultSource);
178
- return db.transaction(() => addValidatedStatus(db, input))();
204
+ return db
205
+ .transaction(() => {
206
+ const result = addValidatedStatus(db, input);
207
+ for (const target of completes)
208
+ changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
209
+ return result;
210
+ })
211
+ .immediate();
179
212
  }
180
- function addValidatedStatus(db, input) {
213
+ function addValidatedStatus(db, input, recordedAt) {
214
+ if (input.uid) {
215
+ const existing = one(db, `${select} WHERE e.uid = ?`, input.uid);
216
+ if (existing) {
217
+ const candidate = toPortableStatusEntry({
218
+ ...input,
219
+ id: existing.id,
220
+ recordedAt: existing.recordedAt,
221
+ });
222
+ if (!isDeepStrictEqual(toPortableStatusEntry(existing), candidate) ||
223
+ (recordedAt !== undefined && recordedAt !== existing.recordedAt))
224
+ throw new ValidationError("conflicting entry uid");
225
+ return { entry: existing, inserted: false };
226
+ }
227
+ }
181
228
  if (input.externalRef) {
182
229
  const existing = one(db, `${select} WHERE e.external_ref = ?`, input.externalRef);
183
230
  if (existing) {
231
+ if (input.uid)
232
+ throw new ValidationError("externalRef conflicts with portable uid");
184
233
  if (existing.project !== input.project)
185
234
  throw new ValidationError(`externalRef is already used by project ${existing.project}`);
186
235
  return { entry: existing, inserted: false };
187
236
  }
188
237
  }
189
- const now = new Date().toISOString();
238
+ const now = recordedAt ?? new Date().toISOString();
190
239
  upsertProjectMetadata(db, { project: input.project });
191
240
  const project = db
192
241
  .prepare("SELECT id FROM projects WHERE slug = ?")
193
242
  .get(input.project);
194
243
  const result = db
195
- .prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref)
196
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
197
- .run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null);
244
+ .prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
246
+ .run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID());
198
247
  return {
199
248
  entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
200
249
  inserted: true,
@@ -209,7 +258,7 @@ export function current(db, project) {
209
258
  const rows = db
210
259
  .prepare(`${select} WHERE e.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY occurred_at DESC, id DESC) AS position FROM status_entries) WHERE position = 1)${where} ORDER BY e.occurred_at DESC, e.id DESC`)
211
260
  .all(...(project ? [project] : []));
212
- return rows.map(entryFromRow);
261
+ return rows.map((row) => annotateEntry(db, entryFromRow(row)));
213
262
  }
214
263
  export function history(db, project, options = {}) {
215
264
  const clauses = [];
@@ -231,7 +280,7 @@ export function history(db, project, options = {}) {
231
280
  const rows = db
232
281
  .prepare(`${select}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?`)
233
282
  .all(...params, limit, offset);
234
- return rows.map(entryFromRow);
283
+ return rows.map((row) => annotateEntry(db, entryFromRow(row)));
235
284
  }
236
285
  export function at(db, project, occurredAt) {
237
286
  return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
@@ -248,3 +297,203 @@ export function countEntries(db, project) {
248
297
  .get();
249
298
  return row.count;
250
299
  }
300
+ function completionFromRow(row) {
301
+ return {
302
+ uid: row.uid,
303
+ targetUid: row.target_uid,
304
+ byUid: row.by_uid,
305
+ previousUid: row.previous_uid,
306
+ action: row.action,
307
+ ...(row.reason === null ? {} : { reason: row.reason }),
308
+ recordedAt: row.recorded_at,
309
+ source: row.source,
310
+ };
311
+ }
312
+ function latestCompletion(db, uid) {
313
+ const row = db
314
+ .prepare("SELECT * FROM completion_events WHERE target_uid = ? ORDER BY id DESC LIMIT 1")
315
+ .get(uid);
316
+ return row && completionFromRow(row);
317
+ }
318
+ export function getEntry(db, ref) {
319
+ const entry = /^\d+$/.test(ref)
320
+ ? one(db, `${select} WHERE e.id = ?`, ref)
321
+ : one(db, `${select} WHERE e.uid = ?`, ref);
322
+ return entry && annotateEntry(db, entry);
323
+ }
324
+ function annotateEntry(db, entry) {
325
+ const latest = latestCompletion(db, entry.uid);
326
+ const followUp = latest?.action === "complete"
327
+ ? one(db, `${select} WHERE e.uid = ?`, latest.byUid)
328
+ : undefined;
329
+ const newest = one(db, `${select} WHERE p.slug = ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, entry.project);
330
+ return {
331
+ ...entry,
332
+ effectiveStatus: followUp ? "completed" : entry.status,
333
+ isCurrent: newest?.id === entry.id,
334
+ ...(followUp && latest
335
+ ? {
336
+ completedBy: {
337
+ uid: followUp.uid,
338
+ id: followUp.id,
339
+ summary: followUp.summary,
340
+ occurredAt: followUp.occurredAt,
341
+ recordedAt: latest.recordedAt,
342
+ },
343
+ }
344
+ : {}),
345
+ };
346
+ }
347
+ function validateCompletionPair(db, targetRef, byRef) {
348
+ const target = getEntry(db, targetRef);
349
+ const by = getEntry(db, byRef);
350
+ if (!target || !by)
351
+ throw new ValidationError("completion entry not found");
352
+ if (target.project !== by.project)
353
+ throw new ValidationError("completion entries must belong to the same project");
354
+ if (target.occurredAt >= by.occurredAt)
355
+ throw new ValidationError("completion target must have a strictly earlier occurrence time");
356
+ return [target, by];
357
+ }
358
+ function insertCompletion(db, event) {
359
+ const existing = db
360
+ .prepare("SELECT * FROM completion_events WHERE uid = ?")
361
+ .get(event.uid);
362
+ if (existing) {
363
+ if (!isDeepStrictEqual(completionFromRow(existing), event))
364
+ throw new ValidationError("conflicting completion uid");
365
+ return;
366
+ }
367
+ validateCompletionPair(db, event.targetUid, event.byUid);
368
+ const latest = latestCompletion(db, event.targetUid);
369
+ // A per-target chain preserves event order and detects divergent imported histories.
370
+ if ((latest?.uid ?? null) !== event.previousUid)
371
+ throw new ValidationError("completion event order conflicts with target history");
372
+ if (event.action === "complete" && latest?.action === "complete")
373
+ throw new ValidationError("target already completed; revoke first");
374
+ if (event.action === "revoke" &&
375
+ (latest?.action !== "complete" || latest.byUid !== event.byUid))
376
+ throw new ValidationError("no matching active completion to revoke");
377
+ db.prepare(`INSERT INTO completion_events(uid, target_uid, by_uid, previous_uid, action, reason, recorded_at, source)
378
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.uid, event.targetUid, event.byUid, event.previousUid, event.action, event.reason ?? null, event.recordedAt, event.source);
379
+ }
380
+ export function changeCompletion(db, targetRef, byRef, action = "complete", reason, source = "human") {
381
+ if (action !== "complete" && action !== "revoke")
382
+ throw new ValidationError("invalid completion action");
383
+ if (!sources.includes(source))
384
+ throw new ValidationError("source is invalid");
385
+ if (reason !== undefined &&
386
+ (typeof reason !== "string" || !reason.trim() || reason.length > 2000))
387
+ throw new ValidationError("reason must be a non-empty string of at most 2000 characters");
388
+ if (action === "revoke" && !reason)
389
+ throw new ValidationError("revocation requires a reason");
390
+ return db
391
+ .transaction(() => {
392
+ const [target, by] = validateCompletionPair(db, targetRef, byRef);
393
+ const latest = latestCompletion(db, target.uid);
394
+ if (action === "complete" &&
395
+ latest?.action === "complete" &&
396
+ latest.byUid === by.uid)
397
+ return latest;
398
+ const event = {
399
+ uid: randomUUID(),
400
+ targetUid: target.uid,
401
+ byUid: by.uid,
402
+ previousUid: latest?.uid ?? null,
403
+ action,
404
+ ...(reason ? { reason: reason.trim() } : {}),
405
+ source,
406
+ recordedAt: new Date().toISOString(),
407
+ };
408
+ insertCompletion(db, event);
409
+ return event;
410
+ })
411
+ .immediate();
412
+ }
413
+ export function exportLedger(db, project) {
414
+ return db.transaction(() => {
415
+ const entries = db
416
+ .prepare(`${select}${project ? " WHERE p.slug = ?" : ""} ORDER BY e.occurred_at, e.id`)
417
+ .all(...(project ? [project] : []))
418
+ .map(entryFromRow)
419
+ .map((entry) => ({
420
+ ...toPortableStatusEntry(entry),
421
+ recordedAt: entry.recordedAt,
422
+ }));
423
+ const rows = db
424
+ .prepare(`SELECT c.* FROM completion_events c JOIN status_entries e ON e.uid = c.target_uid JOIN projects p ON p.id = e.project_id ${project ? "WHERE p.slug = ?" : ""} ORDER BY c.id`)
425
+ .all(...(project ? [project] : []));
426
+ return { version: 2, entries, completions: rows.map(completionFromRow) };
427
+ })();
428
+ }
429
+ function record(value, keys) {
430
+ if (!value || typeof value !== "object" || Array.isArray(value))
431
+ throw new ValidationError("expected an object");
432
+ const result = value;
433
+ for (const key of Object.keys(result))
434
+ if (!keys.includes(key))
435
+ throw new ValidationError(`unknown field: ${key}`);
436
+ return result;
437
+ }
438
+ function validateCompletionEvent(value) {
439
+ const row = record(value, [
440
+ "uid",
441
+ "targetUid",
442
+ "byUid",
443
+ "previousUid",
444
+ "action",
445
+ "reason",
446
+ "recordedAt",
447
+ "source",
448
+ ]);
449
+ if (row.action !== "complete" && row.action !== "revoke")
450
+ throw new ValidationError("invalid completion action");
451
+ if (!sources.includes(row.source))
452
+ throw new ValidationError("source is invalid");
453
+ if (row.reason !== undefined &&
454
+ (typeof row.reason !== "string" ||
455
+ !row.reason.trim() ||
456
+ row.reason.length > 2000))
457
+ throw new ValidationError("invalid completion reason");
458
+ if (row.action === "revoke" && !row.reason)
459
+ throw new ValidationError("revocation requires a reason");
460
+ return {
461
+ uid: validateUid(row.uid),
462
+ targetUid: validateUid(row.targetUid),
463
+ byUid: validateUid(row.byUid),
464
+ previousUid: row.previousUid === null ? null : validateUid(row.previousUid),
465
+ action: row.action,
466
+ ...(row.reason === undefined ? {} : { reason: row.reason }),
467
+ recordedAt: parseTime(row.recordedAt, "recordedAt"),
468
+ source: row.source,
469
+ };
470
+ }
471
+ export function importLedger(db, values) {
472
+ // Both legacy entries and versioned bundles are accepted atomically, including JSONL.
473
+ return db
474
+ .transaction(() => {
475
+ const results = [];
476
+ for (const value of values) {
477
+ if (value && typeof value === "object" && "version" in value) {
478
+ const bundle = record(value, ["version", "entries", "completions"]);
479
+ if (bundle.version !== 2 ||
480
+ !Array.isArray(bundle.entries) ||
481
+ !Array.isArray(bundle.completions))
482
+ throw new ValidationError("invalid ledger bundle");
483
+ for (const item of bundle.entries) {
484
+ if (!item || typeof item !== "object" || Array.isArray(item))
485
+ throw new ValidationError("entry must be an object");
486
+ const { recordedAt, ...input } = item;
487
+ validateUid(input.uid);
488
+ results.push(addValidatedStatus(db, validateStatusEntry(input, "import"), parseTime(recordedAt, "recordedAt")));
489
+ }
490
+ for (const event of bundle.completions)
491
+ insertCompletion(db, validateCompletionEvent(event));
492
+ }
493
+ else
494
+ results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
495
+ }
496
+ return results;
497
+ })
498
+ .immediate();
499
+ }
package/dist/domain.d.ts CHANGED
@@ -19,6 +19,7 @@ export type ProjectMetadataInput = {
19
19
  notes?: string | null;
20
20
  };
21
21
  export type StatusEntryInput = {
22
+ uid?: string;
22
23
  project: string;
23
24
  status: ProjectStatus;
24
25
  summary: string;
@@ -36,8 +37,17 @@ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "external
36
37
  progress?: string;
37
38
  externalRef?: string;
38
39
  recordedAt: string;
40
+ effectiveStatus?: ProjectStatus;
41
+ isCurrent?: boolean;
42
+ completedBy?: {
43
+ uid: string;
44
+ id: number;
45
+ summary: string;
46
+ occurredAt: string;
47
+ recordedAt: string;
48
+ };
39
49
  };
40
- export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt">;
50
+ export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt" | "effectiveStatus" | "isCurrent" | "completedBy">;
41
51
  export declare class ValidationError extends Error {
42
52
  constructor(message: string);
43
53
  }
@@ -45,3 +55,4 @@ export declare function parseTime(value: unknown, field: string): string;
45
55
  export declare function validateProjectMetadataInput(value: unknown): ProjectMetadataInput;
46
56
  export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
47
57
  export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
58
+ export declare function validateUid(value: unknown): string;
package/dist/domain.js CHANGED
@@ -9,6 +9,7 @@ export const statuses = [
9
9
  ];
10
10
  export const sources = ["human", "agent", "import"];
11
11
  const keys = new Set([
12
+ "uid",
12
13
  "project",
13
14
  "status",
14
15
  "summary",
@@ -120,6 +121,7 @@ export function validateStatusEntry(value, defaultSource = "human") {
120
121
  if (externalRef && externalRef.length > 200)
121
122
  throw new ValidationError("externalRef must be at most 200 characters");
122
123
  return {
124
+ ...(input.uid === undefined ? {} : { uid: validateUid(input.uid) }),
123
125
  project,
124
126
  status: statusInput,
125
127
  summary: text(input.summary, "summary", true),
@@ -137,6 +139,7 @@ export function validateStatusEntry(value, defaultSource = "human") {
137
139
  }
138
140
  export function toPortableStatusEntry(entry) {
139
141
  const result = {
142
+ uid: entry.uid,
140
143
  project: entry.project,
141
144
  status: entry.status,
142
145
  summary: entry.summary,
@@ -153,3 +156,9 @@ export function toPortableStatusEntry(entry) {
153
156
  result.externalRef = entry.externalRef;
154
157
  return result;
155
158
  }
159
+ export function validateUid(value) {
160
+ if (typeof value !== "string" ||
161
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value))
162
+ throw new ValidationError("uid must be a lowercase UUID");
163
+ return value;
164
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { createServer, } from "node:http";
3
3
  import { URL } from "node:url";
4
- import { countEntries, history, listProjects, openLedger, } from "./db.js";
4
+ import { countEntries, getEntry, history, listProjects, openLedger, } from "./db.js";
5
5
  import { getWebUIHTML } from "./web/ui.js";
6
6
  export function openBrowser(url) {
7
7
  const platform = process.platform;
@@ -41,6 +41,18 @@ export function handleRequest(req, res, db) {
41
41
  const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
42
42
  const pathname = reqUrl.pathname;
43
43
  const query = parseQueryParams(reqUrl);
44
+ let entryRef;
45
+ let projectSlug;
46
+ try {
47
+ if (pathname.startsWith("/api/entries/"))
48
+ entryRef = decodeURIComponent(pathname.slice("/api/entries/".length));
49
+ if (pathname.startsWith("/api/projects/"))
50
+ projectSlug = decodeURIComponent(pathname.slice("/api/projects/".length));
51
+ }
52
+ catch {
53
+ sendJson(res, 400, { error: "Invalid URL encoding" });
54
+ return;
55
+ }
44
56
  if (pathname === "/" || pathname === "/index.html") {
45
57
  res.writeHead(200, {
46
58
  "Content-Type": "text/html; charset=utf-8",
@@ -55,7 +67,7 @@ export function handleRequest(req, res, db) {
55
67
  return;
56
68
  }
57
69
  if (pathname.startsWith("/api/projects/")) {
58
- const slug = decodeURIComponent(pathname.slice("/api/projects/".length));
70
+ const slug = projectSlug;
59
71
  const projects = listProjects(db);
60
72
  const target = projects.find((p) => p.project === slug);
61
73
  if (!target) {
@@ -66,6 +78,11 @@ export function handleRequest(req, res, db) {
66
78
  }
67
79
  return;
68
80
  }
81
+ if (pathname.startsWith("/api/entries/")) {
82
+ const entry = getEntry(db, entryRef);
83
+ sendJson(res, entry ? 200 : 404, entry ?? { error: "Entry not found" });
84
+ return;
85
+ }
69
86
  if (pathname === "/api/history") {
70
87
  const project = query.project;
71
88
  const since = query.since;