@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.
- package/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
}
|