@danypops/papyrus 0.11.3 → 0.12.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.
Files changed (46) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/domain-tools.ts +108 -52
  4. package/extension/src/index.ts +90 -37
  5. package/extension/src/notes.ts +14 -1
  6. package/extension/src/task-focus-events.ts +57 -0
  7. package/extension/src/tasks.ts +51 -15
  8. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  9. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  10. package/extension/src/tool-rendering/index.ts +107 -0
  11. package/extension/src/tool-rendering/render-model.ts +406 -0
  12. package/package.json +4 -2
  13. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  14. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  15. package/src/adapters/sqlite-artifact-store.ts +20 -11
  16. package/src/adapters/sqlite-discourse-store.ts +325 -0
  17. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  18. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  19. package/src/authority-registry.ts +115 -0
  20. package/src/cli.ts +904 -124
  21. package/src/constants.ts +38 -5
  22. package/src/conversation-journal-service.ts +87 -0
  23. package/src/db.ts +336 -8
  24. package/src/domain/artifact-event.ts +99 -0
  25. package/src/domain/conversation-journal.ts +168 -0
  26. package/src/domain/discourse-store.ts +142 -0
  27. package/src/domain/graph-projection.ts +74 -0
  28. package/src/domain/task-event.ts +4 -0
  29. package/src/domain-services.ts +133 -38
  30. package/src/graph-projection-service.ts +103 -0
  31. package/src/id-migration.ts +200 -0
  32. package/src/module-registry.ts +53 -0
  33. package/src/modules/docs.ts +77 -0
  34. package/src/modules/graph-projection.ts +82 -0
  35. package/src/modules/notes.ts +76 -0
  36. package/src/modules/rules.ts +81 -0
  37. package/src/modules/skills.ts +113 -0
  38. package/src/modules/tasks.ts +164 -0
  39. package/src/ops.ts +142 -15
  40. package/src/ports/artifact-scope-store.ts +20 -0
  41. package/src/ports/artifact-store.ts +10 -5
  42. package/src/ports/conversation-journal-store.ts +17 -0
  43. package/src/ports/graph-projection-store.ts +15 -0
  44. package/src/ports/task-focus-store.ts +62 -20
  45. package/src/service.ts +218 -223
  46. package/src/task-service.ts +70 -38
package/src/constants.ts CHANGED
@@ -7,8 +7,14 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 5;
10
+ export const SQLITE_SCHEMA_VERSION = 10;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
+ /** Bounded forum persistence behind the Discourse mutation authority. */
13
+ export const DISCOURSE_QUERY_MAX_LIMIT = 100;
14
+ export const DISCOURSE_CONTENT_MAX_BYTES = 65_536;
15
+ export const DISCOURSE_EVENT_RETENTION_DEFAULT = 1_000;
16
+ export const DISCOURSE_EVENT_RETENTION_MAX = 10_000;
17
+ export const DISCOURSE_PARTICIPANT_MAX_COUNT = 100;
12
18
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
13
19
  export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
14
20
  export const GATE_COMMAND_TIMEOUT_MS = 30_000;
@@ -19,6 +25,9 @@ export const GATE_FILE_MAX_BYTES = 1_048_576;
19
25
 
20
26
  export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
21
27
  export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
28
+ /** Broadcasts which task is focused, content-free (taskId/sessionId/status/timestamp only), so other extensions (e.g. a token-cost router) can correlate their own telemetry without Papyrus depending on them. */
29
+ export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
30
+ export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
22
31
  export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
23
32
 
24
33
  /** Compact task-context limits keep recurring prompt injection bounded. */
@@ -71,8 +80,23 @@ export const NOTE_LIST_MAX_LIMIT = 200;
71
80
  export const NOTE_HISTORY_MAX_EVENTS = 20;
72
81
  export const NOTE_PROVENANCE_MAX_LENGTH = 128;
73
82
  export const NOTE_REASON_MAX_CHARACTERS = 2_000;
83
+ /** Generic, kind-agnostic mutation event log bounds (doc/task/rule/skill share one log). */
84
+ export const ARTIFACT_EVENT_ACTOR_MAX_LENGTH = 128;
85
+ export const ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT = 25;
86
+ export const ARTIFACT_EVENT_HISTORY_MAX_LIMIT = 200;
87
+ /** Bounds for the generic graph projection protocol (external bounded contexts). */
88
+ export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
89
+ export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
90
+ export const GRAPH_PROJECTION_ID_MAX_LENGTH = 256;
91
+ /** Per-agent-session Task Focus scoping. "global" is the default scope for callers that don't supply a session id (CLI, legacy behavior). */
92
+ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
93
+ export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
94
+ /** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
95
+ export const TASK_FOCUS_MAX_SCOPES = 500;
74
96
  /** Persisted project and focused-graph Task view bounds. */
