@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
@@ -1,12 +1,56 @@
1
+ import {
2
+ ARTIFACT_SCOPE_MAX_ARTIFACTS,
3
+ RULE_TEXT_HARD_LIMIT_CHARACTERS,
4
+ SKILL_INVOCATION_MAX_CALL_DEPTH,
5
+ SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
6
+ } from "./constants.ts";
1
7
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
8
+ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
9
+ import { normalizeProjectRoot } from "./domain/task-scope.ts";
2
10
  import { validateSkillDefinition } from "./domain/skill-definition.ts";
3
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
+ import type { ArtifactScopeStore } from "./ports/artifact-scope-store.ts";
4
13
  import { NOTE_SUBTYPE } from "./note-service.ts";
14
+ import type { AuthorityRegistry } from "./authority-registry.ts";
5
15
 
6
16
  export interface ListFilter {
7
17
  status?: string;
8
18
  text?: string;
9
19
  limit?: number;
20
+ /** When supplied, results are limited to artifacts scoped to this project (or the unscoped bucket, for an empty string is not accepted -- use assignArtifactProject's own validation). */
21
+ projectRoot?: string;
22
+ }
23
+
24
+ /**
25
+ * Shared by listDocuments/listRules/listSkills: when filter.projectRoot is given, resolve
26
+ * via ArtifactScopeStore first and post-filter by kind/status/text (mirrors Tasks.list's
27
+ * established scoped-listing shape); otherwise fall back to the existing unscoped query
28
+ * path unchanged, so every caller that predates project scoping keeps working exactly as
29
+ * before.
30
+ */
31
+ function listScoped(artifacts: ArtifactStore, scopes: ArtifactScopeStore, kind: string, filter: ListFilter, excludeSubtype?: string): Artifact[] {
32
+ if (filter.projectRoot === undefined) return artifacts.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: filter.limit });
33
+ const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
34
+ if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_SCOPE_MAX_ARTIFACTS) {
35
+ throw new Error(`list limit must be between 1 and ${ARTIFACT_SCOPE_MAX_ARTIFACTS}`);
36
+ }
37
+ const projectRoot = normalizeProjectRoot(filter.projectRoot);
38
+ const ids = scopes.ids(projectRoot, ARTIFACT_SCOPE_MAX_ARTIFACTS);
39
+ const text = filter.text?.toLowerCase();
40
+ return ids
41
+ .map((id) => artifacts.get(id))
42
+ .filter((artifact): artifact is Artifact => artifact?.kind === kind && artifact.subtype !== excludeSubtype)
43
+ .filter((artifact) => filter.status === undefined || artifact.status === filter.status)
44
+ .filter((artifact) => text === undefined || artifact.title.toLowerCase().includes(text) || artifact.body.toLowerCase().includes(text))
45
+ .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
46
+ .slice(0, limit);
47
+ }
48
+
49
+ /** Shared by assignDocumentProject/assignRuleProject/assignSkillProject. */
50
+ function assignArtifactProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, kind: string, projectRoot: string | undefined): Artifact {
51
+ requireKind(artifacts, id, kind);
52
+ scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
53
+ return artifacts.get(id)!;
10
54
  }
11
55
 
12
56
  function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifact {
@@ -25,10 +69,25 @@ function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | unde
25
69
  && (defaults as Record<string, unknown>)["subtype"] === NOTE_SUBTYPE;
26
70
  }
27
71
 
