@xzzpig/pi-openspec-x 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.
package/src/skills.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Official-track skill generation for pi-openspec-x.
3
+ *
4
+ * Generates the five openspec-* SKILL.md files from the installed OpenSpec
5
+ * CLI into `<agentDir>/cache/pi-openspec-x/skills/<cliVersion>/`. The CLI
6
+ * version is the cache key: when the versioned directory exists and its
7
+ * stamp matches, no CLI command runs at all (the version itself comes from
8
+ * the once-per-session probe in cli.ts). On a miss, the artifact list is
9
+ * taken from `openspec schemas --json` (spec-driven -> proposal, specs,
10
+ * design, tasks).
11
+ *
12
+ * The generated bodies are English skeletons that keep the CLI's own flavor:
13
+ * they tell the agent when the skill triggers, sketch the workflow steps, and
14
+ * direct it to fetch the authoritative instructions from the CLI at execution
15
+ * time (`openspec status/list/instructions <artifact> --change <id> --json`).
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { openspecXSkillsDir, resolveAgentDir } from "./agent-dir.ts";
20
+ import {
21
+ listOpenspecSchemas,
22
+ probeOpenspecVersion,
23
+ type OpenspecCliOptions,
24
+ type OpenspecSchemaInfo,
25
+ } from "./cli.ts";
26
+
27
+ /** The five generated skills, matching the official openspec skill set. */
28
+ export const OPENSPEC_SKILL_NAMES = [
29
+ "openspec-explore",
30
+ "openspec-propose",
31
+ "openspec-apply-change",
32
+ "openspec-archive-change",
33
+ "openspec-sync-specs",
34
+ ] as const;
35
+
36
+ export type OpenspecSkillName = (typeof OPENSPEC_SKILL_NAMES)[number];
37
+
38
+ /**
39
+ * Trigger descriptions, semantically aligned with the official static skills
40
+ * shipped by `openspec init` (wording may be refined, meaning is preserved).
41
+ */
42
+ const SKILL_DESCRIPTIONS: Record<OpenspecSkillName, string> = {
43
+ "openspec-explore":
44
+ 'Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".',
45
+ "openspec-propose":
46
+ 'Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".',
47
+ "openspec-apply-change":
48
+ 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".',
49
+ "openspec-archive-change":
50
+ 'Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".',
51
+ "openspec-sync-specs":
52
+ 'Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".',
53
+ };
54
+
55
+ // ── rendering ───────────────────────────────────────────────────────────────
56
+
57
+ export interface SkillRenderContext {
58
+ cliVersion: string;
59
+ /** Schema preferred for generation: `spec-driven` when present, else the first reported schema. */
60
+ schema: OpenspecSchemaInfo | undefined;
61
+ }
62
+
63
+ function renderFrontmatter(
64
+ name: OpenspecSkillName,
65
+ context: SkillRenderContext,
66
+ ): string {
67
+ return [
68
+ "---",
69
+ `name: ${name}`,
70
+ `description: ${SKILL_DESCRIPTIONS[name]}`,
71
+ "allowed-tools: Bash(openspec:*)",
72
+ "license: MIT",
73
+ "compatibility: Requires openspec CLI.",
74
+ "metadata:",
75
+ " author: openspec",
76
+ ' version: "1.0"',
77
+ ` generatedBy: "${context.cliVersion}"`,
78
+ "---",
79
+ ].join("\n");
80
+ }
81
+
82
+ function renderAuthoritativeBlock(changeScoped: boolean): string {
83
+ const commands = changeScoped
84
+ ? [
85
+ "openspec list --json",
86
+ 'openspec status --change "<change-id>" --json',
87
+ 'openspec instructions <artifact> --change "<change-id>" --json',
88
+ ]
89
+ : ["openspec list --json", 'openspec status --change "<change-id>" --json'];
90
+ return [
91
+ `This SKILL.md is a generated trigger and workflow skeleton (generated by the pi-openspec-x extension from OpenSpec CLI). The OpenSpec CLI is the single source of truth at execution time: run the \`--json\` commands below fresh in every session and follow their output over anything written here.`,
92
+ "",
93
+ "```bash",
94
+ ...commands,
95
+ "```",
96
+ ].join("\n");
97
+ }
98
+
99
+ function renderArtifactLine(context: SkillRenderContext): string {
100
+ if (!context.schema) {
101
+ return "Run `openspec schemas --json` at execution time to discover the available workflow schemas and their artifacts; none could be captured at generation time.";
102
+ }
103
+ return [
104
+ `Default artifacts for the \`${context.schema.name}\` schema (${context.schema.description}): ${context.schema.artifacts.join(", ")}.`,
105
+ 'Always confirm the artifact set with `openspec status --change "<change-id>" --json` at execution time.',
106
+ ].join(" ");
107
+ }
108
+
109
+ const SKILL_BODIES: Record<
110
+ OpenspecSkillName,
111
+ (context: SkillRenderContext) => string
112
+ > = {
113
+ "openspec-explore": (context) =>
114
+ [
115
+ renderAuthoritativeBlock(false),
116
+ "",
117
+ "## When to use",
118
+ "",
119
+ "- The user wants to think through something before or during an OpenSpec change.",
120
+ '- The user says "openspec explore" or "opsx explore".',
121
+ "",
122
+ "## Workflow skeleton",
123
+ "",
124
+ "1. **Ground in the CLI first.** Run `openspec list --json` (add `--specs` to include specifications) and read the current changes and specs before saying anything substantive. If the project has no OpenSpec root, treat this as a normal exploration conversation and do not create anything.",
125
+ "2. **Clarify the question.** Ask open-ended questions until the problem, constraints, and desired outcome are concrete; summarize them back before proposing anything.",
126
+ "3. **Investigate.** Read the specs, change artifacts, and code the question touches. Quote what you find; distinguish observed behavior from assumptions.",
127
+ "4. **Discuss, do not build.** Offer options, trade-offs, and recommendations. If the user wants to proceed to a change, hand off to the openspec-propose workflow.",
128
+ "",
129
+ renderArtifactLine(context),
130
+ "",
131
+ "## Guardrails",
132
+ "",
133
+ "- Explore mode is conversational: no artifact writes, no implementation.",
134
+ '- Re-run `openspec list --json` / `openspec status --change "<id>" --json` whenever state may have changed; never answer from memory of a previous run.',
135
+ ].join("\n"),
136
+ "openspec-propose": (context) =>
137
+ [
138
+ renderAuthoritativeBlock(true),
139
+ "",
140
+ "## When to use",
141
+ "",
142
+ "- The user wants to describe what they want to build and get a complete proposal ready for implementation.",
143
+ '- The user says "openspec propose" or "opsx propose".',
144
+ "",
145
+ "## Workflow skeleton",
146
+ "",
147
+ '1. **Understand the request.** Derive a kebab-case change id (e.g. "add user authentication" -> `add-user-auth`). Ask when scope-affecting ambiguity exists; make and record reasonable assumptions for minor details.',
148
+ "2. **Ground in the CLI.** Run `openspec list --json` to check the project has an OpenSpec root and to see existing changes. If there is no root, stop and ask the user how to proceed; never create the root as a side effect.",
149
+ '3. **Create the change.** Run `openspec new change "<change-id>"` (pass `--schema <name>` only if the user explicitly requested a schema).',
150
+ '4. **Read the build order.** Run `openspec status --change "<change-id>" --json` and use `applyRequires` plus each artifact\'s `requires` edges to compute the full required artifact set.',
151
+ '5. **Create every artifact in dependency order.** For each artifact run `openspec instructions <artifact> --change "<change-id>" --json`, then write the artifact to the returned `resolvedOutputPath` using its `template`; treat its `context`, `rules`, and `instruction` as constraints (never copy them into the file). Re-read completed dependency artifacts from disk before using them.',
152
+ '6. **Iterate to done.** Re-run `openspec status --change "<change-id>" --json` after each artifact; create newly unblocked artifacts; stop when every artifact in the required set is done or deliberately skipped.',
153
+ "7. **Hand off.** Summarize what was created and point the user to the apply workflow for implementation.",
154
+ "",
155
+ renderArtifactLine(context),
156
+ "",
157
+ "## Guardrails",
158
+ "",
159
+ "- Planning boundary: this workflow authorizes planning only. Do not implement the change or edit project code; stop after the artifacts are complete.",
160
+ "- The `openspec instructions` output is authoritative for each artifact, even for familiar artifact names.",
161
+ '- If the user names a registered store, append `--store "<id>"` to the commands that accept it (discover ids with `openspec store list --json`).',
162
+ ].join("\n"),
163
+ "openspec-apply-change": (context) =>
164
+ [
165
+ renderAuthoritativeBlock(true),
166
+ "",
167
+ "## When to use",
168
+ "",
169
+ "- The user wants to start implementing, continue implementation, or work through the tasks of an existing change.",
170
+ '- The user says "openspec apply", "opsx apply", or "openspec implement".',
171
+ "",
172
+ "## Workflow skeleton",
173
+ "",
174
+ '1. **Ground in the CLI.** Run `openspec list --json` to see in-progress changes and `openspec status --change "<change-id>" --json` for artifact completion. Confirm with the user which change to apply when it is ambiguous.',
175
+ '2. **Fetch authoritative apply instructions.** Run `openspec instructions apply --change "<change-id>" --json` and follow its `instruction`, `context`, and `rules`; they override this skeleton.',
176
+ "3. **Read the artifacts.** Read `tasks.md` (and the other artifacts the instructions name) from disk before starting; never rely on conversation memory.",
177
+ "4. **Implement task by task.** Work through tasks in order, marking each complete exactly as the apply instructions specify. Keep implementation grounded in the tasks; surface blockers instead of silently skipping.",
178
+ '5. **Verify.** Run the project checks the artifacts call for, then re-run `openspec status --change "<change-id>" --json` and report remaining work.',
179
+ "6. **Archive or stop.** When every task is complete, point the user to the archive workflow; archive only when the user asks.",
180
+ "",
181
+ renderArtifactLine(context),
182
+ "",
183
+ "## Guardrails",
184
+ "",
185
+ "- Follow the change's artifacts; if code reality conflicts with them, stop and reconcile with the user instead of improvising.",
186
+ "- Do not archive as a side effect of implementation.",
187
+ ].join("\n"),
188
+ "openspec-archive-change": (context) =>
189
+ [
190
+ renderAuthoritativeBlock(true),
191
+ "",
192
+ "## When to use",
193
+ "",
194
+ "- The user wants to finalize and archive a change after implementation is complete.",
195
+ '- The user says "openspec archive" or "opsx archive".',
196
+ "",
197
+ "## Workflow skeleton",
198
+ "",
199
+ '1. **Ground in the CLI.** Run `openspec list --json` and `openspec status --change "<change-id>" --json` to confirm the change exists and its work is complete. If tasks remain, confirm with the user before archiving.',
200
+ '2. **Fetch authoritative archive instructions.** Run `openspec instructions archive --change "<change-id>" --json` and follow the returned `instruction`, `context`, and `rules`.',
201
+ '3. **Archive.** Run `openspec archive "<change-id>"`, appending any flags the instructions require (for example skip-specs handling for changes without spec deltas).',
202
+ "4. **Verify.** Re-run `openspec list --json` (and `--specs`) to confirm the change moved to archive and main specs were updated as the instructions described.",
203
+ "",
204
+ renderArtifactLine(context),
205
+ "",
206
+ "## Guardrails",
207
+ "",
208
+ "- Archiving updates main specs: read the archive instructions' spec-update rules and verify the resulting specs before declaring success.",
209
+ "- Never hand-edit archived changes; use the CLI.",
210
+ ].join("\n"),
211
+ "openspec-sync-specs": (context) =>
212
+ [
213
+ renderAuthoritativeBlock(true),
214
+ "",
215
+ "## When to use",
216
+ "",
217
+ "- The user wants to update main specs with changes from a delta spec, without archiving the change.",
218
+ '- The user says "openspec sync" or "opsx sync".',
219
+ "",
220
+ "## Workflow skeleton",
221
+ "",
222
+ '1. **Ground in the CLI.** Run `openspec list --json` and `openspec status --change "<change-id>" --json` to locate the change and confirm its `specs` artifact exists.',
223
+ '2. **Fetch authoritative instructions.** Run `openspec instructions specs --change "<change-id>" --json` (or the artifact id the status output names) and follow its `instruction`, `context`, and `rules` for how this change\'s deltas apply to main specs.',
224
+ "3. **Apply the deltas.** Update the main spec files exactly as the instructions and the change's delta spec direct; preserve the main specs' organization and existing capability paths.",
225
+ "4. **Verify.** Run the validation command the instructions name (e.g. `openspec validate --json`) and show what changed.",
226
+ "",
227
+ renderArtifactLine(context),
228
+ "",
229
+ "## Guardrails",
230
+ "",
231
+ "- Sync does not archive: the change stays open until the user explicitly archives it.",
232
+ "- The CLI instructions and the change's delta spec are authoritative over this skeleton.",
233
+ ].join("\n"),
234
+ };
235
+
236
+ export function renderSkill(
237
+ name: OpenspecSkillName,
238
+ context: SkillRenderContext,
239
+ ): string {
240
+ return `${renderFrontmatter(name, context)}\n\n${SKILL_BODIES[name](context)}\n`;
241
+ }
242
+
243
+ // ── cache & generation ──────────────────────────────────────────────────────
244
+
245
+ export interface EnsureOpenspecSkillsOptions extends OpenspecCliOptions {
246
+ /** Override the resolved agent dir (tests). Defaults to `resolveAgentDir(env, homeDir)`. */
247
+ agentDir?: string;
248
+ /** Home dir used by agent-dir resolution when `agentDir` is not given. */
249
+ homeDir?: string;
250
+ }
251
+
252
+ export interface EnsureOpenspecSkillsResult {
253
+ /** `<agentDir>/cache/pi-openspec-x/skills/<cliVersion>` — pass as a skill path to pi. */
254
+ skillsDir: string;
255
+ /** CLI version the cache is keyed on. */
256
+ version: string;
257
+ /** True when the versioned directory was already present and valid. */
258
+ cacheHit: boolean;
259
+ /** Absolute SKILL.md paths, one per generated skill. */
260
+ skillFilePaths: string[];
261
+ }
262
+
263
+ const STAMP_FILE_NAME = ".pi-openspec-x-stamp.json";
264
+
265
+ interface SkillsStamp {
266
+ generatedBy: string;
267
+ skills: string[];
268
+ }
269
+
270
+ function isCacheHit(skillsDir: string, version: string): boolean {
271
+ try {
272
+ const stamp = JSON.parse(
273
+ fs.readFileSync(path.join(skillsDir, STAMP_FILE_NAME), "utf-8"),
274
+ ) as SkillsStamp;
275
+ if (stamp.generatedBy !== version || !Array.isArray(stamp.skills))
276
+ return false;
277
+ return OPENSPEC_SKILL_NAMES.every(
278
+ (name) =>
279
+ stamp.skills.includes(name) &&
280
+ fs.existsSync(path.join(skillsDir, name, "SKILL.md")),
281
+ );
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ function generateSkills(
288
+ skillsDir: string,
289
+ context: SkillRenderContext,
290
+ ): string[] {
291
+ fs.rmSync(skillsDir, { recursive: true, force: true });
292
+ fs.mkdirSync(skillsDir, { recursive: true });
293
+ const skillFilePaths: string[] = [];
294
+ for (const name of OPENSPEC_SKILL_NAMES) {
295
+ const skillDir = path.join(skillsDir, name);
296
+ fs.mkdirSync(skillDir, { recursive: true });
297
+ const filePath = path.join(skillDir, "SKILL.md");
298
+ fs.writeFileSync(filePath, renderSkill(name, context), "utf-8");
299
+ skillFilePaths.push(filePath);
300
+ }
301
+ const stamp: SkillsStamp = {
302
+ generatedBy: context.cliVersion,
303
+ skills: [...OPENSPEC_SKILL_NAMES],
304
+ };
305
+ // The stamp is written last: its presence marks a complete generation.
306
+ fs.writeFileSync(
307
+ path.join(skillsDir, STAMP_FILE_NAME),
308
+ `${JSON.stringify(stamp, null, 2)}\n`,
309
+ "utf-8",
310
+ );
311
+ return skillFilePaths;
312
+ }
313
+
314
+ /**
315
+ * Session-level freeze of the resolved skills directory.
316
+ *
317
+ * Spec: 同一会话内发生 skill 重新发现(如扩展 reload)时,动态 skill 的路径与内容
318
+ * 保持不变,期间发生的 CLI 升级不在本会话生效。 Pi runs one process per session,
319
+ * so pinning the first successful resolution for the process lifetime is exactly
320
+ * that freeze: a re-discovery after an extension reload reuses the pin instead
321
+ * of re-probing the CLI, and a CLI upgrade mid-session therefore cannot swap the
322
+ * skills out from under a running flow.
323
+ */
324
+ const frozenSkills = new Map<string, { skillsDir: string; version: string }>();
325
+
326
+ /** Test-only: drop the session freeze. */
327
+ export function resetSkillsFreezeForTests(): void {
328
+ frozenSkills.clear();
329
+ }
330
+
331
+ function frozenResult(frozen: {
332
+ skillsDir: string;
333
+ version: string;
334
+ }): EnsureOpenspecSkillsResult {
335
+ return {
336
+ skillsDir: frozen.skillsDir,
337
+ version: frozen.version,
338
+ cacheHit: true,
339
+ skillFilePaths: OPENSPEC_SKILL_NAMES.map((name) =>
340
+ path.join(frozen.skillsDir, name, "SKILL.md"),
341
+ ),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Ensure the versioned skills cache exists and return its path.
347
+ *
348
+ * Cache hit: directory present, stamp matches the probed CLI version, and
349
+ * every SKILL.md exists — returns without running any schemas/templates
350
+ * command (the version itself comes from the once-per-session probe).
351
+ * Cache miss: probes the version, reads `openspec schemas --json` for the
352
+ * artifact list, and (re)generates all five skills.
353
+ *
354
+ * Throws the typed cli.ts errors; callers decide how to surface them (the
355
+ * resources_discover handler never rethrows).
356
+ */
357
+ export function ensureOpenspecSkills(
358
+ options: EnsureOpenspecSkillsOptions = {},
359
+ ): EnsureOpenspecSkillsResult {
360
+ const agentDir =
361
+ options.agentDir ?? resolveAgentDir(options.env, options.homeDir);
362
+ const cliOptions: OpenspecCliOptions = {
363
+ env: options.env,
364
+ cwd: options.cwd,
365
+ timeoutMs: options.timeoutMs,
366
+ spawnImpl: options.spawnImpl,
367
+ };
368
+ const freezeKey = `${agentDir}::${options.cwd ?? ""}`;
369
+ const frozen = frozenSkills.get(freezeKey);
370
+ if (frozen) return frozenResult(frozen);
371
+
372
+ const version = probeOpenspecVersion(cliOptions);
373
+ const skillsDir = openspecXSkillsDir(agentDir, version);
374
+ frozenSkills.set(freezeKey, { skillsDir, version });
375
+ if (isCacheHit(skillsDir, version)) {
376
+ return frozenResult({ skillsDir, version });
377
+ }
378
+ const schemas = listOpenspecSchemas(cliOptions);
379
+ const schema =
380
+ schemas.find((entry) => entry.name === "spec-driven") ?? schemas[0];
381
+ const skillFilePaths = generateSkills(skillsDir, {
382
+ cliVersion: version,
383
+ schema,
384
+ });
385
+ return { skillsDir, version, cacheHit: false, skillFilePaths };
386
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Static same-name skill detection for pi-openspec-x.
3
+ *
4
+ * The official static skills that `openspec init` writes under the project's
5
+ * `.pi/skills/openspec-` skill directories collide with the dynamically
6
+ * generated skills this extension provides. When both exist, the static
7
+ * copies are redundant. This module only detects and reports — it never
8
+ * deletes or modifies project files.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { OPENSPEC_SKILL_NAMES } from "./skills.ts";
14
+
15
+ /** customType of the static-conflict notice sent to the session. */
16
+ export const STATIC_CONFLICT_CUSTOM_TYPE =
17
+ "pi-openspec-x/static-skill-conflict";
18
+
19
+ /**
20
+ * Return the absolute paths of `<cwd>/.pi/skills/<name>` directories that
21
+ * exist for the generated skill names, in skill order.
22
+ */
23
+ export function findStaticSkillConflicts(
24
+ cwd: string,
25
+ skillNames: readonly string[] = OPENSPEC_SKILL_NAMES,
26
+ ): string[] {
27
+ const hits: string[] = [];
28
+ for (const name of skillNames) {
29
+ const candidate = path.join(cwd, ".pi", "skills", name);
30
+ try {
31
+ if (fs.statSync(candidate).isDirectory()) hits.push(candidate);
32
+ } catch {}
33
+ }
34
+ return hits;
35
+ }
36
+
37
+ /** Build the notice text listing the conflicting paths and the recommendation. */
38
+ export function staticConflictMessage(
39
+ conflictPaths: readonly string[],
40
+ ): string {
41
+ return [
42
+ "pi-openspec-x provides generated OpenSpec skills with the same names as static skill directories found in this project:",
43
+ "",
44
+ ...conflictPaths.map((conflictPath) => `- ${conflictPath}`),
45
+ "",
46
+ "The extension's skills are generated from the installed OpenSpec CLI and provided automatically; these static copies are redundant and may shadow or duplicate them. Consider removing the directories listed above (pi-openspec-x never deletes or modifies project files itself).",
47
+ ].join("\n");
48
+ }
49
+
50
+ export interface WarnStaticSkillConflictsOptions {
51
+ /**
52
+ * Send at most one notice per cwd for the lifetime of the passed memo set.
53
+ * Defaults to true; pass false to always re-check-and-send.
54
+ */
55
+ oncePerCwd?: boolean;
56
+ /** Memo set of cwds already warned about; owned by the caller. */
57
+ warned?: Set<string>;
58
+ }
59
+
60
+ /**
61
+ * Detect static same-name skills under `cwd` and, when found, send one
62
+ * custom-message notice through `pi.sendMessage`. Returns true when a notice
63
+ * was sent. Best-effort: sendMessage failures are swallowed.
64
+ */
65
+ export function warnStaticSkillConflicts(
66
+ pi: Pick<ExtensionAPI, "sendMessage">,
67
+ cwd: string,
68
+ options: WarnStaticSkillConflictsOptions = {},
69
+ ): boolean {
70
+ const conflictPaths = findStaticSkillConflicts(cwd);
71
+ if (conflictPaths.length === 0) return false;
72
+ if (options.oncePerCwd !== false) {
73
+ if (options.warned?.has(cwd)) return false;
74
+ options.warned?.add(cwd);
75
+ }
76
+ try {
77
+ pi.sendMessage(
78
+ {
79
+ customType: STATIC_CONFLICT_CUSTOM_TYPE,
80
+ content: staticConflictMessage(conflictPaths),
81
+ display: true,
82
+ },
83
+ { deliverAs: "followUp" },
84
+ );
85
+ } catch {
86
+ // Notification is best-effort; never break discovery.
87
+ }
88
+ return true;
89
+ }