project-mgr 0.2.1 → 0.3.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/CHANGELOG.md +17 -0
- package/README.md +21 -0
- package/dist/cli.js +37 -32
- package/dist/db.d.ts +33 -1
- package/dist/db.js +259 -10
- package/dist/domain.d.ts +12 -1
- package/dist/domain.js +9 -0
- package/dist/server.js +19 -2
- package/dist/web/i18n.d.ts +8 -0
- package/dist/web/i18n.js +77 -0
- package/dist/web/locales/en.d.ts +2 -0
- package/dist/web/locales/en.js +52 -0
- package/dist/web/locales/zh.d.ts +54 -0
- package/dist/web/locales/zh.js +52 -0
- package/dist/web/script.d.ts +2 -0
- package/dist/web/script.js +356 -0
- package/dist/web/styles.d.ts +1 -0
- package/dist/web/styles.js +636 -0
- package/dist/web/ui.d.ts +3 -1
- package/dist/web/ui.js +33 -924
- package/package.json +8 -2
- package/skills/project-mgr/SKILL.md +26 -14
- package/.claude/settings.local.json +0 -5
- package/.env_sample +0 -10
- package/.prettierignore +0 -4
- package/AGENTS.md +0 -5
- package/docs/architecture.md +0 -95
- package/ecosystem.config.cjs +0 -21
- package/eslint.config.js +0 -12
- package/pnpm-workspace.yaml +0 -3
- package/src/cli.ts +0 -399
- package/src/db.ts +0 -373
- package/src/domain.ts +0 -226
- package/src/index.ts +0 -5
- package/src/server.ts +0 -202
- package/src/web/ui.ts +0 -977
- package/test/core.test.ts +0 -739
- package/test/server.test.ts +0 -354
- package/tsconfig.json +0 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Explicit completion of multiple predecessor activities, append-only revocation, and completion-source links in the activity feed.
|
|
8
|
+
- Independent Current/Superseded markers, preserved original snapshot details, and a direct entry route with a return-to-list control.
|
|
9
|
+
- Migration 3 adds portable entry and event identities and rejects obsolete writers that omit entry UUIDs.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Invalid percent-encoding in entry/project detail routes returns 400 instead of 500.
|
|
19
|
+
|
|
3
20
|
## 0.2.0 - 2026-08-07
|
|
4
21
|
|
|
5
22
|
### 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,
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type I18nKey, type LocaleMessages } from "./locales/zh.js";
|
|
2
|
+
export type { I18nKey, LocaleMessages };
|
|
3
|
+
export type Locale = "zh" | "en";
|
|
4
|
+
export declare const DEFAULT_LOCALE: Locale;
|
|
5
|
+
export declare const locales: Record<Locale, LocaleMessages>;
|
|
6
|
+
export declare function resolveLocale(lang?: unknown): Locale;
|
|
7
|
+
export declare function getMessages(lang: Locale): LocaleMessages;
|
|
8
|
+
export declare function getClientI18nScript(initialLang: Locale): string;
|