Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/phase-timing-public-skill.md
Original file line number Diff line number Diff line change
@@ -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.

71 changes: 71 additions & 0 deletions skills/openspec-phase-timing/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 2 additions & 5 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,11 +995,8 @@ export class InitCommand {
private async removeSkillDirs(skillsDir: string): Promise<number> {
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 });
Expand Down
6 changes: 4 additions & 2 deletions src/core/shared/skill-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getVerifyChangeSkillTemplate,
getOnboardSkillTemplate,
getOpsxProposeSkillTemplate,
getPhaseTimingSkillTemplate,
getOpsxExploreCommandTemplate,
getOpsxNewCommandTemplate,
getOpsxContinueCommandTemplate,
Expand All @@ -40,7 +41,7 @@ import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js';
export interface SkillTemplateEntry {
template: SkillTemplate;
dirName: string;
workflowId: string;
workflowId?: string;
}

/**
Expand Down Expand Up @@ -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));
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/core/shared/tool-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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];
Expand Down
1 change: 1 addition & 0 deletions src/core/templates/skill-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
77 changes: 77 additions & 0 deletions src/core/templates/workflows/phase-timing.ts
Original file line number Diff line number Diff line change
@@ -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' },
};
}
8 changes: 3 additions & 5 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getCommandContents,
generateSkillContent,
getToolsWithSkillsDir,
SKILL_NAMES,
type ToolVersionStatus,
} from './shared/index.js';
import {
Expand Down Expand Up @@ -509,11 +510,8 @@ export class UpdateCommand {
private async removeSkillDirs(skillsDir: string): Promise<number> {
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 });
Expand Down
11 changes: 10 additions & 1 deletion test/core/templates/skill-templates-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getOpsxProposeSkillTemplate,
getOpsxUpdateCommandTemplate,
getOpsxVerifyCommandTemplate,
getPhaseTimingSkillTemplate,
getSyncSpecsSkillTemplate,
getUpdateChangeSkillTemplate,
getVerifyChangeSkillTemplate,
Expand Down Expand Up @@ -62,6 +63,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d',
getUpdateChangeSkillTemplate: 'd885847ea1af48a2ef41a08f6319888d058d50b81cf5511bda768cd4b59359ee',
getOpsxUpdateCommandTemplate: 'cf43a6bdcdc549180970ddde40893223493a55e171a39290731e0339df530975',
getPhaseTimingSkillTemplate: '966c1ba08a043a1ef60e97060da8244049f225bab7c4e1dfab88444cd92f798d',
};

const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
Expand All @@ -77,6 +79,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-onboard': '1d581c12d4928d751eb79de099e275dabe9c99fc15dc1f502abebd99ad7cb7d2',
'openspec-propose': '4638400113946f4f1ee9f0bd0e965aafb200bd89b64ec7f5406ef5e948e8e218',
'openspec-update-change': '4e6669540bc5332b72db7dd432625cc4b45234ae7674f9b46fcd1309b9697b0d',
'openspec-phase-timing': 'f1d078037f9ebb5b796faa3260b4dea7425da3c7ee12c661292c95fba45c693e',
};

// Intentionally excludes getFeedbackSkillTemplate: this list only models templates
Expand All @@ -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 {
Expand Down Expand Up @@ -144,6 +148,7 @@ describe('skill templates split parity', () => {
getFeedbackSkillTemplate,
getUpdateChangeSkillTemplate,
getOpsxUpdateCommandTemplate,
getPhaseTimingSkillTemplate,
};

const actualHashes = Object.fromEntries(
Expand Down Expand Up @@ -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);
}
}
});

Expand Down