@sigrun/sigrun 0.0.0-stage → 0.1.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 (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Command EXPANSION at admission (Task 2c, pi parity): `/skill:name
3
+ * [args]` and prompt-template `/name [args]` inputs are rewritten into
4
+ * the text the model actually sees, server-side in `submitOp` BEFORE
5
+ * content-part resolution — so the webui, the TUI,
6
+ * `host.conversations.submit` and the HTTP API behave identically, and
7
+ * job prompts can use templates too.
8
+ *
9
+ * Shapes (pi 1.3.0's, byte-for-byte):
10
+ *
11
+ * - `/skill:name args` → `<skill name="NAME" location="PATH">\nReferences
12
+ * are relative to DIR.\n\nBODY\n</skill>` plus `\n\nARGS` ONLY when
13
+ * arguments were given. An unknown skill passes through UNCHANGED
14
+ * (never an error — pi's behaviour). A `disable-model-invocation:
15
+ * true` skill is hidden from the prompt section but still expands
16
+ * here (`listSkills` returns it with the flag; the section filters).
17
+ * - `/template args` → the template body with `$1`/`$@`-family
18
+ * placeholders substituted (see `substituteArgs`).
19
+ *
20
+ * A `/name` that is a core or extension command keeps its existing
21
+ * meaning — expansion claims only `skill:*` and template names. At
22
+ * admission the reserved-name check simply passes such inputs through
23
+ * (the composer drives the endpoint); the CATALOG build additionally
24
+ * skips a colliding template with one warning (R6), and a template
25
+ * claiming the reserved `skill:` prefix is skipped the same way.
26
+ *
27
+ * The user entry carries what was typed: callers place the expanded
28
+ * text as the admitted input with
29
+ * `entry: {kind: "sigrun.user.expanded", data: {typed, command}}`, so
30
+ * the transcript shows the typed text (pi-durable `InputEntry`; the
31
+ * kind must not start with `pi.`).
32
+ */
33
+ import { listSkills, } from "../skills/index.js";
34
+ import { discoverTemplates, } from "./templates.js";
35
+ /** The user entry kind an expanded input is placed as. */
36
+ export const EXPANDED_ENTRY_KIND = "sigrun.user.expanded";
37
+ /**
38
+ * A malformed command line the user typed (an unterminated quote in
39
+ * template arguments) — the one usage error the expansion path raises.
40
+ * Typed so the submit path can map it to a client 400 (the
41
+ * `invalid_input` shape) without string-matching messages, while any
42
+ * OTHER failure still surfaces as a genuine server error.
43
+ */
44
+ export class CommandUsageError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = "CommandUsageError";
48
+ }
49
+ }
50
+ /**
51
+ * Parse a command's raw argument text: whitespace-separated, single and
52
+ * double quotes group, no escape processing beyond a literal quote
53
+ * inside the other quote kind, no variable expansion, no globbing. An
54
+ * UNTERMINATED quote is a usage error (fail loud — never a silent
55
+ * swallow of half the input).
56
+ */
57
+ export function parseCommandArgs(argsString) {
58
+ const args = [];
59
+ let current = "";
60
+ // Tracks whether a token has started, so quoted empty strings (""
61
+ // or '') are kept as arguments.
62
+ let hasToken = false;
63
+ let inQuote = null;
64
+ for (const char of argsString) {
65
+ if (inQuote !== null) {
66
+ if (char === inQuote) {
67
+ inQuote = null;
68
+ }
69
+ else {
70
+ current += char;
71
+ }
72
+ }
73
+ else if (char === '"' || char === "'") {
74
+ inQuote = char;
75
+ hasToken = true;
76
+ }
77
+ else if (/\s/.test(char)) {
78
+ if (hasToken) {
79
+ args.push(current);
80
+ current = "";
81
+ hasToken = false;
82
+ }
83
+ }
84
+ else {
85
+ current += char;
86
+ hasToken = true;
87
+ }
88
+ }
89
+ if (inQuote !== null) {
90
+ throw new CommandUsageError(`unterminated quote in command arguments: ${argsString}`);
91
+ }
92
+ if (hasToken)
93
+ args.push(current);
94
+ return args;
95
+ }
96
+ /**
97
+ * Substitute argument placeholders in template content. Supported forms
98
+ * (pi parity): `$1`, `$2`, …; `$@` and `$ARGUMENTS` (all arguments,
99
+ * space-joined); `${N:-default}`; `${@:-default}`/`${ARGUMENTS:-default}`;
100
+ * `${@:N}` (from the Nth argument onwards); `${@:N:L}` (L arguments
101
+ * starting at the Nth). Replacement is single-pass: argument and
102
+ * default values containing `$…` patterns are NOT recursively
103
+ * substituted.
104
+ */
105
+ export function substituteArgs(content, args) {
106
+ const allArgs = args.join(" ");
107
+ return content.replace(/\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => {
108
+ if (defaultTarget !== undefined) {
109
+ const value = defaultTarget === "@" || defaultTarget === "ARGUMENTS"
110
+ ? allArgs
111
+ : args[Number.parseInt(defaultTarget, 10) - 1];
112
+ return value ? value : (defaultValue ?? "");
113
+ }
114
+ if (sliceStart !== undefined) {
115
+ // 1-indexed (bash convention: args start at 1); 0 is treated
116
+ // as 1.
117
+ let start = Number.parseInt(sliceStart, 10) - 1;
118
+ if (start < 0)
119
+ start = 0;
120
+ if (sliceLength !== undefined) {
121
+ const length = Number.parseInt(sliceLength, 10);
122
+ return args.slice(start, start + length).join(" ");
123
+ }
124
+ return args.slice(start).join(" ");
125
+ }
126
+ if (simple === "ARGUMENTS" || simple === "@")
127
+ return allArgs;
128
+ return args[Number.parseInt(simple ?? "0", 10) - 1] ?? "";
129
+ });
130
+ }
131
+ /**
132
+ * Expand `/name args` against the given templates: a matching name
133
+ * parses the arguments (shell-quoted) and substitutes them into the
134
+ * template body; anything else passes through UNCHANGED.
135
+ */
136
+ export function expandTemplate(text, templates) {
137
+ if (!text.startsWith("/"))
138
+ return text;
139
+ const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text);
140
+ if (match === null)
141
+ return text;
142
+ const template = templates.find((candidate) => candidate.name === match?.[1]);
143
+ if (template === undefined)
144
+ return text;
145
+ return substituteArgs(template.content, parseCommandArgs(match[2] ?? ""));
146
+ }
147
+ /**
148
+ * Expand `/skill:name [args]` against the given skills (pi's exact
149
+ * shape): the `<skill …>` block, plus a blank line and the trimmed
150
+ * arguments ONLY when arguments were given. An unknown skill — or any
151
+ * other text — passes through UNCHANGED.
152
+ */
153
+ export function expandSkillCommand(text, skills) {
154
+ if (!text.startsWith("/skill:"))
155
+ return text;
156
+ const spaceIndex = text.indexOf(" ");
157
+ const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
158
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
159
+ const skill = skills.find((candidate) => candidate.name === skillName);
160
+ if (skill === undefined)
161
+ return text;
162
+ const location = `${skill.dir}/SKILL.md`;
163
+ // Pi parity: the body is the frontmatter-stripped SKILL.md, trimmed —
164
+ // `</skill>` sits on its own line with no stray blank lines.
165
+ const body = skill.body.trim();
166
+ const block = `<skill name="${skill.name}" location="${location}">\n` +
167
+ `References are relative to ${skill.dir}.\n` +
168
+ `\n${body}\n` +
169
+ `</skill>`;
170
+ return args === "" ? block : `${block}\n\n${args}`;
171
+ }
172
+ /**
173
+ * Drop templates whose names cannot be expansion commands: a name the
174
+ * full command set already claims (core or extension — one warning per
175
+ * template), and the reserved `skill:` namespace (the `/skill:` prefix
176
+ * is the skills channel, never a template's).
177
+ */
178
+ export function expandableTemplates(templates, commandNames) {
179
+ const kept = [];
180
+ for (const template of templates) {
181
+ if (commandNames.has(template.name)) {
182
+ console.warn(`templates: skipping "${template.name}" (${template.dir}) — ` +
183
+ "the name is already a command; commands keep their meaning");
184
+ continue;
185
+ }
186
+ if (template.name.startsWith("skill:")) {
187
+ console.warn(`templates: skipping "${template.name}" (${template.dir}) — ` +
188
+ 'the "skill:" prefix is reserved for /skill:name expansion');
189
+ continue;
190
+ }
191
+ kept.push(template);
192
+ }
193
+ return kept;
194
+ }
195
+ /**
196
+ * Expand one input's leading text part, when it starts with `/` and
197
+ * names a skill or an expandable template. Everything else — non-`/`
198
+ * text, a `/name` that is a command, an unknown skill or template —
199
+ * passes through untouched; the OTHER parts (attachments, further text
200
+ * parts) pass through in place regardless.
201
+ *
202
+ * Returns the (possibly rewritten) parts plus `typed`/`command` for the
203
+ * `sigrun.user.expanded` entry; `typed` absent means nothing expanded.
204
+ * An unterminated quote in a template's arguments throws (the usage
205
+ * error `parseCommandArgs` raises) — the submit path maps it to a
206
+ * client error.
207
+ */
208
+ export async function expandInput(parts, expansion, env, context) {
209
+ // Only a LEADING text part starting with `/` is a command: the
210
+ // command is what the user typed first (attachments and later text
211
+ // parts pass through untouched, in place).
212
+ const textPart = parts[0];
213
+ if (textPart === undefined ||
214
+ textPart.type !== "text" ||
215
+ !textPart.text.startsWith("/")) {
216
+ return { parts };
217
+ }
218
+ const text = textPart.text;
219
+ // The command token: everything up to the first whitespace.
220
+ const spaceIndex = text.search(/\s/);
221
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
222
+ const reserved = expansion.commandNames();
223
+ if (reserved.has(commandName))
224
+ return { parts };
225
+ if (commandName.startsWith("skill:")) {
226
+ const skills = await listSkills(env, context, expansion.skills);
227
+ const expanded = expandSkillCommand(text, skills);
228
+ if (expanded === text)
229
+ return { parts };
230
+ return {
231
+ parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
232
+ typed: text,
233
+ command: commandName,
234
+ };
235
+ }
236
+ const templates = expandableTemplates(await discoverTemplates(env, context, expansion.templates), reserved);
237
+ const expanded = expandTemplate(text, templates);
238
+ if (expanded === text)
239
+ return { parts };
240
+ return {
241
+ parts: parts.map((part) => (part === textPart ? { type: "text", text: expanded } : part)),
242
+ typed: text,
243
+ command: commandName,
244
+ };
245
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Prompt-template discovery (Task 2c, pi parity) — the `/name` commands
3
+ * whose bodies are Markdown files. Mirrors the skills convention's
4
+ * three-root precedence EXACTLY (src/skills/index.ts):
5
+ *
6
+ * 1. the WORKSPACE root — `prompts/` under the conversation workspace,
7
+ * read through the conversation `ExecutionEnv`, so discovery follows
8
+ * the resolved workspace;
9
+ * 2. the USER root — `<configDir>/prompts/`, read from the host
10
+ * filesystem;
11
+ * 3. PACKAGE roots — each installed package's `prompts/` directory
12
+ * (attributed to the extension), read from the host filesystem.
13
+ *
14
+ * A name appearing in several roots keeps the HIGHER root's template
15
+ * (first-wins across roots in precedence order); within one root the
16
+ * filenames are unique by construction. Direct `.md` children only — a
17
+ * nested `prompts/sub/x.md` is ignored. Both lists are re-scanned LIVE
18
+ * (per `/v1/commands` request and per expansion — the same
19
+ * live-roots posture as the skills section), so no reload command.
20
+ *
21
+ * Template shape (pi parity): frontmatter `description` (else the first
22
+ * non-empty line of the body) and optional `argument-hint`; the body is
23
+ * everything after the frontmatter block. `expandTemplate` (src/commands/
24
+ * expand.ts) substitutes `$1`/`$@`-family placeholders in it.
25
+ */
26
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
27
+ import { basename, dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ /** The prompts directory name under each of the three roots. */
30
+ export const PROMPTS_DIRECTORY = "prompts";
31
+ const FRONTMATTER_DELIMITER = "---";
32
+ /**
33
+ * Split a leading `---`-delimited frontmatter block off `text`. The
34
+ * block must start at the very first line; the content is everything
35
+ * after the closing delimiter (leading newlines stripped, trailing
36
+ * whitespace kept — substitutions are byte-faithful to the file).
37
+ * Returns the unstripped text as content when no block is present.
38
+ */
39
+ function parseTemplateFile(text) {
40
+ const lines = text.split(/\r?\n/);
41
+ if (lines[0]?.trim() !== FRONTMATTER_DELIMITER) {
42
+ return { content: text.trim() };
43
+ }
44
+ let close = -1;
45
+ for (let i = 1; i < lines.length; i++) {
46
+ if (lines[i]?.trim() === FRONTMATTER_DELIMITER) {
47
+ close = i;
48
+ break;
49
+ }
50
+ }
51
+ if (close === -1) {
52
+ // An unterminated block is not frontmatter — the whole file is
53
+ // body (the skills parser treats the file as invalid instead;
54
+ // templates have no required fields, so the honest read is body).
55
+ return { content: text };
56
+ }
57
+ const fields = {};
58
+ for (const line of lines.slice(1, close)) {
59
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
60
+ if (match === null)
61
+ continue;
62
+ const value = match[2].trim();
63
+ if (value.length >= 2 &&
64
+ ((value.startsWith('"') && value.endsWith('"')) ||
65
+ (value.startsWith("'") && value.endsWith("'")))) {
66
+ fields[match[1]] = value.slice(1, -1);
67
+ }
68
+ else {
69
+ fields[match[1]] = value;
70
+ }
71
+ }
72
+ return {
73
+ description: fields.description === "" ? undefined : fields.description,
74
+ argsHint: fields["argument-hint"] === "" || fields["argument-hint"] === undefined
75
+ ? undefined
76
+ : fields["argument-hint"],
77
+ // Pi parity: the substitution body is the frontmatter-stripped
78
+ // file, TRIMMED — `/name args` expands to the body with no leading
79
+ // or trailing blank lines.
80
+ content: lines.slice(close + 1).join("\n").trim(),
81
+ };
82
+ }
83
+ /** Build one template from a file's text; `undefined` when it is not `.md`. */
84
+ function templateFromFile(file, text, dir, extension) {
85
+ const parsed = parseTemplateFile(text);
86
+ let description = parsed.description;
87
+ if (description === undefined || description === "") {
88
+ const firstLine = parsed.content
89
+ .split(/\r?\n/)
90
+ .find((line) => line.trim() !== "");
91
+ description = firstLine?.trim();
92
+ }
93
+ return {
94
+ name: basename(file).replace(/\.md$/, ""),
95
+ description: description ?? "",
96
+ ...(parsed.argsHint === undefined ? {} : { argsHint: parsed.argsHint }),
97
+ content: parsed.content,
98
+ dir,
99
+ ...(extension === undefined ? {} : { extension }),
100
+ };
101
+ }
102
+ /** Name comparison (plain code-point order, locale-independent). */
103
+ function byTemplateName(a, b) {
104
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
105
+ }
106
+ /**
107
+ * Read the direct `.md` children of one prompts directory through the
108
+ * conversation `ExecutionEnv` (the workspace root). An absent directory
109
+ * is the designed empty case; any other failure fails loud. Nested
110
+ * directories and non-`.md` files are ignored.
111
+ */
112
+ async function scanEnvRoot(env, context) {
113
+ const listing = await env.listDir(PROMPTS_DIRECTORY, context);
114
+ if (!listing.ok) {
115
+ if (listing.error.code === "not_found")
116
+ return [];
117
+ throw new Error(`Failed to list the ${PROMPTS_DIRECTORY}/ directory: ${listing.error.message}`, { cause: listing.error });
118
+ }
119
+ const templates = [];
120
+ for (const entry of [...listing.value].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
121
+ if (entry.kind !== "file" || !entry.name.endsWith(".md"))
122
+ continue;
123
+ const read = await env.readTextFile(join(PROMPTS_DIRECTORY, entry.name), context);
124
+ if (!read.ok) {
125
+ if (read.error.code === "not_found")
126
+ continue;
127
+ throw new Error(`Failed to read ${PROMPTS_DIRECTORY}/${entry.name}: ${read.error.message}`, { cause: read.error });
128
+ }
129
+ templates.push(templateFromFile(`${PROMPTS_DIRECTORY}/${entry.name}`, read.value, PROMPTS_DIRECTORY));
130
+ }
131
+ return templates;
132
+ }
133
+ /**
134
+ * Read the direct `.md` children of ONE host-filesystem prompts
135
+ * directory (the user dir or a package's). Same rules as the workspace
136
+ * root; absent roots are the designed empty case. Reads synchronously —
137
+ * the root is host-local by definition (the skills module's posture).
138
+ */
139
+ function scanFsRoot(root, extension) {
140
+ let entries;
141
+ try {
142
+ entries = readdirSync(root, { withFileTypes: true });
143
+ }
144
+ catch (cause) {
145
+ if (cause?.code === "ENOENT")
146
+ return [];
147
+ throw new Error(`Failed to list the prompts root ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
148
+ }
149
+ const templates = [];
150
+ for (const entry of [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
151
+ const file = join(root, entry.name);
152
+ // A symlink to a file counts (pi parity); a broken one is skipped.
153
+ let isFile = entry.isFile();
154
+ if (entry.isSymbolicLink()) {
155
+ try {
156
+ isFile = statSync(file).isFile();
157
+ }
158
+ catch {
159
+ continue;
160
+ }
161
+ }
162
+ if (!isFile || !entry.name.endsWith(".md"))
163
+ continue;
164
+ let text;
165
+ try {
166
+ text = readFileSync(file, "utf8");
167
+ }
168
+ catch (cause) {
169
+ throw new Error(`Failed to read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
170
+ }
171
+ templates.push(templateFromFile(file, text, root, extension));
172
+ }
173
+ return templates;
174
+ }
175
+ /**
176
+ * Discover the templates across the three roots in precedence order
177
+ * (workspace > user dir > packages, packages in install order),
178
+ * first-wins on the NAME. A name lost to a higher root logs
179
+ * `templates: "<name>" from <lower root> shadowed by <higher root>`
180
+ * (the skills convention's shape).
181
+ */
182
+ export async function discoverTemplates(env, context, opts) {
183
+ const byName = new Map();
184
+ /**
185
+ * First-wins merge with a shadow log. `root` names the root the
186
+ * templates came from ("workspace", "user dir", "package");
187
+ * `extension` names the contributing package for package roots.
188
+ */
189
+ const merge = (templates, root, extension) => {
190
+ for (const template of templates) {
191
+ const existing = byName.get(template.name);
192
+ if (existing !== undefined) {
193
+ const existingRoot = existing.extension ?? "a higher root";
194
+ console.warn(`templates: "${template.name}" from ${extension ?? root} shadowed by ${existingRoot}`);
195
+ continue;
196
+ }
197
+ byName.set(template.name, template);
198
+ }
199
+ };
200
+ if (env !== undefined) {
201
+ merge(await scanEnvRoot(env, context), "workspace");
202
+ }
203
+ merge(scanFsRoot(opts.userDir), "user dir");
204
+ for (const root of opts.packageRoots()) {
205
+ merge(scanFsRoot(root.dir, root.extension), "package", root.extension);
206
+ }
207
+ return [...byName.values()].sort(byTemplateName);
208
+ }
209
+ /**
210
+ * The package prompt roots for one boot's loaded entries: each absolute
211
+ * `file://` entry's package root (the nearest `package.json` at or above
212
+ * the entry's directory) that SHIPS a `prompts/` directory, attributed
213
+ * to the package's manifest name (falling back to the directory name).
214
+ * Bare specifiers (no location) and packages without `prompts/`
215
+ * contribute nothing. The loader's skill dirs stay skills-only; this
216
+ * derivation exists because the loader has no fs coupling by contract
217
+ * and the module dir is only resolvable from the entry URL.
218
+ */
219
+ export function packagePromptRootsOf(entries) {
220
+ const roots = [];
221
+ for (const entry of entries) {
222
+ let entryPath;
223
+ if (entry.startsWith("file://")) {
224
+ try {
225
+ entryPath = fileURLToPath(entry);
226
+ }
227
+ catch {
228
+ continue;
229
+ }
230
+ }
231
+ else if (entry.startsWith("/")) {
232
+ entryPath = entry;
233
+ }
234
+ else {
235
+ continue;
236
+ }
237
+ // The nearest package.json at or above the entry (the loader's
238
+ // bounded-walk shape): the package root owning the entry.
239
+ let current = dirname(entryPath);
240
+ let packageRoot;
241
+ for (let hops = 0; hops < 6; hops++) {
242
+ if (existsSync(join(current, "package.json"))) {
243
+ packageRoot = current;
244
+ break;
245
+ }
246
+ const parent = dirname(current);
247
+ if (parent === current)
248
+ break;
249
+ current = parent;
250
+ }
251
+ if (packageRoot === undefined)
252
+ continue;
253
+ const promptsDir = join(packageRoot, PROMPTS_DIRECTORY);
254
+ if (!existsSync(promptsDir))
255
+ continue;
256
+ let name;
257
+ try {
258
+ const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
259
+ if (typeof parsed.name === "string" && parsed.name !== "") {
260
+ name = parsed.name;
261
+ }
262
+ }
263
+ catch {
264
+ name = undefined;
265
+ }
266
+ roots.push({
267
+ extension: name ?? basename(packageRoot),
268
+ dir: promptsDir,
269
+ });
270
+ }
271
+ return roots;
272
+ }