From 7ec9ff5e3b9088a573e84ac6659cd9589bf0c926 Mon Sep 17 00:00:00 2001 From: Sandeep M Date: Sun, 26 Jul 2026 12:31:55 +0100 Subject: [PATCH] Add public phase timing skill --- .changeset/phase-timing-public-skill.md | 8 ++ skills/openspec-phase-timing/SKILL.md | 71 +++++++++++++++++ src/core/init.ts | 7 +- src/core/shared/skill-generation.ts | 6 +- src/core/shared/tool-detection.ts | 3 +- src/core/templates/skill-templates.ts | 1 + src/core/templates/workflows/phase-timing.ts | 77 +++++++++++++++++++ src/core/update.ts | 8 +- .../templates/skill-templates-parity.test.ts | 11 ++- 9 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 .changeset/phase-timing-public-skill.md create mode 100644 skills/openspec-phase-timing/SKILL.md create mode 100644 src/core/templates/workflows/phase-timing.ts diff --git a/.changeset/phase-timing-public-skill.md b/.changeset/phase-timing-public-skill.md new file mode 100644 index 0000000000..741c67b13f --- /dev/null +++ b/.changeset/phase-timing-public-skill.md @@ -0,0 +1,8 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- Added the public `openspec-phase-timing` skill so users can measure OpenSpec command phase durations locally without sending timing data to external analytics. + diff --git a/skills/openspec-phase-timing/SKILL.md b/skills/openspec-phase-timing/SKILL.md new file mode 100644 index 0000000000..f305bdd3f6 --- /dev/null +++ b/skills/openspec-phase-timing/SKILL.md @@ -0,0 +1,71 @@ +--- +name: openspec-phase-timing +description: Measure OpenSpec command phase and stage durations locally without external analytics. Use when you need a personal timing trace or local profiling for init, update, validate, or similar flows. +allowed-tools: Bash(openspec:*) +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" +--- + +# OpenSpec Phase Timing + +Measure OpenSpec phases locally and keep the results on the user's machine. +This skill is for personal profiling and regression checks, not shared usage analytics. + +## Principles + +- Keep the data local to the user. +- Do not send timings to PostHog or any external service. +- Do not capture file contents, prompts, arguments, or paths unless the user explicitly asks for them in a local report. +- Use monotonic timing (`performance.now()` or equivalent) instead of wall-clock time. +- Keep phase names low-cardinality and stable. + +## Default storage + +Write reports to one of these local locations: + +- Repo-local: `.openspec/telemetry/phase-timings.json` +- User-local: the platform state directory for OpenSpec, for example `$XDG_STATE_HOME/openspec/telemetry/` or the OS equivalent on Windows/macOS + +Prefer the user-local location when the user wants a personal history across repositories. Prefer the repo-local location when the user wants a one-off trace attached to the current checkout. + +## Measurement model + +Record one entry per phase with: + +- `command` +- `phase` +- `durationMs` +- `outcome` (`success` or `error`) +- optional low-cardinality metadata such as `profile`, `delivery`, `extendMode`, or small counts + +Suggested OpenSpec phases: + +- `validate` +- `legacy_cleanup` +- `tool_detection` +- `migration` +- `interactive_prompting` +- `tool_selection` +- `directory_structure` +- `artifact_generation` +- `config_write` +- `success_render` + +## Workflow + +1. Identify the command being profiled. +2. Wrap each phase boundary with a local timer. +3. Ensure failures still emit a timing record in a `finally` block. +4. Write the result to the local storage target. +5. Summarize the slowest phases and repeated bottlenecks for the user. + +## Guardrails + +- Do not add analytics dependencies. +- Do not widen the schema with high-cardinality labels. +- Do not store secrets or user content. +- Do not infer user identity. +- Keep reports readable by the user without external tools. diff --git a/src/core/init.ts b/src/core/init.ts index faf83f658c..cb7607e617 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -995,11 +995,8 @@ export class InitCommand { private async removeSkillDirs(skillsDir: string): Promise { let removed = 0; - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - - const skillDir = path.join(skillsDir, dirName); + for (const skillName of SKILL_NAMES) { + const skillDir = path.join(skillsDir, skillName); try { if (fs.existsSync(skillDir)) { await fs.promises.rm(skillDir, { recursive: true, force: true }); diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index f671b4de73..3eb2092845 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -17,6 +17,7 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, + getPhaseTimingSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -40,7 +41,7 @@ import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js'; export interface SkillTemplateEntry { template: SkillTemplate; dirName: string; - workflowId: string; + workflowId?: string; } /** @@ -70,12 +71,13 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, + { template: getPhaseTimingSkillTemplate(), dirName: 'openspec-phase-timing' }, ]; if (!workflowFilter) return all; const filterSet = new Set(workflowFilter); - return all.filter(entry => filterSet.has(entry.workflowId)); + return all.filter(entry => entry.workflowId === undefined || filterSet.has(entry.workflowId)); } /** diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..9f45f3391b 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import { AI_TOOLS } from '../config.js'; /** - * Names of skill directories created by openspec init. + * Names of skill directories created by openspec init/update. */ export const SKILL_NAMES = [ 'openspec-explore', @@ -24,6 +24,7 @@ export const SKILL_NAMES = [ 'openspec-verify-change', 'openspec-onboard', 'openspec-propose', + 'openspec-phase-timing', ] as const; export type SkillName = (typeof SKILL_NAMES)[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 598fcc4465..8f61d38b44 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -19,3 +19,4 @@ export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './wo export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; +export { getPhaseTimingSkillTemplate } from './workflows/phase-timing.js'; diff --git a/src/core/templates/workflows/phase-timing.ts b/src/core/templates/workflows/phase-timing.ts new file mode 100644 index 0000000000..57576e7178 --- /dev/null +++ b/src/core/templates/workflows/phase-timing.ts @@ -0,0 +1,77 @@ +/** + * Skill Template Workflow Modules + * + * This file is generated by splitting the legacy monolithic + * templates file into workflow-focused modules. + */ +import type { SkillTemplate } from '../types.js'; + +export function getPhaseTimingSkillTemplate(): SkillTemplate { + return { + name: 'openspec-phase-timing', + description: + 'Measure OpenSpec command phase and stage durations locally without external analytics. Use when you need a personal timing trace or local profiling for init, update, validate, or similar flows.', + instructions: `Measure OpenSpec phase durations locally and keep the results on the user's machine. + +This skill is for personal profiling and regression checks, not shared usage analytics. + +## Principles + +- Keep the data local to the user. +- Do not send timings to PostHog or any external service. +- Do not capture file contents, prompts, arguments, or paths unless the user explicitly asks for them in a local report. +- Use monotonic timing (\`performance.now()\` or equivalent) instead of wall-clock time. +- Keep phase names low-cardinality and stable. + +## Default storage + +Write reports to one of these local locations: + +- Repo-local: \`.openspec/telemetry/phase-timings.json\` +- User-local: the platform state directory for OpenSpec, for example \`$XDG_STATE_HOME/openspec/telemetry/\` or the OS equivalent on Windows/macOS + +Prefer the user-local location when the user wants a personal history across repositories. Prefer the repo-local location when the user wants a one-off trace attached to the current checkout. + +## Measurement model + +Record one entry per phase with: + +- \`command\` +- \`phase\` +- \`durationMs\` +- \`outcome\` (\`success\` or \`error\`) +- optional low-cardinality metadata such as \`profile\`, \`delivery\`, \`extendMode\`, or small counts + +Suggested OpenSpec phases: + +- \`validate\` +- \`legacy_cleanup\` +- \`tool_detection\` +- \`migration\` +- \`interactive_prompting\` +- \`tool_selection\` +- \`directory_structure\` +- \`artifact_generation\` +- \`config_write\` +- \`success_render\` + +## Workflow + +1. Identify the command being profiled. +2. Wrap each phase boundary with a local timer. +3. Ensure failures still emit a timing record in a \`finally\` block. +4. Write the result to the local storage target. +5. Summarize the slowest phases and repeated bottlenecks for the user. + +## Guardrails + +- Do not add analytics dependencies. +- Do not widen the schema with high-cardinality labels. +- Do not store secrets or user content. +- Do not infer user identity. +- Keep reports readable by the user without external tools.`, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} diff --git a/src/core/update.ts b/src/core/update.ts index e983dd8383..09f20124b3 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,7 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + SKILL_NAMES, type ToolVersionStatus, } from './shared/index.js'; import { @@ -509,11 +510,8 @@ export class UpdateCommand { private async removeSkillDirs(skillsDir: string): Promise { let removed = 0; - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - - const skillDir = path.join(skillsDir, dirName); + for (const skillName of SKILL_NAMES) { + const skillDir = path.join(skillsDir, skillName); try { if (fs.existsSync(skillDir)) { await fs.promises.rm(skillDir, { recursive: true, force: true }); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index a9b2e6fdb2..8902f36373 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -25,6 +25,7 @@ import { getOpsxProposeSkillTemplate, getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, + getPhaseTimingSkillTemplate, getSyncSpecsSkillTemplate, getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, @@ -62,6 +63,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'd885847ea1af48a2ef41a08f6319888d058d50b81cf5511bda768cd4b59359ee', getOpsxUpdateCommandTemplate: 'cf43a6bdcdc549180970ddde40893223493a55e171a39290731e0339df530975', + getPhaseTimingSkillTemplate: '966c1ba08a043a1ef60e97060da8244049f225bab7c4e1dfab88444cd92f798d', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { @@ -77,6 +79,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-onboard': '1d581c12d4928d751eb79de099e275dabe9c99fc15dc1f502abebd99ad7cb7d2', 'openspec-propose': '4638400113946f4f1ee9f0bd0e965aafb200bd89b64ec7f5406ef5e948e8e218', 'openspec-update-change': '4e6669540bc5332b72db7dd432625cc4b45234ae7674f9b46fcd1309b9697b0d', + 'openspec-phase-timing': 'f1d078037f9ebb5b796faa3260b4dea7425da3c7ee12c661292c95fba45c693e', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -94,6 +97,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], ['openspec-update-change', getUpdateChangeSkillTemplate], + ['openspec-phase-timing', getPhaseTimingSkillTemplate], ]; function stableStringify(value: unknown): string { @@ -144,6 +148,7 @@ describe('skill templates split parity', () => { getFeedbackSkillTemplate, getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate, + getPhaseTimingSkillTemplate, }; const actualHashes = Object.fromEntries( @@ -180,7 +185,11 @@ describe('skill templates split parity', () => { it('teaches store selection in every deployed skill template', () => { for (const { template, dirName } of getSkillTemplates()) { const content = generateSkillContent(template, 'PARITY-BASELINE'); - expect(content, dirName).toContain(STORE_SELECTION_GUIDANCE); + if (dirName === 'openspec-phase-timing') { + expect(content, dirName).not.toContain(STORE_SELECTION_GUIDANCE); + } else { + expect(content, dirName).toContain(STORE_SELECTION_GUIDANCE); + } } });