@danypops/papyrus 0.11.4 → 0.13.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 (57) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/artifact-browser.ts +13 -7
  4. package/extension/src/artifact-status-presentation.ts +53 -0
  5. package/extension/src/context-budget.ts +173 -0
  6. package/extension/src/context-view.ts +172 -0
  7. package/extension/src/docs.ts +6 -5
  8. package/extension/src/domain-tools.ts +108 -52
  9. package/extension/src/index.ts +124 -38
  10. package/extension/src/notes.ts +16 -4
  11. package/extension/src/rules.ts +7 -7
  12. package/extension/src/skill-catalog-footprint.ts +183 -0
  13. package/extension/src/skills.ts +2 -3
  14. package/extension/src/task-focus-events.ts +57 -0
  15. package/extension/src/task-widget.ts +13 -1
  16. package/extension/src/tasks.ts +51 -15
  17. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  18. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  19. package/extension/src/tool-rendering/index.ts +107 -0
  20. package/extension/src/tool-rendering/render-model.ts +406 -0
  21. package/package.json +4 -2
  22. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  23. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  24. package/src/adapters/sqlite-artifact-store.ts +20 -11
  25. package/src/adapters/sqlite-discourse-store.ts +325 -0
  26. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  27. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  28. package/src/authority-registry.ts +115 -0
  29. package/src/cli.ts +904 -124
  30. package/src/constants.ts +77 -5
  31. package/src/conversation-journal-service.ts +87 -0
  32. package/src/db.ts +285 -33
  33. package/src/domain/artifact-event.ts +99 -0
  34. package/src/domain/conversation-journal.ts +168 -0
  35. package/src/domain/discourse-store.ts +142 -0
  36. package/src/domain/graph-projection.ts +74 -0
  37. package/src/domain/skill-definition.ts +57 -8
  38. package/src/domain/task-event.ts +4 -0
  39. package/src/domain-services.ts +201 -40
  40. package/src/graph-projection-service.ts +103 -0
  41. package/src/id-migration.ts +200 -0
  42. package/src/module-registry.ts +53 -0
  43. package/src/modules/docs.ts +77 -0
  44. package/src/modules/graph-projection.ts +82 -0
  45. package/src/modules/notes.ts +76 -0
  46. package/src/modules/rules.ts +81 -0
  47. package/src/modules/skills.ts +113 -0
  48. package/src/modules/tasks.ts +164 -0
  49. package/src/ops.ts +142 -15
  50. package/src/ports/artifact-scope-store.ts +20 -0
  51. package/src/ports/artifact-store.ts +10 -5
  52. package/src/ports/conversation-journal-store.ts +17 -0
  53. package/src/ports/graph-projection-store.ts +15 -0
  54. package/src/ports/task-focus-store.ts +62 -20
  55. package/src/service.ts +218 -223
  56. package/src/skill-execution.ts +169 -75
  57. package/src/task-service.ts +70 -38
