@danypops/papyrus 0.12.0 → 0.13.1

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.
@@ -1,14 +1,14 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Artifact } from "../../src/domain/artifact.ts";
3
3
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
4
+ import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
4
5
  import { callService } from "./service-client.ts";
5
6
 
6
- const RULE_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
7
-
8
- export function ruleRowMeta(rule: Artifact): string {
9
- const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
7
+ export function ruleRowMeta(rule: Artifact, theme: Theme): string {
8
+ const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
9
+ const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
10
10
  const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
11
- return `${severity} · ${condition}`;
11
+ return `${severityText} · ${condition}`;
12
12
  }
13
13
 
14
14
  export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
@@ -23,7 +23,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
23
23
  title: "Rules",
24
24
  listOperation: "rules.list",
25
25
  statusOrder: ["active", "deprecated"],
26
- glyphs: RULE_GLYPHS,
26
+ presentation: RULE_STATUS_PRESENTATION,
27
27
  rowMeta: ruleRowMeta,
28
28
  actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
29
29
  handleAction: async (choice, rule, commandCtx) => {
@@ -0,0 +1,183 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
4
+
5
+ /**
6
+ * Pi-native skills (SKILL.md) carry a real, permanent context tax independent of Papyrus:
7
+ * per Pi's own docs, every discovered skill's name+description is injected into the system
8
+ * prompt unconditionally at startup (the Agent Skills spec's "catalog" tier, ~50-100 tokens
9
+ * per skill). This module measures that tax by replicating Pi's own documented discovery
10
+ * rules (docs/skills.md "Locations" section) directly against the filesystem, rather than
11
+ * trying to parse it back out of the assembled system prompt -- Pi does not document (and
12
+ * this repo must not depend on) the exact wire format it uses to inject the catalog, so
13
+ * re-deriving the same inputs Pi itself reads is the robust approach, not a fragile one.
14
+ * Package-declared skills (pi.skills in package.json / packages' own skills/ directories)
15
+ * are deliberately out of scope: enumerating every installed package for skill declarations
16
+ * is a materially larger, slower scan than reading a handful of known directories, and this
17
+ * tool is a budget estimate, not an exhaustive audit.
18
+ */
19
+ export interface SkillCatalogEntry {
20
+ name: string;
21
+ description: string;
22
+ location: string;
23
+ characters: number;
24
+ estimatedTokens: number;
25
+ }
26
+
27
+ export interface SkillCatalogFootprint {
28
+ entries: SkillCatalogEntry[];
29
+ totalCharacters: number;
30
+ totalEstimatedTokens: number;
31
+ scannedDirectories: string[];
32
+ }
33
+
34
+ export const SKILL_SCAN_MAX_DEPTH = 6;
35
+ export const SKILL_SCAN_MAX_DIRECTORIES = 2000;
36
+ export const SKILL_SCAN_MAX_SKILLS = 500;
37
+
38
+ function unquote(value: string): string {
39
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
40
+ return value.slice(1, -1);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ /**
46
+ * Extracts `name` and `description` from a SKILL.md's YAML frontmatter, tolerating the
47
+ * folded (`>`) and literal (`|`) block-scalar forms real-world skills commonly use for
48
+ * multi-line descriptions. Deliberately not a general YAML parser -- only the two fields
49
+ * the Agent Skills spec requires are extracted; anything else in the frontmatter is ignored.
50
+ */
51
+ export function parseSkillFrontmatter(content: string): { name: string; description: string } | null {
52
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
53
+ if (!match) return null;
54
+ const lines = match[1]!.split(/\r?\n/);
55
+ let name = "";
56
+ let description = "";
57
+ for (let index = 0; index < lines.length; index++) {
58
+ const line = lines[index]!;
59
+ const nameMatch = line.match(/^name:\s*(.*)$/);
60
+ if (nameMatch) {
61
+ name = unquote(nameMatch[1]!.trim());
62
+ continue;
63
+ }
64
+ const descriptionMatch = line.match(/^description:\s*(.*)$/);
65
+ if (!descriptionMatch) continue;
66
+ const rest = descriptionMatch[1]!.trim();
67
+ if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
68
+ const collected: string[] = [];
69
+ let cursor = index + 1;
70
+ while (cursor < lines.length && (lines[cursor] === "" || /^\s+/.test(lines[cursor]!))) {
71
+ collected.push(lines[cursor]!.trim());
72
+ cursor++;
73
+ }
74
+ description = collected.join(rest.startsWith("|") ? "\n" : " ").trim();
75
+ index = cursor - 1;
76
+ } else {
77
+ description = unquote(rest);
78
+ }
79
+ }
80
+ if (!name || !description) return null;
81
+ return { name, description };
82
+ }
83
+
84
+ /** True at the filesystem root on POSIX (`/`) and Windows (`C:\`, `D:\`, ...). */
85
+ function isFilesystemRoot(path: string): boolean {
86
+ return dirname(path) === path;
87
+ }
88
+
89
+ /**
90
+ * Global and project skill directories per Pi's own documented discovery rules, plus any
91
+ * explicit paths configured in settings.json's `skills` array. Project directories are
92
+ * collected walking from `cwd` up to the git repository root (or filesystem root when not
93
+ * in a repo), matching "up to git repo root, or filesystem root when not in a repo" exactly.
94
+ */
95
+ export function discoverSkillDirectories(homeDirectory: string, cwd: string, settingsSkills: readonly string[] = []): string[] {
96
+ const directories = [join(homeDirectory, ".pi", "agent", "skills"), join(homeDirectory, ".agents", "skills")];
97
+ let current = cwd;
98
+ for (let depth = 0; depth < SKILL_SCAN_MAX_DIRECTORIES; depth++) {
99
+ directories.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
100
+ if (existsSync(join(current, ".git")) || isFilesystemRoot(current)) break;
101
+ current = dirname(current);
102
+ }
103
+ directories.push(...settingsSkills);
104
+ return [...new Set(directories)];
105
+ }
106
+
107
+ interface ScanContext {
108
+ entries: SkillCatalogEntry[];
109
+ seenLocations: Set<string>;
110
+ directoriesVisited: number;
111
+ }
112
+
113
+ /** Root-level .md files count as individual skills only in these two locations, per Pi's docs. */
114
+ function allowsRootMarkdownFiles(directory: string): boolean {
115
+ return directory.endsWith(join(".pi", "agent", "skills")) || directory.endsWith(join(".pi", "skills"));
116
+ }
117
+
118
+ function recordSkillFile(context: ScanContext, path: string): void {
119
+ if (context.seenLocations.has(path) || context.entries.length >= SKILL_SCAN_MAX_SKILLS) return;
120
+ let content: string;
121
+ try {
122
+ content = readFileSync(path, "utf8");
123
+ } catch {
124
+ return;
125
+ }
126
+ const parsed = parseSkillFrontmatter(content);
127
+ if (!parsed) return;
128
+ context.seenLocations.add(path);
129
+ const characters = parsed.name.length + parsed.description.length;
130
+ context.entries.push({
131
+ name: parsed.name,
132
+ description: parsed.description,
133
+ location: path,
134
+ characters,
135
+ estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
136
+ });
137
+ }
138
+
139
+ function walk(context: ScanContext, directory: string, depth: number, allowRootMarkdown: boolean): void {
140
+ if (depth > SKILL_SCAN_MAX_DEPTH || context.directoriesVisited >= SKILL_SCAN_MAX_DIRECTORIES) return;
141
+ context.directoriesVisited++;
142
+ let names: string[];
143
+ try {
144
+ names = readdirSync(directory);
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const name of names) {
149
+ if (name === "node_modules" || name === ".git") continue;
150
+ const path = join(directory, name);
151
+ let stat: ReturnType<typeof statSync>;
152
+ try {
153
+ stat = statSync(path);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (stat.isDirectory()) {
158
+ const skillFile = join(path, "SKILL.md");
159
+ if (existsSync(skillFile)) recordSkillFile(context, skillFile);
160
+ else walk(context, path, depth + 1, false);
161
+ } else if (allowRootMarkdown && depth === 0 && name.toLowerCase().endsWith(".md")) {
162
+ recordSkillFile(context, path);
163
+ }
164
+ }
165
+ }
166
+
167
+ /** Bounded, best-effort scan: a missing or unreadable directory is silently skipped, not an error. */
168
+ export function scanSkillCatalogFootprint(directories: readonly string[]): SkillCatalogFootprint {
169
+ const context: ScanContext = { entries: [], seenLocations: new Set(), directoriesVisited: 0 };
170
+ const scanned: string[] = [];
171
+ for (const directory of directories) {
172
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) continue;
173
+ scanned.push(directory);
174
+ walk(context, directory, 0, allowsRootMarkdownFiles(directory));
175
+ }
176
+ const entries = context.entries.sort((a, b) => b.characters - a.characters);
177
+ return {
178
+ entries,
179
+ totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
180
+ totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
181
+ scannedDirectories: scanned,
182
+ };
183
+ }
@@ -3,11 +3,10 @@ import type { Artifact } from "../../src/domain/artifact.ts";
3
3
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
4
4
  import type { TaskGraph } from "../../src/task-service.ts";
5
5
  import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
6
+ import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
6
7
  import { callService } from "./service-client.ts";
7
8
  import { showTaskGraph } from "./task-graph.ts";
8
9
 
9
- const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
10
-
11
10
  function strings(value: unknown): string[] {
12
11
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
13
12
  }
@@ -73,7 +72,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
73
72
  title: "Skills",
74
73
  listOperation: "skills.list",
75
74
  statusOrder: ["active", "deprecated"],
76
- glyphs: SKILL_GLYPHS,
75
+ presentation: SKILL_STATUS_PRESENTATION,
77
76
  rowMeta: skillRowMeta,
78
77
  actions: (skill) => [
79
78
  "Show details",
@@ -8,6 +8,18 @@ export interface TaskWidgetRow {
8
8
  hasOpenChildren: boolean;
9
9
  active: boolean;
10
10
  focusStatus?: "active" | "paused";
11
+ /**
12
+ * Task containment is a DAG, not a tree: a task may have more than one parent (design
13
+ * decision -- see decide-and-execute... no single-parent enforcement was ever wanted).
14
+ * This bounded widget still renders one spanning tree (it only has room for one position
15
+ * per task), so a multi-parent task is only ever shown once, under whichever parent this
16
+ * walk reaches first -- exactly the git-log-graph / npm-ls-dedup pattern of picking one
17
+ * canonical position and flagging the rest, rather than silently dropping the information.
18
+ * parentCount > 1 means "this task also lives under other parents not shown here" --
19
+ * the full DAG (every parent edge, not just one) is always available via the task graph's
20
+ * composition view, which renders true multi-parent edges through Mermaid's flowchart layout.
21
+ */
22
+ parentCount: number;
11
23
  }
12
24
 
13
25
  export interface TaskWidgetProjection {
@@ -36,7 +48,7 @@ export function buildTaskWidgetProjection(
36
48
  if (!node) return;
37
49
  visited.add(id);
38
50
  const open = isOpen(node.task);
39
- if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
51
+ if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus, parentCount: node.parentIds.length });
40
52
  const childDepth = open ? openDepth + 1 : openDepth;
41
53
  for (const childId of node.childIds) visit(childId, childDepth);
42
54
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/constants.ts CHANGED
@@ -30,6 +30,23 @@ export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
30
30
  export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
31
31
  export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
32
32
 
33
+ /**
34
+ * A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
35
+ * prompt for the lifetime of the rule -- the same permanent, always-on-context role as an
36
+ * Agent Skill's name+description (per the Agent Skills spec's progressive-disclosure model:
37
+ * metadata ~100 tokens, always loaded; full instructions <5000 tokens, loaded only on
38
+ * activation). Anthropic's own context-engineering guidance is not a hard length rule but a
39
+ * signal-density principle -- "the smallest possible set of high-signal tokens", explicitly
40
+ * NOT "minimal means short" -- so RULE_TEXT_SOFT_TARGET_CHARACTERS is a target to aim for,
41
+ * not a rejection threshold. RULE_TEXT_HARD_LIMIT_CHARACTERS is the actual enforced ceiling,
42
+ * generous enough to allow a real rule to breathe, but catching genuinely runaway bloat that
43
+ * would tax every single turn. Above the hard limit, split into a short Rule (condition +
44
+ * the invariant) plus a linked Doc for full reasoning -- the pattern this codebase's own
45
+ * active rules already use via "Source: Lexicon <path>" references.
46
+ */
47
+ export const RULE_TEXT_SOFT_TARGET_CHARACTERS = 600;
48
+ export const RULE_TEXT_HARD_LIMIT_CHARACTERS = 4000;
49
+
33
50
  /** Compact task-context limits keep recurring prompt injection bounded. */
34
51
  export const TASK_CONTEXT_CURRENT_LIMIT = 3;
35
52
  export const TASK_CONTEXT_REJECTED_LIMIT = 3;
@@ -57,6 +74,28 @@ export const SKILL_MAX_ENUM_VALUES = 32;
57
74
  export const SKILL_MAX_BLUEPRINTS = 100;
58
75
  export const SKILL_MAX_LINKS = 500;
59
76
  export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
77
+
78
+ /**
79
+ * Skills are special: invoking one queries Papyrus for whatever it's actually graph-linked
80
+ * to (existing Tasks/Rules/Docs via ordinary edges, not just its own static body/extra
81
+ * fields), and a Skill can link to and invoke other Skills. Both traversals are bounded and
82
+ * cycle-safe -- a skill-calls-skill edge cycle must not infinite-loop invocation, matching
83
+ * the cycle-safety discipline already established for ConversationJournal reply chains and
84
+ * task dependency graphs.
85
+ */
86
+ export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
87
+ export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
88
+
89
+ /**
90
+ * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
91
+ * a Jenkins job, not just a text prompt. A pipeline step can itself trigger another workflow
92
+ * Skill's run (a nested sub-pipeline, like a Jenkins job triggering a downstream job and
93
+ * waiting for it), bounded and cycle-safe: a real skill-calls-skill cycle during EXECUTION
94
+ * (not just invocation preview) must fail loudly and roll back the whole atomic run, not
95
+ * silently truncate, since a silently-truncated pipeline would leave a confusing partial
96
+ * Task graph behind.
97
+ */
98
+ export const SKILL_WORKFLOW_MAX_NESTING_DEPTH = 4;
60
99
  export const SKILL_RUN_ID_MAX_LENGTH = 64;
61
100
  /** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
62
101
  export const TASK_DRIVER_MAX_TURNS = 20;
@@ -46,10 +46,29 @@ export interface SkillTaskBlueprint {
46
46
  extra?: Record<string, unknown>;
47
47
  }
48
48
 
49
+ /**
50
+ * A pipeline step that nests another workflow Skill's run inside this one -- the Jenkins
51
+ * "trigger downstream job and wait" / Ansible "include_tasks" primitive. `skillId` is late-
52
+ * bound: existence and workflow-subtype are checked at execution time (skill-execution.ts),
53
+ * not here, since this validator has no store access. `dependsOn`/`parent` place this step in
54
+ * the SAME dependency graph as ordinary task blueprints -- a task can depend on a skill-call
55
+ * ref (meaning: depend on every task the nested run creates), and a skill-call's own `parent`
56
+ * contains the nested run's root tasks under an outer task.
57
+ */
58
+ export interface SkillCallBlueprint {
59
+ ref: string;
60
+ title: string;
61
+ skillId: string;
62
+ arguments?: Record<string, unknown>;
63
+ dependsOn?: string[];
64
+ parent?: string;
65
+ }
66
+
49
67
  export interface SkillBlueprints {
50
68
  docs: SkillDocBlueprint[];
51
69
  rules: SkillRuleBlueprint[];
52
70
  tasks: SkillTaskBlueprint[];
71
+ skills: SkillCallBlueprint[];
53
72
  }
54
73
 
55
74
  export interface SkillBlueprintLink {
@@ -142,19 +161,34 @@ function placeholders(value: unknown, result: Set<string> = new Set()): Set<stri
142
161
  return result;
143
162
  }
144
163
 
145
- function assertAcyclic(tasks: SkillTaskBlueprint[]): void {
146
- const byRef = new Map(tasks.map((task) => [task.ref, task]));
164
+ /** Steps sharing one dependency graph: ordinary tasks and skill-call pipeline steps alike. */
165
+ interface DependentStep {
166
+ ref: string;
167
+ dependsOn?: string[];
168
+ }
169
+
170
+ function assertAcyclic(steps: DependentStep[]): void {
171
+ const byRef = new Map(steps.map((step) => [step.ref, step]));
147
172
  const visiting = new Set<string>();
148
173
  const visited = new Set<string>();
149
174
  const visit = (ref: string): void => {
150
- if (visiting.has(ref)) throw new Error(`skill task dependency cycle includes "${ref}"`);
175
+ if (visiting.has(ref)) throw new Error(`skill step dependency cycle includes "${ref}"`);
151
176
  if (visited.has(ref)) return;
152
177
  visiting.add(ref);
153
178
  for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
154
179
  visiting.delete(ref);
155
180
  visited.add(ref);
156
181
  };
157
- for (const task of tasks) visit(task.ref);
182
+ for (const step of steps) visit(step.ref);
183
+ }
184
+
185
+ function validateSkillCallBlueprint(value: unknown): SkillCallBlueprint {
186
+ const source = record(value, "skill call blueprint");
187
+ const ref = string(source["ref"], "skill call blueprint ref");
188
+ if (!NAME_PATTERN.test(ref)) throw new Error(`invalid skill blueprint ref "${ref}"`);
189
+ const title = string(source["title"], "skill call blueprint title");
190
+ const skillId = string(source["skillId"], "skill call blueprint skillId");
191
+ return { ...source, ref, title, skillId } as SkillCallBlueprint;
158
192
  }
159
193
 
160
194
  export function validateSkillDefinition(value: unknown): SkillDefinition {
@@ -165,23 +199,38 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
165
199
  const docs = array(rawBlueprints["docs"] ?? [], "skill doc blueprints").map((entry) => validateBlueprint<SkillDocBlueprint>(entry, "doc"));
166
200
  const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
167
201
  const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
168
- const all = [...docs, ...rules, ...tasks];
202
+ const skillCalls = array(rawBlueprints["skills"] ?? [], "skill call blueprints").map(validateSkillCallBlueprint);
203
+ const all = [...docs, ...rules, ...tasks, ...skillCalls];
169
204
  if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
170
205
  const refs = new Set<string>();
171
206
  for (const blueprint of all) {
172
207
  if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
173
208
  refs.add(blueprint.ref);
174
209
  }
210
+ // Tasks and skill-call pipeline steps share one dependency graph: a task may depend on a
211
+ // skill-call ref (meaning: depend on every task that nested run creates), and vice versa.
212
+ const stepRefs = new Set<string>([...tasks.map((task) => task.ref), ...skillCalls.map((call) => call.ref)]);
175
213
  for (const task of tasks) {
176
214
  if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`skill task "${task.ref}" dependsOn must be an array`);
177
215
  for (const dependency of task.dependsOn ?? []) {
178
- if (!tasks.some((candidate) => candidate.ref === dependency)) throw new Error(`unknown skill task dependency ref "${dependency}"`);
216
+ if (!stepRefs.has(dependency)) throw new Error(`unknown skill task dependency ref "${dependency}"`);
179
217
  }
218
+ // parent stays task-only: containment under a skill-call step's exploded task SET has no
219
+ // single natural parent, so parent must name an actual task blueprint.
180
220
  if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
181
221
  throw new Error(`unknown skill task parent ref "${task.parent}"`);
182
222
  }
183
223
  }
184
- assertAcyclic(tasks);
224
+ for (const call of skillCalls) {
225
+ if (call.dependsOn !== undefined && !Array.isArray(call.dependsOn)) throw new Error(`skill call "${call.ref}" dependsOn must be an array`);
226
+ for (const dependency of call.dependsOn ?? []) {
227
+ if (!stepRefs.has(dependency)) throw new Error(`unknown skill call dependency ref "${dependency}"`);
228
+ }
229
+ if (call.parent !== undefined && !tasks.some((candidate) => candidate.ref === call.parent)) {
230
+ throw new Error(`unknown skill call parent ref "${call.parent}"`);
231
+ }
232
+ }
233
+ assertAcyclic([...tasks, ...skillCalls]);
185
234
  for (const name of placeholders(all)) {
186
235
  if (!Object.hasOwn(inputs, name)) throw new Error(`unknown skill input placeholder "${name}"`);
187
236
  }
@@ -196,7 +245,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
196
245
  return { from, relation, to };
197
246
  });
198
247
  if (links.length > SKILL_MAX_LINKS) throw new Error(`skill links exceed ${SKILL_MAX_LINKS}`);
199
- return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
248
+ return { version: 1, inputs, blueprints: { docs, rules, tasks, skills: skillCalls }, links };
200
249
  }