28
- function requireNotesFacade(): never {
72
+ /** caller never owns NOTE_SUBTYPE, so requireArtifactAllowed always throws — the trailing throw only satisfies TypeScript's control-flow analysis for a `never`-returning function. */
73
+ function requireNotesFacade(authority: AuthorityRegistry, caller: string): never {
74
+ authority.requireArtifactAllowed("doc", NOTE_SUBTYPE, "create", caller);
29
75
  throw new Error("note creation requires notes.capture");
30
76
  }
31
77
 
78
+ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefined): string | undefined {
79
+ if (!templateId) return undefined;
80
+ const defaults = artifacts.get(templateId)?.extra["defaults"];
81
+ if (typeof defaults !== "object" || defaults === null || Array.isArray(defaults)) return undefined;
82
+ const subtype = (defaults as Record<string, unknown>)["subtype"];
83
+ return typeof subtype === "string" ? subtype : undefined;
84
+ }
85
+
86
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry): Artifact {
87
+ authority.requireArtifactAllowed(document.kind, document.subtype, "status", "docs");
88
+ return document;
89
+ }
90
+
32
91
  export interface CreateDocumentInput {
33
92
  title: string;
34
93
  body?: string;
@@ -36,6 +95,8 @@ export interface CreateDocumentInput {
36
95
  labels?: string[];
37
96
  extra?: Record<string, unknown>;
38
97
  templateId?: string;
98
+ /** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
99
+ projectRoot?: string;
39
100
  }
40
101
 
41
102
  export type DocumentTransition = "activate" | "archive" | "reopen";
@@ -47,21 +108,35 @@ const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: str
47
108
  reopen: { from: ["archived"], to: "draft" },
48
109
  };
49
110
 
50
- export function createDocument(artifacts: ArtifactStore, input: CreateDocumentInput): Artifact {
51
- if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade();
52
- return artifacts.create({
111
+ export function createDocument(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateDocumentInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
112
+ if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
113
+ authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
114
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
115
+ const document = artifacts.create({
53
116
  kind: "doc",
117
+ // Explicit, not defaultStatusFor's "first status row by rowid" fallback -- the same
118
+ // heuristic that made Task creation non-deterministic on a migrated database. Every
119
+ // creation path that has no caller-supplied initial status must set one explicitly.
120
+ status: "draft",
54
121
  title: input.title,
55
122
  body: input.body,
56
123
  subtype: input.subtype,
57
124
  labels: input.labels,
58
125
  extra: input.extra,
59
126
  templateId: input.templateId,
60
- });
127
+ }, context);
128
+ scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
129
+ return document;
61
130
  }
62
131
 
63
- export function listDocuments(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
64
- return artifacts.query({ kind: "doc", excludeSubtype: NOTE_SUBTYPE, ...filter });
132
+ export function listDocuments(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
133
+ return listScoped(artifacts, scopes, "doc", filter, NOTE_SUBTYPE);
134
+ }
135
+
136
+ export function assignDocumentProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
137
+ requireDocument(artifacts, id); // rejects Notes -- project reassignment for notes goes through notes.* like everything else about them
138
+ scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
139
+ return artifacts.get(id)!;
65
140
  }
66
141
 
67
142
  function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
@@ -75,17 +150,19 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
75
150
  return artifacts.get(id, { tree: true })!;
76
151
  }
77
152
 
78
- export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition): Artifact {
79
- const document = requireDocument(artifacts, id);
153
+ export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
154
+ const document = requireMutableDocument(requireDocument(artifacts, id), authority);
80
155
  const transition = DOCUMENT_TRANSITIONS[action];
81
156
  if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
82
- return artifacts.setStatus(id, transition.to)!;
157
+ return artifacts.setStatus(id, transition.to, context)!;
83
158
  }
84
159
 
85
- export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string): Artifact {
86
- requireDocument(artifacts, id);
87
- if (!artifacts.get(targetId)) throw new Error(`target artifact "${targetId}" not found`);
88
- artifacts.link({ from: id, relation, to: targetId });
160
+ export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
161
+ requireMutableDocument(requireDocument(artifacts, id), authority);
162
+ const target = artifacts.get(targetId);
163
+ if (!target) throw new Error(`target artifact "${targetId}" not found`);
164
+ requireMutableDocument(target, authority);
165
+ artifacts.link({ from: id, relation, to: targetId }, context);
89
166
  return showDocument(artifacts, id);
90
167
  }
91
168
 
@@ -97,13 +174,36 @@ export interface CreateRuleInput {
97
174
  severity?: "block" | "warn" | "info";
98
175
  labels?: string[];
99
176
  extra?: Record<string, unknown>;
177
+ projectRoot?: string;
100
178
  }
101
179
 
102
180
  export type RuleTransition = "enable" | "disable";
103
181
 
104
- export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Artifact {
105
- return artifacts.create({
182
+ /**
183
+ * A Rule's condition+action+body is injected into every relevant turn for the rule's entire
184
+ * lifetime -- a permanent tax on every future turn's context budget, not a one-time cost.
185
+ * Rejects (rather than silently truncating or merely warning) once a rule is unambiguously
186
+ * bloated, since a silently-truncated rule would inject different text than what its author
187
+ * reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
188
+ * own comment in constants.ts for the research this threshold is grounded in.
189
+ */
190
+ function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
191
+ const combined = (condition ?? "").length + (action ?? "").length + (body ?? "").length;
192
+ if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
193
+ throw new Error(
194
+ `rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
195
+ "A Rule is injected into every relevant turn for its entire lifetime -- this is a permanent context-budget tax, not a one-time cost. " +
196
+ "Split it: keep a short Rule (the condition and the invariant itself), and move the full reasoning, examples, and research into a linked Doc.",
197
+ );
198
+ }
199
+ }
200
+
201
+ export function createRule(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateRuleInput, context?: ArtifactEventContext): Artifact {
202
+ assertRuleTextWithinBounds(input.condition, input.action, input.body);
203
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
204
+ const rule = artifacts.create({
106
205
  kind: "rule",
206
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
107
207
  title: input.title,
108
208
  body: input.body,
109
209
  labels: input.labels,
@@ -113,11 +213,17 @@ export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Ar
113
213
  ...(input.action ? { action: input.action } : {}),
114
214
  severity: input.severity ?? "info",
115
215
  },
116
- });
216
+ }, context);
217
+ scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
218
+ return rule;
219
+ }
220
+
221
+ export function listRules(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
222
+ return listScoped(artifacts, scopes, "rule", filter);
117
223
  }
118
224
 
119
- export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
120
- return artifacts.query({ kind: "rule", ...filter });
225
+ export function assignRuleProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
226
+ return assignArtifactProject(artifacts, scopes, id, "rule", projectRoot);
121
227
  }
122
228
 
123
229
  /** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
@@ -144,18 +250,18 @@ export function previewRule(artifacts: ArtifactStore, id: string): string {
144
250
  return `• ${rule.title}${condition}\n ${action}`;
145
251
  }
146
252
 
147
- export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition): Artifact {
253
+ export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
148
254
  const rule = requireKind(artifacts, id, "rule");
149
255
  const expected = action === "enable" ? "deprecated" : "active";
150
256
  const target = action === "enable" ? "active" : "deprecated";
151
257
  if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
152
- return artifacts.setStatus(id, target)!;
258
+ return artifacts.setStatus(id, target, context)!;
153
259
  }
154
260
 
155
- export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string): Artifact {
261
+ export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
156
262
  requireKind(artifacts, ruleId, "rule");
157
263
  requireKind(artifacts, taskId, "task");
158
- artifacts.link({ from: ruleId, relation: "gates", to: taskId });
264
+ artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
159
265
  return showRule(artifacts, ruleId);
160
266
  }
161
267
 
@@ -168,6 +274,7 @@ export interface CreateSkillInput {
168
274
  definition?: unknown;
169
275
  labels?: string[];
170
276
  extra?: Record<string, unknown>;
277
+ projectRoot?: string;
171
278
  }
172
279
 
173
280
  export interface CreateArtifactTemplateInput {
@@ -177,18 +284,21 @@ export interface CreateArtifactTemplateInput {
177
284
  required?: string[];
178
285
  body?: string;
179
286
  labels?: string[];
287
+ projectRoot?: string;
180
288
  }
181
289
 
182
290
  export type SkillTransition = "enable" | "disable";
183
291
 
184
- export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
292
+ export function createSkill(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateSkillInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
185
293
  if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
186
294
  throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
187
295
  }
188
296
  const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
189
- if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade();
190
- return artifacts.create({
297
+ if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade(authority, "skills");
298
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
299
+ const skill = artifacts.create({
191
300
  kind: "skill",
301
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
192
302
  subtype: definition ? "workflow" : undefined,
193
303
  title: input.title,
194
304
  body: input.body,
@@ -200,13 +310,17 @@ export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput):
200
310
  ...(input.steps ? { steps: input.steps } : {}),
201
311
  ...(input.tools ? { tools: input.tools } : {}),
202
312
  },
203
- });
313
+ }, context);
314
+ scopes.assign(skill.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
315
+ return skill;
204
316
  }
205
317
 
206
- export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateArtifactTemplateInput): Artifact {
207
- if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade();
208
- return artifacts.create({
318
+ export function createArtifactTemplate(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateArtifactTemplateInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
319
+ if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade(authority, "skills");
320
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
321
+ const template = artifacts.create({
209
322
  kind: "skill",
323
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
210
324
  subtype: "artifact-template",
211
325
  title: input.title,
212
326
  body: input.body,
@@ -216,16 +330,22 @@ export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateAr
216
330
  defaults: input.defaults ?? {},
217
331
  required: input.required ?? ["title"],
218
332
  },
219
- });
333
+ }, context);
334
+ scopes.assign(template.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
335
+ return template;
220
336
  }
221
337
 
222
- export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput): Artifact {
223
- if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade();
224
- return artifacts.create({ ...input, templateId });
338
+ export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
339
+ if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade(authority, "skills");
340
+ return artifacts.create({ ...input, templateId }, context);
225
341
  }
226
342
 
227
- export function listSkills(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
228
- return artifacts.query({ kind: "skill", ...filter });
343
+ export function listSkills(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
344
+ return listScoped(artifacts, scopes, "skill", filter);
345
+ }
346
+
347
+ export function assignSkillProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
348
+ return assignArtifactProject(artifacts, scopes, id, "skill", projectRoot);
229
349
  }
230
350
 
231
351
  export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
@@ -233,8 +353,7 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
233
353
  return artifacts.get(id, { tree: true })!;
234
354
  }
235
355
 
236
- export function skillInvocation(artifacts: ArtifactStore, id: string): string {
237
- const skill = requireKind(artifacts, id, "skill");
356
+ function skillInvocationBody(skill: Artifact): string {
238
357
  if (skill.subtype === "artifact-template") {
239
358
  return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
240
359
  }
@@ -261,10 +380,52 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
261
380
  ].join("\n");
262
381
  }
263
382
 
264
- export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition): Artifact {
383
+ /**
384
+ * Skills are special: invoking one queries Papyrus for the skill's real outgoing graph edges
385
+ * -- not just its own static body/extra fields -- so a Skill linked to existing Tasks, Rules,
386
+ * or Docs surfaces that linked context on invocation. A Skill can also link to and invoke
387
+ * OTHER Skills (any relation whose target is itself a Skill, e.g. the same "triggers" relation
388
+ * workflow execution already uses for skill-to-task edges): invoking the parent recursively
389
+ * composes the linked skill's own invocation. Bounded and cycle-safe -- a skill-calls-skill
390
+ * edge cycle degrades to a marker instead of infinite-looping, matching the cycle-safety
391
+ * discipline already established for ConversationJournal reply chains and task dependency
392
+ * graphs. `visited` and `depth` are recursion-internal; callers should not pass them.
393
+ */
394
+ export function skillInvocation(artifacts: ArtifactStore, id: string, visited: Set<string> = new Set(), depth = 0): string {
395
+ const skill = requireKind(artifacts, id, "skill");
396
+ visited.add(id);
397
+ const sections = [skillInvocationBody(skill)];
398
+
399
+ const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, SKILL_INVOCATION_MAX_LINKED_ARTIFACTS);
400
+ const linkedArtifactLines: string[] = [];
401
+ const linkedSkillSections: string[] = [];
402
+ for (const edge of edges) {
403
+ const target = artifacts.get(edge.to);
404
+ if (!target) continue; // dangling edge -- defensive, should not happen
405
+ if (target.kind !== "skill") {
406
+ linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}" (${target.id})`);
407
+ continue;
408
+ }
409
+ if (visited.has(target.id)) {
410
+ linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- already invoked above in this chain, not repeated.`);
411
+ } else if (depth + 1 > SKILL_INVOCATION_MAX_CALL_DEPTH) {
412
+ linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- call depth limit reached, invoke it separately.`);
413
+ } else {
414
+ const nested = skillInvocation(artifacts, target.id, visited, depth + 1);
415
+ linkedSkillSections.push(`Also invoke linked skill (${edge.relation}) "${target.title}" (${target.id}):\n${nested}`);
416
+ }
417
+ }
418
+ if (linkedArtifactLines.length > 0) {
419
+ sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedArtifactLines].join("\n"));
420
+ }
421
+ for (const section of linkedSkillSections) sections.push(section);
422
+ return sections.join("\n\n");
423
+ }
424
+
425
+ export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
265
426
  const skill = requireKind(artifacts, id, "skill");