@@ -0,0 +1,113 @@
1
+ /**
2
+ * modules/skills.ts — Skills as a Papyrus-native registered module
3
+ * (step 5, continued, of the incremental refactor in
4
+ * reducing-papyrus-consumer-change-amplification-with-modules--pvdo).
5
+ *
6
+ * skills.instantiate is intentionally NOT registered here even though its operation name
7
+ * starts with "skills.": when the target template's targetKind is "task" it calls
8
+ * tasks.create() directly instead of the generic instantiateTemplate path — a genuine
9
+ * cross-module concern, same category as rules.injectable (see modules/rules.ts). It
10
+ * stays a composition-root operation in src/service.ts.
11
+ *
12
+ * skills.run depends on the Task-domain ports (TaskEventStore, TaskScopeStore) as
13
+ * constructor parameters. These are shared port contracts every module may depend on,
14
+ * the same way every module already depends on ArtifactStore — not "another module's
15
+ * infrastructure" in the sense of a concrete class. skill-execution.ts already has this
16
+ * port dependency pre-existing; untangling it is a separate, larger concern than this
17
+ * extraction.
18
+ */
19
+ import type { AuthorityRegistry } from "../authority-registry.ts";
20
+ import { assignSkillProject, createArtifactTemplate, createSkill, listSkills, showSkill, skillInvocation, transitionSkill } from "../domain-services.ts";
21
+ import type { OperationDefinition } from "../module-registry.ts";
22
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
23
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
24
+ import type { TaskEventStore } from "../ports/task-event-store.ts";
25
+ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
26
+ import { instantiateSkillWorkflow } from "../skill-execution.ts";
27
+
28
+ const MODULE_ID = "skills";
29
+
30
+ type OperationInput = Record<string, unknown>;
31
+
32
+ function string(input: OperationInput, key: string): string {
33
+ const value = input[key];
34
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
35
+ return value;
36
+ }
37
+
38
+ function optionalString(input: OperationInput, key: string): string | undefined {
39
+ const value = input[key];
40
+ if (value === undefined) return undefined;
41
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
42
+ return value;
43
+ }
44
+
45
+ function optionalNumber(input: OperationInput, key: string): number | undefined {
46
+ const value = input[key];
47
+ if (value === undefined) return undefined;
48
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
49
+ return value;
50
+ }
51
+
52
+ const eventContext = (input: OperationInput) => ({
53
+ actor: optionalString(input, "actor"),
54
+ source: optionalString(input, "source"),
55
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
56
+ });
57
+
58
+ const eventContextFor = (input: OperationInput, source: string) => {
59
+ const context = eventContext(input);
60
+ return { ...context, source: context.source ?? source };
61
+ };
62
+
63
+ const artifactFilter = (input: OperationInput) => ({
64
+ status: optionalString(input, "status"),
65
+ text: optionalString(input, "text"),
66
+ limit: optionalNumber(input, "limit"),
67
+ projectRoot: optionalString(input, "project_root"),
68
+ });
69
+
70
+ export interface SkillsModuleDeps {
71
+ artifacts: ArtifactStore;
72
+ events: TaskEventStore;
73
+ scopes: TaskScopeStore;
74
+ /** Docs/Rules/Skills project scoping (distinct from `scopes`, which is Task-run project scoping for skills.run's materialized blueprint tasks). */
75
+ artifactScopes: ArtifactScopeStore;
76
+ authority: AuthorityRegistry;
77
+ }
78
+
79
+ /** Registers every skills.* operation except skills.instantiate (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
80
+ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. skills.instantiate is deliberately absent -- see the module comment above. */
81
+ export const SKILLS_OPERATION_NAMES = [
82
+ "skills.create", "skills.create_template", "skills.list", "skills.show", "skills.invoke", "skills.run", "skills.enable", "skills.disable", "skills.assign_project",
83
+ ] as const;
84
+
85
+ export function skillsOperations({ artifacts, events, scopes, artifactScopes, authority }: SkillsModuleDeps): OperationDefinition[] {
86
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
87
+ name, moduleId: MODULE_ID, execute,
88
+ });
89
+ return [
90
+ define("skills.create", (input: OperationInput) => createSkill(artifacts, artifactScopes, {
91
+ title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
92
+ steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
93
+ definition: input["definition"],
94
+ labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
95
+ projectRoot: optionalString(input, "project_root"),
96
+ }, authority, eventContext(input))),
97
+ define("skills.create_template", (input: OperationInput) => createArtifactTemplate(artifacts, artifactScopes, {
98
+ title: string(input, "title"), targetKind: string(input, "target_kind"), defaults: input["defaults"] as Record<string, unknown> | undefined,
99
+ required: input["required"] as string[] | undefined, body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
100
+ projectRoot: optionalString(input, "project_root"),
101
+ }, authority, eventContext(input))),
102
+ define("skills.list", (input: OperationInput) => listSkills(artifacts, artifactScopes, artifactFilter(input))),
103
+ define("skills.show", (input: OperationInput) => showSkill(artifacts, string(input, "id"))),
104
+ define("skills.invoke", (input: OperationInput) => skillInvocation(artifacts, string(input, "id"))),
105
+ define("skills.run", (input: OperationInput) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
106
+ runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
107
+ arguments: input["arguments"] as Record<string, unknown> | undefined,
108
+ }, { events, scopes, projectRoot: string(input, "project_root"), context: eventContextFor(input, "skill-run") })),
109
+ define("skills.enable", (input: OperationInput) => transitionSkill(artifacts, string(input, "id"), "enable", eventContext(input))),
110
+ define("skills.disable", (input: OperationInput) => transitionSkill(artifacts, string(input, "id"), "disable", eventContext(input))),
111
+ define("skills.assign_project", (input: OperationInput) => assignSkillProject(artifacts, artifactScopes, string(input, "id"), optionalString(input, "project_root"))),
112
+ ];
113
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * modules/tasks.ts — Tasks as the second Papyrus-native registered module
3
+ * (step 5, continued, of the incremental refactor in
4
+ * reducing-papyrus-consumer-change-amplification-with-modules--pvdo).
5
+ *
6
+ * Deliberately more representative than modules/notes.ts: Tasks owns real schema
7
+ * (task_events, task_focus, task_scopes and their migrations, still in src/db.ts for
8
+ * this slice — module-owned migrations are a separate follow-up,
9
+ * add-a-module-migration-ledger-keyed-by-moduleid-version-with-3e7k), a much larger
10
+ * operation surface, and cross-module edges (rules.gate links a rule to a task;
11
+ * graph.link routes depends_on through Tasks.depend for cycle safety — those two
12
+ * remain in src/service.ts since they are graph.* / rules.* operations, not tasks.*).
13
+ *
14
+ * Task-domain-internal files (task-context.ts, task-execution.ts, domain/task-event.ts,
15
+ * domain/task-scope.ts) are imported directly — they belong to this bounded context,
16
+ * unlike a different module's infrastructure. Generic input-parsing helpers are
17
+ * duplicated locally rather than imported from src/service.ts, matching the precedent
18
+ * set by modules/notes.ts: a module does not import another module's infrastructure,
19
+ * including the composition root's own helpers.
20
+ */
21
+ import type { Checklist } from "../domain/checklist.ts";
22
+ import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.ts";
23
+ import type { TaskViewMode } from "../domain/task-scope.ts";
24
+ import type { OperationDefinition } from "../module-registry.ts";
25
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
26
+ import { taskContext } from "../task-context.ts";
27
+ import { projectTaskExecution } from "../task-execution.ts";
28
+ import { Tasks, type TaskStatus } from "../task-service.ts";
29
+
30
+ const MODULE_ID = "tasks";
31
+
32
+ type OperationInput = Record<string, unknown>;
33
+
34
+ function string(input: OperationInput, key: string): string {
35
+ const value = input[key];
36
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
37
+ return value;
38
+ }
39
+
40
+ function optionalString(input: OperationInput, key: string): string | undefined {
41
+ const value = input[key];
42
+ if (value === undefined) return undefined;
43
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
44
+ return value;
45
+ }
46
+
47
+ function optionalStringArray(input: OperationInput, key: string): string[] | undefined {
48
+ const value = input[key];
49
+ if (value === undefined) return undefined;
50
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${key} must be an array of strings`);
51
+ return value as string[];
52
+ }
53
+
54
+ function optionalNumber(input: OperationInput, key: string): number | undefined {
55
+ const value = input[key];
56
+ if (value === undefined) return undefined;
57
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
58
+ return value;
59
+ }
60
+
61
+ const eventContext = (input: OperationInput): TaskEventContext => ({
62
+ actor: optionalString(input, "actor"),
63
+ source: optionalString(input, "source"),
64
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
65
+ reason: optionalString(input, "reason"),
66
+ });
67
+
68
+ const taskFilter = (input: OperationInput) => ({
69
+ status: optionalString(input, "status"),
70
+ text: optionalString(input, "text"),
71
+ limit: optionalNumber(input, "limit"),
72
+ projectRoot: string(input, "project_root"),
73
+ scope: optionalString(input, "scope") as TaskViewMode | undefined,
74
+ rootTaskId: optionalString(input, "root_task_id"),
75
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
76
+ });
77
+
78
+ /**
79
+ * Registers every tasks.* operation against one Tasks instance. Behavior is unchanged from
80
+ * the prior inline handlers in src/service.ts. tasks.context needs the raw ArtifactStore
81
+ * port directly (taskContext is a plain-artifact query, not a Tasks method), so the
82
+ * composition root passes the same artifacts port it already constructs Tasks with —
83
+ * this is not "another module's infrastructure", it is the shared port every module writes
84
+ * through.
85
+ */
86
+ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
87
+ export const TASKS_OPERATION_NAMES = [
88
+ "tasks.create", "tasks.update", "tasks.list", "tasks.graph", "tasks.plan", "tasks.show", "tasks.history",
89
+ "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus",
90
+ "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
91
+ "tasks.run_gates", "tasks.set_checklist", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
92
+ "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
93
+ ] as const;
94
+
95
+ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): OperationDefinition[] {
96
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
97
+ name, moduleId: MODULE_ID, execute,
98
+ });
99
+ return [
100
+ define("tasks.create", (input: OperationInput) => tasks.create({
101
+ title: string(input, "title"),
102
+ body: optionalString(input, "body"),
103
+ status: optionalString(input, "status") as TaskStatus | undefined,
104
+ labels: input["labels"] as string[] | undefined,
105
+ extra: input["extra"] as Record<string, unknown> | undefined,
106
+ gates: input["gates"] as Parameters<Tasks["create"]>[0]["gates"],
107
+ checklist: input["checklist"] as Checklist | undefined,
108
+ templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
109
+ parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
110
+ dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
111
+ projectRoot: string(input, "project_root"),
112
+ projectSource: "cwd",
113
+ }, eventContext(input))),
114
+ define("tasks.update", (input: OperationInput) => tasks.update(string(input, "id"), {
115
+ ...(input["title"] !== undefined ? { title: optionalString(input, "title")! } : {}),
116
+ ...(input["body"] !== undefined ? { body: optionalString(input, "body")! } : {}),
117
+ ...(input["labels"] !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}),
118
+ ...(input["status"] !== undefined ? { status: string(input, "status") as "todo" } : {}),
119
+ }, eventContext(input))),
120
+ define("tasks.list", (input: OperationInput) => tasks.list(taskFilter(input))),
121
+ define("tasks.graph", (input: OperationInput) => tasks.graph(taskFilter(input))),
122
+ define("tasks.plan", (input: OperationInput) => projectTaskExecution(tasks.graph(taskFilter(input)))),
123
+ define("tasks.show", (input: OperationInput) => tasks.show(string(input, "id"))),
124
+ define("tasks.history", (input: OperationInput) => tasks.history(string(input, "id"), {
125
+ limit: optionalNumber(input, "limit"),
126
+ cursor: optionalNumber(input, "cursor"),
127
+ direction: optionalString(input, "direction") as TaskEventDirection | undefined,
128
+ })),
129
+ define("tasks.scope", (input: OperationInput) => tasks.scopeSelection(string(input, "project_root"))),
130
+ define("tasks.set_scope", (input: OperationInput) => tasks.setView(
131
+ string(input, "project_root"),
132
+ string(input, "scope") as TaskViewMode,
133
+ optionalString(input, "root_task_id"),
134
+ )),
135
+ define("tasks.assign_project", (input: OperationInput) => tasks.assignProject(
136
+ string(input, "id"),
137
+ string(input, "project_root"),
138
+ eventContext(input),
139
+ )),
140
+ define("tasks.active", (input: OperationInput) => tasks.active(taskFilter(input))),
141
+ define("tasks.focused", (input: OperationInput) => tasks.focused(taskFilter(input))),
142
+ define("tasks.focus", (input: OperationInput) => tasks.focus(string(input, "id"), eventContext(input))),
143
+ define("tasks.pause", (input: OperationInput) => tasks.pauseFocus(eventContext(input))),
144
+ define("tasks.unpause", (input: OperationInput) => tasks.unpauseFocus(eventContext(input))),
145
+ define("tasks.clear_focus", (input: OperationInput) => tasks.clearFocus(eventContext(input))),
146
+ define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
147
+ define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
148
+ define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
149
+ define("tasks.run_gates", (input: OperationInput) => tasks.runGates(string(input, "id"), eventContext(input))),
150
+ define("tasks.set_checklist", (input: OperationInput) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist)),
151
+ define("tasks.context", (input: OperationInput) => taskContext(
152
+ artifacts,
153
+ tasks.active(taskFilter(input))?.id,
154
+ new Set(tasks.list(taskFilter(input)).map((task) => task.id)),
155
+ )),
156
+ define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
157
+ define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
158
+ define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
159
+ define("tasks.depend", (input: OperationInput) => tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
160
+ define("tasks.undepend", (input: OperationInput) => tasks.undepend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
161
+ define("tasks.contain", (input: OperationInput) => tasks.contain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
162
+ define("tasks.uncontain", (input: OperationInput) => tasks.uncontain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
163
+ ];
164
+ }
package/src/ops.ts CHANGED
@@ -9,6 +9,16 @@ import { inTransaction } from "./db.ts";
9
9
  import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
10
10
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
11
11
  import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
12
+ import {
13
+ normalizeArtifactEventQuery,
14
+ resolveArtifactEvent,
15
+ type AppendArtifactEvent,
16
+ type ArtifactEvent,
17
+ type ArtifactEventContext,
18
+ type ArtifactEventPage,
19
+ type ArtifactEventQuery,
20
+ type ArtifactEventType,
21
+ } from "./domain/artifact-event.ts";
12
22
  export type { Artifact } from "./domain/artifact.ts";
13
23
  export type { Gate, GateResult } from "./domain/gate.ts";
14
24
  export type CreateInput = CreateArtifactInput;
@@ -93,15 +103,6 @@ function resolveCreateInput(db: Db, input: CreateInput): ResolvedCreateInput {
93
103
  return merged as ResolvedCreateInput;
94
104
  }
95
105
 
96
- function slugify(s: string): string {
97
- return s
98
- .toLowerCase()
99
- .replace(/[^a-z0-9\s-]/g, "")
100
- .trim()
101
- .replace(/\s+/g, "-")
102
- .slice(0, 60) + "-" + Math.random().toString(36).slice(2, 6);
103
- }
104
-
105
106
  function defaultStatusFor(db: Db, kind: string): string {
106
107
  // Explicit per-kind mapping, never row order -- see DEFAULT_STATUS_BY_KIND's doc comment
107
108
  // for the production defect this replaced (row order is not a semantic guarantee).
@@ -127,9 +128,114 @@ function rowToArtifact(row: Record<string, unknown>): Artifact {
127
128
  };
128
129
  }
129
130
 
130
- export function createArtifact(db: Db, input: CreateInput): Artifact {
131
+ /**
132
+ * Appends one immutable row to the generic, kind-agnostic mutation event log.
133
+ * This is the one choke point every ArtifactStore mutation funnels through, so every
134
+ * kind (doc, task, rule, skill) gets an audit trail for free — no domain call site
135
+ * can skip it. See src/domain/artifact-event.ts for why actor/source always default
136
+ * to explicit sentinels rather than a silently blank column.
137
+ */
138
+ export function appendArtifactEvent(db: Db, input: AppendArtifactEvent): ArtifactEvent {
139
+ const event = resolveArtifactEvent(input);
140
+ const now = new Date().toISOString();
141
+ let id: number | bigint = 0;
142
+ inTransaction(db, () => {
143
+ const result = db.prepare(`
144
+ INSERT INTO artifact_events (
145
+ artifact_id, occurred_at, event_type, actor, source, session_id,
146
+ from_status, to_status, relation, related_id, event_schema_version
147
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
148
+ `).run(
149
+ event.artifactId,
150
+ now,
151
+ event.type,
152
+ event.actor,
153
+ event.source,
154
+ event.sessionId ?? null,
155
+ event.fromStatus ?? null,
156
+ event.toStatus ?? null,
157
+ event.relation ?? null,
158
+ event.relatedId ?? null,
159
+ );
160
+ id = result.lastInsertRowid;
161
+ });
162
+ return {
163
+ id: Number(id),
164
+ artifactId: event.artifactId,
165
+ occurredAt: now,
166
+ type: event.type,
167
+ actor: event.actor,
168
+ source: event.source,
169
+ ...(event.sessionId === undefined ? {} : { sessionId: event.sessionId }),
170
+ ...(event.fromStatus === undefined ? {} : { fromStatus: event.fromStatus }),
171
+ ...(event.toStatus === undefined ? {} : { toStatus: event.toStatus }),
172
+ ...(event.relation === undefined ? {} : { relation: event.relation }),
173
+ ...(event.relatedId === undefined ? {} : { relatedId: event.relatedId }),
174
+ schemaVersion: 1,
175
+ };
176
+ }
177
+
178
+ interface ArtifactEventRow {
179
+ id: number;
180
+ artifact_id: string;
181
+ occurred_at: string;
182
+ event_type: ArtifactEventType;
183
+ actor: string;
184
+ source: string;
185
+ session_id: string | null;
186
+ from_status: string | null;
187
+ to_status: string | null;
188
+ relation: string | null;
189
+ related_id: string | null;
190
+ event_schema_version: 1;
191
+ }
192
+
193
+ function mapArtifactEventRow(row: ArtifactEventRow): ArtifactEvent {
194
+ return {
195
+ id: row.id,
196
+ artifactId: row.artifact_id,
197
+ occurredAt: row.occurred_at,
198
+ type: row.event_type,
199
+ actor: row.actor,
200
+ source: row.source,
201
+ ...(row.session_id === null ? {} : { sessionId: row.session_id }),
202
+ ...(row.from_status === null ? {} : { fromStatus: row.from_status }),
203
+ ...(row.to_status === null ? {} : { toStatus: row.to_status }),
204
+ ...(row.relation === null ? {} : { relation: row.relation }),
205
+ ...(row.related_id === null ? {} : { relatedId: row.related_id }),
206
+ schemaVersion: row.event_schema_version,
207
+ };
208
+ }
209
+
210
+ /** Bounded query over the generic mutation event log — requires artifactId, actor, or sessionId to stay indexed. */
211
+ export function queryArtifactEvents(db: Db, query: ArtifactEventQuery): ArtifactEventPage {
212
+ const { artifactId, actor, sessionId, since, limit, direction, cursor } = normalizeArtifactEventQuery(query);
213
+ const conditions: string[] = [];
214
+ const params: unknown[] = [];
215
+ if (artifactId) { conditions.push("(artifact_id = ? OR related_id = ?)"); params.push(artifactId, artifactId); }
216
+ if (actor) { conditions.push("actor = ?"); params.push(actor); }
217
+ if (sessionId) { conditions.push("session_id = ?"); params.push(sessionId); }
218
+ if (since) { conditions.push("occurred_at >= ?"); params.push(since); }
219
+ const comparator = direction === "desc" ? "<" : ">";
220
+ if (cursor !== undefined) { conditions.push(`id ${comparator} ?`); params.push(cursor); }
221
+ const order = direction === "desc" ? "DESC" : "ASC";
222
+ const rows = db.prepare(`
223
+ SELECT * FROM artifact_events
224
+ WHERE ${conditions.join(" AND ")}
225
+ ORDER BY occurred_at ${order}, id ${order}
226
+ LIMIT ?
227
+ `).all(...params, limit + 1) as ArtifactEventRow[];
228
+ const hasMore = rows.length > limit;
229
+ const events = rows.slice(0, limit).map(mapArtifactEventRow);
230
+ return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
231
+ }
232
+
233
+ export function createArtifact(db: Db, input: CreateInput, context?: ArtifactEventContext): Artifact {
131
234
  const resolved = resolveCreateInput(db, input);
132
- const id = resolved.id ?? slugify(resolved.title);
235
+ // id is an opaque backend identity, never derived from title -- a title-derived slug
236
+ // conflated "identity" with "human-readable label" and leaked a bit of randomness into
237
+ // both. crypto.randomUUID() is native to Bun/Node; no dependency needed for this.
238
+ const id = resolved.id ?? crypto.randomUUID();
133
239
  const status = resolved.status ?? defaultStatusFor(db, resolved.kind);
134
240
  const now = new Date().toISOString();
135
241
  const labels = JSON.stringify(resolved.labels ?? []);
@@ -140,6 +246,7 @@ export function createArtifact(db: Db, input: CreateInput): Artifact {
140
246
  "INSERT INTO artifacts (id, kind, title, status, subtype, body, labels, extra, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
141
247
  );
142
248
  stmt.run(id, resolved.kind, resolved.title, status, subtype, resolved.body ?? "", labels, extra, now, now);
249
+ appendArtifactEvent(db, { artifactId: id, type: "created", toStatus: status, ...context });
143
250
  });
144
251
  return getArtifact(db, id)!;
145
252
  }
@@ -211,7 +318,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
211
318
  return rows.map(rowToArtifact);
212
319
  }
213
320
 
214
- export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string): void {
321
+ export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): void {
215
322
  const fromArt = getArtifact(db, fromId);
216
323
  const toArt = getArtifact(db, toId);
217
324
  if (!fromArt || !toArt) throw new Error("artifact not found");
@@ -219,11 +326,28 @@ export function linkArtifacts(db: Db, fromId: string, relation: string, toId: st
219
326
  const allowed = db.prepare("SELECT 1 FROM relation_names WHERE name = ?").get(relation);
220
327
  if (!allowed) throw new Error(`unknown relation "${relation}" — register it first`);
221
328
  inTransaction(db, () => {
329
+ const existed = db.prepare("SELECT 1 FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").get(fromId, relation, toId);
222
330
  db.prepare("INSERT OR IGNORE INTO edges (from_id, relation, to_id) VALUES (?, ?, ?)").run(fromId, relation, toId);
331
+ if (!existed) {
332
+ appendArtifactEvent(db, { artifactId: fromId, type: "linked", relation, relatedId: toId, ...context });
333
+ }
334
+ });
335
+ }
336
+
337
+ /** Idempotent: removing an already-absent relationship is a no-op that returns false, not an error. */
338
+ export function unlinkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): boolean {
339
+ let removed = false;
340
+ inTransaction(db, () => {
341
+ const existed = db.prepare("SELECT 1 FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").get(fromId, relation, toId);
342
+ if (!existed) return;
343
+ db.prepare("DELETE FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").run(fromId, relation, toId);
344
+ appendArtifactEvent(db, { artifactId: fromId, type: "unlinked", relation, relatedId: toId, ...context });
345
+ removed = true;
223
346
  });
347
+ return removed;
224
348
  }
225
349
 
226
- export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput): Artifact | null {
350
+ export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput, context?: ArtifactEventContext): Artifact | null {
227
351
  const artifact = getArtifact(db, id);
228
352
  if (!artifact) return null;
229
353
  const now = new Date().toISOString();
@@ -235,11 +359,12 @@ export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactI
235
359
  now,
236
360
  id,
237
361
  );
362
+ appendArtifactEvent(db, { artifactId: id, type: "updated", ...context });
238
363
  });
239
364
  return getArtifact(db, id);
240
365
  }
241
366
 
242
- export function updateStatus(db: Db, id: string, status: string): Artifact | null {
367
+ export function updateStatus(db: Db, id: string, status: string, context?: ArtifactEventContext): Artifact | null {
243
368
  const art = getArtifact(db, id);
244
369
  if (!art) return null;
245
370
  // Validate status is registered for this kind
@@ -248,15 +373,17 @@ export function updateStatus(db: Db, id: string, status: string): Artifact | nul
248
373
  const now = new Date().toISOString();
249
374
  inTransaction(db, () => {
250
375
  db.prepare("UPDATE artifacts SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
376
+ appendArtifactEvent(db, { artifactId: id, type: "status_changed", fromStatus: art.status, toStatus: status, ...context });
251
377
  });
252
378
  return getArtifact(db, id);
253
379
  }
254
380
 
255
- export function updateExtra(db: Db, id: string, extra: Record<string, unknown>): Artifact | null {
381
+ export function updateExtra(db: Db, id: string, extra: Record<string, unknown>, context?: ArtifactEventContext): Artifact | null {
256
382
  if (!getArtifact(db, id)) return null;
257
383
  const now = new Date().toISOString();
258
384
  inTransaction(db, () => {
259
385
  db.prepare("UPDATE artifacts SET extra = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(extra), now, id);
386
+ appendArtifactEvent(db, { artifactId: id, type: "extra_set", ...context });
260
387
  });
261
388
  return getArtifact(db, id);
262
389
  }
@@ -0,0 +1,20 @@
1
+ import type { TaskScopeSource } from "../domain/task-scope.ts";
2
+
3
+ /**
4
+ * Project scoping for Docs/Rules/Skills, mirroring TaskScopeStore's shape (task_scopes) but
5
+ * kept as its own table/port rather than folding non-Task kinds into Task-named
6
+ * infrastructure. TaskScopeSource ("cwd" | "explicit" | "unscoped") is already kind-agnostic
7
+ * and reused as-is -- no reason to redefine the same three values under a new name.
8
+ */
9
+ export interface ArtifactScope {
10
+ artifactId: string;
11
+ projectRoot?: string;
12
+ source: TaskScopeSource;
13
+ }
14
+
15
+ export interface ArtifactScopeStore {
16
+ assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): ArtifactScope;
17
+ get(artifactId: string): ArtifactScope | undefined;
18
+ /** Bounded id listing for one project (or the unscoped bucket when projectRoot is undefined). */
19
+ ids(projectRoot: string | undefined, limit: number): string[];
20
+ }
@@ -8,14 +8,19 @@ import type {
8
8
  RelationshipQuery,
9
9
  UpdateArtifactInput,
10
10
  } from "../domain/artifact.ts";
11
+ import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
11
12
 
12
13
  export interface ArtifactStore {
13
- create(input: CreateArtifactInput): Artifact;
14
+ create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
14
15
  get(id: string, options?: ArtifactGraphOptions): Artifact | null;
15
16
  query(filter: ArtifactQuery): Artifact[];
16
- link(link: ArtifactLink): void;
17
- setStatus(id: string, status: string): Artifact | null;
18
- setExtra(id: string, extra: Record<string, unknown>): Artifact | null;
19
- updateContent(id: string, input: UpdateArtifactInput): Artifact | null;
17
+ link(link: ArtifactLink, context?: ArtifactEventContext): void;
18
+ /** Idempotent: removing an already-absent relationship is a no-op that returns false, not an error. */
19
+ unlink(link: ArtifactLink, context?: ArtifactEventContext): boolean;
20
+ setStatus(id: string, status: string, context?: ArtifactEventContext): Artifact | null;
21
+ setExtra(id: string, extra: Record<string, unknown>, context?: ArtifactEventContext): Artifact | null;
22
+ updateContent(id: string, input: UpdateArtifactInput, context?: ArtifactEventContext): Artifact | null;
20
23
  relationships(filter?: RelationshipQuery): ArtifactEdge[];
24
+ /** Bounded query over the generic mutation event log shared by every kind. */
25
+ events(query: ArtifactEventQuery): ArtifactEventPage;
21
26
  }
@@ -0,0 +1,17 @@
1
+ import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
2
+
3
+ /**
4
+ * Persistence port for ConversationJournal. Deliberately minimal and host-neutral: no
5
+ * mention of any host runtime, no query beyond what a bounded thread read needs. Idempotency
6
+ * (checking operationId before insert) is the service's job, not the store's -- this port is
7
+ * dumb storage, matching Discourse's own store/service split (see the layering decision doc).
8
+ */
9
+ export interface ConversationJournalStore {
10
+ ensureThread(threadId: string): JournalThread;
11
+ getThread(threadId: string): JournalThread | undefined;
12
+ findPostByOperationId(operationId: string): JournalPost | undefined;
13
+ insertPost(post: JournalPost): void;
14
+ getPost(id: string): JournalPost | undefined;
15
+ /** All posts for one thread, unbounded at the store layer -- the service applies the read bound. */
16
+ postsForThread(threadId: string): readonly JournalPost[];
17
+ }
@@ -0,0 +1,15 @@
1
+ import type { ProjectionCheckpoint } from "../domain/graph-projection.ts";
2
+
3
+ /**
4
+ * Producer-scoped state a graph projection consumer needs beyond the generic ArtifactStore:
5
+ * the (producerId, externalId) -> Papyrus artifact id identity map, and the per-producer
6
+ * checkpoint. This is projection-specific bookkeeping, not Context Mesh content itself, so
7
+ * it is its own small port rather than bloating ArtifactStore.
8
+ */
9
+ export interface GraphProjectionStore {
10
+ getCheckpoint(producerId: string): ProjectionCheckpoint | null;
11
+ resolveIdentity(producerId: string, externalId: string): string | undefined;
12
+ /** Idempotent: recording the same (producerId, externalId) -> artifactId mapping twice is a no-op. */
13
+ recordIdentity(producerId: string, externalId: string, artifactId: string): void;
14
+ commitCheckpoint(checkpoint: ProjectionCheckpoint): void;
15
+ }