201
250
 
202
251
  export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
@@ -1,4 +1,9 @@
1
- import { ARTIFACT_SCOPE_MAX_ARTIFACTS } from "./constants.ts";
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";
2
7
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
3
8
  import type { ArtifactEventContext } from "./domain/artifact-event.ts";
4
9
  import { normalizeProjectRoot } from "./domain/task-scope.ts";
@@ -174,7 +179,27 @@ export interface CreateRuleInput {
174
179
 
175
180
  export type RuleTransition = "enable" | "disable";
176
181
 
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
+
177
201
  export function createRule(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateRuleInput, context?: ArtifactEventContext): Artifact {
202
+ assertRuleTextWithinBounds(input.condition, input.action, input.body);
178
203
  const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
179
204
  const rule = artifacts.create({
180
205
  kind: "rule",
@@ -328,8 +353,7 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
328
353
  return artifacts.get(id, { tree: true })!;
329
354
  }
330
355
 
331
- export function skillInvocation(artifacts: ArtifactStore, id: string): string {
332
- const skill = requireKind(artifacts, id, "skill");
356
+ function skillInvocationBody(skill: Artifact): string {
333
357
  if (skill.subtype === "artifact-template") {
334
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.`;
335
359
  }
@@ -356,6 +380,48 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
356
380
  ].join("\n");
357
381
  }
358
382
 
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
+
359
425
  export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
360
426
  const skill = requireKind(artifacts, id, "skill");
361
427
  const expected = action === "enable" ? "deprecated" : "active";