266
427
  const expected = action === "enable" ? "deprecated" : "active";
267
428
  const target = action === "enable" ? "active" : "deprecated";
268
429
  if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
269
- return artifacts.setStatus(id, target)!;
430
+ return artifacts.setStatus(id, target, context)!;
270
431
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * graph-projection-service.ts — the generic graph projection protocol for external bounded
3
+ * contexts. See src/domain/graph-projection.ts for the batch/checkpoint shapes and the
4
+ * documented, deliberate scope limits of this first walking-skeleton slice.
5
+ */
6
+ import { GRAPH_PROJECTION_ID_MAX_LENGTH, GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH, GRAPH_PROJECTION_MAX_EDGES_PER_BATCH } from "./constants.ts";
7
+ import { GRAPH_PROJECTION_SCHEMA_VERSION, type GraphProjectionBatch, type GraphProjectionResult } from "./domain/graph-projection.ts";
8
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
9
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
10
+ import type { GraphProjectionStore } from "./ports/graph-projection-store.ts";
11
+ import type { AuthorityRegistry } from "./authority-registry.ts";
12
+
13
+ function boundedId(value: string, label: string): string {
14
+ if (!value || value.length === 0) throw new Error(`${label} is required`);
15
+ if (value.length > GRAPH_PROJECTION_ID_MAX_LENGTH) throw new Error(`${label} exceeds ${GRAPH_PROJECTION_ID_MAX_LENGTH} characters`);
16
+ return value;
17
+ }
18
+
19
+ export class GraphProjection {
20
+ constructor(
21
+ private readonly artifacts: ArtifactStore,
22
+ private readonly store: GraphProjectionStore,
23
+ private readonly authority: AuthorityRegistry,
24
+ ) {}
25
+
26
+ checkpoint(producerId: string) {
27
+ return this.store.getCheckpoint(boundedId(producerId, "producer_id"));
28
+ }
29
+
30
+ apply(batch: GraphProjectionBatch): GraphProjectionResult {
31
+ if (batch.schemaVersion !== GRAPH_PROJECTION_SCHEMA_VERSION) {
32
+ throw new Error(`unsupported graph projection schema version "${batch.schemaVersion}", expected "${GRAPH_PROJECTION_SCHEMA_VERSION}"`);
33
+ }
34
+ const producerId = boundedId(batch.producerId, "producer_id");
35
+ const batchId = boundedId(batch.batchId, "batch_id");
36
+ if (!Number.isInteger(batch.sequence) || batch.sequence < 1) throw new Error("sequence must be a positive integer");
37
+ if (batch.artifacts.length > GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH) {
38
+ throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH} artifacts; got ${batch.artifacts.length}`);
39
+ }
40
+ if (batch.edges.length > GRAPH_PROJECTION_MAX_EDGES_PER_BATCH) {
41
+ throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_EDGES_PER_BATCH} edges; got ${batch.edges.length}`);
42
+ }
43
+ for (const artifact of batch.artifacts) boundedId(artifact.externalId, "artifact externalId");
44
+ for (const edge of batch.edges) { boundedId(edge.from, "edge from"); boundedId(edge.to, "edge to"); }
45
+
46
+ const existingCheckpoint = this.store.getCheckpoint(producerId);
47
+ if (existingCheckpoint?.lastBatchId === batchId && existingCheckpoint.lastSequence === batch.sequence) {
48
+ return { producerId, batchId, sequence: batch.sequence, artifactsUpserted: 0, artifactsCreated: 0, edgesUpserted: 0, alreadyApplied: true };
49
+ }
50
+ if (existingCheckpoint === null) {
51
+ if (batch.sequence !== 1) throw new Error(`first batch for producer "${producerId}" must have sequence 1, got ${batch.sequence}`);
52
+ } else {
53
+ if (batch.sequence <= existingCheckpoint.lastSequence) {
54
+ throw new Error(`stale batch: sequence ${batch.sequence} is not after checkpoint sequence ${existingCheckpoint.lastSequence} for producer "${producerId}"`);
55
+ }
56
+ if (batch.sequence > existingCheckpoint.lastSequence + 1) {
57
+ throw new Error(`sequence gap for producer "${producerId}": expected ${existingCheckpoint.lastSequence + 1}, got ${batch.sequence}`);
58
+ }
59
+ }
60
+
61
+ // Authority up front, before any write: a producer that doesn't own a subtype/relation
62
+ // it's trying to project into must fail closed, not partially apply then fail midway.
63
+ for (const artifact of batch.artifacts) this.authority.requireArtifactAllowed(artifact.kind, artifact.subtype, "create", producerId);
64
+ for (const edge of batch.edges) this.authority.requireRelationAllowed(edge.relation, "link", producerId);
65
+
66
+ const atomic = requireAtomicArtifactStore(this.artifacts);
67
+ let artifactsCreated = 0;
68
+ let artifactsUpserted = 0;
69
+ let edgesUpserted = 0;
70
+ atomic.atomic(() => {
71
+ for (const projected of batch.artifacts) {
72
+ const existingId = this.store.resolveIdentity(producerId, projected.externalId);
73
+ if (existingId) {
74
+ this.artifacts.updateContent(existingId, { title: projected.title, body: projected.body, labels: projected.labels ? [...projected.labels] : undefined });
75
+ if (projected.extra !== undefined) this.artifacts.setExtra(existingId, projected.extra);
76
+ } else {
77
+ const created = this.artifacts.create({
78
+ kind: projected.kind,
79
+ subtype: projected.subtype,
80
+ title: projected.title,
81
+ body: projected.body,
82
+ labels: projected.labels ? [...projected.labels] : undefined,
83
+ extra: projected.extra,
84
+ });
85
+ this.store.recordIdentity(producerId, projected.externalId, created.id);
86
+ artifactsCreated++;
87
+ }
88
+ artifactsUpserted++;
89
+ }
90
+ for (const edge of batch.edges) {
91
+ const fromId = this.store.resolveIdentity(producerId, edge.from);
92
+ if (!fromId) throw new Error(`edge references unknown externalId "${edge.from}" for producer "${producerId}"`);
93
+ const toId = this.store.resolveIdentity(producerId, edge.to);
94
+ if (!toId) throw new Error(`edge references unknown externalId "${edge.to}" for producer "${producerId}"`);
95
+ this.artifacts.link({ from: fromId, relation: edge.relation, to: toId });
96
+ edgesUpserted++;
97
+ }
98
+ this.store.commitCheckpoint({ producerId, lastSequence: batch.sequence, lastBatchId: batchId, appliedAt: new Date().toISOString() });
99
+ });
100
+
101
+ return { producerId, batchId, sequence: batch.sequence, artifactsUpserted, artifactsCreated, edgesUpserted, alreadyApplied: false };
102
+ }
103
+ }