75
97
  export const TASK_SCOPE_MAX_TASKS = 1_000;
98
+ /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
99
+ export const ARTIFACT_SCOPE_MAX_ARTIFACTS = 1_000;
76
100
  export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
77
101
  export const GRAPH_RENDER_PADDING_X = 2;
78
102
  export const GRAPH_RENDER_PADDING_Y = 1;
@@ -93,14 +117,23 @@ export const DEFAULT_METADATA_DEPTH = 6;
93
117
  export const DEFAULT_METADATA_ITEMS = 100;
94
118
  export const MAX_METADATA_DEPTH = 12;
95
119
  export const MAX_METADATA_ITEMS = 500;
120
+ /** Independent bounds for model-facing tool content and persisted renderer details. */
121
+ export const TOOL_MODEL_CONTENT_MAX_CHARACTERS = 12_000;
122
+ export const TOOL_DETAILS_BODY_MAX_CHARACTERS = 20_000;
123
+ export const TOOL_DETAILS_FIELD_MAX_CHARACTERS = 1_000;
124
+ export const TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS = 1_000;
125
+ export const TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS = 131_072;
126
+ export const TOOL_COLLAPSED_ROW_LIMIT = 5;
127
+ export const TOOL_DETAILS_MAX_ITEMS = 100;
128
+ export const TOOL_DETAILS_MAX_EDGES = 200;
96
129
 
97
130
  /** Reconciliation instruction appended whenever Papyrus has open work. */
98
131
  export const TASK_RECONCILIATION_INSTRUCTION = [
99
132
  "Reconcile before concluding or moving on:",
100
133
  '• For each current task, ask: "Did we accomplish this task?"',
101
- "• If yes, run its gates before marking it done; a claim is not verification.",
102
- "• If no, continue with the next concrete action toward its desired state.",
103
- "• Address blocked work or explicitly move failed review to rejected with the reason.",
134
+ "• If yes, run its gates before marking it done; a claim is not verification. A written summary is not evidence -- identify what would actually prove each requirement in the task's desired state and checklist, and treat indirect or merely-plausible signals as not sufficient.",
135
+ "• If no, continue with the next concrete action toward its desired state. Do not shrink the task's scope to whatever fits in this turn, and do not substitute a narrower, easier, or merely-passing-looking change for the actual desired outcome.",
136
+ "• Address blocked work or explicitly move failed review to rejected with the reason. Do not reject or call something blocked on the first obstacle -- only after the same blocking condition genuinely recurs, and only when the task truly cannot proceed without external input or a change outside the agent's control.",
104
137
  ].join("\n");
105
138
 
106
139
  /** $XDG_DATA_HOME/papyrus/papyrus.db */
