project-mgr 0.1.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/dist/db.js ADDED
@@ -0,0 +1,131 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { validateStatusEntry, ValidationError, } from "./domain.js";
5
+ const migration = `
6
+ CREATE TABLE IF NOT EXISTS projects (
7
+ id INTEGER PRIMARY KEY,
8
+ slug TEXT NOT NULL UNIQUE,
9
+ display_name TEXT NOT NULL,
10
+ created_at TEXT NOT NULL
11
+ );
12
+ CREATE TABLE IF NOT EXISTS status_entries (
13
+ id INTEGER PRIMARY KEY,
14
+ project_id INTEGER NOT NULL REFERENCES projects(id),
15
+ status TEXT NOT NULL,
16
+ summary TEXT NOT NULL,
17
+ progress TEXT,
18
+ blockers_json TEXT NOT NULL DEFAULT '[]',
19
+ next_steps_json TEXT NOT NULL DEFAULT '[]',
20
+ decisions_json TEXT NOT NULL DEFAULT '[]',
21
+ tags_json TEXT NOT NULL DEFAULT '[]',
22
+ occurred_at TEXT NOT NULL,
23
+ recorded_at TEXT NOT NULL,
24
+ source TEXT NOT NULL,
25
+ external_ref TEXT UNIQUE
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
28
+ `;
29
+ export function databasePath() {
30
+ return (process.env.PMGR_DB_PATH ||
31
+ resolve(process.cwd(), "data", "project-mgr.sqlite"));
32
+ }
33
+ export function openLedger(path = databasePath()) {
34
+ if (path !== ":memory:")
35
+ mkdirSync(dirname(path), { recursive: true });
36
+ const db = new Database(path);
37
+ db.pragma("foreign_keys = ON");
38
+ db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)");
39
+ db.transaction(() => {
40
+ if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()) {
41
+ db.exec(migration);
42
+ db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
43
+ }
44
+ }).immediate();
45
+ return db;
46
+ }
47
+ function entryFromRow(row) {
48
+ return {
49
+ id: row.id,
50
+ project: row.project,
51
+ status: row.status,
52
+ summary: row.summary,
53
+ ...(row.progress ? { progress: row.progress } : {}),
54
+ blockers: JSON.parse(row.blockers_json),
55
+ nextSteps: JSON.parse(row.next_steps_json),
56
+ decisions: JSON.parse(row.decisions_json),
57
+ tags: JSON.parse(row.tags_json),
58
+ occurredAt: row.occurred_at,
59
+ recordedAt: row.recorded_at,
60
+ source: row.source,
61
+ ...(row.external_ref ? { externalRef: row.external_ref } : {}),
62
+ };
63
+ }
64
+ const select = `SELECT e.*, p.slug AS project FROM status_entries e JOIN projects p ON p.id = e.project_id`;
65
+ function one(db, sql, ...params) {
66
+ const row = db.prepare(sql).get(...params);
67
+ return row && entryFromRow(row);
68
+ }
69
+ export function addStatus(db, value, defaultSource = "human") {
70
+ const input = validateStatusEntry(value, defaultSource);
71
+ return db.transaction(() => addValidatedStatus(db, input))();
72
+ }
73
+ function addValidatedStatus(db, input) {
74
+ if (input.externalRef) {
75
+ const existing = one(db, `${select} WHERE e.external_ref = ?`, input.externalRef);
76
+ if (existing) {
77
+ if (existing.project !== input.project)
78
+ throw new ValidationError(`externalRef is already used by project ${existing.project}`);
79
+ return { entry: existing, inserted: false };
80
+ }
81
+ }
82
+ const now = new Date().toISOString();
83
+ db.prepare("INSERT INTO projects(slug, display_name, created_at) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING").run(input.project, input.project, now);
84
+ const project = db
85
+ .prepare("SELECT id FROM projects WHERE slug = ?")
86
+ .get(input.project);
87
+ const result = db
88
+ .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)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
90
+ .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);
91
+ return {
92
+ entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
93
+ inserted: true,
94
+ };
95
+ }
96
+ export function importStatuses(db, values) {
97
+ const inputs = values.map((value) => validateStatusEntry(value, "import"));
98
+ return db.transaction(() => inputs.map((input) => addValidatedStatus(db, input)))();
99
+ }
100
+ export function current(db, project) {
101
+ const where = project ? " AND p.slug = ?" : "";
102
+ const rows = db
103
+ .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`)
104
+ .all(...(project ? [project] : []));
105
+ return rows.map(entryFromRow);
106
+ }
107
+ export function history(db, project, options = {}) {
108
+ const clauses = [];
109
+ const params = [];
110
+ if (project) {
111
+ clauses.push("p.slug = ?");
112
+ params.push(project);
113
+ }
114
+ if (options.since) {
115
+ clauses.push("e.occurred_at >= ?");
116
+ params.push(options.since);
117
+ }
118
+ if (options.until) {
119
+ clauses.push("e.occurred_at <= ?");
120
+ params.push(options.until);
121
+ }
122
+ const limit = options.limit ?? 100;
123
+ const offset = options.offset ?? 0;
124
+ const rows = db
125
+ .prepare(`${select}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?`)
126
+ .all(...params, limit, offset);
127
+ return rows.map(entryFromRow);
128
+ }
129
+ export function at(db, project, occurredAt) {
130
+ return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
131
+ }
@@ -0,0 +1,30 @@
1
+ export declare const statuses: readonly ["idea", "planned", "active", "blocked", "paused", "completed", "archived"];
2
+ export declare const sources: readonly ["human", "agent", "import"];
3
+ export type ProjectStatus = (typeof statuses)[number];
4
+ export type Source = (typeof sources)[number];
5
+ export type StatusEntryInput = {
6
+ project: string;
7
+ status: ProjectStatus;
8
+ summary: string;
9
+ progress?: string;
10
+ blockers?: string[];
11
+ nextSteps?: string[];
12
+ decisions?: string[];
13
+ tags?: string[];
14
+ occurredAt?: string;
15
+ source?: Source;
16
+ externalRef?: string;
17
+ };
18
+ export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef">> & {
19
+ id: number;
20
+ progress?: string;
21
+ externalRef?: string;
22
+ recordedAt: string;
23
+ };
24
+ export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt">;
25
+ export declare class ValidationError extends Error {
26
+ constructor(message: string);
27
+ }
28
+ export declare function parseTime(value: unknown, field: string): string;
29
+ export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
30
+ export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
package/dist/domain.js ADDED
@@ -0,0 +1,106 @@
1
+ export const statuses = [
2
+ "idea",
3
+ "planned",
4
+ "active",
5
+ "blocked",
6
+ "paused",
7
+ "completed",
8
+ "archived",
9
+ ];
10
+ export const sources = ["human", "agent", "import"];
11
+ const keys = new Set([
12
+ "project",
13
+ "status",
14
+ "summary",
15
+ "progress",
16
+ "blockers",
17
+ "nextSteps",
18
+ "decisions",
19
+ "tags",
20
+ "occurredAt",
21
+ "source",
22
+ "externalRef",
23
+ ]);
24
+ const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
25
+ export class ValidationError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "ValidationError";
29
+ }
30
+ }
31
+ function text(value, field, required = false) {
32
+ if (value === undefined && !required)
33
+ return undefined;
34
+ if (typeof value !== "string" || !value.trim())
35
+ throw new ValidationError(`${field} must be a non-empty string`);
36
+ const normalized = value.trim();
37
+ if (normalized.length > 2_000)
38
+ throw new ValidationError(`${field} must be at most 2000 characters`);
39
+ return normalized;
40
+ }
41
+ function textList(value, field) {
42
+ if (value === undefined)
43
+ return [];
44
+ if (!Array.isArray(value) || value.length > 100)
45
+ throw new ValidationError(`${field} must be an array of at most 100 strings`);
46
+ return value.map((item) => text(item, field, true));
47
+ }
48
+ export function parseTime(value, field) {
49
+ if (typeof value !== "string" ||
50
+ !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
51
+ Number.isNaN(Date.parse(value))) {
52
+ throw new ValidationError(`${field} must be a valid ISO 8601 timestamp`);
53
+ }
54
+ return new Date(value).toISOString();
55
+ }
56
+ export function validateStatusEntry(value, defaultSource = "human") {
57
+ if (!value || typeof value !== "object" || Array.isArray(value))
58
+ throw new ValidationError("entry must be an object");
59
+ const input = value;
60
+ for (const key of Object.keys(input))
61
+ if (!keys.has(key))
62
+ throw new ValidationError(`unknown field: ${key}`);
63
+ const project = text(input.project, "project", true);
64
+ if (!projectName.test(project))
65
+ throw new ValidationError("project must use letters, numbers, dots, underscores, or hyphens");
66
+ if (!statuses.includes(input.status))
67
+ throw new ValidationError("status is invalid");
68
+ const source = input.source === undefined ? defaultSource : input.source;
69
+ if (!sources.includes(source))
70
+ throw new ValidationError("source is invalid");
71
+ const externalRef = text(input.externalRef, "externalRef");
72
+ if (externalRef && externalRef.length > 200)
73
+ throw new ValidationError("externalRef must be at most 200 characters");
74
+ return {
75
+ project,
76
+ status: input.status,
77
+ summary: text(input.summary, "summary", true),
78
+ progress: text(input.progress, "progress"),
79
+ blockers: textList(input.blockers, "blockers"),
80
+ nextSteps: textList(input.nextSteps, "nextSteps"),
81
+ decisions: textList(input.decisions, "decisions"),
82
+ tags: textList(input.tags, "tags"),
83
+ occurredAt: input.occurredAt === undefined
84
+ ? new Date().toISOString()
85
+ : parseTime(input.occurredAt, "occurredAt"),
86
+ source: source,
87
+ externalRef,
88
+ };
89
+ }
90
+ export function toPortableStatusEntry(entry) {
91
+ return {
92
+ project: entry.project,
93
+ status: entry.status,
94
+ summary: entry.summary,
95
+ ...(entry.progress === undefined ? {} : { progress: entry.progress }),
96
+ blockers: entry.blockers,
97
+ nextSteps: entry.nextSteps,
98
+ decisions: entry.decisions,
99
+ tags: entry.tags,
100
+ occurredAt: entry.occurredAt,
101
+ source: entry.source,
102
+ ...(entry.externalRef === undefined
103
+ ? {}
104
+ : { externalRef: entry.externalRef }),
105
+ };
106
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./db.js";
2
+ export * from "./domain.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./db.js";
2
+ export * from "./domain.js";
@@ -0,0 +1,95 @@
1
+ # Architecture
2
+
3
+ ## Design goals
4
+
5
+ `project-mgr` is a local, single-machine, append-only project status ledger. The first release follows four principles:
6
+
7
+ - History is never overwritten: every write appends a status snapshot.
8
+ - One source of truth: the CLI and any future interfaces must reuse the same domain and database logic.
9
+ - Clear time semantics: distinguish when a status occurred from when the system recorded it.
10
+ - Portable: exports can be imported into a new database to restore the status timeline.
11
+
12
+ The first release has no work that needs to run continuously, so it provides neither an HTTP API nor PM2 configuration. Consider a service layer only when other local processes need a stable interface or background jobs.
13
+
14
+ ## Module boundaries
15
+
16
+ ```text
17
+ User / script
18
+ │
19
+ ▼
20
+ src/cli.ts Argument parsing, file input, output formatting, command-level constraints
21
+ │
22
+ ├──────────────► src/domain.ts
23
+ │ Types, field validation, time normalization, portable record conversion
24
+ ▼
25
+ src/db.ts SQLite connection, migrations, transactions, idempotent writes, and queries
26
+ │
27
+ ▼
28
+ SQLite file
29
+ ```
30
+
31
+ `src/index.ts` exports only public types and functions. Business validation does not depend on the CLI, so bulk imports and direct database API calls use the same rules.
32
+
33
+ The CLI accepts `pmgr <action>` for `add`, `import`, `current`, `history`, `at`, and `export`; `pmgr status <action>` remains a compatible form. Both forms share the same validation and execution path.
34
+
35
+ ## Data model
36
+
37
+ The database contains three structures:
38
+
39
+ - `schema_migrations`: Applied migration versions.
40
+ - `projects`: Project slug, display name, and creation time.
41
+ - `status_entries`: Immutable status snapshots and their project foreign keys.
42
+
43
+ Key `status_entries` fields:
44
+
45
+ | Field | Meaning |
46
+ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
47
+ | `status`, `summary`, `progress` | Status enum, summary, and completed work |
48
+ | `blockers_json`, `next_steps_json`, `decisions_json`, `tags_json` | JSON-encoded string arrays |
49
+ | `occurred_at` | When the status actually occurred, supplied by the caller or generated during validation |
50
+ | `recorded_at` | When the database actually wrote the entry |
51
+ | `source` | `human`, `agent`, or `import` |
52
+ | `external_ref` | Optional globally unique idempotency key |
53
+
54
+ The `project_id, occurred_at DESC, id DESC` index supports current-status and history queries.
55
+
56
+ ## Initialization and migrations
57
+
58
+ After opening the database, `openLedger()` enables foreign keys, creates the migration table, and checks for and applies migration 1 within a `BEGIN IMMEDIATE` transaction. The immediate transaction prevents migration checks and writes from interleaving when several local processes open the database for the first time at once.
59
+
60
+ The database path is read from `PMGR_DB_PATH` first; otherwise, it uses `data/project-mgr.sqlite` in the process working directory. Non-memory databases create their parent directory automatically.
61
+
62
+ ## Write flow
63
+
64
+ A single write proceeds as follows:
65
+
66
+ 1. Reject unknown fields and validate the project name, status, source, string arrays, and time.
67
+ 2. Normalize all timestamps to UTC ISO 8601 strings.
68
+ 3. In a transaction, check `externalRef`: repeated calls for the same project return the existing entry; cross-project conflicts fail.
69
+ 4. Create the project when needed, then append a status snapshot.
70
+ 5. Read and return the final entry from the database.
71
+
72
+ Bulk import validates all input first, then executes the same write logic for each entry in one transaction. A database constraint or any failed write rolls back the entire batch.
73
+
74
+ ## Query semantics
75
+
76
+ - Current status: take the first entry for each project ordered by `occurred_at DESC, id DESC`.
77
+ - History: use the same occurrence-time and ID descending order, with inclusive `since` / `until` bounds and `limit` / `offset`.
78
+ - Status at a time: take the first entry with `occurred_at <=` the specified time.
79
+ - Same occurrence time: the later-written, larger `id` wins, keeping results deterministic.
80
+
81
+ All ISO 8601 timestamps are converted to UTC on write, so SQLite string ordering matches time ordering.
82
+
83
+ ## Import and export
84
+
85
+ Complete records returned by database queries include the internal `id` and `recordedAt`. The export layer removes these read-only fields while preserving business fields, original source, and the idempotency key, so JSON and JSONL can pass strict input validation when imported again.
86
+
87
+ Exports have no record limit and reverse descending history results into oldest-to-newest order. When restoring to an empty database, entries with the same `occurredAt` receive IDs in their original append order, so the current status remains the same.
88
+
89
+ ## Consistency and limits
90
+
91
+ - SQLite transactions guarantee atomic single writes and bulk imports.
92
+ - `externalRef` is unique across the entire database; cross-project reuse is explicitly rejected.
93
+ - Current status is derived from history; no separate cache table can drift.
94
+ - Version 1 is for trusted local users and includes no authentication, authorization, multi-user synchronization, or encryption.
95
+ - JSON arrays are stored as text. Evaluate a normalized schema only when cross-field full-text search or complex tag statistics are needed.
@@ -0,0 +1,12 @@
1
+ import plugin from "@typescript-eslint/eslint-plugin";
2
+ import parser from "@typescript-eslint/parser";
3
+
4
+ export default [
5
+ {
6
+ files: ["src/**/*.ts", "test/**/*.ts"],
7
+ ignores: ["dist/**", "coverage/**", "node_modules/**"],
8
+ languageOptions: { parser },
9
+ plugins: { "@typescript-eslint": plugin },
10
+ rules: { "@typescript-eslint/no-unused-vars": "error" },
11
+ },
12
+ ];
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "project-mgr",
3
+ "version": "0.1.0",
4
+ "description": "Local-first append-only project status ledger",
5
+ "type": "module",
6
+ "bin": {
7
+ "pmgr": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "start": "node --env-file-if-exists=.env dist/cli.js",
12
+ "pmgr": "node --env-file-if-exists=.env --import tsx src/cli.ts",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint": "eslint .",
15
+ "format": "prettier --write .",
16
+ "format-check": "prettier --check .",
17
+ "test": "tsx --test test/**/*.test.ts",
18
+ "coverage": "c8 --all --extension .ts --exclude 'dist/**' --exclude 'test/**' --check-coverage --statements 100 --branches 100 --functions 100 --lines 100 pnpm test"
19
+ },
20
+ "dependencies": {
21
+ "better-sqlite3": "^12.6.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/better-sqlite3": "^7.6.13",
25
+ "@types/node": "^22.15.3",
26
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
27
+ "@typescript-eslint/parser": "^8.35.0",
28
+ "c8": "^10.1.3",
29
+ "eslint": "^9.30.0",
30
+ "prettier": "^3.6.2",
31
+ "tsx": "^4.20.3",
32
+ "typescript": "^5.8.3"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "packageManager": "pnpm@10.30.2"
38
+ }
@@ -0,0 +1,3 @@
1
+ onlyBuiltDependencies:
2
+ - better-sqlite3
3
+ - esbuild