@@ -175,5 +208,5 @@ export const DEFAULT_STATUS_BY_KIND: Readonly<Record<string, string>> = {
175
208
  export const SEED_RELATIONS = [
176
209
  "references", "implements", "follows", "depends_on",
177
210
  "documents", "blocks", "supersedes", "relates_to",
178
- "gates", "triggers", "contains", "part_of",
211
+ "gates", "triggers", "contains", "part_of", "reply_to", "discusses",
179
212
  ] as const;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * conversation-journal-service.ts — host-neutral ConversationJournal application layer.
3
+ *
4
+ * Owns idempotency (checking operationId before insert) and bounds enforcement; the
5
+ * ConversationJournalStore port underneath is dumb storage. See
6
+ * src/domain/conversation-journal.ts for the domain shapes and the design rationale.
7
+ */
8
+ import {
9
+ ancestorChain,
10
+ boundContent,
11
+ buildThreadTree,
12
+ CONVERSATION_JOURNAL_READ_MAX_POSTS,
13
+ validateAppendPostCommand,
14
+ type AppendPostCommand,
15
+ type AppendPostResult,
16
+ type JournalPost,
17
+ type JournalThread,
18
+ type ReadThreadQuery,
19
+ type ThreadPage,
20
+ type ThreadTreeNode,
21
+ } from "./domain/conversation-journal.ts";
22
+ import type { ConversationJournalStore } from "./ports/conversation-journal-store.ts";
23
+
24
+ export class ConversationJournalService {
25
+ constructor(private readonly store: ConversationJournalStore) {}
26
+
27
+ appendPost(command: AppendPostCommand): AppendPostResult {
28
+ validateAppendPostCommand(command);
29
+
30
+ const existing = this.store.findPostByOperationId(command.operationId);
31
+ if (existing) return { post: existing, replayed: true };
32
+
33
+ if (command.replyToPostId !== undefined && !this.store.getPost(command.replyToPostId)) {
34
+ throw new Error(`replyToPostId "${command.replyToPostId}" not found`);
35
+ }
36
+
37
+ this.store.ensureThread(command.threadId);
38
+ const { content, truncated } = boundContent(command.content);
39
+ const post: JournalPost = {
40
+ id: crypto.randomUUID(),
41
+ threadId: command.threadId,
42
+ ...(command.replyToPostId !== undefined ? { replyToPostId: command.replyToPostId } : {}),
43
+ authorId: command.authorId,
44
+ content,
45
+ truncated,
46
+ timestamp: new Date().toISOString(),
47
+ sourceSessionId: command.sourceSessionId,
48
+ operationId: command.operationId,
49
+ references: command.references ? [...command.references] : [],
50
+ };
51
+ this.store.insertPost(post);
52
+ return { post, replayed: false };
53
+ }
54
+
55
+ getThread(threadId: string): JournalThread | undefined {
56
+ return this.store.getThread(threadId);
57
+ }
58
+
59
+ getPost(id: string): JournalPost | undefined {
60
+ return this.store.getPost(id);
61
+ }
62
+
63
+ /** Bounded, explicit-completeness thread read. Never silently drops posts past the bound. */
64
+ readThread(query: ReadThreadQuery): ThreadPage {
65
+ const limit = query.limit;
66
+ if (!Number.isInteger(limit) || limit < 1 || limit > CONVERSATION_JOURNAL_READ_MAX_POSTS) {
67
+ throw new Error(`readThread limit must be between 1 and ${CONVERSATION_JOURNAL_READ_MAX_POSTS}`);
68
+ }
69
+ const all = [...this.store.postsForThread(query.threadId)].sort(
70
+ (left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id),
71
+ );
72
+ const truncated = all.length > limit;
73
+ return { posts: all.slice(0, limit), truncated };
74
+ }
75
+
76
+ /** Reconstructed reply tree for a thread, bounded and orphan/cycle-safe -- see buildThreadTree. */
77
+ readThreadTree(threadId: string): ThreadTreeNode[] {
78
+ return buildThreadTree(this.store.postsForThread(threadId));
79
+ }
80
+
81
+ /** Root-first ancestor chain for one post -- the host-neutral equivalent of Pi's getBranch(). */
82
+ ancestorsOf(postId: string): JournalPost[] {
83
+ const posts = this.store.postsForThread(this.store.getPost(postId)?.threadId ?? "");
84
+ const byId = new Map(posts.map((post) => [post.id, post]));
85
+ return ancestorChain(postId, byId);
86
+ }
87
+ }
package/src/db.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Dual-runtime: bun:sqlite (Bun) / node:sqlite (Node/pi host).
4
4
  * Four kinds (doc/task/rule/skill) are FK-enforced; relations are universal (any→any).
5
5
  */
6
+ import { createHash } from "node:crypto";
6
7
  import { createRequire } from "node:module";
7
8
  import { mkdirSync } from "node:fs";
8
9
  import { join, dirname } from "node:path";
@@ -65,6 +66,14 @@ export function inTransaction<T>(db: Db, fn: () => T): T {
65
66
  }
66
67
 
67
68
  const SCHEMA = `
69
+ CREATE TABLE IF NOT EXISTS module_migrations (
70
+ module_id TEXT NOT NULL,
71
+ version INTEGER NOT NULL,
72
+ name TEXT NOT NULL,
73
+ checksum TEXT NOT NULL,
74
+ applied_at TEXT NOT NULL,
75
+ PRIMARY KEY (module_id, version)
76
+ );
68
77
  CREATE TABLE IF NOT EXISTS kinds (
69
78
  name TEXT PRIMARY KEY,
70
79
  description TEXT
@@ -98,8 +107,8 @@ CREATE TABLE IF NOT EXISTS relation_names (
98
107
  description TEXT
99
108
  );
100
109
  CREATE TABLE IF NOT EXISTS task_focus (
101
- scope TEXT PRIMARY KEY CHECK (scope = 'global'),
102
- task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
110
+ scope TEXT PRIMARY KEY,
111
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
103
112
  status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
104
113
  pause_reason TEXT,
105
114
  updated_at TEXT NOT NULL
@@ -138,6 +147,109 @@ CREATE TABLE IF NOT EXISTS task_views (
138
147
  updated_at TEXT NOT NULL,
139
148
  CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
140
149
  );
150
+ CREATE TABLE IF NOT EXISTS discourse_threads (
151
+ store_id TEXT NOT NULL,
152
+ forum_id TEXT NOT NULL,
153
+ topic_id TEXT NOT NULL,
154
+ thread_id TEXT NOT NULL,
155
+ artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
156
+ PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
157
+ );
158
+ CREATE TABLE IF NOT EXISTS discourse_posts (
159
+ store_id TEXT NOT NULL,
160
+ sequence INTEGER NOT NULL,
161
+ id TEXT NOT NULL,
162
+ artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
163
+ operation_id TEXT NOT NULL,
164
+ command_json TEXT NOT NULL,
165
+ forum_id TEXT NOT NULL,
166
+ topic_id TEXT NOT NULL,
167
+ thread_id TEXT NOT NULL,
168
+ author_id TEXT NOT NULL,
169
+ content_json TEXT NOT NULL,
170
+ timestamp INTEGER NOT NULL,
171
+ correlation_id TEXT,
172
+ causation_id TEXT,
173
+ reply_to_post_id TEXT,
174
+ references_json TEXT NOT NULL,
175
+ question_type TEXT CHECK (question_type IN ('question', 'answer')),
176
+ response_id TEXT,
177
+ target_id TEXT,
178
+ PRIMARY KEY (store_id, id),
179
+ UNIQUE (store_id, operation_id),
180
+ UNIQUE (store_id, sequence)
181
+ );
182
+ CREATE INDEX IF NOT EXISTS discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
183
+ CREATE TABLE IF NOT EXISTS discourse_events (
184
+ store_id TEXT NOT NULL,
185
+ sequence INTEGER NOT NULL,
186
+ event_json TEXT NOT NULL,
187
+ PRIMARY KEY (store_id, sequence)
188
+ );
189
+ CREATE TABLE IF NOT EXISTS discourse_cursors (
190
+ store_id TEXT NOT NULL,
191
+ consumer_id TEXT NOT NULL,
192
+ sequence INTEGER NOT NULL,
193
+ PRIMARY KEY (store_id, consumer_id)
194
+ );
195
+ CREATE TABLE IF NOT EXISTS discourse_projection_cursors (
196
+ store_id TEXT NOT NULL,
197
+ projection_id TEXT NOT NULL,
198
+ sequence INTEGER NOT NULL,
199
+ PRIMARY KEY (store_id, projection_id)
200
+ );
201
+ CREATE TRIGGER IF NOT EXISTS discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
202
+ WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
203
+ BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
204
+ CREATE TRIGGER IF NOT EXISTS discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
205
+ WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
206
+ BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
207
+ CREATE TRIGGER IF NOT EXISTS discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
208
+ WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
209
+ OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
210
+ BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
211
+ CREATE TABLE IF NOT EXISTS artifact_events (
212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id),
214
+ occurred_at TEXT NOT NULL,
215
+ event_type TEXT NOT NULL,
216
+ actor TEXT NOT NULL,
217
+ source TEXT NOT NULL,
218
+ session_id TEXT,
219
+ from_status TEXT,
220
+ to_status TEXT,
221
+ relation TEXT,
222
+ related_id TEXT,
223
+ event_schema_version INTEGER NOT NULL DEFAULT 1
224
+ );
225
+ CREATE INDEX IF NOT EXISTS artifact_events_artifact_idx ON artifact_events(artifact_id, occurred_at, id);
226
+ CREATE INDEX IF NOT EXISTS artifact_events_related_idx ON artifact_events(related_id, occurred_at, id);
227
+ CREATE INDEX IF NOT EXISTS artifact_events_actor_idx ON artifact_events(actor, occurred_at, id);
228
+ CREATE INDEX IF NOT EXISTS artifact_events_session_idx ON artifact_events(session_id, occurred_at, id);
229
+ CREATE TRIGGER IF NOT EXISTS artifact_events_no_update BEFORE UPDATE ON artifact_events
230
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
231
+ CREATE TRIGGER IF NOT EXISTS artifact_events_no_delete BEFORE DELETE ON artifact_events
232
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
233
+ CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
234
+ producer_id TEXT PRIMARY KEY,
235
+ last_sequence INTEGER NOT NULL,
236
+ last_batch_id TEXT NOT NULL,
237
+ applied_at TEXT NOT NULL
238
+ );
239
+ CREATE TABLE IF NOT EXISTS graph_projection_identities (
240
+ producer_id TEXT NOT NULL,
241
+ external_id TEXT NOT NULL,
242
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id),
243
+ PRIMARY KEY (producer_id, external_id)
244
+ );
245
+ CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_projection_identities(artifact_id);
246
+ CREATE TABLE IF NOT EXISTS artifact_scopes (
247
+ artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
248
+ project_root TEXT,
249
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
250
+ assigned_at TEXT NOT NULL
251
+ );
252
+ CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
141
253
  `;
142
254
 
143
255
  const SEED_SQL = `
@@ -170,6 +282,8 @@ INSERT OR IGNORE INTO relation_names VALUES ('gates','This rule gates that task
170
282
  INSERT OR IGNORE INTO relation_names VALUES ('triggers','This skill applies to that work (skill→task)');
171
283
  INSERT OR IGNORE INTO relation_names VALUES ('contains','Parent contains a nested artifact (any→any)');
172
284
  INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a parent artifact (any→any)');
285
+ INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
286
+ INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
173
287
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
174
288
  `;
175
289
 
@@ -183,16 +297,102 @@ export function schemaVersion(db: Db): number {
183
297
  return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
184
298
  }
185
299
 
300
+ /**
301
+ * One row per (module_id, version) applied migration, checksummed so a since-edited
302
+ * definition is detected rather than silently trusted.
303
+ */
304
+ export interface ModuleMigrationRow {
305
+ readonly moduleId: string;
306
+ readonly version: number;
307
+ readonly name: string;
308
+ readonly checksum: string;
309
+ readonly appliedAt: string;
310
+ }
311
+
312
+ /**
313
+ * "core"'s ledger history. Each entry's checksum is a FROZEN, hardcoded literal, computed
314
+ * once at the moment that version was introduced and embedded here permanently -- never
315
+ * recomputed from the live, evolving SCHEMA/SEED_SQL constants. That distinction is the
316
+ * entire point: an earlier version of this file computed the checksum from the current
317
+ * SCHEMA text every time, which meant "core version 1" silently redefined itself every
318
+ * time SCHEMA grew, and would have thrown a checksum-mismatch error against every
319
+ * database that had already recorded the OLDER value -- including a real, already-
320
+ * deployed production database. Only the LAST entry's DDL actually runs (SCHEMA+SEED_SQL,
321
+ * the full current shape) for a truly fresh database; every earlier entry is pure
322
+ * historical bookkeeping, backfilled alongside it without being replayed, so a new
323
+ * database is not forced to pass through migrations it never structurally needed. A
324
+ * database that already recorded an earlier version keeps that exact checksum forever;
325
+ * adding schema later means appending a new entry here, never editing an existing one.
326
+ */
327
+ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; checksum: string }> = [
328
+ { version: 1, name: "baseline", checksum: "af81e9f51d915ba538af3f468dc044bda5e2c5a5f5037e9c7c01540f87288763" },
329
+ { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
330
+ ];
331
+
332
+ export function migrationLedger(db: Db): ModuleMigrationRow[] {
333
+ // A database that has never reached current schema (still awaiting explicit migrateDb())
334
+ // has no ledger table yet -- "nothing recorded" is the correct answer, not an error.
335
+ const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'module_migrations'").get() != null;
336
+ if (!tableExists) return [];
337
+ const rows = db.prepare("SELECT module_id, version, name, checksum, applied_at FROM module_migrations ORDER BY module_id, version").all() as Array<{
338
+ module_id: string; version: number; name: string; checksum: string; applied_at: string;
339
+ }>;
340
+ return rows.map((row) => ({ moduleId: row.module_id, version: row.version, name: row.name, checksum: row.checksum, appliedAt: row.applied_at }));
341
+ }
342
+
343
+ /**
344
+ * Ensures the ledger correctly reflects every entry in CORE_LEDGER_VERSIONS once a
345
+ * database is confirmed at the full current schema, however it got there: a truly empty
346
+ * database runs the current bootstrap DDL once (for the last/current entry only) and
347
+ * backfills every entry as historical bookkeeping; a database that already reached
348
+ * current shape (a fresh bootstrap from a past release, or a full upgrade through the
349
+ * pre-ledger sequential migrateDb() chain below) is backfilled without re-running any DDL
350
+ * against data that already exists. Verifies every already-recorded entry's stored
351
+ * checksum on every open so a since-edited definition is caught, not silently trusted --
352
+ * and because each entry's checksum is frozen at introduction (see the constant's own
353
+ * comment), an already-recorded entry can never spuriously mismatch just because a later
354
+ * entry was appended.
355
+ */
356
+ function ensureCoreLedger(db: Db, alreadyAtCurrentSchema: boolean): void {
357
+ // Idempotent and standalone: must succeed even on a truly empty database, before the rest
358
+ // of SCHEMA (which also declares this table) has run.
359
+ db.exec(`
360
+ CREATE TABLE IF NOT EXISTS module_migrations (
361
+ module_id TEXT NOT NULL,
362
+ version INTEGER NOT NULL,
363
+ name TEXT NOT NULL,
364
+ checksum TEXT NOT NULL,
365
+ applied_at TEXT NOT NULL,
366
+ PRIMARY KEY (module_id, version)
367
+ );
368
+ `);
369
+ inTransaction(db, () => {
370
+ for (const [index, entry] of CORE_LEDGER_VERSIONS.entries()) {
371
+ const existingRow = db.prepare("SELECT checksum FROM module_migrations WHERE module_id = 'core' AND version = ?").get(entry.version) as { checksum: string } | null;
372
+ if (existingRow != null) {
373
+ if (existingRow.checksum !== entry.checksum) {
374
+ throw new Error(`module migration "core" version ${entry.version} checksum mismatch: the frozen definition for this version was edited after it was applied`);
375
+ }
376
+ continue;
377
+ }
378
+ const isLast = index === CORE_LEDGER_VERSIONS.length - 1;
379
+ if (isLast && !alreadyAtCurrentSchema) {
380
+ db.exec(SCHEMA);
381
+ db.exec(SEED_SQL);
382
+ db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
383
+ }
384
+ db.prepare("INSERT INTO module_migrations (module_id, version, name, checksum, applied_at) VALUES ('core', ?, ?, ?, ?)")
385
+ .run(entry.version, entry.name, entry.checksum, new Date().toISOString());
386
+ }
387
+ });
388
+ }
389
+
186
390
  function bootstrapEmptyDatabase(db: Db): void {
187
391
  const existing = db
188
392
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
189
393
  .get();
190
394
  if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
191
- inTransaction(db, () => {
192
- db.exec(SCHEMA);
193
- db.exec(SEED_SQL);
194
- db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
195
- });
395
+ ensureCoreLedger(db, false);
196
396
  }
197
397
 
198
398
  export function migrateDb(db: Db): MigrationResult {
@@ -201,7 +401,7 @@ export function migrateDb(db: Db): MigrationResult {
201
401
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
202
402
  }
203
403
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
204
- if (from !== 1 && from !== 2 && from !== 3 && from !== 4) throw new Error(`no explicit migration path from database schema ${from}`);
404
+ if (from !== 1 && from !== 2 && from !== 3 && from !== 4 && from !== 5 && from !== 6 && from !== 7) throw new Error(`no explicit migration path from database schema ${from}`);
205
405
  const applied: string[] = [];
206
406
 
207
407
  inTransaction(db, () => {
@@ -290,7 +490,134 @@ export function migrateDb(db: Db): MigrationResult {
290
490
  `);
291
491
  applied.push("task-focus-continuation");
292
492
  }
493
+ if (schemaVersion(db) === 5) {
494
+ db.exec(`
495
+ INSERT OR IGNORE INTO relation_names VALUES ('reply_to','Append-only message replies to another message in the same thread');
496
+ INSERT OR IGNORE INTO relation_names VALUES ('discusses','Message or turn concerns a verified artifact');
497
+ CREATE TABLE discourse_threads (
498
+ store_id TEXT NOT NULL, forum_id TEXT NOT NULL, topic_id TEXT NOT NULL, thread_id TEXT NOT NULL,
499
+ artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
500
+ PRIMARY KEY (store_id, forum_id, topic_id, thread_id)
501
+ );
502
+ CREATE TABLE discourse_posts (
503
+ store_id TEXT NOT NULL, sequence INTEGER NOT NULL, id TEXT NOT NULL,
504
+ artifact_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id), operation_id TEXT NOT NULL,
505
+ command_json TEXT NOT NULL, forum_id TEXT NOT NULL, topic_id TEXT NOT NULL, thread_id TEXT NOT NULL,
506
+ author_id TEXT NOT NULL, content_json TEXT NOT NULL, timestamp INTEGER NOT NULL,
507
+ correlation_id TEXT, causation_id TEXT, reply_to_post_id TEXT, references_json TEXT NOT NULL,
508
+ question_type TEXT CHECK (question_type IN ('question', 'answer')), response_id TEXT, target_id TEXT,
509
+ PRIMARY KEY (store_id, id), UNIQUE (store_id, operation_id), UNIQUE (store_id, sequence)
510
+ );
511
+ CREATE INDEX discourse_posts_thread_idx ON discourse_posts(store_id, forum_id, topic_id, thread_id, sequence);
512
+ CREATE TABLE discourse_events (
513
+ store_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_json TEXT NOT NULL,
514
+ PRIMARY KEY (store_id, sequence)
515
+ );
516
+ CREATE TABLE discourse_cursors (
517
+ store_id TEXT NOT NULL, consumer_id TEXT NOT NULL, sequence INTEGER NOT NULL,
518
+ PRIMARY KEY (store_id, consumer_id)
519
+ );
520
+ CREATE TABLE discourse_projection_cursors (
521
+ store_id TEXT NOT NULL, projection_id TEXT NOT NULL, sequence INTEGER NOT NULL,
522
+ PRIMARY KEY (store_id, projection_id)
523
+ );
524
+ CREATE TRIGGER discourse_threads_artifact_type BEFORE INSERT ON discourse_threads
525
+ WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-thread')
526
+ BEGIN SELECT RAISE(ABORT, 'discourse thread artifact must be a context-thread Doc'); END;
527
+ CREATE TRIGGER discourse_posts_artifact_type BEFORE INSERT ON discourse_posts
528
+ WHEN NOT EXISTS (SELECT 1 FROM artifacts WHERE id = NEW.artifact_id AND kind = 'doc' AND subtype = 'context-message')
529
+ BEGIN SELECT RAISE(ABORT, 'discourse post artifact must be a context-message Doc'); END;
530
+ CREATE TRIGGER discourse_artifact_type_immutable BEFORE UPDATE OF kind, subtype ON artifacts
531
+ WHEN (EXISTS (SELECT 1 FROM discourse_threads WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-thread'))
532
+ OR (EXISTS (SELECT 1 FROM discourse_posts WHERE artifact_id = OLD.id) AND (NEW.kind != 'doc' OR NEW.subtype != 'context-message'))
533
+ BEGIN SELECT RAISE(ABORT, 'discourse Context Mesh artifact type is immutable'); END;
534
+ PRAGMA user_version = 6;
535
+ `);
536
+ applied.push("discourse-context-mesh");
537
+ }
538
+ if (schemaVersion(db) === 6) {
539
+ db.exec(`
540
+ CREATE TABLE artifact_events (
541
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
542
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id),
543
+ occurred_at TEXT NOT NULL,
544
+ event_type TEXT NOT NULL,
545
+ actor TEXT NOT NULL,
546
+ source TEXT NOT NULL,
547
+ session_id TEXT,
548
+ from_status TEXT,
549
+ to_status TEXT,
550
+ relation TEXT,
551
+ related_id TEXT,
552
+ event_schema_version INTEGER NOT NULL DEFAULT 1
553
+ );
554
+ CREATE INDEX artifact_events_artifact_idx ON artifact_events(artifact_id, occurred_at, id);
555
+ CREATE INDEX artifact_events_related_idx ON artifact_events(related_id, occurred_at, id);
556
+ CREATE INDEX artifact_events_actor_idx ON artifact_events(actor, occurred_at, id);
557
+ CREATE INDEX artifact_events_session_idx ON artifact_events(session_id, occurred_at, id);
558
+ CREATE TRIGGER artifact_events_no_update BEFORE UPDATE ON artifact_events
559
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
560
+ CREATE TRIGGER artifact_events_no_delete BEFORE DELETE ON artifact_events
561
+ BEGIN SELECT RAISE(ABORT, 'artifact_events are append-only'); END;
562
+ PRAGMA user_version = 7;
563
+ `);
564
+ applied.push("artifact-event-log");
565
+ }
566
+ if (schemaVersion(db) === 7) {
567
+ db.exec(`
568
+ CREATE TABLE task_focus_v7 (
569
+ scope TEXT PRIMARY KEY,
570
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
571
+ status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
572
+ pause_reason TEXT,
573
+ updated_at TEXT NOT NULL
574
+ );
575
+ INSERT INTO task_focus_v7 SELECT scope, task_id, status, pause_reason, updated_at FROM task_focus;
576
+ DROP TABLE task_focus;
577
+ ALTER TABLE task_focus_v7 RENAME TO task_focus;
578
+ PRAGMA user_version = 8;
579
+ `);
580
+ applied.push("task-focus-session-scope");
581
+ }
582
+ if (schemaVersion(db) === 8) {
583
+ // IF NOT EXISTS here, unlike earlier migration branches: a fully-bootstrapped
584
+ // :memory: fixture (used by unrelated tests that only roll user_version back to
585
+ // simulate an older *file* database) already has every table the current bootstrap
586
+ // DDL declares, this one included -- so this branch must be safe to run whether or
587
+ // not that already happened, not assume a truly-old database created it first.
588
+ db.exec(`
589
+ CREATE TABLE IF NOT EXISTS graph_projection_checkpoints (
590
+ producer_id TEXT PRIMARY KEY,
591
+ last_sequence INTEGER NOT NULL,
592
+ last_batch_id TEXT NOT NULL,
593
+ applied_at TEXT NOT NULL
594
+ );
595
+ CREATE TABLE IF NOT EXISTS graph_projection_identities (
596
+ producer_id TEXT NOT NULL,
597
+ external_id TEXT NOT NULL,
598
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id),
599
+ PRIMARY KEY (producer_id, external_id)
600
+ );
601
+ CREATE INDEX IF NOT EXISTS graph_projection_identities_artifact_idx ON graph_projection_identities(artifact_id);
602
+ PRAGMA user_version = 9;
603
+ `);
604
+ applied.push("graph-projection-protocol");
605
+ }
606
+ if (schemaVersion(db) === 9) {
607
+ db.exec(`
608
+ CREATE TABLE IF NOT EXISTS artifact_scopes (
609
+ artifact_id TEXT PRIMARY KEY REFERENCES artifacts(id),
610
+ project_root TEXT,
611
+ source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
612
+ assigned_at TEXT NOT NULL
613
+ );
614
+ CREATE INDEX IF NOT EXISTS artifact_scopes_project_idx ON artifact_scopes(project_root, artifact_id);
615
+ PRAGMA user_version = 10;
616
+ `);
617
+ applied.push("docs-rules-skills-project-scope");
618
+ }
293
619
  });
620
+ if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
294
621
  return { from, to: schemaVersion(db), applied };
295
622
  }
296
623
 
@@ -306,6 +633,7 @@ export function openDb(path: string): Db {
306
633
  throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
307
634
  }
308
635
  if (current === 0) bootstrapEmptyDatabase(db);
636
+ else if (current === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
309
637
  db.exec("PRAGMA optimize=0x10002");
310
638
  return db;
311
639
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Generic, kind-agnostic mutation event log — the "who did what, when" answer
3
+ * shared by every artifact kind (doc, task, rule, skill), not reinvented per domain.
4
+ *
5
+ * Modeled after scribe's parchment.Event/EventFilter/GetEvents shape, but avoids its
6
+ * known gap: there, the Actor column is defined and filterable yet never populated by
7
+ * any caller. Here, actor/source default to explicit sentinels ("system"/"unknown")
8
+ * rather than being silently blank, and the event is appended by the same choke point
9
+ * that performs the mutation (src/ops.ts), so no domain call site can skip it.
10
+ */
11
+ import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
12
+
13
+ export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked"] as const;
14
+ export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
15
+ export type ArtifactEventDirection = "asc" | "desc";
16
+
17
+ /** Caller-supplied identity for a mutation. All fields are advisory (self-reported), not cryptographically verified. */
18
+ export interface ArtifactEventContext {
19
+ actor?: string;
20
+ source?: string;
21
+ sessionId?: string;
22
+ }
23
+
24
+ export interface ArtifactEvent {
25
+ id: number;
26
+ artifactId: string;
27
+ occurredAt: string;
28
+ type: ArtifactEventType;
29
+ actor: string;
30
+ source: string;
31
+ sessionId?: string;
32
+ fromStatus?: string;
33
+ toStatus?: string;
34
+ relation?: string;
35
+ relatedId?: string;
36
+ schemaVersion: 1;
37
+ }
38
+
39
+ export interface AppendArtifactEvent {
40
+ artifactId: string;
41
+ type: ArtifactEventType;
42
+ actor?: string;
43
+ source?: string;
44
+ sessionId?: string;
45
+ fromStatus?: string;
46
+ toStatus?: string;
47
+ relation?: string;
48
+ relatedId?: string;
49
+ }
50
+
51
+ export interface ArtifactEventQuery {
52
+ artifactId?: string;
53
+ actor?: string;
54
+ sessionId?: string;
55
+ since?: string;
56
+ limit?: number;
57
+ cursor?: number;
58
+ direction?: ArtifactEventDirection;
59
+ }
60
+
61
+ export interface ArtifactEventPage {
62
+ events: ArtifactEvent[];
63
+ nextCursor?: number;
64
+ }
65
+
66
+ /** No caller-supplied identity defaults to these explicit sentinels — never a silent blank. */
67
+ export const ARTIFACT_EVENT_DEFAULT_ACTOR = "system";
68
+ export const ARTIFACT_EVENT_DEFAULT_SOURCE = "unknown";
69
+
70
+ function boundedString(value: string, field: string, maximum: number): string {
71
+ if (value.length === 0 || value.length > maximum) throw new Error(`${field} must be between 1 and ${maximum} characters`);
72
+ return value;
73
+ }
74
+
75
+ /** Fills defaults and enforces bounds. The one place every appended event is normalized. */
76
+ export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
77
+ if (!input.artifactId) throw new Error("artifactId is required");
78
+ const actor = boundedString(input.actor ?? ARTIFACT_EVENT_DEFAULT_ACTOR, "actor", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
79
+ const source = boundedString(input.source ?? ARTIFACT_EVENT_DEFAULT_SOURCE, "source", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
80
+ if (input.sessionId !== undefined) boundedString(input.sessionId, "sessionId", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
81
+ return { ...input, actor, source };
82
+ }
83
+
84
+ export function normalizeArtifactEventQuery(query: ArtifactEventQuery): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
85
+ if (!query.artifactId && !query.actor && !query.sessionId) {
86
+ throw new Error("artifact event query requires artifactId, actor, or sessionId to stay bounded");
87
+ }
88
+ const limit = query.limit ?? ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT;
89
+ if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_EVENT_HISTORY_MAX_LIMIT) {
90
+ throw new Error(`artifact event limit must be between 1 and ${ARTIFACT_EVENT_HISTORY_MAX_LIMIT}`);
91
+ }
92
+ if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
93
+ throw new Error("artifact event cursor must be a positive integer");
94
+ }
95
+ if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
96
+ throw new Error("artifact event direction must be asc or desc");
97
+ }
98
+ return { ...query, limit, direction: query.direction ?? "desc" };
99
+ }