From 7bc08e238e463e22a4665fa0d873725d01477c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 18:10:25 +0100 Subject: [PATCH 01/34] feat: add extensible lifecycle hooks for schemas and project config (#682) Add hooks system that allows schemas and projects to define LLM instructions at operation lifecycle points (pre/post for new, archive, sync, apply). - Schema YAML and project config support optional `hooks` section - New `openspec hooks` CLI command resolves and returns hooks for a lifecycle point - Without --change, schema is resolved from config.yaml's default schema field - Skill templates updated to call hooks at all lifecycle boundaries - 23 new tests covering parsing, resolution, CLI, and edge cases --- .../add-lifecycle-hooks/.openspec.yaml | 2 + .../changes/add-lifecycle-hooks/design.md | 196 +++++++++++++++++ .../changes/add-lifecycle-hooks/proposal.md | 41 ++++ .../specs/artifact-graph/spec.md | 43 ++++ .../specs/cli-artifact-workflow/spec.md | 44 ++++ .../specs/instruction-loader/spec.md | 31 +++ .../specs/lifecycle-hooks/spec.md | 137 ++++++++++++ .../specs/opsx-archive-skill/spec.md | 33 +++ .../specs/specs-sync-skill/spec.md | 28 +++ openspec/changes/add-lifecycle-hooks/tasks.md | 42 ++++ src/cli/index.ts | 18 ++ src/commands/workflow/hooks.ts | 106 +++++++++ src/commands/workflow/index.ts | 3 + src/core/artifact-graph/index.ts | 8 + src/core/artifact-graph/instruction-loader.ts | 73 +++++++ src/core/artifact-graph/types.ts | 22 ++ src/core/project-config.ts | 43 ++++ src/core/templates/skill-templates.ts | 204 ++++++++++++++---- test/commands/artifact-workflow.test.ts | 114 ++++++++++ .../artifact-graph/instruction-loader.test.ts | 193 +++++++++++++++++ test/core/artifact-graph/schema.test.ts | 83 +++++++ test/core/project-config.test.ts | 149 +++++++++++++ 22 files changed, 1577 insertions(+), 36 deletions(-) create mode 100644 openspec/changes/add-lifecycle-hooks/.openspec.yaml create mode 100644 openspec/changes/add-lifecycle-hooks/design.md create mode 100644 openspec/changes/add-lifecycle-hooks/proposal.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/artifact-graph/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/instruction-loader/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md create mode 100644 openspec/changes/add-lifecycle-hooks/tasks.md create mode 100644 src/commands/workflow/hooks.ts diff --git a/openspec/changes/add-lifecycle-hooks/.openspec.yaml b/openspec/changes/add-lifecycle-hooks/.openspec.yaml new file mode 100644 index 000000000..9bc4ae2f6 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md new file mode 100644 index 000000000..7c26ef733 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -0,0 +1,196 @@ +## Context + +OpenSpec schemas define artifact creation workflows (proposal, specs, design, tasks) with instructions for each artifact. Operations like archive, sync, new, and apply are orchestrated by skills (LLM prompt files) that call CLI commands. There is currently no mechanism for schemas or projects to inject custom behavior at operation lifecycle points. + +The existing `openspec instructions` command already demonstrates the pattern: it reads schema + config, merges them (instruction from schema, context/rules from config), and outputs enriched data for the LLM. Hooks follow the same architecture. + +Key files: +- Schema types: `src/core/artifact-graph/types.ts` (Zod schemas for `SchemaYaml`) +- Schema resolution: `src/core/artifact-graph/resolver.ts` +- Instruction loading: `src/core/artifact-graph/instruction-loader.ts` +- Project config: `src/core/project-config.ts` +- CLI commands: `src/commands/workflow/instructions.ts` +- Skill templates: `src/core/templates/skill-templates.ts` (source of truth, generates agent skills via `openspec update`) + +## Goals / Non-Goals + +**Goals:** +- Allow schemas to define LLM instruction hooks at operation lifecycle points +- Allow projects to add/extend hooks via config.yaml +- Expose hooks via a CLI command for skills to consume +- Update archive skill as first consumer (other skills follow same pattern) + +**Non-Goals:** +- Shell script execution (`run` field) — deferred to future iteration +- Variable substitution in hook instructions — deferred +- Hook-level dependency/ordering between hooks — schema first, config second is sufficient +- Hooks on artifact creation — artifact `instruction` already covers this + +## Decisions + +### Decision 1: Hook YAML structure + +Hooks use a flat key-value structure under a `hooks` key, where each key is a lifecycle point: + +```yaml +# In schema.yaml or config.yaml +hooks: + post-archive: + instruction: | + Review the archived change and generate ADR entries... + pre-apply: + instruction: | + Before implementing, verify all specs are consistent... +``` + +**Why this over nested structure**: Flat keys are simpler to parse, validate, and merge. Each lifecycle point maps to exactly one hook per source (schema or config). No need for arrays of hooks per point — if a schema author needs multiple actions, they write them as a single instruction. + +**Alternative considered**: Array of hooks per lifecycle point (`post-archive: [{instruction: ...}, {instruction: ...}]`). Rejected because it adds complexity without clear benefit — a single instruction can contain multiple steps, and the schema/config split already provides two layers. + +### Decision 2: New CLI command `openspec hooks` + +A new top-level command rather than a flag on `openspec instructions`: + +```bash +openspec hooks [--change ""] [--json] +``` + +The `--change` flag is optional. When provided, hooks are resolved from the change's schema (via metadata) and the project config. When omitted, the schema is resolved from `config.yaml`'s default `schema` field, and hooks are returned from both schema and config. This ensures lifecycle points like `pre-new` (where no change exists yet) still receive schema-level hooks. + +Output (JSON mode, with change): +```json +{ + "lifecyclePoint": "post-archive", + "changeName": "add-dark-mode", + "hooks": [ + { "source": "schema", "instruction": "Generate ADR entries..." }, + { "source": "config", "instruction": "Notify Slack channel..." } + ] +} +``` + +Output (JSON mode, without change — schema resolved from config.yaml): +```json +{ + "lifecyclePoint": "pre-new", + "changeName": null, + "hooks": [ + { "source": "schema", "instruction": "Verify prerequisites before creating change..." }, + { "source": "config", "instruction": "Notify Slack channel..." } + ] +} +``` + +Output (text mode): +``` +## Hooks: post-archive (change: add-dark-mode) + +### From schema (spec-driven) +Generate ADR entries... + +### From config +Notify Slack channel... +``` + +**Why new command over extending `instructions`**: The `instructions` command is artifact-scoped — it takes an artifact ID and returns creation instructions. Hooks are operation-scoped — they relate to lifecycle events, not artifacts. A separate command keeps concerns clean and makes skill integration straightforward. + +**Alternative considered**: `openspec instructions --hook post-archive`. Rejected because it conflates two different concepts (artifact instructions vs operation hooks) in one command. + +### Decision 3: Schema type extension + +Extend `SchemaYamlSchema` in `types.ts` with an optional `hooks` field: + +```typescript +const HookSchema = z.object({ + instruction: z.string().min(1), +}); + +const HooksSchema = z.record(z.string(), HookSchema).optional(); + +// Added to SchemaYamlSchema +hooks: HooksSchema, +``` + +Validation of lifecycle point keys happens at a higher level (in the hook resolution function), not in the Zod schema. This keeps the schema format forward-compatible — new lifecycle points can be added without changing the Zod schema. + +**Why not validate keys in Zod**: Using `z.enum()` for keys would make the schema rigid. A `z.record()` with runtime validation of keys (with warnings for unknown keys) is more resilient and matches the pattern used for config `rules` validation. + +### Decision 4: Config extension + +Extend `ProjectConfigSchema` in `project-config.ts` with the same `hooks` structure: + +```typescript +hooks: z.record(z.string(), z.object({ + instruction: z.string(), +})).optional(), +``` + +Parsed using the same resilient field-by-field approach already used for `rules`. + +### Decision 5: Hook resolution function + +New function in `instruction-loader.ts`: + +```typescript +interface ResolvedHook { + source: 'schema' | 'config'; + instruction: string; +} + +function resolveHooks( + projectRoot: string, + changeName: string | null, + lifecyclePoint: string +): ResolvedHook[] +``` + +This function: +1. If `changeName` is provided, resolves the schema from the change's metadata (via existing `resolveSchemaForChange`) +2. If `changeName` is null, resolves the schema from `config.yaml`'s `schema` field (if configured) +3. Reads schema hooks for the lifecycle point (if a schema was resolved) +4. Reads config hooks for the lifecycle point +5. Returns array: schema hooks first (if any), then config hooks +6. Warns on unrecognized lifecycle points + +### Decision 6: Skill integration pattern + +Skills call the CLI command and follow the returned instructions. Example for archive skill: + +``` +# Before archive operation: +openspec hooks pre-archive --change "" --json +→ If hooks returned, follow each instruction in order + +# [normal archive steps...] + +# After archive operation: +openspec hooks post-archive --change "" --json +→ If hooks returned, follow each instruction in order +``` + +The skill templates in `src/core/templates/skill-templates.ts` are updated to include these steps, and `openspec generate` regenerates the output files. This is the same pattern as how skills already call `openspec instructions` and `openspec status`. + +## Testing Strategy + +Three levels of testing, following existing patterns in the codebase: + +**Unit tests** — Pure logic, no filesystem or CLI: +- `test/core/artifact-graph/schema.test.ts` — Extend with hook parsing tests: valid hooks, missing hooks, empty hooks, invalid instruction +- `test/core/project-config.test.ts` — Extend with config hook parsing: valid, invalid, unknown lifecycle points, resilient parsing +- `test/core/artifact-graph/instruction-loader.test.ts` — Extend with `resolveHooks()` tests: schema only, config only, both (ordering), neither, null changeName (config-only) + +**CLI integration tests** — Run the actual CLI binary: +- `test/commands/artifact-workflow.test.ts` — Extend with `openspec hooks` command tests: with --change, without --change, no hooks found, invalid lifecycle point, JSON output format + +**Skill template tests** — Verify generated content: +- Existing skill template tests (if any) extended to verify hook steps appear in generated output + +## Risks / Trade-offs + +- **[LLM compliance]** Hooks are instructions the LLM should follow, but there's no guarantee it will execute them perfectly. → Mitigation: Same limitation applies to artifact instructions, which work well in practice. Hook instructions should be written as clear, actionable prompts. +- **[Hook sprawl]** Users might define too many hooks, making operations slow. → Mitigation: Start with 8 lifecycle points only. Each hook adds one CLI call + LLM reasoning time, which is bounded. +- **[Schema/config conflict]** Both define hooks for the same point — user might expect override semantics. → Mitigation: Document clearly that both execute (schema first, config second). This is additive, not override. + +## Resolved Questions + +- **Should `openspec hooks` work without `--change`?** Yes. Without `--change`, the schema is resolved from `config.yaml`'s default `schema` field, so both schema and config hooks are returned. This is essential for lifecycle points like `pre-new` where the change doesn't exist yet but the project's default schema is known. If no schema is configured in `config.yaml`, only config hooks are returned. diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md new file mode 100644 index 000000000..c28077dc9 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -0,0 +1,41 @@ +## Why + +OpenSpec schemas define artifact creation instructions but have no way to inject custom behavior at operation lifecycle points (archive, sync, new, apply). Users need project-specific and workflow-specific actions at these points — consolidating error logs on archive, generating ADRs, notifying external systems, updating documentation indexes — but today the only option is manual post-hoc work or hardcoding behavior into skills. + +Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), #328 (Claude Hooks integration), #331 (auto-update project.md on archive), #369 (post-archive actions). + +## What Changes + +- Add a `hooks` section to `schema.yaml` for workflow-level lifecycle hooks (LLM instructions that run at operation boundaries) +- Add a `hooks` section to `config.yaml` for project-level lifecycle hooks +- Create a CLI command to resolve and return hooks for a given lifecycle point. With `--change`, resolves schema from the change's metadata. Without `--change`, resolves schema from `config.yaml`'s default `schema` field. Both modes return schema + config hooks (schema first) +- Update skills (archive, sync, new, apply) to query and execute hooks at their lifecycle points +- Hooks are LLM instructions only in this iteration — no `run` field for shell execution (deferred to future iteration) + +Supported lifecycle points: +- `pre-new` / `post-new` — creating a change +- `pre-archive` / `post-archive` — archiving a change +- `pre-sync` / `post-sync` — syncing delta specs +- `pre-apply` / `post-apply` — implementing tasks + +## Capabilities + +### New Capabilities +- `lifecycle-hooks`: Core hook resolution system — schema/config parsing, merging, and CLI exposure of lifecycle hook instructions + +### Modified Capabilities +- `artifact-graph`: Schema YAML type extended with optional `hooks` section +- `instruction-loader`: New function/command to resolve hooks for a lifecycle point +- `cli-archive`: Archive operation awareness of pre/post hooks (CLI level) +- `opsx-archive-skill`: Archive skill executes hooks at pre/post-archive points +- `specs-sync-skill`: Sync skill executes hooks at pre/post-sync points +- `cli-artifact-workflow`: New `openspec hooks` command registered alongside existing workflow commands + +## Impact + +- **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward compatible (no hooks = no change) +- **Config format**: `config.yaml` gains optional `hooks` field — fully backward compatible +- **CLI**: New `openspec hooks` command to resolve and retrieve hooks for a lifecycle point (with optional `--change` flag) +- **Skills**: Archive, sync, new, and apply skills gain hook execution steps +- **Existing schemas**: Unaffected — `hooks` is optional +- **Tests**: New tests for hook parsing, merging, resolution, and validation diff --git a/openspec/changes/add-lifecycle-hooks/specs/artifact-graph/spec.md b/openspec/changes/add-lifecycle-hooks/specs/artifact-graph/spec.md new file mode 100644 index 000000000..e14dc7ad6 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/artifact-graph/spec.md @@ -0,0 +1,43 @@ +# artifact-graph Delta Spec + +## MODIFIED Requirements + +### Requirement: Schema Loading + +The system SHALL load artifact graph definitions from YAML schema files within schema directories, including an optional `hooks` section. + +#### Scenario: Valid schema loaded + +- **WHEN** a schema directory contains a valid `schema.yaml` file +- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies + +#### Scenario: Schema with hooks section + +- **WHEN** a schema `schema.yaml` contains a `hooks` section +- **THEN** the system parses the hooks alongside artifacts +- **AND** the parsed schema includes the hooks data + +#### Scenario: Invalid schema rejected + +- **WHEN** a schema YAML file is missing required fields +- **THEN** the system throws an error with a descriptive message + +#### Scenario: Cyclic dependencies detected + +- **WHEN** a schema contains cyclic artifact dependencies +- **THEN** the system throws an error listing the artifact IDs in the cycle + +#### Scenario: Invalid dependency reference + +- **WHEN** an artifact's `requires` array references a non-existent artifact ID +- **THEN** the system throws an error identifying the invalid reference + +#### Scenario: Duplicate artifact IDs rejected + +- **WHEN** a schema contains multiple artifacts with the same ID +- **THEN** the system throws an error identifying the duplicate + +#### Scenario: Schema directory not found + +- **WHEN** resolving a schema name that has no corresponding directory +- **THEN** the system throws an error listing available schemas diff --git a/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md new file mode 100644 index 000000000..a1d2b46db --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,44 @@ +# cli-artifact-workflow Delta Spec + +## ADDED Requirements + +### Requirement: Hooks Command + +The system SHALL provide an `openspec hooks` command to retrieve resolved lifecycle hooks for a given point and change. + +#### Scenario: Retrieve hooks with change context + +- **WHEN** user runs `openspec hooks --change ""` +- **THEN** the system resolves the schema from the change's metadata +- **AND** reads hooks from both schema and config +- **AND** outputs the resolved hooks in order (schema first, config second) + +#### Scenario: Retrieve hooks without change context + +- **WHEN** user runs `openspec hooks ` without `--change` +- **THEN** the system resolves the schema from `config.yaml`'s default `schema` field +- **AND** reads hooks from both the resolved schema and config (schema first, config second) +- **AND** sets `changeName` to null in JSON output +- **AND** if no schema is configured in `config.yaml`, returns config hooks only + +#### Scenario: JSON output + +- **WHEN** user runs `openspec hooks [--change ""] --json` +- **THEN** the system outputs JSON with `lifecyclePoint`, `changeName` (string or null), and `hooks` array +- **AND** each hook includes `source` ("schema" or "config") and `instruction` fields + +#### Scenario: Text output + +- **WHEN** user runs `openspec hooks ` without `--json` +- **THEN** the system outputs human-readable formatted hooks grouped by source + +#### Scenario: No hooks found + +- **WHEN** no hooks are defined for the given lifecycle point +- **THEN** the system outputs an empty hooks array in JSON mode +- **AND** an informational message in text mode + +#### Scenario: Invalid lifecycle point + +- **WHEN** the lifecycle point argument is not a recognized value +- **THEN** the system exits with an error listing valid lifecycle points diff --git a/openspec/changes/add-lifecycle-hooks/specs/instruction-loader/spec.md b/openspec/changes/add-lifecycle-hooks/specs/instruction-loader/spec.md new file mode 100644 index 000000000..0b7987b7d --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/instruction-loader/spec.md @@ -0,0 +1,31 @@ +# instruction-loader Delta Spec + +## ADDED Requirements + +### Requirement: Hook Resolution + +The system SHALL resolve lifecycle hooks for a given lifecycle point by reading from both schema and project config, returning them in execution order. + +#### Scenario: Resolve hooks with change context + +- **WHEN** `resolveHooks(projectRoot, changeName, lifecyclePoint)` is called with a non-null `changeName` +- **THEN** the system reads the schema associated with the change +- **AND** reads the project config +- **AND** returns hooks from both sources, schema hooks first, config hooks second + +#### Scenario: Resolve hooks without change context + +- **WHEN** `resolveHooks(projectRoot, null, lifecyclePoint)` is called with null `changeName` +- **THEN** the system resolves the schema from `config.yaml`'s default `schema` field +- **AND** reads hooks from both the resolved schema and project config (schema first, config second) +- **AND** if no schema is configured in `config.yaml`, returns only config hooks + +#### Scenario: No hooks defined + +- **WHEN** neither schema nor config define hooks for the given lifecycle point +- **THEN** the system returns an empty array + +#### Scenario: Hook result structure + +- **WHEN** hooks are resolved +- **THEN** each hook object includes `source` (string: "schema" or "config") and `instruction` (string) diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md new file mode 100644 index 000000000..8fb25c14e --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -0,0 +1,137 @@ +# Lifecycle Hooks Specification + +## Purpose +Lifecycle hooks allow schemas and projects to define LLM instructions that execute at operation boundaries (pre/post archive, sync, new, apply). Schema-level hooks define workflow-inherent behavior; project-level hooks add project-specific customization. Both are surfaced to the LLM via a CLI command. + +## Requirements + +### Requirement: Hook Definition in Schema + +The system SHALL support an optional `hooks` section in `schema.yaml` where each key is a lifecycle point and each value contains an `instruction` field. + +#### Scenario: Schema with hooks defined + +- **WHEN** a `schema.yaml` contains a `hooks` section with valid lifecycle point keys +- **THEN** the system parses each hook with its `instruction` field +- **AND** makes them available for resolution + +#### Scenario: Schema without hooks + +- **WHEN** a `schema.yaml` does not contain a `hooks` section +- **THEN** the system proceeds normally with no hooks +- **AND** no errors are raised + +#### Scenario: Invalid lifecycle point key + +- **WHEN** a `hooks` section contains a key that is not a recognized lifecycle point +- **THEN** the system emits a warning identifying the unrecognized key +- **AND** ignores the invalid entry + +### Requirement: Hook Definition in Project Config + +The system SHALL support an optional `hooks` section in `config.yaml` with the same structure as schema hooks. + +#### Scenario: Config with hooks defined + +- **WHEN** `config.yaml` contains a `hooks` section with valid lifecycle point keys +- **THEN** the system parses each hook with its `instruction` field +- **AND** makes them available for resolution + +#### Scenario: Config without hooks + +- **WHEN** `config.yaml` does not contain a `hooks` section +- **THEN** the system proceeds normally with no hooks + +### Requirement: Valid Lifecycle Points + +The system SHALL recognize the following lifecycle points as valid hook keys: + +- `pre-new`, `post-new` +- `pre-archive`, `post-archive` +- `pre-sync`, `post-sync` +- `pre-apply`, `post-apply` + +#### Scenario: All valid lifecycle points accepted + +- **WHEN** hooks are defined for any of the recognized lifecycle points +- **THEN** the system accepts and stores them without warnings + +#### Scenario: Unknown lifecycle point + +- **WHEN** a hook is defined for an unrecognized lifecycle point (e.g., `post-deploy`) +- **THEN** the system emits a warning: `Unknown lifecycle point: "post-deploy"` +- **AND** ignores the entry + +### Requirement: Hook Resolution Order + +The system SHALL resolve hooks for a given lifecycle point by returning schema hooks first, then config hooks. + +#### Scenario: Both schema and config define hooks for the same point + +- **WHEN** both `schema.yaml` and `config.yaml` define a hook for `post-archive` +- **THEN** the system returns both hooks in order: schema hook first, config hook second +- **AND** each hook is tagged with its source (`schema` or `config`) + +#### Scenario: Only schema defines a hook + +- **WHEN** only `schema.yaml` defines a hook for `post-archive` +- **THEN** the system returns only the schema hook + +#### Scenario: Only config defines a hook + +- **WHEN** only `config.yaml` defines a hook for `post-archive` +- **THEN** the system returns only the config hook + +#### Scenario: No hooks defined for a point + +- **WHEN** neither schema nor config define a hook for a lifecycle point +- **THEN** the system returns an empty list + +### Requirement: Hook CLI Command + +The system SHALL expose a CLI command to retrieve resolved hooks for a given lifecycle point, optionally scoped to a change. + +#### Scenario: Retrieve hooks with change context + +- **WHEN** executing `openspec hooks --change ""` +- **THEN** the system resolves the schema from the change's metadata +- **AND** reads hooks from both schema and config +- **AND** outputs the resolved hooks in order (schema first, config second) + +#### Scenario: Retrieve hooks without change context + +- **WHEN** executing `openspec hooks ` without `--change` +- **THEN** the system resolves the schema from `config.yaml`'s default `schema` field +- **AND** reads hooks from both the resolved schema and config (schema first, config second) +- **AND** sets `changeName` to null in JSON output +- **AND** if no schema is configured in `config.yaml`, returns config hooks only + +#### Scenario: JSON output + +- **WHEN** executing with `--json` flag +- **THEN** the system outputs a JSON object with `lifecyclePoint`, `changeName` (string or null), and `hooks` array +- **AND** each hook includes `source` ("schema" or "config") and `instruction` fields + +#### Scenario: No hooks found + +- **WHEN** no hooks are defined for the given lifecycle point +- **THEN** the system outputs an empty result (empty array in JSON mode, informational message in text mode) + +#### Scenario: Invalid lifecycle point argument + +- **WHEN** the lifecycle point argument is not a recognized value +- **THEN** the system exits with an error listing valid lifecycle points + +### Requirement: Hook Instruction Content + +Hook instructions SHALL be free-form text intended as LLM prompts. The system does not interpret or execute them — it surfaces them for the LLM agent to follow. + +#### Scenario: Multiline instruction + +- **WHEN** a hook instruction contains multiple lines +- **THEN** the system preserves the full content including newlines + +#### Scenario: Instruction with template references + +- **WHEN** a hook instruction references file paths or change context +- **THEN** the system passes the instruction as-is (no variable substitution in this iteration) diff --git a/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md b/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md new file mode 100644 index 000000000..296e99afc --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md @@ -0,0 +1,33 @@ +# opsx-archive-skill Delta Spec + +## ADDED Requirements + +### Requirement: Lifecycle Hook Execution + +The archive skill SHALL execute lifecycle hooks at the pre-archive and post-archive points. + +#### Scenario: Pre-archive hooks exist + +- **WHEN** the agent begins the archive operation +- **AND** hooks are defined for the `pre-archive` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec hooks pre-archive --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) +- **AND** proceeds with the archive operation after completing hook instructions + +#### Scenario: Post-archive hooks exist + +- **WHEN** the archive operation completes successfully (change moved to archive) +- **AND** hooks are defined for the `post-archive` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec hooks post-archive --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) + +#### Scenario: No hooks defined + +- **WHEN** no hooks are defined for `pre-archive` or `post-archive` +- **THEN** the agent proceeds with the archive operation as normal +- **AND** no additional steps are added + +#### Scenario: Hook instruction references change context + +- **WHEN** a hook instruction references the change name, archived location, or change artifacts +- **THEN** the agent uses its knowledge of the current operation context to fulfill the instruction diff --git a/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md b/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md new file mode 100644 index 000000000..9da14baae --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md @@ -0,0 +1,28 @@ +# specs-sync-skill Delta Spec + +## ADDED Requirements + +### Requirement: Lifecycle Hook Execution + +The sync skill SHALL execute lifecycle hooks at the pre-sync and post-sync points. + +#### Scenario: Pre-sync hooks exist + +- **WHEN** the agent begins the sync operation +- **AND** hooks are defined for the `pre-sync` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec hooks pre-sync --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) +- **AND** proceeds with the sync operation after completing hook instructions + +#### Scenario: Post-sync hooks exist + +- **WHEN** the sync operation completes successfully +- **AND** hooks are defined for the `post-sync` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec hooks post-sync --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) + +#### Scenario: No hooks defined + +- **WHEN** no hooks are defined for `pre-sync` or `post-sync` +- **THEN** the agent proceeds with the sync operation as normal +- **AND** no additional steps are added diff --git a/openspec/changes/add-lifecycle-hooks/tasks.md b/openspec/changes/add-lifecycle-hooks/tasks.md new file mode 100644 index 000000000..30366d131 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/tasks.md @@ -0,0 +1,42 @@ +## 1. Schema Type Extension + +- [x] 1.1 Add `HookSchema` and `HooksSchema` Zod types to `src/core/artifact-graph/types.ts` +- [x] 1.2 Add optional `hooks` field to `SchemaYamlSchema` +- [x] 1.3 Export `Hook` and `Hooks` TypeScript types + +## 2. Project Config Extension + +- [x] 2.1 Add optional `hooks` field to `ProjectConfigSchema` in `src/core/project-config.ts` +- [x] 2.2 Add resilient parsing for hooks field (field-by-field, consistent with existing `rules` parsing) +- [x] 2.3 Add validation: warn on unrecognized lifecycle point keys + +## 3. Hook Resolution Function + +- [x] 3.1 Define `ResolvedHook` interface and `VALID_LIFECYCLE_POINTS` constant in `src/core/artifact-graph/types.ts` (exported via index) +- [x] 3.2 Implement `resolveHooks(projectRoot, changeName | null, lifecyclePoint)` function (null changeName = config-only hooks) +- [x] 3.3 Handle edge cases: no schema hooks, no config hooks, no hooks at all, invalid lifecycle point, null changeName + +## 4. CLI Command + +- [x] 4.1 Create `openspec hooks` command in `src/commands/workflow/hooks.ts` +- [x] 4.2 Implement text output format (human-readable) +- [x] 4.3 Implement JSON output format +- [x] 4.4 Add argument validation (lifecycle point must be valid, --change is optional) +- [x] 4.5 Implement config-only mode: when --change is omitted, return only config hooks (no schema resolution) +- [x] 4.6 Register command in CLI entry point (`src/cli/index.ts`) + +## 5. Skill Template Updates + +- [x] 5.1 Update `getArchiveChangeSkillTemplate()` and `getOpsxArchiveCommandTemplate()` in `src/core/templates/skill-templates.ts` to call `openspec hooks pre-archive` and `openspec hooks post-archive` +- [x] 5.2 Update apply skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-apply points +- [x] 5.3 Update new change skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-new points +- [x] 5.4 Update sync skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-sync points +- [x] 5.5 Regenerate agent skills with `openspec update` to update generated files + +## 6. Tests + +- [x] 6.1 Unit: Extend `test/core/artifact-graph/schema.test.ts` — schema parsing with hooks (valid, missing, empty, invalid instruction) +- [x] 6.2 Unit: Extend `test/core/project-config.test.ts` — config hooks parsing (valid, invalid, unknown lifecycle points, resilient field-by-field) +- [x] 6.3 Unit: Extend `test/core/artifact-graph/instruction-loader.test.ts` — `resolveHooks()` (schema only, config only, both with ordering, neither, null changeName = config-only) +- [x] 6.4 CLI integration: Extend `test/commands/artifact-workflow.test.ts` — `openspec hooks` command (with --change, without --change, no hooks, invalid lifecycle point, JSON output) +- [x] 6.5 Verify existing tests still pass (no regressions from schema/config type changes) diff --git a/src/cli/index.ts b/src/cli/index.ts index 006f21c36..9bfd7abe5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,12 +23,14 @@ import { templatesCommand, schemasCommand, newChangeCommand, + hooksCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, + type HooksOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; @@ -487,6 +489,22 @@ program } }); +// Hooks command +program + .command('hooks [lifecycle-point]') + .description('Retrieve resolved lifecycle hooks for a given point') + .option('--change ', 'Change name (omit for config-only hooks)') + .option('--json', 'Output as JSON (for agent use)') + .action(async (lifecyclePoint: string | undefined, options: HooksOptions) => { + try { + await hooksCommand(lifecyclePoint, options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + // New command group with change subcommand const newCmd = program.command('new').description('Create new items'); diff --git a/src/commands/workflow/hooks.ts b/src/commands/workflow/hooks.ts new file mode 100644 index 000000000..65f46fc87 --- /dev/null +++ b/src/commands/workflow/hooks.ts @@ -0,0 +1,106 @@ +/** + * Hooks Command + * + * Retrieves resolved lifecycle hooks for a given lifecycle point. + * Merges schema hooks (if change provided) and config hooks. + */ + +import ora from 'ora'; +import { + resolveHooks, + VALID_LIFECYCLE_POINTS, + type ResolvedHook, +} from '../../core/artifact-graph/index.js'; +import { validateChangeExists } from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface HooksOptions { + change?: string; + json?: boolean; +} + +export interface HooksOutput { + lifecyclePoint: string; + changeName: string | null; + hooks: ResolvedHook[]; +} + +// ----------------------------------------------------------------------------- +// Command +// ----------------------------------------------------------------------------- + +export async function hooksCommand( + lifecyclePoint: string | undefined, + options: HooksOptions +): Promise { + const spinner = ora('Resolving hooks...').start(); + + try { + const projectRoot = process.cwd(); + + // Validate lifecycle point + if (!lifecyclePoint) { + spinner.stop(); + throw new Error( + `Missing required argument . Valid points:\n ${VALID_LIFECYCLE_POINTS.join('\n ')}` + ); + } + + const validPoints = new Set(VALID_LIFECYCLE_POINTS); + if (!validPoints.has(lifecyclePoint)) { + spinner.stop(); + throw new Error( + `Invalid lifecycle point: "${lifecyclePoint}". Valid points:\n ${VALID_LIFECYCLE_POINTS.join('\n ')}` + ); + } + + // Resolve change name if provided + let changeName: string | null = null; + if (options.change) { + changeName = await validateChangeExists(options.change, projectRoot); + } + + const hooks = resolveHooks(projectRoot, changeName, lifecyclePoint); + + spinner.stop(); + + const output: HooksOutput = { + lifecyclePoint, + changeName, + hooks, + }; + + if (options.json) { + console.log(JSON.stringify(output, null, 2)); + return; + } + + printHooksText(output); + } catch (error) { + spinner.stop(); + throw error; + } +} + +function printHooksText(output: HooksOutput): void { + const { lifecyclePoint, changeName, hooks } = output; + + const context = changeName ? `change: ${changeName}` : 'project-wide'; + console.log(`## Hooks: ${lifecyclePoint} (${context})`); + console.log(); + + if (hooks.length === 0) { + console.log('No hooks defined for this lifecycle point.'); + return; + } + + for (const hook of hooks) { + const label = hook.source === 'schema' ? 'From schema' : 'From config'; + console.log(`### ${label}`); + console.log(hook.instruction.trim()); + console.log(); + } +} diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 232b2dbe3..dae2d8253 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,4 +19,7 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; +export { hooksCommand } from './hooks.js'; +export type { HooksOptions } from './hooks.js'; + export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index 2322fec78..edc93d193 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -1,8 +1,14 @@ // Types export { ArtifactSchema, + HookSchema, + HooksSchema, SchemaYamlSchema, + VALID_LIFECYCLE_POINTS, type Artifact, + type Hook, + type Hooks, + type LifecyclePoint, type SchemaYaml, type CompletedSet, type BlockedArtifacts, @@ -35,10 +41,12 @@ export { loadChangeContext, generateInstructions, formatChangeStatus, + resolveHooks, TemplateLoadError, type ChangeContext, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, + type ResolvedHook, } from './instruction-loader.js'; diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index e5852b43d..f36bebd07 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -6,6 +6,7 @@ import { detectCompleted } from './state.js'; import { resolveSchemaForChange } from '../../utils/change-metadata.js'; import { readProjectConfig, validateConfigRules } from '../project-config.js'; import type { Artifact, CompletedSet } from './types.js'; +import { VALID_LIFECYCLE_POINTS } from './types.js'; // Session-level cache for validation warnings (avoid repeating same warnings) const shownWarnings = new Set(); @@ -361,3 +362,75 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus { artifacts: artifactStatuses, }; } + +// ----------------------------------------------------------------------------- +// Hook Resolution +// ----------------------------------------------------------------------------- + +/** + * A resolved lifecycle hook with its source and instruction. + */ +export interface ResolvedHook { + /** Where this hook was defined */ + source: 'schema' | 'config'; + /** LLM instruction to follow at this lifecycle point */ + instruction: string; +} + +/** + * Resolves lifecycle hooks for a given lifecycle point. + * + * Resolution order: + * 1. Schema hooks (from the change's schema, or from config.yaml's default schema) + * 2. Config hooks (from project config.yaml) + * + * @param projectRoot - Project root directory + * @param changeName - Change name (null = resolve schema from config.yaml) + * @param lifecyclePoint - The lifecycle point to resolve hooks for + * @returns Array of resolved hooks in execution order (schema first, config second) + */ +export function resolveHooks( + projectRoot: string, + changeName: string | null, + lifecyclePoint: string +): ResolvedHook[] { + const validPoints = new Set(VALID_LIFECYCLE_POINTS); + if (!validPoints.has(lifecyclePoint)) { + const valid = VALID_LIFECYCLE_POINTS.join(', '); + throw new Error(`Invalid lifecycle point: "${lifecyclePoint}". Valid points: ${valid}`); + } + + const hooks: ResolvedHook[] = []; + const config = readProjectConfig(projectRoot); + + // 1. Schema hooks + // If a change is specified, resolve schema from the change's metadata. + // Otherwise, resolve schema from config.yaml's default schema. + let schemaName: string | undefined; + if (changeName) { + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); + schemaName = resolveSchemaForChange(changeDir); + } else { + schemaName = config?.schema ?? undefined; + } + + if (schemaName) { + const schema = resolveSchema(schemaName, projectRoot); + if (schema.hooks?.[lifecyclePoint]) { + hooks.push({ + source: 'schema', + instruction: schema.hooks[lifecyclePoint].instruction, + }); + } + } + + // 2. Config hooks + if (config?.hooks?.[lifecyclePoint]) { + hooks.push({ + source: 'config', + instruction: config.hooks[lifecyclePoint].instruction, + }); + } + + return hooks; +} diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index fb0d12703..155472fe5 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -20,6 +20,14 @@ export const ApplyPhaseSchema = z.object({ instruction: z.string().optional(), }); +// Single lifecycle hook definition +export const HookSchema = z.object({ + instruction: z.string().min(1, { error: 'Hook instruction is required' }), +}); + +// Hooks section: record of lifecycle point → hook definition +export const HooksSchema = z.record(z.string(), HookSchema); + // Full schema YAML structure export const SchemaYamlSchema = z.object({ name: z.string().min(1, { error: 'Schema name is required' }), @@ -28,11 +36,15 @@ export const SchemaYamlSchema = z.object({ artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }), // Optional apply phase configuration (for schema-aware apply instructions) apply: ApplyPhaseSchema.optional(), + // Optional lifecycle hooks (LLM instructions at operation boundaries) + hooks: HooksSchema.optional(), }); // Derived TypeScript types export type Artifact = z.infer; export type ApplyPhase = z.infer; +export type Hook = z.infer; +export type Hooks = z.infer; export type SchemaYaml = z.infer; // Per-change metadata schema @@ -53,6 +65,16 @@ export const ChangeMetadataSchema = z.object({ export type ChangeMetadata = z.infer; +// Valid lifecycle points for hooks +export const VALID_LIFECYCLE_POINTS = [ + 'pre-new', 'post-new', + 'pre-archive', 'post-archive', + 'pre-sync', 'post-sync', + 'pre-apply', 'post-apply', +] as const; + +export type LifecyclePoint = typeof VALID_LIFECYCLE_POINTS[number]; + // Runtime state types (not Zod - internal only) // Slice 1: Simple completion tracking via filesystem diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 6c1ea04a5..bf5b97596 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import { VALID_LIFECYCLE_POINTS } from './artifact-graph/types.js'; /** * Zod schema for project configuration. @@ -38,6 +39,15 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional: lifecycle hooks (LLM instructions at operation boundaries) + hooks: z + .record( + z.string(), // lifecycle point (e.g., "post-archive") + z.object({ instruction: z.string().min(1) }) + ) + .optional() + .describe('Lifecycle hooks keyed by lifecycle point'), }); export type ProjectConfig = z.infer; @@ -152,6 +162,39 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse hooks field + if (raw.hooks !== undefined) { + if (typeof raw.hooks === 'object' && raw.hooks !== null && !Array.isArray(raw.hooks)) { + const parsedHooks: Record = {}; + let hasValidHooks = false; + const validPoints = new Set(VALID_LIFECYCLE_POINTS); + + for (const [point, hook] of Object.entries(raw.hooks)) { + // Warn on unrecognized lifecycle points + if (!validPoints.has(point)) { + console.warn(`Unknown lifecycle point in hooks: "${point}". Valid points: ${VALID_LIFECYCLE_POINTS.join(', ')}`); + continue; + } + + const hookResult = z.object({ instruction: z.string().min(1) }).safeParse(hook); + if (hookResult.success) { + parsedHooks[point] = hookResult.data; + hasValidHooks = true; + } else { + console.warn( + `Invalid hook for '${point}': instruction must be a non-empty string, ignoring` + ); + } + } + + if (hasValidHooks) { + config.hooks = parsedHooks; + } + } else { + console.warn(`Invalid 'hooks' field in config (must be object)`); + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 481611930..81dc1727b 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -343,20 +343,36 @@ export function getNewChangeSkillTemplate(): SkillTemplate { **Otherwise**: Omit \`--schema\` to use the default. -3. **Create the change directory** +3. **Execute pre-new hooks** + + Run \`openspec hooks pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +4. **Create the change directory** \`\`\`bash openspec new change "" \`\`\` Add \`--schema \` only if the user requested a specific workflow. This creates a scaffolded change at \`openspec/changes//\` with the selected schema. -4. **Show the artifact status** +5. **Execute post-new hooks** + + Run \`openspec hooks post-new --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +6. **Show the artifact status** \`\`\`bash openspec status --change "" \`\`\` This shows which artifacts need to be created and which are ready (dependencies satisfied). -5. **Get instructions for the first artifact** +7. **Get instructions for the first artifact** The first artifact depends on the schema (e.g., \`proposal\` for spec-driven). Check the status output to find the first artifact with status "ready". \`\`\`bash @@ -364,7 +380,7 @@ export function getNewChangeSkillTemplate(): SkillTemplate { \`\`\` This outputs the template and context for creating the first artifact. -6. **STOP and wait for user direction** +8. **STOP and wait for user direction** **Output** @@ -531,7 +547,15 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). -2. **Check status to understand the schema** +2. **Execute pre-apply hooks** + + Run \`openspec hooks pre-apply --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Check status to understand the schema** \`\`\`bash openspec status --change "" --json \`\`\` @@ -539,7 +563,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - \`schemaName\`: The workflow being used (e.g., "spec-driven") - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) -3. **Get apply instructions** +4. **Get apply instructions** \`\`\`bash openspec instructions apply --change "" --json @@ -556,14 +580,14 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation -4. **Read context files** +5. **Read context files** Read the files listed in \`contextFiles\` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output -5. **Show current progress** +6. **Show current progress** Display: - Schema being used @@ -571,7 +595,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - Remaining tasks overview - Dynamic instruction from CLI -6. **Implement tasks (loop until done or blocked)** +7. **Implement tasks (loop until done or blocked)** For each pending task: - Show which task is being worked on @@ -586,7 +610,15 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { - Error or blocker encountered → report and wait for guidance - User interrupts -7. **On completion or pause, show status** +8. **Execute post-apply hooks** + + Run \`openspec hooks post-apply --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +9. **On completion or pause, show status** Display: - Tasks completed this session @@ -795,7 +827,15 @@ This is an **agent-driven** operation - you will read delta specs and directly e **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. -2. **Find delta specs** +2. **Execute pre-sync hooks** + + Run \`openspec hooks pre-sync --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Find delta specs** Look for delta spec files in \`openspec/changes//specs/*/spec.md\`. @@ -807,7 +847,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e If no delta specs found, inform user and stop. -3. **For each delta spec, apply changes to main specs** +4. **For each delta spec, apply changes to main specs** For each capability with a delta spec at \`openspec/changes//specs//spec.md\`: @@ -840,7 +880,15 @@ This is an **agent-driven** operation - you will read delta specs and directly e - Add Purpose section (can be brief, mark as TBD) - Add Requirements section with the ADDED requirements -4. **Show summary** +5. **Execute post-sync hooks** + + Run \`openspec hooks post-sync --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +6. **Show summary** After applying all changes, summarize: - Which capabilities were updated @@ -1686,27 +1734,43 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { **Otherwise**: Omit \`--schema\` to use the default. -3. **Create the change directory** +3. **Execute pre-new hooks** + + Run \`openspec hooks pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +4. **Create the change directory** \`\`\`bash openspec new change "" \`\`\` Add \`--schema \` only if the user requested a specific workflow. This creates a scaffolded change at \`openspec/changes//\` with the selected schema. -4. **Show the artifact status** +5. **Execute post-new hooks** + + Run \`openspec hooks post-new --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +6. **Show the artifact status** \`\`\`bash openspec status --change "" \`\`\` This shows which artifacts need to be created and which are ready (dependencies satisfied). -5. **Get instructions for the first artifact** +7. **Get instructions for the first artifact** The first artifact depends on the schema. Check the status output to find the first artifact with status "ready". \`\`\`bash openspec instructions --change "" \`\`\` This outputs the template and context for creating the first artifact. -6. **STOP and wait for user direction** +8. **STOP and wait for user direction** **Output** @@ -1869,7 +1933,15 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { Always announce: "Using change: " and how to override (e.g., \`/opsx:apply \`). -2. **Check status to understand the schema** +2. **Execute pre-apply hooks** + + Run \`openspec hooks pre-apply --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Check status to understand the schema** \`\`\`bash openspec status --change "" --json \`\`\` @@ -1877,7 +1949,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - \`schemaName\`: The workflow being used (e.g., "spec-driven") - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) -3. **Get apply instructions** +4. **Get apply instructions** \`\`\`bash openspec instructions apply --change "" --json @@ -1894,14 +1966,14 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation -4. **Read context files** +5. **Read context files** Read the files listed in \`contextFiles\` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output -5. **Show current progress** +6. **Show current progress** Display: - Schema being used @@ -1909,7 +1981,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - Remaining tasks overview - Dynamic instruction from CLI -6. **Implement tasks (loop until done or blocked)** +7. **Implement tasks (loop until done or blocked)** For each pending task: - Show which task is being worked on @@ -1924,7 +1996,15 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { - Error or blocker encountered → report and wait for guidance - User interrupts -7. **On completion or pause, show status** +8. **Execute post-apply hooks** + + Run \`openspec hooks post-apply --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +9. **On completion or pause, show status** Display: - Tasks completed this session @@ -2125,7 +2205,15 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. -2. **Check artifact completion status** +2. **Execute pre-archive hooks** + + Run \`openspec hooks pre-archive --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Check artifact completion status** Run \`openspec status --change "" --json\` to check artifact completion. @@ -2138,7 +2226,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { - Use **AskUserQuestion tool** to confirm user wants to proceed - Proceed if user confirms -3. **Check task completion status** +4. **Check task completion status** Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks. @@ -2151,7 +2239,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { **If no tasks file exists:** Proceed without task-related warning. -4. **Assess delta spec sync state** +5. **Assess delta spec sync state** Check for delta specs at \`openspec/changes//specs/\`. If none exist, proceed without sync prompt. @@ -2166,7 +2254,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. -5. **Perform the archive** +6. **Perform the archive** Create the archive directory if it doesn't exist: \`\`\`bash @@ -2183,7 +2271,17 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- \`\`\` -6. **Display summary** +7. **Execute post-archive hooks** + + Run \`openspec hooks post-archive --change "" --json\` to check for lifecycle hooks. + + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec hooks post-archive --json\` (config-only hooks). + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +8. **Display summary** Show archive completion summary including: - Change name @@ -2493,7 +2591,15 @@ This is an **agent-driven** operation - you will read delta specs and directly e **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. -2. **Find delta specs** +2. **Execute pre-sync hooks** + + Run \`openspec hooks pre-sync --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Find delta specs** Look for delta spec files in \`openspec/changes//specs/*/spec.md\`. @@ -2505,7 +2611,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e If no delta specs found, inform user and stop. -3. **For each delta spec, apply changes to main specs** +4. **For each delta spec, apply changes to main specs** For each capability with a delta spec at \`openspec/changes//specs//spec.md\`: @@ -2538,7 +2644,15 @@ This is an **agent-driven** operation - you will read delta specs and directly e - Add Purpose section (can be brief, mark as TBD) - Add Requirements section with the ADDED requirements -4. **Show summary** +5. **Execute post-sync hooks** + + Run \`openspec hooks post-sync --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +6. **Show summary** After applying all changes, summarize: - Which capabilities were updated @@ -2802,7 +2916,15 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. -2. **Check artifact completion status** +2. **Execute pre-archive hooks** + + Run \`openspec hooks pre-archive --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + + If the \`hooks\` array is empty, skip this step. + +3. **Check artifact completion status** Run \`openspec status --change "" --json\` to check artifact completion. @@ -2815,7 +2937,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { - Prompt user for confirmation to continue - Proceed if user confirms -3. **Check task completion status** +4. **Check task completion status** Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks. @@ -2828,7 +2950,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { **If no tasks file exists:** Proceed without task-related warning. -4. **Assess delta spec sync state** +5. **Assess delta spec sync state** Check for delta specs at \`openspec/changes//specs/\`. If none exist, proceed without sync prompt. @@ -2843,7 +2965,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. -5. **Perform the archive** +6. **Perform the archive** Create the archive directory if it doesn't exist: \`\`\`bash @@ -2860,7 +2982,17 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- \`\`\` -6. **Display summary** +7. **Execute post-archive hooks** + + Run \`openspec hooks post-archive --change "" --json\` to check for lifecycle hooks. + + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec hooks post-archive --json\` (config-only hooks). + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + + If the \`hooks\` array is empty, skip this step. + +8. **Display summary** Show archive completion summary including: - Change name diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 181629940..5d2a2921e 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -841,5 +841,119 @@ context: Updated context expect(result2.stdout).not.toContain('Initial context'); }, 60000); }); + + describe('hooks command', () => { + it('should show hooks for a lifecycle point with config hooks', async () => { + // Create config.yaml with hooks + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `hooks: + pre-archive: + instruction: "Run pre-archive check" +` + ); + + // Run hooks command without --change flag + const result = await runCLI(['hooks', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); + expect(result.exitCode).toBe(0); + + const output = getOutput(result); + expect(output).toContain('Run pre-archive check'); + expect(output).toContain('From config'); + }, 60000); + + it('should output JSON format', async () => { + // Create config.yaml with hooks + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `hooks: + pre-archive: + instruction: "Run pre-archive check" +` + ); + + // Run hooks command with --json flag + const result = await runCLI( + ['hooks', 'pre-archive', '--json'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result.exitCode).toBe(0); + + const jsonData = JSON.parse(result.stdout); + + expect(jsonData.lifecyclePoint).toBe('pre-archive'); + expect(jsonData.changeName).toBeNull(); + expect(Array.isArray(jsonData.hooks)).toBe(true); + expect(jsonData.hooks.length).toBe(1); + expect(jsonData.hooks[0].source).toBe('config'); + expect(jsonData.hooks[0].instruction).toBe('Run pre-archive check'); + }, 60000); + + it('should show no hooks when none defined', async () => { + // Run hooks command without any config hooks + const result = await runCLI(['hooks', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); + expect(result.exitCode).toBe(0); + + const output = getOutput(result); + expect(output).toContain('No hooks defined'); + }, 60000); + + it('should error on invalid lifecycle point', async () => { + // Run hooks command with invalid lifecycle point + const result = await runCLI(['hooks', 'invalid-point'], { cwd: tempDir, timeoutMs: 30000 }); + expect(result.exitCode).toBe(1); + + const output = getOutput(result); + expect(output).toContain('Invalid lifecycle point'); + }, 60000); + + it('should error on missing lifecycle point argument', async () => { + // Run hooks command without lifecycle point argument + const result = await runCLI(['hooks'], { cwd: tempDir, timeoutMs: 30000 }); + expect(result.exitCode).toBe(1); + + const output = getOutput(result); + // Should contain an error about missing argument + expect(output.toLowerCase()).toMatch(/missing|required|argument/); + }, 60000); + + it('should show hooks with --change flag', async () => { + // Create config.yaml with hooks + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `hooks: + pre-archive: + instruction: "Run pre-archive check" +` + ); + + // Create a test change + await createTestChange('test-change'); + + // Run hooks command with --change flag + const result = await runCLI( + ['hooks', 'pre-archive', '--change', 'test-change'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result.exitCode).toBe(0); + + const output = getOutput(result); + expect(output).toContain('Run pre-archive check'); + + // Also test JSON output to verify changeName + const jsonResult = await runCLI( + ['hooks', 'pre-archive', '--change', 'test-change', '--json'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(jsonResult.exitCode).toBe(0); + + const jsonData = JSON.parse(jsonResult.stdout); + + expect(jsonData.lifecyclePoint).toBe('pre-archive'); + expect(jsonData.changeName).toBe('test-change'); + expect(Array.isArray(jsonData.hooks)).toBe(true); + expect(jsonData.hooks.length).toBeGreaterThan(0); + }, 60000); + }); }); }); diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index 9d8f612cd..fb725fae3 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -8,6 +8,9 @@ import { generateInstructions, formatChangeStatus, TemplateLoadError, + resolveHooks, + ResolvedHook, + VALID_LIFECYCLE_POINTS, } from '../../../src/core/artifact-graph/instruction-loader.js'; describe('instruction-loader', () => { @@ -606,4 +609,194 @@ rules: expect(specsIdx).toBeLessThan(tasksIdx); }); }); + + describe('resolveHooks', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Helper to create a custom schema with hooks in the test project. + */ + function createSchemaWithHooks( + schemaName: string, + hooks: Record + ): void { + const schemaDir = path.join(tempDir, 'openspec', 'schemas', schemaName); + fs.mkdirSync(schemaDir, { recursive: true }); + + const schemaContent = `name: ${schemaName} +version: 1 +artifacts: + - id: proposal + generates: proposal.md + description: Test proposal + template: templates/proposal.md +${Object.keys(hooks).length > 0 ? 'hooks:\n' : ''}${Object.entries(hooks) + .map(([point, hook]) => ` ${point}:\n instruction: "${hook.instruction}"`) + .join('\n')} +`; + + fs.writeFileSync(path.join(schemaDir, 'schema.yaml'), schemaContent); + + // Create a minimal template + const templatesDir = path.join(schemaDir, 'templates'); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync(path.join(templatesDir, 'proposal.md'), '# Proposal'); + } + + /** + * Helper to create a change that uses a specific schema. + */ + function createChangeWithSchema(changeName: string, schemaName: string): void { + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync( + path.join(changeDir, '.openspec.yaml'), + `schema: ${schemaName}\ncreated: "2025-01-05"\n` + ); + } + + /** + * Helper to create a project config with hooks. + */ + function createConfigWithHooks(hooks: Record): void { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + + const configContent = `schema: spec-driven +${Object.keys(hooks).length > 0 ? 'hooks:\n' : ''}${Object.entries(hooks) + .map(([point, hook]) => ` ${point}:\n instruction: "${hook.instruction}"`) + .join('\n')} +`; + + fs.writeFileSync(path.join(configDir, 'config.yaml'), configContent); + } + + it('should return empty array when no hooks defined', () => { + // Create a change using built-in spec-driven schema (no hooks) + createChangeWithSchema('my-change', 'spec-driven'); + + const hooks = resolveHooks(tempDir, 'my-change', 'pre-archive'); + + expect(hooks).toEqual([]); + }); + + it('should return schema hooks when defined', () => { + // Create custom schema with pre-archive hook + createSchemaWithHooks('test-schema', { + 'pre-archive': { instruction: 'Schema pre-archive hook' }, + }); + + // Create change pointing to custom schema + createChangeWithSchema('my-change', 'test-schema'); + + const hooks = resolveHooks(tempDir, 'my-change', 'pre-archive'); + + expect(hooks).toHaveLength(1); + expect(hooks[0]).toEqual({ + source: 'schema', + instruction: 'Schema pre-archive hook', + }); + }); + + it('should return config hooks when defined', () => { + // Create config with pre-archive hook + createConfigWithHooks({ + 'pre-archive': { instruction: 'Config hook' }, + }); + + // No change name (null) - only config hooks + const hooks = resolveHooks(tempDir, null, 'pre-archive'); + + expect(hooks).toHaveLength(1); + expect(hooks[0]).toEqual({ + source: 'config', + instruction: 'Config hook', + }); + }); + + it('should return schema hooks first, then config hooks', () => { + // Create custom schema with pre-archive hook + createSchemaWithHooks('test-schema', { + 'pre-archive': { instruction: 'Schema hook' }, + }); + + // Create change pointing to custom schema + createChangeWithSchema('my-change', 'test-schema'); + + // Create config with pre-archive hook + createConfigWithHooks({ + 'pre-archive': { instruction: 'Config hook' }, + }); + + const hooks = resolveHooks(tempDir, 'my-change', 'pre-archive'); + + expect(hooks).toHaveLength(2); + expect(hooks[0]).toEqual({ + source: 'schema', + instruction: 'Schema hook', + }); + expect(hooks[1]).toEqual({ + source: 'config', + instruction: 'Config hook', + }); + }); + + it('should return only config hooks when changeName is null', () => { + // Create custom schema with hooks (should be ignored) + createSchemaWithHooks('test-schema', { + 'pre-archive': { instruction: 'Schema hook' }, + }); + + // Create change pointing to custom schema (should be ignored) + createChangeWithSchema('my-change', 'test-schema'); + + // Create config with pre-archive hook + createConfigWithHooks({ + 'pre-archive': { instruction: 'Config hook' }, + }); + + // Pass null for changeName - should only return config hooks + const hooks = resolveHooks(tempDir, null, 'pre-archive'); + + expect(hooks).toHaveLength(1); + expect(hooks[0]).toEqual({ + source: 'config', + instruction: 'Config hook', + }); + }); + + it('should throw on invalid lifecycle point', () => { + createChangeWithSchema('my-change', 'spec-driven'); + + expect(() => resolveHooks(tempDir, 'my-change', 'invalid-point')).toThrow( + /Invalid lifecycle point/ + ); + expect(() => resolveHooks(tempDir, 'my-change', 'invalid-point')).toThrow( + /invalid-point/ + ); + }); + + it('should return empty array when lifecycle point has no hooks', () => { + // Create custom schema with hooks for pre-archive only + createSchemaWithHooks('test-schema', { + 'pre-archive': { instruction: 'Schema pre-archive hook' }, + }); + + // Create change pointing to custom schema + createChangeWithSchema('my-change', 'test-schema'); + + // Query for different lifecycle point (post-sync) + const hooks = resolveHooks(tempDir, 'my-change', 'post-sync'); + + expect(hooks).toEqual([]); + }); + }); }); diff --git a/test/core/artifact-graph/schema.test.ts b/test/core/artifact-graph/schema.test.ts index 069216a3a..ca953c2b4 100644 --- a/test/core/artifact-graph/schema.test.ts +++ b/test/core/artifact-graph/schema.test.ts @@ -203,5 +203,88 @@ artifacts: const schema = parseSchema(yaml); expect(schema.artifacts[0].requires).toEqual([]); }); + + describe('hooks parsing', () => { + it('should parse schema with valid hooks', () => { + const yaml = ` +name: test-schema +version: 1 +description: A test schema +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal + template: templates/proposal.md + requires: [] +hooks: + pre-archive: + instruction: Run pre-archive validation + post-archive: + instruction: Run post-archive cleanup +`; + const schema = parseSchema(yaml); + + expect(schema.hooks).toBeDefined(); + expect(schema.hooks).toHaveProperty('pre-archive'); + expect(schema.hooks).toHaveProperty('post-archive'); + expect(schema.hooks?.['pre-archive'].instruction).toBe('Run pre-archive validation'); + expect(schema.hooks?.['post-archive'].instruction).toBe('Run post-archive cleanup'); + }); + + it('should parse schema without hooks', () => { + const yaml = ` +name: test-schema +version: 1 +description: A test schema +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal + template: templates/proposal.md + requires: [] +`; + const schema = parseSchema(yaml); + + expect(schema.hooks).toBeUndefined(); + }); + + it('should parse schema with empty hooks', () => { + const yaml = ` +name: test-schema +version: 1 +description: A test schema +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal + template: templates/proposal.md + requires: [] +hooks: {} +`; + const schema = parseSchema(yaml); + + expect(schema.hooks).toBeDefined(); + expect(Object.keys(schema.hooks || {})).toHaveLength(0); + }); + + it('should reject hook with empty instruction', () => { + const yaml = ` +name: test-schema +version: 1 +description: A test schema +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal + template: templates/proposal.md + requires: [] +hooks: + pre-archive: + instruction: "" +`; + expect(() => parseSchema(yaml)).toThrow(SchemaValidationError); + expect(() => parseSchema(yaml)).toThrow(/instruction/); + }); + }); }); }); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 88944659d..c88663d53 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -480,6 +480,155 @@ rules: ]); }); }); + + describe('hooks parsing', () => { + it('should parse valid hooks', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +hooks: + pre-archive: + instruction: "Run cleanup" + post-archive: + instruction: "Notify team" +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + hooks: { + 'pre-archive': { instruction: 'Run cleanup' }, + 'post-archive': { instruction: 'Notify team' }, + }, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should ignore hooks with unknown lifecycle points', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +hooks: + invalid-point: + instruction: "something" + pre-archive: + instruction: "valid" +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + hooks: { + 'pre-archive': { instruction: 'valid' }, + }, + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown lifecycle point') + ); + }); + + it('should skip hooks with empty instruction', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +hooks: + pre-archive: + instruction: "" +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + }); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('should handle hooks that is not an object', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +hooks: "not an object" +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid') + ); + }); + + it('should parse config with hooks alongside other fields', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +context: "Project context here" +rules: + proposal: + - Valid rule one + - Valid rule two +hooks: + pre-sync: + instruction: "Backup data" + post-apply: + instruction: "Deploy changes" +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + context: 'Project context here', + rules: { + proposal: ['Valid rule one', 'Valid rule two'], + }, + hooks: { + 'pre-sync': { instruction: 'Backup data' }, + 'post-apply': { instruction: 'Deploy changes' }, + }, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should handle hooks: null gracefully', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +context: "Valid context" +hooks: +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + context: 'Valid context', + }); + }); + }); }); describe('validateConfigRules', () => { From 1544bf691d6f63ea3f1d9a4606f392c19bbc3cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 18:23:17 +0100 Subject: [PATCH 02/34] fix(test): assert specific warning message for empty hook instruction --- test/core/project-config.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index c88663d53..c50ef37a7 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -552,7 +552,9 @@ hooks: expect(config).toEqual({ schema: 'spec-driven', }); - expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('instruction must be a non-empty string') + ); }); it('should handle hooks that is not an object', () => { From 98b2928d64bd2e96ddf7ea7289174037c1b06828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 18:24:05 +0100 Subject: [PATCH 03/34] docs: improve readability of proposal use case examples --- openspec/changes/add-lifecycle-hooks/proposal.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index c28077dc9..baac2eed6 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -1,6 +1,12 @@ ## Why -OpenSpec schemas define artifact creation instructions but have no way to inject custom behavior at operation lifecycle points (archive, sync, new, apply). Users need project-specific and workflow-specific actions at these points — consolidating error logs on archive, generating ADRs, notifying external systems, updating documentation indexes — but today the only option is manual post-hoc work or hardcoding behavior into skills. +OpenSpec schemas define artifact creation instructions but have no way to inject custom behavior at operation lifecycle points (archive, sync, new, apply). Users need project-specific and workflow-specific actions at these points, such as: +- Consolidating error logs on archive +- Generating ADRs +- Notifying external systems +- Updating documentation indexes + +Today the only option is manual post-hoc work or hardcoding behavior into skills. Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), #328 (Claude Hooks integration), #331 (auto-update project.md on archive), #369 (post-archive actions). From 320f57bb3ea7d28b5c956b30c5ee61b57c769d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 18:26:39 +0100 Subject: [PATCH 04/34] docs: add examples, schema vs config rationale, and validation details to proposal --- .../changes/add-lifecycle-hooks/proposal.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index baac2eed6..fda41066b 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -24,6 +24,36 @@ Supported lifecycle points: - `pre-sync` / `post-sync` — syncing delta specs - `pre-apply` / `post-apply` — implementing tasks +### Example: schema.yaml +```yaml +name: feature-change +hooks: + post-archive: + instruction: | + Review the archived change and update the project changelog with key decisions + pre-apply: + instruction: | + Verify all prerequisite tasks are complete before implementation +``` + +### Example: config.yaml +```yaml +schema: spec-driven +hooks: + post-new: + instruction: | + Notify the team in Slack about the new change + pre-sync: + instruction: | + Ensure local working directory is clean +``` + +### Schema vs. Config hooks + +- **Schema hooks**: Workflow-specific instructions that travel with the schema (e.g., "generate ADR on archive") +- **Config hooks**: Project-specific instructions that apply across all schemas in that project (e.g., "notify team on sync") +- **Merge strategy**: Schema hooks run first, then config hooks, allowing projects to add context-specific behavior on top of workflow defaults + ## Capabilities ### New Capabilities @@ -39,9 +69,12 @@ Supported lifecycle points: ## Impact -- **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward compatible (no hooks = no change) -- **Config format**: `config.yaml` gains optional `hooks` field — fully backward compatible +- **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward-compatible (no hooks = no change) +- **Config format**: `config.yaml` gains optional `hooks` field — fully backward-compatible - **CLI**: New `openspec hooks` command to resolve and retrieve hooks for a lifecycle point (with optional `--change` flag) - **Skills**: Archive, sync, new, and apply skills gain hook execution steps - **Existing schemas**: Unaffected — `hooks` is optional - **Tests**: New tests for hook parsing, merging, resolution, and validation +- **Validation**: Hook keys are validated against `VALID_LIFECYCLE_POINTS` at parse time; unknown keys emit warnings +- **Error handling**: Malformed hooks (empty instruction, non-object values) are skipped with warnings — resilient field-by-field parsing +- **Security**: Hooks are LLM instructions only (no shell execution in this iteration), limiting the security surface From a98c70ad93ab5f8f6b6fe29bce325896e6337f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 18:40:56 +0100 Subject: [PATCH 05/34] docs: clarify config schema field reference in proposal --- openspec/changes/add-lifecycle-hooks/proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index fda41066b..6f6445ee5 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -14,7 +14,7 @@ Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), # - Add a `hooks` section to `schema.yaml` for workflow-level lifecycle hooks (LLM instructions that run at operation boundaries) - Add a `hooks` section to `config.yaml` for project-level lifecycle hooks -- Create a CLI command to resolve and return hooks for a given lifecycle point. With `--change`, resolves schema from the change's metadata. Without `--change`, resolves schema from `config.yaml`'s default `schema` field. Both modes return schema + config hooks (schema first) +- Create a CLI command to resolve and return hooks for a given lifecycle point. With `--change`, resolves schema from the change's metadata. Without `--change`, resolves schema from the `schema` field in `config.yaml`. Both modes return schema + config hooks (schema first) - Update skills (archive, sync, new, apply) to query and execute hooks at their lifecycle points - Hooks are LLM instructions only in this iteration — no `run` field for shell execution (deferred to future iteration) From f5116db2a7fdf6b462b65a06fe30d21cc4057ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 19:19:44 +0100 Subject: [PATCH 06/34] test: add CLI integration test for schema + config hooks merge ordering --- test/commands/artifact-workflow.test.ts | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 5d2a2921e..b984f01ce 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -917,6 +917,66 @@ context: Updated context expect(output.toLowerCase()).toMatch(/missing|required|argument/); }, 60000); + it('should return schema hooks before config hooks', async () => { + // Create a custom schema with hooks + const userDataDir = path.join(tempDir, 'user-data-hooks'); + const schemaDir = path.join(userDataDir, 'openspec', 'schemas', 'hooked'); + const templatesDir = path.join(schemaDir, 'templates'); + await fs.mkdir(templatesDir, { recursive: true }); + + const schemaContent = ` +name: hooked +version: 1 +description: Schema with hooks +artifacts: + - id: proposal + generates: proposal.md + description: Proposal + template: proposal.md + requires: [] +hooks: + pre-archive: + instruction: "Schema hook: run pre-archive validation" +`; + await fs.writeFile(path.join(schemaDir, 'schema.yaml'), schemaContent); + await fs.writeFile(path.join(templatesDir, 'proposal.md'), '# Proposal\n'); + + // Create config.yaml with hooks and schema reference + await fs.writeFile( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: hooked +hooks: + pre-archive: + instruction: "Config hook: notify team" +` + ); + + // Create a change using the hooked schema + const changeDir = await createTestChange('hooked-change'); + await fs.writeFile( + path.join(changeDir, '.openspec.yaml'), + 'schema: hooked\n' + ); + + // Run hooks command with --change and --json + const result = await runCLI( + ['hooks', 'pre-archive', '--change', 'hooked-change', '--json'], + { + cwd: tempDir, + timeoutMs: 30000, + env: { XDG_DATA_HOME: userDataDir }, + } + ); + expect(result.exitCode).toBe(0); + + const jsonData = JSON.parse(result.stdout); + expect(jsonData.hooks.length).toBe(2); + expect(jsonData.hooks[0].source).toBe('schema'); + expect(jsonData.hooks[0].instruction).toContain('Schema hook'); + expect(jsonData.hooks[1].source).toBe('config'); + expect(jsonData.hooks[1].instruction).toContain('Config hook'); + }, 60000); + it('should show hooks with --change flag', async () => { // Create config.yaml with hooks await fs.writeFile( From 43763cc47205ff68151d5f24fdd8bf823527406b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 19:27:22 +0100 Subject: [PATCH 07/34] chore: ignore and untrack .github/ directory --- .github/CODEOWNERS | 2 - .github/workflows/README.md | 20 -- .github/workflows/ci.yml | 324 -------------------------- .github/workflows/release-prepare.yml | 60 ----- 4 files changed, 406 deletions(-) delete mode 100644 .github/CODEOWNERS delete mode 100644 .github/workflows/README.md delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release-prepare.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index e066888ea..000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -# Default code ownership -* @TabishB diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index 9befe1c4a..000000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Github Workflows - -## Testing CI Locally - -Test GitHub Actions workflows locally using [act](https://nektosact.com/): - -```bash -# Test all PR checks -act pull_request - -# Test specific job -act pull_request -j nix-flake-validate - -# Dry run to see what would execute -act pull_request --dryrun -``` - -The `.actrc` file configures act to use the appropriate Docker image. - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9d435a46f..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,324 +0,0 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - # Detect which files changed to enable path-based filtering - changes: - name: Detect changes - runs-on: ubuntu-latest - outputs: - nix: ${{ steps.filter.outputs.nix }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check for Nix-related changes - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - nix: - - 'flake.nix' - - 'flake.lock' - - 'package.json' - - 'pnpm-lock.yaml' - - 'scripts/update-flake.sh' - - '.github/workflows/ci.yml' - - test_pr: - name: Test - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.event_name == 'pull_request' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build project - run: pnpm run build - - - name: Run tests - run: pnpm test - - - name: Upload test coverage - uses: actions/upload-artifact@v4 - with: - name: coverage-report-pr - path: coverage/ - retention-days: 7 - - test_matrix: - name: Test (${{ matrix.label }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - if: github.event_name != 'pull_request' - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - shell: bash - label: linux-bash - - os: macos-latest - shell: bash - label: macos-bash - - os: windows-latest - shell: pwsh - label: windows-pwsh - - defaults: - run: - shell: ${{ matrix.shell }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Print environment diagnostics - run: | - node -p "JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build project - run: pnpm run build - - - name: Run tests - run: pnpm test - - - name: Upload test coverage - if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 - with: - name: coverage-report-main - path: coverage/ - retention-days: 7 - - lint: - name: Lint & Type Check - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build project - run: pnpm run build - - - name: Type check - run: pnpm exec tsc --noEmit - - - name: Lint - run: pnpm lint - - - name: Check for build artifacts - run: | - if [ ! -d "dist" ]; then - echo "Error: dist directory not found after build" - exit 1 - fi - if [ ! -f "dist/cli/index.js" ]; then - echo "Error: CLI entry point not found" - exit 1 - fi - - nix-flake-validate: - name: Nix Flake Validation - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: changes - if: needs.changes.outputs.nix == 'true' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v21 - - - name: Setup Nix cache - uses: DeterminateSystems/magic-nix-cache-action@v13 - - - name: Build with Nix - run: nix build - - - name: Verify build output - run: | - if [ ! -e "result" ]; then - echo "Error: Nix build output 'result' symlink not found" - exit 1 - fi - if [ ! -f "result/bin/openspec" ]; then - echo "Error: openspec binary not found in build output" - exit 1 - fi - echo "✅ Build output verified" - - - name: Test binary execution - run: | - VERSION=$(nix run . -- --version) - echo "OpenSpec version: $VERSION" - if [ -z "$VERSION" ]; then - echo "Error: Version command returned empty output" - exit 1 - fi - echo "✅ Binary execution successful" - - - name: Validate update script - run: | - echo "Testing update-flake.sh script..." - bash scripts/update-flake.sh - echo "✅ Update script executed successfully" - - - name: Check flake.nix modifications - run: | - if git diff --quiet flake.nix; then - echo "ℹ️ flake.nix unchanged (hash already up-to-date)" - else - echo "✅ flake.nix was updated by script" - git diff flake.nix - fi - - - name: Restore flake.nix - if: always() - run: git checkout -- flake.nix || true - - validate-changesets: - name: Validate Changesets - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Validate changesets - run: | - if command -v changeset &> /dev/null; then - pnpm exec changeset status --since=origin/main - else - echo "Changesets not configured, skipping validation" - fi - - required-checks-pr: - name: All checks passed - runs-on: ubuntu-latest - needs: [test_pr, lint, nix-flake-validate] - if: always() && github.event_name == 'pull_request' - steps: - - name: Verify all checks passed - run: | - if [[ "${{ needs.test_pr.result }}" != "success" ]]; then - echo "Test job failed" - exit 1 - fi - if [[ "${{ needs.lint.result }}" != "success" ]]; then - echo "Lint job failed" - exit 1 - fi - # Nix validation may be skipped if no Nix-related files changed - if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then - echo "Nix flake validation job failed" - exit 1 - fi - if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then - echo "Nix flake validation skipped (no Nix-related changes)" - fi - echo "All required checks passed!" - - required-checks-main: - name: All checks passed - runs-on: ubuntu-latest - needs: [test_matrix, lint, nix-flake-validate] - if: always() && github.event_name != 'pull_request' - steps: - - name: Verify all checks passed - run: | - if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then - echo "Matrix test job failed" - exit 1 - fi - if [[ "${{ needs.lint.result }}" != "success" ]]; then - echo "Lint job failed" - exit 1 - fi - # Nix validation may be skipped if no Nix-related files changed - if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then - echo "Nix flake validation job failed" - exit 1 - fi - if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then - echo "Nix flake validation skipped (no Nix-related changes)" - fi - echo "All required checks passed!" diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml deleted file mode 100644 index 0a58d8e87..000000000 --- a/.github/workflows/release-prepare.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release (prepare) - -on: - push: - branches: [main] - -permissions: - contents: write - pull-requests: write - id-token: write # Required for npm OIDC trusted publishing - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'Fission-AI/OpenSpec' - runs-on: ubuntu-latest - steps: - # Generate GitHub App token first - used for checkout and changesets - # This allows git operations to trigger CI workflows on the version PR - # (GITHUB_TOKEN cannot trigger workflows by design) - - name: Generate GitHub App Token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - uses: pnpm/action-setup@v4 - with: - version: 9 - - - uses: actions/setup-node@v4 - with: - node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC - cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - - - run: pnpm install --frozen-lockfile - - # Opens/updates the Version Packages PR; publishes when the Version PR merges - - name: Create/Update Version PR - id: changesets - uses: changesets/action@v1 - with: - title: 'chore(release): version packages' - createGithubReleases: true - # Use CI-specific release script: relies on version PR having been merged - # so package.json already contains the bumped version. - publish: pnpm run release:ci - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # npm authentication handled via OIDC trusted publishing (no token needed) From 2934d7ba3516a131d382951128d18a4066548ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 19:30:08 +0100 Subject: [PATCH 08/34] chore: add .github/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index abea4d16e..ce8b6a348 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ result # OpenCode .opencode/ opencode.json + +# GitHub (prompts/skills are generated, workflows managed upstream) +.github/ From 2a85b0bc8d929664e1ff7f8d0e7c7060fcf12aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 19:50:46 +0100 Subject: [PATCH 09/34] chore: narrow .gitignore to only generated .github files --- .github/CODEOWNERS | 2 + .github/workflows/README.md | 20 ++ .github/workflows/ci.yml | 324 ++++++++++++++++++++++++++ .github/workflows/release-prepare.yml | 60 +++++ .gitignore | 5 +- 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-prepare.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..e066888ea --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default code ownership +* @TabishB diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..9befe1c4a --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,20 @@ +# Github Workflows + +## Testing CI Locally + +Test GitHub Actions workflows locally using [act](https://nektosact.com/): + +```bash +# Test all PR checks +act pull_request + +# Test specific job +act pull_request -j nix-flake-validate + +# Dry run to see what would execute +act pull_request --dryrun +``` + +The `.actrc` file configures act to use the appropriate Docker image. + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9d435a46f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,324 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Detect which files changed to enable path-based filtering + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + nix: ${{ steps.filter.outputs.nix }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check for Nix-related changes + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + nix: + - 'flake.nix' + - 'flake.lock' + - 'package.json' + - 'pnpm-lock.yaml' + - 'scripts/update-flake.sh' + - '.github/workflows/ci.yml' + + test_pr: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project + run: pnpm run build + + - name: Run tests + run: pnpm test + + - name: Upload test coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-report-pr + path: coverage/ + retention-days: 7 + + test_matrix: + name: Test (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + shell: bash + label: linux-bash + - os: macos-latest + shell: bash + label: macos-bash + - os: windows-latest + shell: pwsh + label: windows-pwsh + + defaults: + run: + shell: ${{ matrix.shell }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Print environment diagnostics + run: | + node -p "JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project + run: pnpm run build + + - name: Run tests + run: pnpm test + + - name: Upload test coverage + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-main + path: coverage/ + retention-days: 7 + + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project + run: pnpm run build + + - name: Type check + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Check for build artifacts + run: | + if [ ! -d "dist" ]; then + echo "Error: dist directory not found after build" + exit 1 + fi + if [ ! -f "dist/cli/index.js" ]; then + echo "Error: CLI entry point not found" + exit 1 + fi + + nix-flake-validate: + name: Nix Flake Validation + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: changes + if: needs.changes.outputs.nix == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v21 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v13 + + - name: Build with Nix + run: nix build + + - name: Verify build output + run: | + if [ ! -e "result" ]; then + echo "Error: Nix build output 'result' symlink not found" + exit 1 + fi + if [ ! -f "result/bin/openspec" ]; then + echo "Error: openspec binary not found in build output" + exit 1 + fi + echo "✅ Build output verified" + + - name: Test binary execution + run: | + VERSION=$(nix run . -- --version) + echo "OpenSpec version: $VERSION" + if [ -z "$VERSION" ]; then + echo "Error: Version command returned empty output" + exit 1 + fi + echo "✅ Binary execution successful" + + - name: Validate update script + run: | + echo "Testing update-flake.sh script..." + bash scripts/update-flake.sh + echo "✅ Update script executed successfully" + + - name: Check flake.nix modifications + run: | + if git diff --quiet flake.nix; then + echo "ℹ️ flake.nix unchanged (hash already up-to-date)" + else + echo "✅ flake.nix was updated by script" + git diff flake.nix + fi + + - name: Restore flake.nix + if: always() + run: git checkout -- flake.nix || true + + validate-changesets: + name: Validate Changesets + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate changesets + run: | + if command -v changeset &> /dev/null; then + pnpm exec changeset status --since=origin/main + else + echo "Changesets not configured, skipping validation" + fi + + required-checks-pr: + name: All checks passed + runs-on: ubuntu-latest + needs: [test_pr, lint, nix-flake-validate] + if: always() && github.event_name == 'pull_request' + steps: + - name: Verify all checks passed + run: | + if [[ "${{ needs.test_pr.result }}" != "success" ]]; then + echo "Test job failed" + exit 1 + fi + if [[ "${{ needs.lint.result }}" != "success" ]]; then + echo "Lint job failed" + exit 1 + fi + # Nix validation may be skipped if no Nix-related files changed + if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then + echo "Nix flake validation job failed" + exit 1 + fi + if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then + echo "Nix flake validation skipped (no Nix-related changes)" + fi + echo "All required checks passed!" + + required-checks-main: + name: All checks passed + runs-on: ubuntu-latest + needs: [test_matrix, lint, nix-flake-validate] + if: always() && github.event_name != 'pull_request' + steps: + - name: Verify all checks passed + run: | + if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then + echo "Matrix test job failed" + exit 1 + fi + if [[ "${{ needs.lint.result }}" != "success" ]]; then + echo "Lint job failed" + exit 1 + fi + # Nix validation may be skipped if no Nix-related files changed + if [[ "${{ needs.nix-flake-validate.result }}" != "success" && "${{ needs.nix-flake-validate.result }}" != "skipped" ]]; then + echo "Nix flake validation job failed" + exit 1 + fi + if [[ "${{ needs.nix-flake-validate.result }}" == "skipped" ]]; then + echo "Nix flake validation skipped (no Nix-related changes)" + fi + echo "All required checks passed!" diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 000000000..0a58d8e87 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,60 @@ +name: Release (prepare) + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + id-token: write # Required for npm OIDC trusted publishing + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + if: github.repository == 'Fission-AI/OpenSpec' + runs-on: ubuntu-latest + steps: + # Generate GitHub App token first - used for checkout and changesets + # This allows git operations to trigger CI workflows on the version PR + # (GITHUB_TOKEN cannot trigger workflows by design) + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - run: pnpm install --frozen-lockfile + + # Opens/updates the Version Packages PR; publishes when the Version PR merges + - name: Create/Update Version PR + id: changesets + uses: changesets/action@v1 + with: + title: 'chore(release): version packages' + createGithubReleases: true + # Use CI-specific release script: relies on version PR having been merged + # so package.json already contains the bumped version. + publish: pnpm run release:ci + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + # npm authentication handled via OIDC trusted publishing (no token needed) diff --git a/.gitignore b/.gitignore index ce8b6a348..dc038cf73 100644 --- a/.gitignore +++ b/.gitignore @@ -154,5 +154,6 @@ result .opencode/ opencode.json -# GitHub (prompts/skills are generated, workflows managed upstream) -.github/ +# GitHub generated files (openspec update) +.github/prompts/ +.github/skills/ From 28ce2160e89ad1a5fd0adc3ddb3cc3b0b8a24dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Mon, 9 Feb 2026 19:51:42 +0100 Subject: [PATCH 10/34] chore: add .codex/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index dc038cf73..bbb6cce6c 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,9 @@ result .opencode/ opencode.json +# Codex +.codex/ + # GitHub generated files (openspec update) .github/prompts/ .github/skills/ From f13ba9a3c6300a1a5b3270d1aea22823d942d2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 10:13:25 +0100 Subject: [PATCH 11/34] feat: merge hooks into instructions command and add pre/post-verify lifecycle points Consolidate the standalone `openspec hooks` command into `openspec instructions --hook `, add pre-verify and post-verify lifecycle points (10 total), update all skill templates to use the new invocation, and document the unified instructions command. --- docs/cli.md | 40 ++++++--- .../changes/add-lifecycle-hooks/design.md | 77 +++++++++++------ .../changes/add-lifecycle-hooks/proposal.md | 39 +++++---- .../specs/cli-artifact-workflow/spec.md | 21 +++-- .../specs/lifecycle-hooks/spec.md | 20 +++-- .../specs/opsx-archive-skill/spec.md | 4 +- .../specs/opsx-verify-skill/spec.md | 33 ++++++++ .../specs/specs-sync-skill/spec.md | 4 +- openspec/changes/add-lifecycle-hooks/tasks.md | 51 ++++++----- openspec/config.yaml | 14 ++++ src/cli/index.ts | 35 ++++---- src/core/artifact-graph/types.ts | 5 +- src/core/templates/skill-templates.ts | 84 ++++++++++++------- test/commands/artifact-workflow.test.ts | 60 ++++++++----- 14 files changed, 326 insertions(+), 161 deletions(-) create mode 100644 openspec/changes/add-lifecycle-hooks/specs/opsx-verify-skill/spec.md diff --git a/docs/cli.md b/docs/cli.md index e064e9dac..228bf8036 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -458,7 +458,7 @@ Next: Create design using /opsx:continue ### `openspec instructions` -Get enriched instructions for creating an artifact or applying tasks. Used by AI agents to understand what to create next. +Get enriched instructions for creating an artifact, applying tasks, or retrieving lifecycle hooks. Used by AI agents to understand what to do next. ``` openspec instructions [artifact] [options] @@ -474,34 +474,52 @@ openspec instructions [artifact] [options] | Option | Description | |--------|-------------| -| `--change ` | Change name (required in non-interactive mode) | +| `--change ` | Change name (required for artifact mode; optional for hook mode) | | `--schema ` | Schema override | +| `--hook ` | Retrieve lifecycle hooks for a given point (mutually exclusive with `[artifact]`) | | `--json` | Output as JSON | -**Special case:** Use `apply` as the artifact to get task implementation instructions. +This command has three modes: + +**Artifact mode** (`openspec instructions --change `): Returns instructions for creating a specific artifact, including template, dependencies, and project context. + +**Apply mode** (`openspec instructions apply --change `): Returns task implementation instructions with progress tracking and context files. + +**Hook mode** (`openspec instructions --hook [--change ]`): Returns lifecycle hooks for a given point. With `--change`, resolves hooks from the change's schema and project config. Without `--change`, resolves from `config.yaml`'s default schema and config. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. + +Valid lifecycle points: `pre-new`, `post-new`, `pre-apply`, `post-apply`, `pre-verify`, `post-verify`, `pre-sync`, `post-sync`, `pre-archive`, `post-archive`. **Examples:** ```bash -# Get instructions for next artifact -openspec instructions --change add-dark-mode - # Get specific artifact instructions openspec instructions design --change add-dark-mode # Get apply/implementation instructions openspec instructions apply --change add-dark-mode +# Get lifecycle hooks for a point (with change context) +openspec instructions --hook pre-archive --change add-dark-mode --json + +# Get lifecycle hooks (project-wide, no change context) +openspec instructions --hook post-new --json + # JSON for agent consumption openspec instructions design --change add-dark-mode --json ``` -**Output includes:** +**Hook output (JSON):** -- Template content for the artifact -- Project context from config -- Content from dependency artifacts -- Per-artifact rules from config +```json +{ + "lifecyclePoint": "pre-archive", + "changeName": "add-dark-mode", + "hooks": [ + { "source": "schema", "instruction": "Generate ADR entries..." }, + { "source": "config", "instruction": "Notify Slack channel..." } + ] +} +``` --- diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md index 7c26ef733..4ea6cbfc8 100644 --- a/openspec/changes/add-lifecycle-hooks/design.md +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -1,8 +1,8 @@ ## Context -OpenSpec schemas define artifact creation workflows (proposal, specs, design, tasks) with instructions for each artifact. Operations like archive, sync, new, and apply are orchestrated by skills (LLM prompt files) that call CLI commands. There is currently no mechanism for schemas or projects to inject custom behavior at operation lifecycle points. +OpenSpec schemas define artifact creation workflows (proposal, specs, design, tasks) with instructions for each artifact. Operations like archive, sync, new, apply, and verify are orchestrated by skills (LLM prompt files) that call CLI commands. There is currently no mechanism for schemas or projects to inject custom behavior at operation lifecycle points. -The existing `openspec instructions` command already demonstrates the pattern: it reads schema + config, merges them (instruction from schema, context/rules from config), and outputs enriched data for the LLM. Hooks follow the same architecture. +The existing `openspec instructions` command already demonstrates the pattern: it reads schema + config, merges them, and outputs enriched data for the LLM. It already supports two modes — artifact instructions (`openspec instructions `) and apply instructions (`openspec instructions apply`). Hooks follow the same architecture and fit naturally as a third mode via `--hook`. Key files: - Schema types: `src/core/artifact-graph/types.ts` (Zod schemas for `SchemaYaml`) @@ -10,6 +10,7 @@ Key files: - Instruction loading: `src/core/artifact-graph/instruction-loader.ts` - Project config: `src/core/project-config.ts` - CLI commands: `src/commands/workflow/instructions.ts` +- Hook resolution: `src/commands/workflow/hooks.ts` (internal module) - Skill templates: `src/core/templates/skill-templates.ts` (source of truth, generates agent skills via `openspec update`) ## Goals / Non-Goals @@ -17,8 +18,8 @@ Key files: **Goals:** - Allow schemas to define LLM instruction hooks at operation lifecycle points - Allow projects to add/extend hooks via config.yaml -- Expose hooks via a CLI command for skills to consume -- Update archive skill as first consumer (other skills follow same pattern) +- Expose hooks via `openspec instructions --hook` for skills to consume +- Update all operation skills (archive, sync, new, apply, verify) to execute hooks **Non-Goals:** - Shell script execution (`run` field) — deferred to future iteration @@ -38,25 +39,29 @@ hooks: post-archive: instruction: | Review the archived change and generate ADR entries... - pre-apply: + pre-verify: instruction: | - Before implementing, verify all specs are consistent... + Run the full test suite before verification begins... ``` **Why this over nested structure**: Flat keys are simpler to parse, validate, and merge. Each lifecycle point maps to exactly one hook per source (schema or config). No need for arrays of hooks per point — if a schema author needs multiple actions, they write them as a single instruction. **Alternative considered**: Array of hooks per lifecycle point (`post-archive: [{instruction: ...}, {instruction: ...}]`). Rejected because it adds complexity without clear benefit — a single instruction can contain multiple steps, and the schema/config split already provides two layers. -### Decision 2: New CLI command `openspec hooks` +### Decision 2: `--hook` flag on `openspec instructions` -A new top-level command rather than a flag on `openspec instructions`: +Hooks are exposed as a `--hook ` flag on the existing `instructions` command rather than as a separate top-level command: ```bash -openspec hooks [--change ""] [--json] +openspec instructions --hook [--change ""] [--json] ``` +The `--hook` flag is mutually exclusive with the `[artifact]` positional argument. If both are provided, the command exits with an error: `"--hook cannot be used with an artifact argument"`. + The `--change` flag is optional. When provided, hooks are resolved from the change's schema (via metadata) and the project config. When omitted, the schema is resolved from `config.yaml`'s default `schema` field, and hooks are returned from both schema and config. This ensures lifecycle points like `pre-new` (where no change exists yet) still receive schema-level hooks. +Hook resolution logic lives in `src/commands/workflow/hooks.ts` as an internal module. The `instructions` command imports and delegates to it when `--hook` is present. + Output (JSON mode, with change): ```json { @@ -92,9 +97,7 @@ Generate ADR entries... Notify Slack channel... ``` -**Why new command over extending `instructions`**: The `instructions` command is artifact-scoped — it takes an artifact ID and returns creation instructions. Hooks are operation-scoped — they relate to lifecycle events, not artifacts. A separate command keeps concerns clean and makes skill integration straightforward. - -**Alternative considered**: `openspec instructions --hook post-archive`. Rejected because it conflates two different concepts (artifact instructions vs operation hooks) in one command. +**Why `--hook` on `instructions` instead of separate command**: The `instructions` command is the single entry point for "what does the LLM need right now". It already has two modes (artifact and apply), and `apply` is already operation-scoped rather than artifact-scoped. Adding hooks as a third mode is consistent. Fewer top-level commands keeps the CLI surface clean. ### Decision 3: Schema type extension @@ -152,45 +155,71 @@ This function: 5. Returns array: schema hooks first (if any), then config hooks 6. Warns on unrecognized lifecycle points -### Decision 6: Skill integration pattern +### Decision 6: Valid lifecycle points -Skills call the CLI command and follow the returned instructions. Example for archive skill: +10 lifecycle points covering all operations: + +``` +pre-new post-new — creating a change +pre-apply post-apply — implementing tasks +pre-verify post-verify — verifying implementation +pre-sync post-sync — syncing delta specs +pre-archive post-archive — archiving a change +``` + +These are defined in `VALID_LIFECYCLE_POINTS` in `types.ts` and validated at runtime. + +### Decision 7: Skill integration pattern + +Skills call `openspec instructions --hook` and follow the returned instructions. Example for archive skill: ``` # Before archive operation: -openspec hooks pre-archive --change "" --json +openspec instructions --hook pre-archive --change "" --json → If hooks returned, follow each instruction in order # [normal archive steps...] # After archive operation: -openspec hooks post-archive --change "" --json +openspec instructions --hook post-archive --change "" --json → If hooks returned, follow each instruction in order ``` -The skill templates in `src/core/templates/skill-templates.ts` are updated to include these steps, and `openspec generate` regenerates the output files. This is the same pattern as how skills already call `openspec instructions` and `openspec status`. +The same pattern applies to all skills: new, apply, verify, sync, archive. + +The skill templates in `src/core/templates/skill-templates.ts` are updated to include these steps, and `openspec update` regenerates the output files. This is the same pattern as how skills already call `openspec instructions` and `openspec status`. + +### Decision 8: Documentation + +The `instructions` command gets documented with all three modes: +- Artifact mode: `openspec instructions --change ` +- Apply mode: `openspec instructions apply --change ` +- Hook mode: `openspec instructions --hook [--change ]` + +Documentation covers the mutual exclusivity constraint, the hook resolution order (schema first, config second), and examples for each mode. ## Testing Strategy Three levels of testing, following existing patterns in the codebase: **Unit tests** — Pure logic, no filesystem or CLI: -- `test/core/artifact-graph/schema.test.ts` — Extend with hook parsing tests: valid hooks, missing hooks, empty hooks, invalid instruction -- `test/core/project-config.test.ts` — Extend with config hook parsing: valid, invalid, unknown lifecycle points, resilient parsing -- `test/core/artifact-graph/instruction-loader.test.ts` — Extend with `resolveHooks()` tests: schema only, config only, both (ordering), neither, null changeName (config-only) +- `test/core/artifact-graph/schema.test.ts` — Hook parsing tests: valid hooks, missing hooks, empty hooks, invalid instruction +- `test/core/project-config.test.ts` — Config hook parsing: valid, invalid, unknown lifecycle points, resilient parsing +- `test/core/artifact-graph/instruction-loader.test.ts` — `resolveHooks()` tests: schema only, config only, both (ordering), neither, null changeName (config-only) **CLI integration tests** — Run the actual CLI binary: -- `test/commands/artifact-workflow.test.ts` — Extend with `openspec hooks` command tests: with --change, without --change, no hooks found, invalid lifecycle point, JSON output format +- `test/commands/artifact-workflow.test.ts` — `openspec instructions --hook` tests: with --change, without --change, no hooks found, invalid lifecycle point, JSON output format, mutual exclusivity error with positional artifact **Skill template tests** — Verify generated content: -- Existing skill template tests (if any) extended to verify hook steps appear in generated output +- Existing skill template tests extended to verify hook steps appear in generated output ## Risks / Trade-offs - **[LLM compliance]** Hooks are instructions the LLM should follow, but there's no guarantee it will execute them perfectly. → Mitigation: Same limitation applies to artifact instructions, which work well in practice. Hook instructions should be written as clear, actionable prompts. -- **[Hook sprawl]** Users might define too many hooks, making operations slow. → Mitigation: Start with 8 lifecycle points only. Each hook adds one CLI call + LLM reasoning time, which is bounded. +- **[Hook sprawl]** Users might define too many hooks, making operations slow. → Mitigation: Start with 10 lifecycle points only. Each hook adds one CLI call + LLM reasoning time, which is bounded. - **[Schema/config conflict]** Both define hooks for the same point — user might expect override semantics. → Mitigation: Document clearly that both execute (schema first, config second). This is additive, not override. ## Resolved Questions -- **Should `openspec hooks` work without `--change`?** Yes. Without `--change`, the schema is resolved from `config.yaml`'s default `schema` field, so both schema and config hooks are returned. This is essential for lifecycle points like `pre-new` where the change doesn't exist yet but the project's default schema is known. If no schema is configured in `config.yaml`, only config hooks are returned. +- **Should `--hook` work without `--change`?** Yes. Without `--change`, the schema is resolved from `config.yaml`'s default `schema` field, so both schema and config hooks are returned. This is essential for lifecycle points like `pre-new` where the change doesn't exist yet but the project's default schema is known. If no schema is configured in `config.yaml`, only config hooks are returned. +- **Why not a separate `openspec hooks` command?** The `instructions` command already serves as the "what does the LLM need" entry point with two modes (artifact, apply). Adding hooks as `--hook` is consistent and avoids adding another top-level command. The hook resolution logic stays in its own module (`hooks.ts`) for separation of concerns. diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index 6f6445ee5..26bea193f 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -1,8 +1,9 @@ ## Why -OpenSpec schemas define artifact creation instructions but have no way to inject custom behavior at operation lifecycle points (archive, sync, new, apply). Users need project-specific and workflow-specific actions at these points, such as: +OpenSpec schemas define artifact creation instructions but have no way to inject custom behavior at operation lifecycle points (archive, sync, new, apply, verify). Users need project-specific and workflow-specific actions at these points, such as: - Consolidating error logs on archive - Generating ADRs +- Running test suites before verification - Notifying external systems - Updating documentation indexes @@ -14,15 +15,18 @@ Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), # - Add a `hooks` section to `schema.yaml` for workflow-level lifecycle hooks (LLM instructions that run at operation boundaries) - Add a `hooks` section to `config.yaml` for project-level lifecycle hooks -- Create a CLI command to resolve and return hooks for a given lifecycle point. With `--change`, resolves schema from the change's metadata. Without `--change`, resolves schema from the `schema` field in `config.yaml`. Both modes return schema + config hooks (schema first) -- Update skills (archive, sync, new, apply) to query and execute hooks at their lifecycle points +- Create hook resolution function (schema + config merge, schema first) +- Expose hooks via a `--hook ` flag on `openspec instructions`. Supports optional `--change ` to resolve hooks from the change's schema; without `--change`, resolves from config.yaml's default schema. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. Hook resolution logic lives in `hooks.ts` as an internal module +- Update skills (archive, sync, new, apply, verify) to query and execute hooks at their lifecycle points +- Document the `instructions` command covering all modes (artifact, apply, `--hook`) - Hooks are LLM instructions only in this iteration — no `run` field for shell execution (deferred to future iteration) Supported lifecycle points: - `pre-new` / `post-new` — creating a change -- `pre-archive` / `post-archive` — archiving a change -- `pre-sync` / `post-sync` — syncing delta specs - `pre-apply` / `post-apply` — implementing tasks +- `pre-verify` / `post-verify` — verifying implementation +- `pre-sync` / `post-sync` — syncing delta specs +- `pre-archive` / `post-archive` — archiving a change ### Example: schema.yaml ```yaml @@ -31,9 +35,9 @@ hooks: post-archive: instruction: | Review the archived change and update the project changelog with key decisions - pre-apply: + pre-verify: instruction: | - Verify all prerequisite tasks are complete before implementation + Run the full test suite before verification begins ``` ### Example: config.yaml @@ -61,20 +65,23 @@ hooks: ### Modified Capabilities - `artifact-graph`: Schema YAML type extended with optional `hooks` section -- `instruction-loader`: New function/command to resolve hooks for a lifecycle point -- `cli-archive`: Archive operation awareness of pre/post hooks (CLI level) +- `instruction-loader`: Hook resolution function + `--hook` flag integration in instructions command +- `cli-artifact-workflow`: `openspec instructions` gains `--hook` flag +- `cli-archive`: Archive operation awareness of pre/post hooks - `opsx-archive-skill`: Archive skill executes hooks at pre/post-archive points - `specs-sync-skill`: Sync skill executes hooks at pre/post-sync points -- `cli-artifact-workflow`: New `openspec hooks` command registered alongside existing workflow commands +- `opsx-verify-skill`: Verify skill executes hooks at pre/post-verify points ## Impact -- **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward-compatible (no hooks = no change) +- **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward-compatible - **Config format**: `config.yaml` gains optional `hooks` field — fully backward-compatible -- **CLI**: New `openspec hooks` command to resolve and retrieve hooks for a lifecycle point (with optional `--change` flag) -- **Skills**: Archive, sync, new, and apply skills gain hook execution steps +- **CLI**: `openspec instructions --hook ` exposes hooks. `--hook` is mutually exclusive with `[artifact]` positional — error if both provided +- **Skills**: Archive, sync, new, apply, and verify skills use `openspec instructions --hook` +- **Lifecycle points**: 10 total — `pre/post` for new, apply, verify, sync, archive - **Existing schemas**: Unaffected — `hooks` is optional -- **Tests**: New tests for hook parsing, merging, resolution, and validation -- **Validation**: Hook keys are validated against `VALID_LIFECYCLE_POINTS` at parse time; unknown keys emit warnings +- **Tests**: Hook tests via `instructions --hook`, verify hook tests +- **Validation**: Hook keys validated against `VALID_LIFECYCLE_POINTS`; unknown keys emit warnings +- **Documentation**: `instructions` command documented with all three modes - **Error handling**: Malformed hooks (empty instruction, non-object values) are skipped with warnings — resilient field-by-field parsing -- **Security**: Hooks are LLM instructions only (no shell execution in this iteration), limiting the security surface +- **Security**: Hooks are LLM instructions only (no shell execution in this iteration) diff --git a/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md index a1d2b46db..672b75fe3 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/cli-artifact-workflow/spec.md @@ -1,35 +1,40 @@ # cli-artifact-workflow Delta Spec -## ADDED Requirements +## MODIFIED Requirements -### Requirement: Hooks Command +### Requirement: Instructions Command Hook Mode -The system SHALL provide an `openspec hooks` command to retrieve resolved lifecycle hooks for a given point and change. +The `openspec instructions` command SHALL support a `--hook ` flag to retrieve resolved lifecycle hooks. #### Scenario: Retrieve hooks with change context -- **WHEN** user runs `openspec hooks --change ""` +- **WHEN** user runs `openspec instructions --hook --change ""` - **THEN** the system resolves the schema from the change's metadata - **AND** reads hooks from both schema and config - **AND** outputs the resolved hooks in order (schema first, config second) #### Scenario: Retrieve hooks without change context -- **WHEN** user runs `openspec hooks ` without `--change` +- **WHEN** user runs `openspec instructions --hook ` without `--change` - **THEN** the system resolves the schema from `config.yaml`'s default `schema` field - **AND** reads hooks from both the resolved schema and config (schema first, config second) - **AND** sets `changeName` to null in JSON output - **AND** if no schema is configured in `config.yaml`, returns config hooks only +#### Scenario: Mutual exclusivity with artifact argument + +- **WHEN** user runs `openspec instructions --hook ` +- **THEN** the system exits with an error indicating that `--hook` cannot be used with an artifact argument + #### Scenario: JSON output -- **WHEN** user runs `openspec hooks [--change ""] --json` +- **WHEN** user runs `openspec instructions --hook [--change ""] --json` - **THEN** the system outputs JSON with `lifecyclePoint`, `changeName` (string or null), and `hooks` array - **AND** each hook includes `source` ("schema" or "config") and `instruction` fields #### Scenario: Text output -- **WHEN** user runs `openspec hooks ` without `--json` +- **WHEN** user runs `openspec instructions --hook ` without `--json` - **THEN** the system outputs human-readable formatted hooks grouped by source #### Scenario: No hooks found @@ -40,5 +45,5 @@ The system SHALL provide an `openspec hooks` command to retrieve resolved lifecy #### Scenario: Invalid lifecycle point -- **WHEN** the lifecycle point argument is not a recognized value +- **WHEN** the `--hook` value is not a recognized lifecycle point - **THEN** the system exits with an error listing valid lifecycle points diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md index 8fb25c14e..1a487fbe9 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -1,7 +1,7 @@ # Lifecycle Hooks Specification ## Purpose -Lifecycle hooks allow schemas and projects to define LLM instructions that execute at operation boundaries (pre/post archive, sync, new, apply). Schema-level hooks define workflow-inherent behavior; project-level hooks add project-specific customization. Both are surfaced to the LLM via a CLI command. +Lifecycle hooks allow schemas and projects to define LLM instructions that execute at operation boundaries (pre/post archive, sync, new, apply, verify). Schema-level hooks define workflow-inherent behavior; project-level hooks add project-specific customization. Both are surfaced to the LLM via the `openspec instructions --hook` flag. ## Requirements @@ -47,9 +47,10 @@ The system SHALL support an optional `hooks` section in `config.yaml` with the s The system SHALL recognize the following lifecycle points as valid hook keys: - `pre-new`, `post-new` -- `pre-archive`, `post-archive` -- `pre-sync`, `post-sync` - `pre-apply`, `post-apply` +- `pre-verify`, `post-verify` +- `pre-sync`, `post-sync` +- `pre-archive`, `post-archive` #### Scenario: All valid lifecycle points accepted @@ -87,25 +88,30 @@ The system SHALL resolve hooks for a given lifecycle point by returning schema h - **WHEN** neither schema nor config define a hook for a lifecycle point - **THEN** the system returns an empty list -### Requirement: Hook CLI Command +### Requirement: Hook CLI Exposure -The system SHALL expose a CLI command to retrieve resolved hooks for a given lifecycle point, optionally scoped to a change. +The system SHALL expose hooks via `openspec instructions --hook `, optionally scoped to a change with `--change`. #### Scenario: Retrieve hooks with change context -- **WHEN** executing `openspec hooks --change ""` +- **WHEN** executing `openspec instructions --hook --change ""` - **THEN** the system resolves the schema from the change's metadata - **AND** reads hooks from both schema and config - **AND** outputs the resolved hooks in order (schema first, config second) #### Scenario: Retrieve hooks without change context -- **WHEN** executing `openspec hooks ` without `--change` +- **WHEN** executing `openspec instructions --hook ` without `--change` - **THEN** the system resolves the schema from `config.yaml`'s default `schema` field - **AND** reads hooks from both the resolved schema and config (schema first, config second) - **AND** sets `changeName` to null in JSON output - **AND** if no schema is configured in `config.yaml`, returns config hooks only +#### Scenario: Mutual exclusivity with artifact argument + +- **WHEN** executing `openspec instructions --hook ` +- **THEN** the system exits with an error indicating that `--hook` cannot be used with an artifact argument + #### Scenario: JSON output - **WHEN** executing with `--json` flag diff --git a/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md b/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md index 296e99afc..795e1b5aa 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/opsx-archive-skill/spec.md @@ -10,7 +10,7 @@ The archive skill SHALL execute lifecycle hooks at the pre-archive and post-arch - **WHEN** the agent begins the archive operation - **AND** hooks are defined for the `pre-archive` lifecycle point -- **THEN** the agent retrieves hook instructions via `openspec hooks pre-archive --change "" --json` +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook pre-archive --change "" --json` - **AND** follows each hook instruction in order (schema hooks first, then config hooks) - **AND** proceeds with the archive operation after completing hook instructions @@ -18,7 +18,7 @@ The archive skill SHALL execute lifecycle hooks at the pre-archive and post-arch - **WHEN** the archive operation completes successfully (change moved to archive) - **AND** hooks are defined for the `post-archive` lifecycle point -- **THEN** the agent retrieves hook instructions via `openspec hooks post-archive --change "" --json` +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook post-archive --change "" --json` - **AND** follows each hook instruction in order (schema hooks first, then config hooks) #### Scenario: No hooks defined diff --git a/openspec/changes/add-lifecycle-hooks/specs/opsx-verify-skill/spec.md b/openspec/changes/add-lifecycle-hooks/specs/opsx-verify-skill/spec.md new file mode 100644 index 000000000..394a9bc68 --- /dev/null +++ b/openspec/changes/add-lifecycle-hooks/specs/opsx-verify-skill/spec.md @@ -0,0 +1,33 @@ +# opsx-verify-skill Delta Spec + +## ADDED Requirements + +### Requirement: Lifecycle Hook Execution + +The verify skill SHALL execute lifecycle hooks at the pre-verify and post-verify points. + +#### Scenario: Pre-verify hooks exist + +- **WHEN** the agent begins the verify operation +- **AND** hooks are defined for the `pre-verify` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook pre-verify --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) +- **AND** proceeds with the verify operation after completing hook instructions + +#### Scenario: Post-verify hooks exist + +- **WHEN** the verify operation completes successfully +- **AND** hooks are defined for the `post-verify` lifecycle point +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook post-verify --change "" --json` +- **AND** follows each hook instruction in order (schema hooks first, then config hooks) + +#### Scenario: No hooks defined + +- **WHEN** no hooks are defined for `pre-verify` or `post-verify` +- **THEN** the agent proceeds with the verify operation as normal +- **AND** no additional steps are added + +#### Scenario: Hook instruction references change context + +- **WHEN** a hook instruction references the change name or change artifacts +- **THEN** the agent uses its knowledge of the current operation context to fulfill the instruction diff --git a/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md b/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md index 9da14baae..29ecfb999 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/specs-sync-skill/spec.md @@ -10,7 +10,7 @@ The sync skill SHALL execute lifecycle hooks at the pre-sync and post-sync point - **WHEN** the agent begins the sync operation - **AND** hooks are defined for the `pre-sync` lifecycle point -- **THEN** the agent retrieves hook instructions via `openspec hooks pre-sync --change "" --json` +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook pre-sync --change "" --json` - **AND** follows each hook instruction in order (schema hooks first, then config hooks) - **AND** proceeds with the sync operation after completing hook instructions @@ -18,7 +18,7 @@ The sync skill SHALL execute lifecycle hooks at the pre-sync and post-sync point - **WHEN** the sync operation completes successfully - **AND** hooks are defined for the `post-sync` lifecycle point -- **THEN** the agent retrieves hook instructions via `openspec hooks post-sync --change "" --json` +- **THEN** the agent retrieves hook instructions via `openspec instructions --hook post-sync --change "" --json` - **AND** follows each hook instruction in order (schema hooks first, then config hooks) #### Scenario: No hooks defined diff --git a/openspec/changes/add-lifecycle-hooks/tasks.md b/openspec/changes/add-lifecycle-hooks/tasks.md index 30366d131..cd0e8f6c1 100644 --- a/openspec/changes/add-lifecycle-hooks/tasks.md +++ b/openspec/changes/add-lifecycle-hooks/tasks.md @@ -13,30 +13,41 @@ ## 3. Hook Resolution Function - [x] 3.1 Define `ResolvedHook` interface and `VALID_LIFECYCLE_POINTS` constant in `src/core/artifact-graph/types.ts` (exported via index) -- [x] 3.2 Implement `resolveHooks(projectRoot, changeName | null, lifecyclePoint)` function (null changeName = config-only hooks) +- [x] 3.2 Implement `resolveHooks(projectRoot, changeName | null, lifecyclePoint)` function in `src/core/artifact-graph/instruction-loader.ts` - [x] 3.3 Handle edge cases: no schema hooks, no config hooks, no hooks at all, invalid lifecycle point, null changeName -## 4. CLI Command +## 4. Verify Lifecycle Points -- [x] 4.1 Create `openspec hooks` command in `src/commands/workflow/hooks.ts` -- [x] 4.2 Implement text output format (human-readable) -- [x] 4.3 Implement JSON output format -- [x] 4.4 Add argument validation (lifecycle point must be valid, --change is optional) -- [x] 4.5 Implement config-only mode: when --change is omitted, return only config hooks (no schema resolution) -- [x] 4.6 Register command in CLI entry point (`src/cli/index.ts`) +- [x] 4.1 Add `pre-verify` and `post-verify` to `VALID_LIFECYCLE_POINTS` in `src/core/artifact-graph/types.ts` -## 5. Skill Template Updates +## 5. CLI: Merge hooks into instructions command -- [x] 5.1 Update `getArchiveChangeSkillTemplate()` and `getOpsxArchiveCommandTemplate()` in `src/core/templates/skill-templates.ts` to call `openspec hooks pre-archive` and `openspec hooks post-archive` -- [x] 5.2 Update apply skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-apply points -- [x] 5.3 Update new change skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-new points -- [x] 5.4 Update sync skill templates in `src/core/templates/skill-templates.ts` to call hooks at pre/post-sync points -- [x] 5.5 Regenerate agent skills with `openspec update` to update generated files +- [x] 5.1 Add `--hook ` option to `instructions` command in `src/cli/index.ts` +- [x] 5.2 Add mutual exclusivity validation: if both `[artifact]` and `--hook` are provided, exit with error `"--hook cannot be used with an artifact argument"` +- [x] 5.3 When `--hook` is present (and no positional artifact), delegate to `hooksCommand()` from `src/commands/workflow/hooks.ts` +- [x] 5.4 Remove standalone `hooks` command registration from `src/cli/index.ts` +- [x] 5.5 Keep `hooksCommand` import in `src/cli/index.ts` (used by instructions routing via delegation) -## 6. Tests +## 6. Skill Template Updates -- [x] 6.1 Unit: Extend `test/core/artifact-graph/schema.test.ts` — schema parsing with hooks (valid, missing, empty, invalid instruction) -- [x] 6.2 Unit: Extend `test/core/project-config.test.ts` — config hooks parsing (valid, invalid, unknown lifecycle points, resilient field-by-field) -- [x] 6.3 Unit: Extend `test/core/artifact-graph/instruction-loader.test.ts` — `resolveHooks()` (schema only, config only, both with ordering, neither, null changeName = config-only) -- [x] 6.4 CLI integration: Extend `test/commands/artifact-workflow.test.ts` — `openspec hooks` command (with --change, without --change, no hooks, invalid lifecycle point, JSON output) -- [x] 6.5 Verify existing tests still pass (no regressions from schema/config type changes) +- [x] 6.1 Update archive skill templates (`getArchiveChangeSkillTemplate()`, `getOpsxArchiveCommandTemplate()`) to call hooks at pre/post-archive points +- [x] 6.2 Update apply skill templates to call hooks at pre/post-apply points +- [x] 6.3 Update new change skill templates to call hooks at pre/post-new points +- [x] 6.4 Update sync skill templates to call hooks at pre/post-sync points +- [x] 6.5 Change all hook invocations in skill templates from `openspec hooks ` to `openspec instructions --hook ` (~18 occurrences in `src/core/templates/skill-templates.ts`) +- [x] 6.6 Add pre/post-verify hook steps to `getVerifyChangeSkillTemplate()` and `getOpsxVerifyCommandTemplate()` in `src/core/templates/skill-templates.ts` +- [x] 6.7 Regenerate agent skills with `openspec update --force` to update generated files + +## 7. Documentation + +- [x] 7.1 Document the `instructions` command covering all three modes (artifact, apply, `--hook`) with examples and mutual exclusivity note + +## 8. Tests + +- [x] 8.1 Unit: Extend `test/core/artifact-graph/schema.test.ts` — schema parsing with hooks (valid, missing, empty, invalid instruction) +- [x] 8.2 Unit: Extend `test/core/project-config.test.ts` — config hooks parsing (valid, invalid, unknown lifecycle points, resilient field-by-field) +- [x] 8.3 Unit: Extend `test/core/artifact-graph/instruction-loader.test.ts` — `resolveHooks()` (schema only, config only, both with ordering, neither, null changeName = config-only) +- [x] 8.4 CLI integration: Update existing hook tests in `test/commands/artifact-workflow.test.ts` to use `instructions --hook` instead of `hooks` command +- [x] 8.5 CLI integration: Add mutual exclusivity test — `instructions --hook ` returns error +- [x] 8.6 CLI integration: Add `pre-verify`/`post-verify` as valid lifecycle points in tests +- [x] 8.7 Verify existing tests still pass (no regressions) diff --git a/openspec/config.yaml b/openspec/config.yaml index 83ed33952..42706992c 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,6 +5,20 @@ context: | Package manager: pnpm CLI framework: Commander.js + Spec compliance: + - Before explore, creating or updating any artifact, or implementing/modifying code, + run `openspec list --specs --json` to discover existing specs and their summaries. + Identify relevant specs from the summaries, then read each in + openspec/specs//*.md. + - Ensure changes don't contradict existing requirements. + - Code must conform to all applicable spec requirements. + - If a spec is unclear, incomplete, or potentially conflicts with the + implementation, stop and inform the user. The user decides whether to + proceed as-is or explore an alternative approach. + - If a decision changes an existing spec's assumptions, note it explicitly. + - If new specs dependencies are discovered, update the relevant artifacts + (design.md, proposal.md, tasks.md, etc.) to reflect them before continuing. + Cross-platform requirements: - This tool runs on macOS, Linux, AND Windows - Always use path.join() or path.resolve() for file paths - never hardcode slashes diff --git a/src/cli/index.ts b/src/cli/index.ts index 9bfd7abe5..b7a277c9a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -32,6 +32,8 @@ import { type NewChangeOptions, type HooksOptions, } from '../commands/workflow/index.js'; + +type InstructionsActionOptions = InstructionsOptions & { hook?: string }; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; const program = new Command(); @@ -439,14 +441,23 @@ program // Instructions command program .command('instructions [artifact]') - .description('Output enriched instructions for creating an artifact or applying tasks') + .description('Output enriched instructions for creating an artifact, applying tasks, or retrieving lifecycle hooks') .option('--change ', 'Change name') .option('--schema ', 'Schema override (auto-detected from config.yaml)') + .option('--hook ', 'Retrieve lifecycle hooks for a given point (mutually exclusive with [artifact])') .option('--json', 'Output as JSON') - .action(async (artifactId: string | undefined, options: InstructionsOptions) => { + .action(async (artifactId: string | undefined, options: InstructionsActionOptions) => { try { - // Special case: "apply" is not an artifact, but a command to get apply instructions - if (artifactId === 'apply') { + // Mutual exclusivity: --hook cannot be used with an artifact argument + if (options.hook && artifactId) { + throw new Error('--hook cannot be used with an artifact argument'); + } + + if (options.hook) { + // Hook mode: delegate to hooksCommand + await hooksCommand(options.hook, { change: options.change, json: options.json }); + } else if (artifactId === 'apply') { + // Special case: "apply" is not an artifact, but a command to get apply instructions await applyInstructionsCommand(options); } else { await instructionsCommand(artifactId, options); @@ -489,22 +500,6 @@ program } }); -// Hooks command -program - .command('hooks [lifecycle-point]') - .description('Retrieve resolved lifecycle hooks for a given point') - .option('--change ', 'Change name (omit for config-only hooks)') - .option('--json', 'Output as JSON (for agent use)') - .action(async (lifecyclePoint: string | undefined, options: HooksOptions) => { - try { - await hooksCommand(lifecyclePoint, options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - // New command group with change subcommand const newCmd = program.command('new').description('Create new items'); diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index 155472fe5..2baa1a067 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -68,9 +68,10 @@ export type ChangeMetadata = z.infer; // Valid lifecycle points for hooks export const VALID_LIFECYCLE_POINTS = [ 'pre-new', 'post-new', - 'pre-archive', 'post-archive', - 'pre-sync', 'post-sync', 'pre-apply', 'post-apply', + 'pre-verify', 'post-verify', + 'pre-sync', 'post-sync', + 'pre-archive', 'post-archive', ] as const; export type LifecyclePoint = typeof VALID_LIFECYCLE_POINTS[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 81dc1727b..f2d512abe 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -345,7 +345,7 @@ export function getNewChangeSkillTemplate(): SkillTemplate { 3. **Execute pre-new hooks** - Run \`openspec hooks pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. @@ -360,7 +360,7 @@ export function getNewChangeSkillTemplate(): SkillTemplate { 5. **Execute post-new hooks** - Run \`openspec hooks post-new --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-new --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -549,7 +549,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { 2. **Execute pre-apply hooks** - Run \`openspec hooks pre-apply --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-apply --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -612,7 +612,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { 8. **Execute post-apply hooks** - Run \`openspec hooks post-apply --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-apply --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -829,7 +829,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e 2. **Execute pre-sync hooks** - Run \`openspec hooks pre-sync --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-sync --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -882,7 +882,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e 5. **Execute post-sync hooks** - Run \`openspec hooks post-sync --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-sync --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -1736,7 +1736,7 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { 3. **Execute pre-new hooks** - Run \`openspec hooks pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. @@ -1751,7 +1751,7 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { 5. **Execute post-new hooks** - Run \`openspec hooks post-new --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-new --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -1935,7 +1935,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { 2. **Execute pre-apply hooks** - Run \`openspec hooks pre-apply --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-apply --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -1998,7 +1998,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { 8. **Execute post-apply hooks** - Run \`openspec hooks post-apply --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-apply --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -2207,7 +2207,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { 2. **Execute pre-archive hooks** - Run \`openspec hooks pre-archive --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-archive --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -2273,9 +2273,9 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { 7. **Execute post-archive hooks** - Run \`openspec hooks post-archive --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-archive --change "" --json\` to check for lifecycle hooks. - **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec hooks post-archive --json\` (config-only hooks). + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec instructions --hook post-archive --json\` (config-only hooks). If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -2593,7 +2593,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e 2. **Execute pre-sync hooks** - Run \`openspec hooks pre-sync --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-sync --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -2646,7 +2646,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e 5. **Execute post-sync hooks** - Run \`openspec hooks post-sync --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-sync --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -2761,7 +2761,13 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { This returns the change directory and context files. Read all available artifacts from \`contextFiles\`. -4. **Initialize verification report structure** +4. **Execute pre-verify hooks** + + Run \`openspec instructions --hook pre-verify --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + +5. **Initialize verification report structure** Create a report structure with three dimensions: - **Completeness**: Track tasks and spec coverage @@ -2770,7 +2776,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { Each dimension can have CRITICAL, WARNING, or SUGGESTION issues. -5. **Verify Completeness** +6. **Verify Completeness** **Task Completion**: - If tasks.md exists in contextFiles, read it @@ -2790,7 +2796,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { - Add CRITICAL issue: "Requirement not found: " - Recommendation: "Implement requirement X: " -6. **Verify Correctness** +7. **Verify Correctness** **Requirement Implementation Mapping**: - For each requirement from delta specs: @@ -2809,7 +2815,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { - Add WARNING: "Scenario not covered: " - Recommendation: "Add test or implementation for scenario: " -7. **Verify Coherence** +8. **Verify Coherence** **Design Adherence**: - If design.md exists in contextFiles: @@ -2827,7 +2833,7 @@ export function getVerifyChangeSkillTemplate(): SkillTemplate { - Add SUGGESTION: "Code pattern deviation:
" - Recommendation: "Consider following project pattern: " -8. **Generate Verification Report** +9. **Generate Verification Report** **Summary Scorecard**: \`\`\` @@ -2885,7 +2891,13 @@ Use clear markdown with: - Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) - Code references in format: \`file.ts:123\` - Specific, actionable recommendations -- No vague suggestions like "consider reviewing"`, +- No vague suggestions like "consider reviewing" + +10. **Execute post-verify hooks** + + Run \`openspec instructions --hook post-verify --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the report.`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -2918,7 +2930,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { 2. **Execute pre-archive hooks** - Run \`openspec hooks pre-archive --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook pre-archive --change "" --json\` to check for lifecycle hooks. If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. @@ -2984,9 +2996,9 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { 7. **Execute post-archive hooks** - Run \`openspec hooks post-archive --change "" --json\` to check for lifecycle hooks. + Run \`openspec instructions --hook post-archive --change "" --json\` to check for lifecycle hooks. - **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec hooks post-archive --json\` (config-only hooks). + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec instructions --hook post-archive --json\` (config-only hooks). If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. @@ -3374,7 +3386,13 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { This returns the change directory and context files. Read all available artifacts from \`contextFiles\`. -4. **Initialize verification report structure** +4. **Execute pre-verify hooks** + + Run \`openspec instructions --hook pre-verify --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. + +5. **Initialize verification report structure** Create a report structure with three dimensions: - **Completeness**: Track tasks and spec coverage @@ -3383,7 +3401,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { Each dimension can have CRITICAL, WARNING, or SUGGESTION issues. -5. **Verify Completeness** +6. **Verify Completeness** **Task Completion**: - If tasks.md exists in contextFiles, read it @@ -3403,7 +3421,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { - Add CRITICAL issue: "Requirement not found: " - Recommendation: "Implement requirement X: " -6. **Verify Correctness** +7. **Verify Correctness** **Requirement Implementation Mapping**: - For each requirement from delta specs: @@ -3422,7 +3440,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { - Add WARNING: "Scenario not covered: " - Recommendation: "Add test or implementation for scenario: " -7. **Verify Coherence** +8. **Verify Coherence** **Design Adherence**: - If design.md exists in contextFiles: @@ -3440,7 +3458,7 @@ export function getOpsxVerifyCommandTemplate(): CommandTemplate { - Add SUGGESTION: "Code pattern deviation:
" - Recommendation: "Consider following project pattern: " -8. **Generate Verification Report** +9. **Generate Verification Report** **Summary Scorecard**: \`\`\` @@ -3498,7 +3516,13 @@ Use clear markdown with: - Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) - Code references in format: \`file.ts:123\` - Specific, actionable recommendations -- No vague suggestions like "consider reviewing"` +- No vague suggestions like "consider reviewing" + +10. **Execute post-verify hooks** + + Run \`openspec instructions --hook post-verify --change "" --json\` to check for lifecycle hooks. + + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the report.` }; } /** diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index b984f01ce..0382f999a 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -842,7 +842,7 @@ context: Updated context }, 60000); }); - describe('hooks command', () => { + describe('instructions --hook', () => { it('should show hooks for a lifecycle point with config hooks', async () => { // Create config.yaml with hooks await fs.writeFile( @@ -853,8 +853,8 @@ context: Updated context ` ); - // Run hooks command without --change flag - const result = await runCLI(['hooks', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); + // Run instructions --hook without --change flag + const result = await runCLI(['instructions', '--hook', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); expect(result.exitCode).toBe(0); const output = getOutput(result); @@ -872,9 +872,9 @@ context: Updated context ` ); - // Run hooks command with --json flag + // Run instructions --hook with --json flag const result = await runCLI( - ['hooks', 'pre-archive', '--json'], + ['instructions', '--hook', 'pre-archive', '--json'], { cwd: tempDir, timeoutMs: 30000 } ); expect(result.exitCode).toBe(0); @@ -890,8 +890,8 @@ context: Updated context }, 60000); it('should show no hooks when none defined', async () => { - // Run hooks command without any config hooks - const result = await runCLI(['hooks', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); + // Run instructions --hook without any config hooks + const result = await runCLI(['instructions', '--hook', 'pre-archive'], { cwd: tempDir, timeoutMs: 30000 }); expect(result.exitCode).toBe(0); const output = getOutput(result); @@ -899,22 +899,44 @@ context: Updated context }, 60000); it('should error on invalid lifecycle point', async () => { - // Run hooks command with invalid lifecycle point - const result = await runCLI(['hooks', 'invalid-point'], { cwd: tempDir, timeoutMs: 30000 }); + // Run instructions --hook with invalid lifecycle point + const result = await runCLI(['instructions', '--hook', 'invalid-point'], { cwd: tempDir, timeoutMs: 30000 }); expect(result.exitCode).toBe(1); const output = getOutput(result); expect(output).toContain('Invalid lifecycle point'); }, 60000); - it('should error on missing lifecycle point argument', async () => { - // Run hooks command without lifecycle point argument - const result = await runCLI(['hooks'], { cwd: tempDir, timeoutMs: 30000 }); + it('should error when --hook used with artifact argument', async () => { + // Run instructions with both artifact and --hook (mutual exclusivity) + const result = await runCLI( + ['instructions', 'proposal', '--hook', 'pre-archive'], + { cwd: tempDir, timeoutMs: 30000 } + ); expect(result.exitCode).toBe(1); const output = getOutput(result); - // Should contain an error about missing argument - expect(output.toLowerCase()).toMatch(/missing|required|argument/); + expect(output).toContain('--hook cannot be used with an artifact argument'); + }, 60000); + + it('should accept pre-verify and post-verify as valid lifecycle points', async () => { + // Run instructions --hook with pre-verify + const result1 = await runCLI( + ['instructions', '--hook', 'pre-verify', '--json'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result1.exitCode).toBe(0); + const json1 = JSON.parse(result1.stdout); + expect(json1.lifecyclePoint).toBe('pre-verify'); + + // Run instructions --hook with post-verify + const result2 = await runCLI( + ['instructions', '--hook', 'post-verify', '--json'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result2.exitCode).toBe(0); + const json2 = JSON.parse(result2.stdout); + expect(json2.lifecyclePoint).toBe('post-verify'); }, 60000); it('should return schema hooks before config hooks', async () => { @@ -958,9 +980,9 @@ hooks: 'schema: hooked\n' ); - // Run hooks command with --change and --json + // Run instructions --hook with --change and --json const result = await runCLI( - ['hooks', 'pre-archive', '--change', 'hooked-change', '--json'], + ['instructions', '--hook', 'pre-archive', '--change', 'hooked-change', '--json'], { cwd: tempDir, timeoutMs: 30000, @@ -990,9 +1012,9 @@ hooks: // Create a test change await createTestChange('test-change'); - // Run hooks command with --change flag + // Run instructions --hook with --change flag const result = await runCLI( - ['hooks', 'pre-archive', '--change', 'test-change'], + ['instructions', '--hook', 'pre-archive', '--change', 'test-change'], { cwd: tempDir, timeoutMs: 30000 } ); expect(result.exitCode).toBe(0); @@ -1002,7 +1024,7 @@ hooks: // Also test JSON output to verify changeName const jsonResult = await runCLI( - ['hooks', 'pre-archive', '--change', 'test-change', '--json'], + ['instructions', '--hook', 'pre-archive', '--change', 'test-change', '--json'], { cwd: tempDir, timeoutMs: 30000 } ); expect(jsonResult.exitCode).toBe(0); From f93ca3d4c2f1d60e03e3a22d81c136e8ed42a57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 10:15:07 +0100 Subject: [PATCH 12/34] refactor: remove unnecessary export from HooksOutput interface --- src/commands/workflow/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/workflow/hooks.ts b/src/commands/workflow/hooks.ts index 65f46fc87..3d25b6d6d 100644 --- a/src/commands/workflow/hooks.ts +++ b/src/commands/workflow/hooks.ts @@ -22,7 +22,7 @@ export interface HooksOptions { json?: boolean; } -export interface HooksOutput { +interface HooksOutput { lifecyclePoint: string; changeName: string | null; hooks: ResolvedHook[]; From 70811f3480650cba84f79dc8a251da7ee79ffb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 10:49:48 +0100 Subject: [PATCH 13/34] feat: add pre/post-continue and pre/post-ff lifecycle hooks Add 4 new lifecycle points (14 total). The ff skill fires pre-ff/post-ff around the entire operation and pre-continue/post-continue for each artifact iteration within it. The continue skill fires pre-continue/post-continue around each artifact creation. --- docs/cli.md | 2 +- .../changes/add-lifecycle-hooks/design.md | 31 ++++-- .../changes/add-lifecycle-hooks/proposal.md | 8 +- .../specs/lifecycle-hooks/spec.md | 2 + openspec/changes/add-lifecycle-hooks/tasks.md | 10 +- openspec/config.yaml | 30 +++++ src/core/artifact-graph/types.ts | 2 + src/core/templates/skill-templates.ts | 104 +++++++++++++++--- test/commands/artifact-workflow.test.ts | 12 ++ 9 files changed, 170 insertions(+), 31 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 228bf8036..527b07774 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -487,7 +487,7 @@ This command has three modes: **Hook mode** (`openspec instructions --hook [--change ]`): Returns lifecycle hooks for a given point. With `--change`, resolves hooks from the change's schema and project config. Without `--change`, resolves from `config.yaml`'s default schema and config. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. -Valid lifecycle points: `pre-new`, `post-new`, `pre-apply`, `post-apply`, `pre-verify`, `post-verify`, `pre-sync`, `post-sync`, `pre-archive`, `post-archive`. +Valid lifecycle points: `pre-new`, `post-new`, `pre-continue`, `post-continue`, `pre-ff`, `post-ff`, `pre-apply`, `post-apply`, `pre-verify`, `post-verify`, `pre-sync`, `post-sync`, `pre-archive`, `post-archive`. Note: `pre-continue`/`post-continue` hooks also fire for each artifact iteration inside the `ff` skill. **Examples:** diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md index 4ea6cbfc8..e36dfa4c9 100644 --- a/openspec/changes/add-lifecycle-hooks/design.md +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -19,7 +19,7 @@ Key files: - Allow schemas to define LLM instruction hooks at operation lifecycle points - Allow projects to add/extend hooks via config.yaml - Expose hooks via `openspec instructions --hook` for skills to consume -- Update all operation skills (archive, sync, new, apply, verify) to execute hooks +- Update all operation skills (archive, sync, new, apply, verify, continue, ff) to execute hooks **Non-Goals:** - Shell script execution (`run` field) — deferred to future iteration @@ -157,16 +157,20 @@ This function: ### Decision 6: Valid lifecycle points -10 lifecycle points covering all operations: +14 lifecycle points covering all operations: ``` -pre-new post-new — creating a change -pre-apply post-apply — implementing tasks -pre-verify post-verify — verifying implementation -pre-sync post-sync — syncing delta specs -pre-archive post-archive — archiving a change +pre-new post-new — creating a change +pre-continue post-continue — creating an artifact (one invocation of continue) +pre-ff post-ff — fast-forward artifact generation (wraps the entire ff run) +pre-apply post-apply — implementing tasks +pre-verify post-verify — verifying implementation +pre-sync post-sync — syncing delta specs +pre-archive post-archive — archiving a change ``` +The `ff` skill fires `pre-ff`/`post-ff` around the entire operation, and `pre-continue`/`post-continue` for each artifact iteration within it. This allows hooks to run both per-artifact (continue) and per-batch (ff). + These are defined in `VALID_LIFECYCLE_POINTS` in `types.ts` and validated at runtime. ### Decision 7: Skill integration pattern @@ -185,7 +189,18 @@ openspec instructions --hook post-archive --change "" --json → If hooks returned, follow each instruction in order ``` -The same pattern applies to all skills: new, apply, verify, sync, archive. +The same pattern applies to all skills: new, continue, ff, apply, verify, sync, archive. + +The `ff` skill has a nested pattern: it fires `pre-ff` at the start, then for each artifact creation it fires `pre-continue`/`post-continue` (reusing the continue hooks), and finally `post-ff` at the end: + +``` +ff: + pre-ff + ├── pre-continue → create artifact 1 → post-continue + ├── pre-continue → create artifact 2 → post-continue + └── pre-continue → create artifact N → post-continue + post-ff +``` The skill templates in `src/core/templates/skill-templates.ts` are updated to include these steps, and `openspec update` regenerates the output files. This is the same pattern as how skills already call `openspec instructions` and `openspec status`. diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index 26bea193f..ece11ab58 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -17,12 +17,14 @@ Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), # - Add a `hooks` section to `config.yaml` for project-level lifecycle hooks - Create hook resolution function (schema + config merge, schema first) - Expose hooks via a `--hook ` flag on `openspec instructions`. Supports optional `--change ` to resolve hooks from the change's schema; without `--change`, resolves from config.yaml's default schema. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. Hook resolution logic lives in `hooks.ts` as an internal module -- Update skills (archive, sync, new, apply, verify) to query and execute hooks at their lifecycle points +- Update skills (archive, sync, new, apply, verify, continue, ff) to query and execute hooks at their lifecycle points - Document the `instructions` command covering all modes (artifact, apply, `--hook`) - Hooks are LLM instructions only in this iteration — no `run` field for shell execution (deferred to future iteration) Supported lifecycle points: - `pre-new` / `post-new` — creating a change +- `pre-continue` / `post-continue` — creating an artifact (also fires inside ff) +- `pre-ff` / `post-ff` — fast-forward artifact generation - `pre-apply` / `post-apply` — implementing tasks - `pre-verify` / `post-verify` — verifying implementation - `pre-sync` / `post-sync` — syncing delta specs @@ -77,8 +79,8 @@ hooks: - **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward-compatible - **Config format**: `config.yaml` gains optional `hooks` field — fully backward-compatible - **CLI**: `openspec instructions --hook ` exposes hooks. `--hook` is mutually exclusive with `[artifact]` positional — error if both provided -- **Skills**: Archive, sync, new, apply, and verify skills use `openspec instructions --hook` -- **Lifecycle points**: 10 total — `pre/post` for new, apply, verify, sync, archive +- **Skills**: Archive, sync, new, apply, verify, continue, and ff skills use `openspec instructions --hook` +- **Lifecycle points**: 14 total — `pre/post` for new, continue, ff, apply, verify, sync, archive. The ff skill fires `pre-ff`/`post-ff` around the entire operation, and `pre-continue`/`post-continue` for each artifact iteration within it - **Existing schemas**: Unaffected — `hooks` is optional - **Tests**: Hook tests via `instructions --hook`, verify hook tests - **Validation**: Hook keys validated against `VALID_LIFECYCLE_POINTS`; unknown keys emit warnings diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md index 1a487fbe9..61b6a1a65 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -47,6 +47,8 @@ The system SHALL support an optional `hooks` section in `config.yaml` with the s The system SHALL recognize the following lifecycle points as valid hook keys: - `pre-new`, `post-new` +- `pre-continue`, `post-continue` +- `pre-ff`, `post-ff` - `pre-apply`, `post-apply` - `pre-verify`, `post-verify` - `pre-sync`, `post-sync` diff --git a/openspec/changes/add-lifecycle-hooks/tasks.md b/openspec/changes/add-lifecycle-hooks/tasks.md index cd0e8f6c1..a86c18e3a 100644 --- a/openspec/changes/add-lifecycle-hooks/tasks.md +++ b/openspec/changes/add-lifecycle-hooks/tasks.md @@ -16,9 +16,10 @@ - [x] 3.2 Implement `resolveHooks(projectRoot, changeName | null, lifecyclePoint)` function in `src/core/artifact-graph/instruction-loader.ts` - [x] 3.3 Handle edge cases: no schema hooks, no config hooks, no hooks at all, invalid lifecycle point, null changeName -## 4. Verify Lifecycle Points +## 4. Additional Lifecycle Points - [x] 4.1 Add `pre-verify` and `post-verify` to `VALID_LIFECYCLE_POINTS` in `src/core/artifact-graph/types.ts` +- [x] 4.2 Add `pre-continue`, `post-continue`, `pre-ff`, `post-ff` to `VALID_LIFECYCLE_POINTS` in `src/core/artifact-graph/types.ts` ## 5. CLI: Merge hooks into instructions command @@ -36,7 +37,9 @@ - [x] 6.4 Update sync skill templates to call hooks at pre/post-sync points - [x] 6.5 Change all hook invocations in skill templates from `openspec hooks ` to `openspec instructions --hook ` (~18 occurrences in `src/core/templates/skill-templates.ts`) - [x] 6.6 Add pre/post-verify hook steps to `getVerifyChangeSkillTemplate()` and `getOpsxVerifyCommandTemplate()` in `src/core/templates/skill-templates.ts` -- [x] 6.7 Regenerate agent skills with `openspec update --force` to update generated files +- [x] 6.8 Add pre/post-continue hook steps to `getContinueChangeSkillTemplate()` and `getOpsxContinueCommandTemplate()` +- [x] 6.9 Add pre-ff/post-ff hook steps and pre/post-continue per-artifact hooks to `getFfChangeSkillTemplate()` and `getOpsxFfCommandTemplate()` +- [x] 6.10 Regenerate agent skills with `openspec update --force` ## 7. Documentation @@ -50,4 +53,5 @@ - [x] 8.4 CLI integration: Update existing hook tests in `test/commands/artifact-workflow.test.ts` to use `instructions --hook` instead of `hooks` command - [x] 8.5 CLI integration: Add mutual exclusivity test — `instructions --hook ` returns error - [x] 8.6 CLI integration: Add `pre-verify`/`post-verify` as valid lifecycle points in tests -- [x] 8.7 Verify existing tests still pass (no regressions) +- [x] 8.8 CLI integration: Add `pre-continue`/`post-continue`/`pre-ff`/`post-ff` as valid lifecycle points in tests +- [x] 8.9 Verify existing tests still pass (no regressions) diff --git a/openspec/config.yaml b/openspec/config.yaml index 42706992c..6b56248bf 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -26,6 +26,36 @@ context: | - Tests must use path.join() for expected path values, not hardcoded strings - Consider case sensitivity differences in file systems +hooks: + pre-new: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-new`" + post-new: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-new`" + pre-continue: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-continue`" + post-continue: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-continue`" + pre-ff: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-ff`" + post-ff: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-ff`" + pre-apply: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-apply`" + post-apply: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-apply`" + pre-verify: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-verify`" + post-verify: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-verify`" + pre-sync: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-sync`" + post-sync: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-sync`" + pre-archive: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-archive`" + post-archive: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-archive`" + rules: specs: - Include scenarios for Windows path handling when dealing with file paths diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index 2baa1a067..1936b0e7f 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -68,6 +68,8 @@ export type ChangeMetadata = z.infer; // Valid lifecycle points for hooks export const VALID_LIFECYCLE_POINTS = [ 'pre-new', 'post-new', + 'pre-continue', 'post-continue', + 'pre-ff', 'post-ff', 'pre-apply', 'post-apply', 'pre-verify', 'post-verify', 'pre-sync', 'post-sync', diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index f2d512abe..824311b58 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -440,7 +440,13 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete -3. **Act based on status**: +3. **Execute pre-continue hooks** + \`\`\`bash + openspec instructions --hook pre-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +4. **Act based on status**: --- @@ -479,11 +485,17 @@ export function getContinueChangeSkillTemplate(): SkillTemplate { - This shouldn't happen with a valid schema - Show status and suggest checking for issues -4. **After creating an artifact, show progress** +5. **After creating an artifact, show progress** \`\`\`bash openspec status --change "" \`\`\` +6. **Execute post-continue hooks** + \`\`\`bash + openspec instructions --hook post-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + **Output** After each invocation, show: @@ -728,7 +740,13 @@ export function getFfChangeSkillTemplate(): SkillTemplate { \`\`\` This creates a scaffolded change at \`openspec/changes//\`. -3. **Get the artifact build order** +3. **Execute pre-ff hooks** + \`\`\`bash + openspec instructions --hook pre-ff --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +4. **Get the artifact build order** \`\`\`bash openspec status --change "" --json \`\`\` @@ -736,13 +754,19 @@ export function getFfChangeSkillTemplate(): SkillTemplate { - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): - a. **For each artifact that is \`ready\` (dependencies satisfied)**: + a. **Execute pre-continue hooks** (before each artifact): + \`\`\`bash + openspec instructions --hook pre-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + b. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash openspec instructions --change "" --json @@ -759,16 +783,28 @@ export function getFfChangeSkillTemplate(): SkillTemplate { - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file - Show brief progress: "✓ Created " - b. **Continue until all \`applyRequires\` artifacts are complete** + c. **Execute post-continue hooks** (after each artifact): + \`\`\`bash + openspec instructions --hook post-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + d. **Continue until all \`applyRequires\` artifacts are complete** - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done - c. **If an artifact requires user input** (unclear context): + e. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Execute post-ff hooks** + \`\`\`bash + openspec instructions --hook post-ff --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + +7. **Show final status** \`\`\`bash openspec status --change "" \`\`\` @@ -1828,7 +1864,13 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") - \`isComplete\`: Boolean indicating if all artifacts are complete -3. **Act based on status**: +3. **Execute pre-continue hooks** + \`\`\`bash + openspec instructions --hook pre-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +4. **Act based on status**: --- @@ -1867,11 +1909,17 @@ export function getOpsxContinueCommandTemplate(): CommandTemplate { - This shouldn't happen with a valid schema - Show status and suggest checking for issues -4. **After creating an artifact, show progress** +5. **After creating an artifact, show progress** \`\`\`bash openspec status --change "" \`\`\` +6. **Execute post-continue hooks** + \`\`\`bash + openspec instructions --hook post-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + **Output** After each invocation, show: @@ -2113,7 +2161,13 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { \`\`\` This creates a scaffolded change at \`openspec/changes//\`. -3. **Get the artifact build order** +3. **Execute pre-ff hooks** + \`\`\`bash + openspec instructions --hook pre-ff --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +4. **Get the artifact build order** \`\`\`bash openspec status --change "" --json \`\`\` @@ -2121,13 +2175,19 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) - \`artifacts\`: list of all artifacts with their status and dependencies -4. **Create artifacts in sequence until apply-ready** +5. **Create artifacts in sequence until apply-ready** Use the **TodoWrite tool** to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): - a. **For each artifact that is \`ready\` (dependencies satisfied)**: + a. **Execute pre-continue hooks** (before each artifact): + \`\`\`bash + openspec instructions --hook pre-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + b. **For each artifact that is \`ready\` (dependencies satisfied)**: - Get instructions: \`\`\`bash openspec instructions --change "" --json @@ -2144,16 +2204,28 @@ export function getOpsxFfCommandTemplate(): CommandTemplate { - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file - Show brief progress: "✓ Created " - b. **Continue until all \`applyRequires\` artifacts are complete** + c. **Execute post-continue hooks** (after each artifact): + \`\`\`bash + openspec instructions --hook post-continue --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + d. **Continue until all \`applyRequires\` artifacts are complete** - After creating each artifact, re-run \`openspec status --change "" --json\` - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array - Stop when all \`applyRequires\` artifacts are done - c. **If an artifact requires user input** (unclear context): + e. **If an artifact requires user input** (unclear context): - Use **AskUserQuestion tool** to clarify - Then continue with creation -5. **Show final status** +6. **Execute post-ff hooks** + \`\`\`bash + openspec instructions --hook post-ff --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + +7. **Show final status** \`\`\`bash openspec status --change "" \`\`\` diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 0382f999a..99c208cc7 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -939,6 +939,18 @@ context: Updated context expect(json2.lifecyclePoint).toBe('post-verify'); }, 60000); + it('should accept pre-continue, post-continue, pre-ff, post-ff as valid lifecycle points', async () => { + for (const point of ['pre-continue', 'post-continue', 'pre-ff', 'post-ff']) { + const result = await runCLI( + ['instructions', '--hook', point, '--json'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.lifecyclePoint).toBe(point); + } + }, 60000); + it('should return schema hooks before config hooks', async () => { // Create a custom schema with hooks const userDataDir = path.join(tempDir, 'user-data-hooks'); From 1e8a023aaa2526da7e6680304acbf1d0b9a9f255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 11:28:59 +0100 Subject: [PATCH 14/34] feat: add lifecycle hooks for explore, bulk-archive, and onboard Complete the lifecycle hooks coverage across all skills (20 total lifecycle points). Add pre/post hooks for explore, bulk-archive, and onboard. Bulk-archive also fires pre-archive/post-archive per individual change within the batch. --- docs/cli.md | 2 +- .../changes/add-lifecycle-hooks/design.md | 27 +-- .../changes/add-lifecycle-hooks/proposal.md | 13 +- .../specs/lifecycle-hooks/spec.md | 3 + openspec/changes/add-lifecycle-hooks/tasks.md | 5 + openspec/config.yaml | 12 ++ src/core/artifact-graph/types.ts | 3 + src/core/templates/skill-templates.ts | 162 ++++++++++++++---- test/commands/artifact-workflow.test.ts | 4 +- 9 files changed, 181 insertions(+), 50 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 527b07774..1ca15f23e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -487,7 +487,7 @@ This command has three modes: **Hook mode** (`openspec instructions --hook [--change ]`): Returns lifecycle hooks for a given point. With `--change`, resolves hooks from the change's schema and project config. Without `--change`, resolves from `config.yaml`'s default schema and config. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. -Valid lifecycle points: `pre-new`, `post-new`, `pre-continue`, `post-continue`, `pre-ff`, `post-ff`, `pre-apply`, `post-apply`, `pre-verify`, `post-verify`, `pre-sync`, `post-sync`, `pre-archive`, `post-archive`. Note: `pre-continue`/`post-continue` hooks also fire for each artifact iteration inside the `ff` skill. +Valid lifecycle points: `pre-explore`, `post-explore`, `pre-new`, `post-new`, `pre-continue`, `post-continue`, `pre-ff`, `post-ff`, `pre-apply`, `post-apply`, `pre-verify`, `post-verify`, `pre-sync`, `post-sync`, `pre-archive`, `post-archive`, `pre-bulk-archive`, `post-bulk-archive`, `pre-onboard`, `post-onboard`. Note: `pre-continue`/`post-continue` hooks also fire for each artifact iteration inside the `ff` skill, and `pre-archive`/`post-archive` hooks fire for each individual change inside `bulk-archive`. **Examples:** diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md index e36dfa4c9..9ab327af6 100644 --- a/openspec/changes/add-lifecycle-hooks/design.md +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -19,7 +19,7 @@ Key files: - Allow schemas to define LLM instruction hooks at operation lifecycle points - Allow projects to add/extend hooks via config.yaml - Expose hooks via `openspec instructions --hook` for skills to consume -- Update all operation skills (archive, sync, new, apply, verify, continue, ff) to execute hooks +- Update all skills (explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard) to execute hooks **Non-Goals:** - Shell script execution (`run` field) — deferred to future iteration @@ -157,19 +157,24 @@ This function: ### Decision 6: Valid lifecycle points -14 lifecycle points covering all operations: +20 lifecycle points covering all operations: ``` -pre-new post-new — creating a change -pre-continue post-continue — creating an artifact (one invocation of continue) -pre-ff post-ff — fast-forward artifact generation (wraps the entire ff run) -pre-apply post-apply — implementing tasks -pre-verify post-verify — verifying implementation -pre-sync post-sync — syncing delta specs -pre-archive post-archive — archiving a change +pre-explore post-explore — entering/exiting explore mode +pre-new post-new — creating a change +pre-continue post-continue — creating an artifact (one invocation of continue) +pre-ff post-ff — fast-forward artifact generation (wraps the entire ff run) +pre-apply post-apply — implementing tasks +pre-verify post-verify — verifying implementation +pre-sync post-sync — syncing delta specs +pre-archive post-archive — archiving a change +pre-bulk-archive post-bulk-archive — batch archiving (wraps the entire bulk operation) +pre-onboard post-onboard — onboarding session ``` -The `ff` skill fires `pre-ff`/`post-ff` around the entire operation, and `pre-continue`/`post-continue` for each artifact iteration within it. This allows hooks to run both per-artifact (continue) and per-batch (ff). +Nesting patterns: +- The `ff` skill fires `pre-ff`/`post-ff` around the entire operation, and `pre-continue`/`post-continue` for each artifact iteration within it. +- The `bulk-archive` skill fires `pre-bulk-archive`/`post-bulk-archive` around the batch, and `pre-archive`/`post-archive` for each individual change within it. These are defined in `VALID_LIFECYCLE_POINTS` in `types.ts` and validated at runtime. @@ -189,7 +194,7 @@ openspec instructions --hook post-archive --change "" --json → If hooks returned, follow each instruction in order ``` -The same pattern applies to all skills: new, continue, ff, apply, verify, sync, archive. +The same pattern applies to all skills: explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard. The `ff` skill has a nested pattern: it fires `pre-ff` at the start, then for each artifact creation it fires `pre-continue`/`post-continue` (reusing the continue hooks), and finally `post-ff` at the end: diff --git a/openspec/changes/add-lifecycle-hooks/proposal.md b/openspec/changes/add-lifecycle-hooks/proposal.md index ece11ab58..945df56f7 100644 --- a/openspec/changes/add-lifecycle-hooks/proposal.md +++ b/openspec/changes/add-lifecycle-hooks/proposal.md @@ -17,18 +17,21 @@ Related issues: #682 (extensible hook capability), #557 (ADR lifecycle hooks), # - Add a `hooks` section to `config.yaml` for project-level lifecycle hooks - Create hook resolution function (schema + config merge, schema first) - Expose hooks via a `--hook ` flag on `openspec instructions`. Supports optional `--change ` to resolve hooks from the change's schema; without `--change`, resolves from config.yaml's default schema. The `--hook` flag is mutually exclusive with the `[artifact]` positional argument — using both produces an error. Hook resolution logic lives in `hooks.ts` as an internal module -- Update skills (archive, sync, new, apply, verify, continue, ff) to query and execute hooks at their lifecycle points +- Update all skills (explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard) to query and execute hooks at their lifecycle points - Document the `instructions` command covering all modes (artifact, apply, `--hook`) - Hooks are LLM instructions only in this iteration — no `run` field for shell execution (deferred to future iteration) -Supported lifecycle points: +Supported lifecycle points (20 total): +- `pre-explore` / `post-explore` — entering/exiting explore mode - `pre-new` / `post-new` — creating a change - `pre-continue` / `post-continue` — creating an artifact (also fires inside ff) - `pre-ff` / `post-ff` — fast-forward artifact generation - `pre-apply` / `post-apply` — implementing tasks - `pre-verify` / `post-verify` — verifying implementation - `pre-sync` / `post-sync` — syncing delta specs -- `pre-archive` / `post-archive` — archiving a change +- `pre-archive` / `post-archive` — archiving a change (also fires per change inside bulk-archive) +- `pre-bulk-archive` / `post-bulk-archive` — batch archiving +- `pre-onboard` / `post-onboard` — onboarding session ### Example: schema.yaml ```yaml @@ -79,8 +82,8 @@ hooks: - **Schema format**: `schema.yaml` gains optional `hooks` field — fully backward-compatible - **Config format**: `config.yaml` gains optional `hooks` field — fully backward-compatible - **CLI**: `openspec instructions --hook ` exposes hooks. `--hook` is mutually exclusive with `[artifact]` positional — error if both provided -- **Skills**: Archive, sync, new, apply, verify, continue, and ff skills use `openspec instructions --hook` -- **Lifecycle points**: 14 total — `pre/post` for new, continue, ff, apply, verify, sync, archive. The ff skill fires `pre-ff`/`post-ff` around the entire operation, and `pre-continue`/`post-continue` for each artifact iteration within it +- **Skills**: All skills (explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard) use `openspec instructions --hook` +- **Lifecycle points**: 20 total — `pre/post` for explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard. The ff skill fires `pre-ff`/`post-ff` around the entire operation and `pre-continue`/`post-continue` for each artifact iteration. The bulk-archive skill fires `pre-bulk-archive`/`post-bulk-archive` around the batch and `pre-archive`/`post-archive` for each individual change - **Existing schemas**: Unaffected — `hooks` is optional - **Tests**: Hook tests via `instructions --hook`, verify hook tests - **Validation**: Hook keys validated against `VALID_LIFECYCLE_POINTS`; unknown keys emit warnings diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md index 61b6a1a65..b2bd1c616 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -46,6 +46,7 @@ The system SHALL support an optional `hooks` section in `config.yaml` with the s The system SHALL recognize the following lifecycle points as valid hook keys: +- `pre-explore`, `post-explore` - `pre-new`, `post-new` - `pre-continue`, `post-continue` - `pre-ff`, `post-ff` @@ -53,6 +54,8 @@ The system SHALL recognize the following lifecycle points as valid hook keys: - `pre-verify`, `post-verify` - `pre-sync`, `post-sync` - `pre-archive`, `post-archive` +- `pre-bulk-archive`, `post-bulk-archive` +- `pre-onboard`, `post-onboard` #### Scenario: All valid lifecycle points accepted diff --git a/openspec/changes/add-lifecycle-hooks/tasks.md b/openspec/changes/add-lifecycle-hooks/tasks.md index a86c18e3a..5390eec29 100644 --- a/openspec/changes/add-lifecycle-hooks/tasks.md +++ b/openspec/changes/add-lifecycle-hooks/tasks.md @@ -20,6 +20,7 @@ - [x] 4.1 Add `pre-verify` and `post-verify` to `VALID_LIFECYCLE_POINTS` in `src/core/artifact-graph/types.ts` - [x] 4.2 Add `pre-continue`, `post-continue`, `pre-ff`, `post-ff` to `VALID_LIFECYCLE_POINTS` in `src/core/artifact-graph/types.ts` +- [x] 4.3 Add `pre-explore`, `post-explore`, `pre-bulk-archive`, `post-bulk-archive`, `pre-onboard`, `post-onboard` to `VALID_LIFECYCLE_POINTS` (20 total) ## 5. CLI: Merge hooks into instructions command @@ -40,6 +41,9 @@ - [x] 6.8 Add pre/post-continue hook steps to `getContinueChangeSkillTemplate()` and `getOpsxContinueCommandTemplate()` - [x] 6.9 Add pre-ff/post-ff hook steps and pre/post-continue per-artifact hooks to `getFfChangeSkillTemplate()` and `getOpsxFfCommandTemplate()` - [x] 6.10 Regenerate agent skills with `openspec update --force` +- [x] 6.11 Add pre/post-explore hook steps to `getExploreSkillTemplate()` and `getOpsxExploreCommandTemplate()` +- [x] 6.12 Add pre/post-bulk-archive hook steps to `getBulkArchiveChangeSkillTemplate()` and `getOpsxBulkArchiveCommandTemplate()`, including per-change pre/post-archive hooks +- [x] 6.13 Add pre/post-onboard hook steps to `getOnboardInstructions()` ## 7. Documentation @@ -55,3 +59,4 @@ - [x] 8.6 CLI integration: Add `pre-verify`/`post-verify` as valid lifecycle points in tests - [x] 8.8 CLI integration: Add `pre-continue`/`post-continue`/`pre-ff`/`post-ff` as valid lifecycle points in tests - [x] 8.9 Verify existing tests still pass (no regressions) +- [x] 8.10 CLI integration: Add `pre-explore`/`post-explore`/`pre-bulk-archive`/`post-bulk-archive`/`pre-onboard`/`post-onboard` as valid lifecycle points in tests diff --git a/openspec/config.yaml b/openspec/config.yaml index 6b56248bf..3dee5e94e 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -27,6 +27,10 @@ context: | - Consider case sensitivity differences in file systems hooks: + pre-explore: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-explore`" + post-explore: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-explore`" pre-new: instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-new`" post-new: @@ -55,6 +59,14 @@ hooks: instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-archive`" post-archive: instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-archive`" + pre-bulk-archive: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-bulk-archive`" + post-bulk-archive: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-bulk-archive`" + pre-onboard: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-onboard`" + post-onboard: + instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-onboard`" rules: specs: diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index 1936b0e7f..d1859e89d 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -67,6 +67,7 @@ export type ChangeMetadata = z.infer; // Valid lifecycle points for hooks export const VALID_LIFECYCLE_POINTS = [ + 'pre-explore', 'post-explore', 'pre-new', 'post-new', 'pre-continue', 'post-continue', 'pre-ff', 'post-ff', @@ -74,6 +75,8 @@ export const VALID_LIFECYCLE_POINTS = [ 'pre-verify', 'post-verify', 'pre-sync', 'post-sync', 'pre-archive', 'post-archive', + 'pre-bulk-archive', 'post-bulk-archive', + 'pre-onboard', 'post-onboard', ] as const; export type LifecyclePoint = typeof VALID_LIFECYCLE_POINTS[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 824311b58..0b549a773 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -95,9 +95,17 @@ Depending on what the user brings, you might: You have full context of the OpenSpec system. Use it naturally, don't force it. +### Execute pre-explore hooks + +At the start, run: +\`\`\`bash +openspec instructions --hook pre-explore --json +\`\`\` +If hooks are returned, follow each instruction in order before proceeding. + ### Check for context -At the start, quickly check what exists: +Then check what exists: \`\`\`bash openspec list --json \`\`\` @@ -303,7 +311,15 @@ But this summary is optional. Sometimes the thinking IS the value. - **Don't auto-capture** - Offer to save insights, don't just do it - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality -- **Do question assumptions** - Including the user's and your own`, +- **Do question assumptions** - Including the user's and your own + +## Post-explore hooks + +When the exploration session ends (user moves on, starts a change, or explicitly exits explore mode), run: +\`\`\`bash +openspec instructions --hook post-explore --json +\`\`\` +If hooks are returned, follow each instruction in order.`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -1019,9 +1035,17 @@ function getOnboardInstructions(): string { --- +## Pre-onboard hooks + +Before starting, run: +\`\`\`bash +openspec instructions --hook pre-onboard --json +\`\`\` +If hooks are returned, follow each instruction in order before proceeding. + ## Preflight -Before starting, check if the OpenSpec CLI is installed: +Check if the OpenSpec CLI is installed: \`\`\`bash # Unix/macOS @@ -1533,6 +1557,14 @@ Exit gracefully. --- +## Post-onboard hooks + +When the onboarding session ends (user completes the cycle, exits, or moves on), run: +\`\`\`bash +openspec instructions --hook post-onboard --json +\`\`\` +If hooks are returned, follow each instruction in order. + ## Guardrails - **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive) @@ -1643,9 +1675,17 @@ Depending on what the user brings, you might: You have full context of the OpenSpec system. Use it naturally, don't force it. +### Execute pre-explore hooks + +At the start, run: +\`\`\`bash +openspec instructions --hook pre-explore --json +\`\`\` +If hooks are returned, follow each instruction in order before proceeding. + ### Check for context -At the start, quickly check what exists: +Then check what exists: \`\`\`bash openspec list --json \`\`\` @@ -1732,7 +1772,15 @@ When things crystallize, you might offer a summary - but it's optional. Sometime - **Don't auto-capture** - Offer to save insights, don't just do it - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality -- **Do question assumptions** - Including the user's and your own` +- **Do question assumptions** - Including the user's and your own + +## Post-explore hooks + +When the exploration session ends (user moves on, starts a change, or explicitly exits explore mode), run: +\`\`\`bash +openspec instructions --hook post-explore --json +\`\`\` +If hooks are returned, follow each instruction in order.` }; } @@ -2405,13 +2453,19 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Steps** -1. **Get active changes** +1. **Execute pre-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook pre-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +2. **Get active changes** Run \`openspec list --json\` to get all active changes. If no active changes exist, inform user and stop. -2. **Prompt for change selection** +3. **Prompt for change selection** Use **AskUserQuestion tool** with multi-select to let user choose changes: - Show each change with its schema @@ -2420,7 +2474,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **IMPORTANT**: Do NOT auto-select. Always let the user choose. -3. **Batch validation - gather status for all selected changes** +4. **Batch validation - gather status for all selected changes** For each selected change, collect: @@ -2436,7 +2490,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - List which capability specs exist - For each, extract requirement names (lines matching \`### Requirement: \`) -4. **Detect spec conflicts** +5. **Detect spec conflicts** Build a map of \`capability -> [changes that touch it]\`: @@ -2447,7 +2501,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig A conflict exists when 2+ selected changes have delta specs for the same capability. -5. **Resolve conflicts agentically** +6. **Resolve conflicts agentically** **For each conflict**, investigate the codebase: @@ -2467,7 +2521,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - In what order (if both) - Rationale (what was found in codebase) -6. **Show consolidated status table** +7. **Show consolidated status table** Display a table summarizing all changes: @@ -2492,7 +2546,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks \`\`\` -7. **Confirm batch operation** +8. **Confirm batch operation** Use **AskUserQuestion tool** with a single confirmation: @@ -2504,27 +2558,41 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig If there are incomplete changes, make clear they'll be archived with warnings. -8. **Execute archive for each confirmed change** +9. **Execute archive for each confirmed change** + + Process changes in the determined order (respecting conflict resolution). - Process changes in the determined order (respecting conflict resolution): + **For each change**: - a. **Sync specs** if delta specs exist: + a. **Execute pre-archive hooks**: + \`\`\`bash + openspec instructions --hook pre-archive --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + b. **Sync specs** if delta specs exist: - Use the openspec-sync-specs approach (agent-driven intelligent merge) - For conflicts, apply in resolved order - Track if sync was done - b. **Perform the archive**: + c. **Perform the archive**: \`\`\`bash mkdir -p openspec/changes/archive mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- \`\`\` - c. **Track outcome** for each change: + d. **Execute post-archive hooks**: + \`\`\`bash + openspec instructions --hook post-archive --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + e. **Track outcome** for each change: - Success: archived successfully - Failed: error during archive (record error) - Skipped: user chose not to archive (if applicable) -9. **Display summary** +10. **Display summary** Show final results: @@ -2620,6 +2688,12 @@ Failed K changes: No active changes found. Use \`/opsx:new\` to create a new change. \`\`\` +11. **Execute post-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook post-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order. + **Guardrails** - Allow any number of changes (1+ is fine, 2+ is the typical use case) - Always prompt for selection, never auto-select @@ -3187,13 +3261,19 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Steps** -1. **Get active changes** +1. **Execute pre-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook pre-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order before proceeding. + +2. **Get active changes** Run \`openspec list --json\` to get all active changes. If no active changes exist, inform user and stop. -2. **Prompt for change selection** +3. **Prompt for change selection** Use **AskUserQuestion tool** with multi-select to let user choose changes: - Show each change with its schema @@ -3202,7 +3282,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **IMPORTANT**: Do NOT auto-select. Always let the user choose. -3. **Batch validation - gather status for all selected changes** +4. **Batch validation - gather status for all selected changes** For each selected change, collect: @@ -3218,7 +3298,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - List which capability specs exist - For each, extract requirement names (lines matching \`### Requirement: \`) -4. **Detect spec conflicts** +5. **Detect spec conflicts** Build a map of \`capability -> [changes that touch it]\`: @@ -3229,7 +3309,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig A conflict exists when 2+ selected changes have delta specs for the same capability. -5. **Resolve conflicts agentically** +6. **Resolve conflicts agentically** **For each conflict**, investigate the codebase: @@ -3249,7 +3329,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - In what order (if both) - Rationale (what was found in codebase) -6. **Show consolidated status table** +7. **Show consolidated status table** Display a table summarizing all changes: @@ -3274,7 +3354,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks \`\`\` -7. **Confirm batch operation** +8. **Confirm batch operation** Use **AskUserQuestion tool** with a single confirmation: @@ -3286,27 +3366,41 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig If there are incomplete changes, make clear they'll be archived with warnings. -8. **Execute archive for each confirmed change** +9. **Execute archive for each confirmed change** + + Process changes in the determined order (respecting conflict resolution). - Process changes in the determined order (respecting conflict resolution): + **For each change**: - a. **Sync specs** if delta specs exist: + a. **Execute pre-archive hooks**: + \`\`\`bash + openspec instructions --hook pre-archive --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + b. **Sync specs** if delta specs exist: - Use the openspec-sync-specs approach (agent-driven intelligent merge) - For conflicts, apply in resolved order - Track if sync was done - b. **Perform the archive**: + c. **Perform the archive**: \`\`\`bash mkdir -p openspec/changes/archive mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- \`\`\` - c. **Track outcome** for each change: + d. **Execute post-archive hooks**: + \`\`\`bash + openspec instructions --hook post-archive --change "" --json + \`\`\` + If hooks are returned, follow each instruction in order. + + e. **Track outcome** for each change: - Success: archived successfully - Failed: error during archive (record error) - Skipped: user chose not to archive (if applicable) -9. **Display summary** +10. **Display summary** Show final results: @@ -3402,6 +3496,12 @@ Failed K changes: No active changes found. Use \`/opsx:new\` to create a new change. \`\`\` +11. **Execute post-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook post-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order. + **Guardrails** - Allow any number of changes (1+ is fine, 2+ is the typical use case) - Always prompt for selection, never auto-select diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 99c208cc7..6b7480615 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -939,8 +939,8 @@ context: Updated context expect(json2.lifecyclePoint).toBe('post-verify'); }, 60000); - it('should accept pre-continue, post-continue, pre-ff, post-ff as valid lifecycle points', async () => { - for (const point of ['pre-continue', 'post-continue', 'pre-ff', 'post-ff']) { + it('should accept all lifecycle points as valid', async () => { + for (const point of ['pre-explore', 'post-explore', 'pre-continue', 'post-continue', 'pre-ff', 'post-ff', 'pre-bulk-archive', 'post-bulk-archive', 'pre-onboard', 'post-onboard']) { const result = await runCLI( ['instructions', '--hook', point, '--json'], { cwd: tempDir, timeoutMs: 30000 } From f45322365748654a1fbb6bc9cf2c0cc5bab91278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 11:46:05 +0100 Subject: [PATCH 15/34] fix: improve hook instruction formatting and CLI validation - Use trimEnd() instead of trim() to preserve leading whitespace in hook instructions - Reject --schema in hook mode with explicit error instead of silently ignoring it - Clarify pre-new hook wording: schema hooks may apply if config.yaml sets a default schema (not "config-only") - Unify hook ordering note across all skill templates: consistently state "schema hooks first, then config hooks" --- src/cli/index.ts | 4 ++++ src/commands/workflow/hooks.ts | 2 +- src/core/templates/skill-templates.ts | 24 ++++++++++++------------ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index b7a277c9a..e73a70dfd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -454,6 +454,10 @@ program } if (options.hook) { + // --schema is not supported in hook mode + if (options.schema) { + throw new Error('--schema cannot be used with --hook'); + } // Hook mode: delegate to hooksCommand await hooksCommand(options.hook, { change: options.change, json: options.json }); } else if (artifactId === 'apply') { diff --git a/src/commands/workflow/hooks.ts b/src/commands/workflow/hooks.ts index 3d25b6d6d..9a0bb66de 100644 --- a/src/commands/workflow/hooks.ts +++ b/src/commands/workflow/hooks.ts @@ -100,7 +100,7 @@ function printHooksText(output: HooksOutput): void { for (const hook of hooks) { const label = hook.source === 'schema' ? 'From schema' : 'From config'; console.log(`### ${label}`); - console.log(hook.instruction.trim()); + console.log(hook.instruction.trimEnd()); console.log(); } } diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 0b549a773..26ed456fe 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -361,9 +361,9 @@ export function getNewChangeSkillTemplate(): SkillTemplate { 3. **Execute pre-new hooks** - Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (schema hooks may also apply if config.yaml sets a default schema). - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. If the \`hooks\` array is empty, skip this step. @@ -642,7 +642,7 @@ export function getApplyChangeSkillTemplate(): SkillTemplate { Run \`openspec instructions --hook post-apply --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -936,7 +936,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e Run \`openspec instructions --hook post-sync --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -1820,9 +1820,9 @@ export function getOpsxNewCommandTemplate(): CommandTemplate { 3. **Execute pre-new hooks** - Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (config-only, since the change does not exist yet). + Run \`openspec instructions --hook pre-new --json\` to check for lifecycle hooks (schema hooks may also apply if config.yaml sets a default schema). - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before proceeding. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before proceeding. If the \`hooks\` array is empty, skip this step. @@ -2096,7 +2096,7 @@ export function getOpsxApplyCommandTemplate(): CommandTemplate { Run \`openspec instructions --hook post-apply --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -2397,7 +2397,7 @@ export function getArchiveChangeSkillTemplate(): SkillTemplate { **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec instructions --hook post-archive --json\` (config-only hooks). - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -2794,7 +2794,7 @@ This is an **agent-driven** operation - you will read delta specs and directly e Run \`openspec instructions --hook post-sync --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -3043,7 +3043,7 @@ Use clear markdown with: Run \`openspec instructions --hook post-verify --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the report.`, + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the report.`, license: 'MIT', compatibility: 'Requires openspec CLI.', metadata: { author: 'openspec', version: '1.0' }, @@ -3146,7 +3146,7 @@ export function getOpsxArchiveCommandTemplate(): CommandTemplate { **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to \`openspec instructions --hook post-archive --json\` (config-only hooks). - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the summary. + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the summary. If the \`hooks\` array is empty, skip this step. @@ -3694,7 +3694,7 @@ Use clear markdown with: Run \`openspec instructions --hook post-verify --change "" --json\` to check for lifecycle hooks. - If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order. Complete all hook instructions before displaying the report.` + If the \`hooks\` array is non-empty, follow each hook's \`instruction\` in order (schema hooks first, then config hooks). Complete all hook instructions before displaying the report.` }; } /** From 49da5befc5d2c8b7c0be6bd115af323c58501ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 11:56:12 +0100 Subject: [PATCH 16/34] refactor: remove duplicated lifecycle point validation from hooks command The validation is already performed inside resolveHooks() in instruction-loader.ts. The CLI-level check for missing argument (undefined) is kept since it's specific to the command interface. --- src/commands/workflow/hooks.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/commands/workflow/hooks.ts b/src/commands/workflow/hooks.ts index 9a0bb66de..3f3bf5422 100644 --- a/src/commands/workflow/hooks.ts +++ b/src/commands/workflow/hooks.ts @@ -49,14 +49,6 @@ export async function hooksCommand( ); } - const validPoints = new Set(VALID_LIFECYCLE_POINTS); - if (!validPoints.has(lifecyclePoint)) { - spinner.stop(); - throw new Error( - `Invalid lifecycle point: "${lifecyclePoint}". Valid points:\n ${VALID_LIFECYCLE_POINTS.join('\n ')}` - ); - } - // Resolve change name if provided let changeName: string | null = null; if (options.change) { From f65a5321e6dabcaec221c0510780de01efc67ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 11:59:29 +0100 Subject: [PATCH 17/34] test: validate all 20 lifecycle points in hook acceptance test --- test/commands/artifact-workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 6b7480615..260708818 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -940,7 +940,7 @@ context: Updated context }, 60000); it('should accept all lifecycle points as valid', async () => { - for (const point of ['pre-explore', 'post-explore', 'pre-continue', 'post-continue', 'pre-ff', 'post-ff', 'pre-bulk-archive', 'post-bulk-archive', 'pre-onboard', 'post-onboard']) { + for (const point of ['pre-explore', 'post-explore', 'pre-new', 'post-new', 'pre-continue', 'post-continue', 'pre-ff', 'post-ff', 'pre-apply', 'post-apply', 'pre-verify', 'post-verify', 'pre-sync', 'post-sync', 'pre-archive', 'post-archive', 'pre-bulk-archive', 'post-bulk-archive', 'pre-onboard', 'post-onboard']) { const result = await runCLI( ['instructions', '--hook', point, '--json'], { cwd: tempDir, timeoutMs: 30000 } From 2362ddb961b8f7e9636d846851f07e522272152a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:35:33 +0100 Subject: [PATCH 18/34] =?UTF-8?q?fix:=20correct=20lifecycle=20points=20cou?= =?UTF-8?q?nt=20in=20design=20doc=20(10=20=E2=86=92=2020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/add-lifecycle-hooks/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md index 9ab327af6..46a00ddb9 100644 --- a/openspec/changes/add-lifecycle-hooks/design.md +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -236,7 +236,7 @@ Three levels of testing, following existing patterns in the codebase: ## Risks / Trade-offs - **[LLM compliance]** Hooks are instructions the LLM should follow, but there's no guarantee it will execute them perfectly. → Mitigation: Same limitation applies to artifact instructions, which work well in practice. Hook instructions should be written as clear, actionable prompts. -- **[Hook sprawl]** Users might define too many hooks, making operations slow. → Mitigation: Start with 10 lifecycle points only. Each hook adds one CLI call + LLM reasoning time, which is bounded. +- **[Hook sprawl]** Users might define too many hooks, making operations slow. → Mitigation: Start with 20 lifecycle points only. Each hook adds one CLI call + LLM reasoning time, which is bounded. - **[Schema/config conflict]** Both define hooks for the same point — user might expect override semantics. → Mitigation: Document clearly that both execute (schema first, config second). This is additive, not override. ## Resolved Questions From 1ab276ca630b95c972bba4f465483a5b274b97e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:35:48 +0100 Subject: [PATCH 19/34] fix: list all 10 operations in lifecycle hooks spec purpose section --- .../changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md index b2bd1c616..aca8d83a4 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -1,7 +1,7 @@ # Lifecycle Hooks Specification ## Purpose -Lifecycle hooks allow schemas and projects to define LLM instructions that execute at operation boundaries (pre/post archive, sync, new, apply, verify). Schema-level hooks define workflow-inherent behavior; project-level hooks add project-specific customization. Both are surfaced to the LLM via the `openspec instructions --hook` flag. +Lifecycle hooks allow schemas and projects to define LLM instructions that execute at operation boundaries (pre/post for each operation: explore, new, continue, ff, apply, verify, sync, archive, bulk-archive, onboard). Schema-level hooks define workflow-inherent behavior; project-level hooks add project-specific customization. Both are surfaced to the LLM via the `openspec instructions --hook` flag. ## Requirements From a48ec4edcf9f8a6f29aa16e97e15fa88912b108a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:36:12 +0100 Subject: [PATCH 20/34] fix: remove numbering gaps in tasks checklist (6.7, 8.7) --- openspec/changes/add-lifecycle-hooks/tasks.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openspec/changes/add-lifecycle-hooks/tasks.md b/openspec/changes/add-lifecycle-hooks/tasks.md index 5390eec29..cfd47b3b0 100644 --- a/openspec/changes/add-lifecycle-hooks/tasks.md +++ b/openspec/changes/add-lifecycle-hooks/tasks.md @@ -38,12 +38,12 @@ - [x] 6.4 Update sync skill templates to call hooks at pre/post-sync points - [x] 6.5 Change all hook invocations in skill templates from `openspec hooks ` to `openspec instructions --hook ` (~18 occurrences in `src/core/templates/skill-templates.ts`) - [x] 6.6 Add pre/post-verify hook steps to `getVerifyChangeSkillTemplate()` and `getOpsxVerifyCommandTemplate()` in `src/core/templates/skill-templates.ts` -- [x] 6.8 Add pre/post-continue hook steps to `getContinueChangeSkillTemplate()` and `getOpsxContinueCommandTemplate()` -- [x] 6.9 Add pre-ff/post-ff hook steps and pre/post-continue per-artifact hooks to `getFfChangeSkillTemplate()` and `getOpsxFfCommandTemplate()` -- [x] 6.10 Regenerate agent skills with `openspec update --force` -- [x] 6.11 Add pre/post-explore hook steps to `getExploreSkillTemplate()` and `getOpsxExploreCommandTemplate()` -- [x] 6.12 Add pre/post-bulk-archive hook steps to `getBulkArchiveChangeSkillTemplate()` and `getOpsxBulkArchiveCommandTemplate()`, including per-change pre/post-archive hooks -- [x] 6.13 Add pre/post-onboard hook steps to `getOnboardInstructions()` +- [x] 6.7 Add pre/post-continue hook steps to `getContinueChangeSkillTemplate()` and `getOpsxContinueCommandTemplate()` +- [x] 6.8 Add pre-ff/post-ff hook steps and pre/post-continue per-artifact hooks to `getFfChangeSkillTemplate()` and `getOpsxFfCommandTemplate()` +- [x] 6.9 Regenerate agent skills with `openspec update --force` +- [x] 6.10 Add pre/post-explore hook steps to `getExploreSkillTemplate()` and `getOpsxExploreCommandTemplate()` +- [x] 6.11 Add pre/post-bulk-archive hook steps to `getBulkArchiveChangeSkillTemplate()` and `getOpsxBulkArchiveCommandTemplate()`, including per-change pre/post-archive hooks +- [x] 6.12 Add pre/post-onboard hook steps to `getOnboardInstructions()` ## 7. Documentation @@ -57,6 +57,6 @@ - [x] 8.4 CLI integration: Update existing hook tests in `test/commands/artifact-workflow.test.ts` to use `instructions --hook` instead of `hooks` command - [x] 8.5 CLI integration: Add mutual exclusivity test — `instructions --hook ` returns error - [x] 8.6 CLI integration: Add `pre-verify`/`post-verify` as valid lifecycle points in tests -- [x] 8.8 CLI integration: Add `pre-continue`/`post-continue`/`pre-ff`/`post-ff` as valid lifecycle points in tests -- [x] 8.9 Verify existing tests still pass (no regressions) -- [x] 8.10 CLI integration: Add `pre-explore`/`post-explore`/`pre-bulk-archive`/`post-bulk-archive`/`pre-onboard`/`post-onboard` as valid lifecycle points in tests +- [x] 8.7 CLI integration: Add `pre-continue`/`post-continue`/`pre-ff`/`post-ff` as valid lifecycle points in tests +- [x] 8.8 Verify existing tests still pass (no regressions) +- [x] 8.9 CLI integration: Add `pre-explore`/`post-explore`/`pre-bulk-archive`/`post-bulk-archive`/`pre-onboard`/`post-onboard` as valid lifecycle points in tests From b23083eab333f999697810b76e77d942f533ea4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:36:42 +0100 Subject: [PATCH 21/34] fix: remove test scaffolding hooks from production config --- openspec/config.yaml | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/openspec/config.yaml b/openspec/config.yaml index 3dee5e94e..42706992c 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -26,48 +26,6 @@ context: | - Tests must use path.join() for expected path values, not hardcoded strings - Consider case sensitivity differences in file systems -hooks: - pre-explore: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-explore`" - post-explore: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-explore`" - pre-new: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-new`" - post-new: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-new`" - pre-continue: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-continue`" - post-continue: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-continue`" - pre-ff: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-ff`" - post-ff: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-ff`" - pre-apply: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-apply`" - post-apply: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-apply`" - pre-verify: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-verify`" - post-verify: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-verify`" - pre-sync: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-sync`" - post-sync: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-sync`" - pre-archive: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-archive`" - post-archive: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-archive`" - pre-bulk-archive: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-bulk-archive`" - post-bulk-archive: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-bulk-archive`" - pre-onboard: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] pre-onboard`" - post-onboard: - instruction: "Append a line to HOOKSTEST.md in the project root: `[config] post-onboard`" - rules: specs: - Include scenarios for Windows path handling when dealing with file paths From f92301b719210d32dfb1f3a408b5a8051337f365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:36:56 +0100 Subject: [PATCH 22/34] fix: add error handling for readProjectConfig in resolveHooks --- src/core/artifact-graph/instruction-loader.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index f36bebd07..70854fa74 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -401,7 +401,12 @@ export function resolveHooks( } const hooks: ResolvedHook[] = []; - const config = readProjectConfig(projectRoot); + let config = null; + try { + config = readProjectConfig(projectRoot); + } catch { + // If config read fails, continue without config hooks + } // 1. Schema hooks // If a change is specified, resolve schema from the change's metadata. From 2af999b58746ee27abe216ffcbeba4613d4c24ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:38:01 +0100 Subject: [PATCH 23/34] fix: move post-bulk-archive hooks before summary display Consistent with other post-hooks (post-archive, post-apply, post-sync) which execute before the final summary is shown. --- src/core/templates/skill-templates.ts | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 26ed456fe..be51e27a3 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -2592,7 +2592,13 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - Failed: error during archive (record error) - Skipped: user chose not to archive (if applicable) -10. **Display summary** +10. **Execute post-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook post-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order. + +11. **Display summary** Show final results: @@ -2688,12 +2694,6 @@ Failed K changes: No active changes found. Use \`/opsx:new\` to create a new change. \`\`\` -11. **Execute post-bulk-archive hooks** - \`\`\`bash - openspec instructions --hook post-bulk-archive --json - \`\`\` - If hooks are returned, follow each instruction in order. - **Guardrails** - Allow any number of changes (1+ is fine, 2+ is the typical use case) - Always prompt for selection, never auto-select @@ -3400,7 +3400,13 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - Failed: error during archive (record error) - Skipped: user chose not to archive (if applicable) -10. **Display summary** +10. **Execute post-bulk-archive hooks** + \`\`\`bash + openspec instructions --hook post-bulk-archive --json + \`\`\` + If hooks are returned, follow each instruction in order. + +11. **Display summary** Show final results: @@ -3496,12 +3502,6 @@ Failed K changes: No active changes found. Use \`/opsx:new\` to create a new change. \`\`\` -11. **Execute post-bulk-archive hooks** - \`\`\`bash - openspec instructions --hook post-bulk-archive --json - \`\`\` - If hooks are returned, follow each instruction in order. - **Guardrails** - Allow any number of changes (1+ is fine, 2+ is the typical use case) - Always prompt for selection, never auto-select From 6804ad319dcfe5ee66da459c46c52af034348b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:38:18 +0100 Subject: [PATCH 24/34] refactor: hoist Zod hook schema outside loop in config parser --- src/core/project-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/project-config.ts b/src/core/project-config.ts index bf5b97596..6376a1954 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -168,6 +168,7 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { const parsedHooks: Record = {}; let hasValidHooks = false; const validPoints = new Set(VALID_LIFECYCLE_POINTS); + const hookSchema = z.object({ instruction: z.string().min(1) }); for (const [point, hook] of Object.entries(raw.hooks)) { // Warn on unrecognized lifecycle points @@ -176,7 +177,7 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { continue; } - const hookResult = z.object({ instruction: z.string().min(1) }).safeParse(hook); + const hookResult = hookSchema.safeParse(hook); if (hookResult.success) { parsedHooks[point] = hookResult.data; hasValidHooks = true; From 03f7a8ba58a81aeffb02c433260fc47b5084b7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:38:48 +0100 Subject: [PATCH 25/34] refactor: hoist lifecycle points Set to module scope --- src/core/artifact-graph/instruction-loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index 70854fa74..be3aded25 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -10,6 +10,7 @@ import { VALID_LIFECYCLE_POINTS } from './types.js'; // Session-level cache for validation warnings (avoid repeating same warnings) const shownWarnings = new Set(); +const validLifecyclePoints = new Set(VALID_LIFECYCLE_POINTS); /** * Error thrown when loading a template fails. @@ -394,8 +395,7 @@ export function resolveHooks( changeName: string | null, lifecyclePoint: string ): ResolvedHook[] { - const validPoints = new Set(VALID_LIFECYCLE_POINTS); - if (!validPoints.has(lifecyclePoint)) { + if (!validLifecyclePoints.has(lifecyclePoint)) { const valid = VALID_LIFECYCLE_POINTS.join(', '); throw new Error(`Invalid lifecycle point: "${lifecyclePoint}". Valid points: ${valid}`); } From acf43abfaab58657952c19a220c2dd807df96bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:39:04 +0100 Subject: [PATCH 26/34] refactor: move type alias after all imports in CLI entry --- src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index e73a70dfd..4b3d1509a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -32,9 +32,9 @@ import { type NewChangeOptions, type HooksOptions, } from '../commands/workflow/index.js'; +import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; type InstructionsActionOptions = InstructionsOptions & { hook?: string }; -import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; const program = new Command(); const require = createRequire(import.meta.url); From 167c1d11d07af47e013a4e90c64ac781c95bc181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:39:27 +0100 Subject: [PATCH 27/34] refactor: import VALID_LIFECYCLE_POINTS in acceptance test --- test/commands/artifact-workflow.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 260708818..2ff8f4c26 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { runCLI } from '../helpers/run-cli.js'; +import { VALID_LIFECYCLE_POINTS } from '../../src/core/artifact-graph/types.js'; describe('artifact-workflow CLI commands', () => { let tempDir: string; @@ -940,7 +941,7 @@ context: Updated context }, 60000); it('should accept all lifecycle points as valid', async () => { - for (const point of ['pre-explore', 'post-explore', 'pre-new', 'post-new', 'pre-continue', 'post-continue', 'pre-ff', 'post-ff', 'pre-apply', 'post-apply', 'pre-verify', 'post-verify', 'pre-sync', 'post-sync', 'pre-archive', 'post-archive', 'pre-bulk-archive', 'post-bulk-archive', 'pre-onboard', 'post-onboard']) { + for (const point of VALID_LIFECYCLE_POINTS) { const result = await runCLI( ['instructions', '--hook', point, '--json'], { cwd: tempDir, timeoutMs: 30000 } From b480ec5a9104a0ea1bdaa81c9a6e8b6bade7955f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:42:07 +0100 Subject: [PATCH 28/34] fix: remove test spec compliance block from production config --- openspec/config.yaml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/openspec/config.yaml b/openspec/config.yaml index 42706992c..83ed33952 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,20 +5,6 @@ context: | Package manager: pnpm CLI framework: Commander.js - Spec compliance: - - Before explore, creating or updating any artifact, or implementing/modifying code, - run `openspec list --specs --json` to discover existing specs and their summaries. - Identify relevant specs from the summaries, then read each in - openspec/specs//*.md. - - Ensure changes don't contradict existing requirements. - - Code must conform to all applicable spec requirements. - - If a spec is unclear, incomplete, or potentially conflicts with the - implementation, stop and inform the user. The user decides whether to - proceed as-is or explore an alternative approach. - - If a decision changes an existing spec's assumptions, note it explicitly. - - If new specs dependencies are discovered, update the relevant artifacts - (design.md, proposal.md, tasks.md, etc.) to reflect them before continuing. - Cross-platform requirements: - This tool runs on macOS, Linux, AND Windows - Always use path.join() or path.resolve() for file paths - never hardcode slashes From d73ed5f7ba6a2370627b28ea84dd94cfb337a7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:55:35 +0100 Subject: [PATCH 29/34] fix: add language identifiers to fenced code blocks in design doc --- openspec/changes/add-lifecycle-hooks/design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-lifecycle-hooks/design.md b/openspec/changes/add-lifecycle-hooks/design.md index 46a00ddb9..7add4b386 100644 --- a/openspec/changes/add-lifecycle-hooks/design.md +++ b/openspec/changes/add-lifecycle-hooks/design.md @@ -87,7 +87,7 @@ Output (JSON mode, without change — schema resolved from config.yaml): ``` Output (text mode): -``` +```text ## Hooks: post-archive (change: add-dark-mode) ### From schema (spec-driven) @@ -159,7 +159,7 @@ This function: 20 lifecycle points covering all operations: -``` +```text pre-explore post-explore — entering/exiting explore mode pre-new post-new — creating a change pre-continue post-continue — creating an artifact (one invocation of continue) @@ -182,7 +182,7 @@ These are defined in `VALID_LIFECYCLE_POINTS` in `types.ts` and validated at run Skills call `openspec instructions --hook` and follow the returned instructions. Example for archive skill: -``` +```bash # Before archive operation: openspec instructions --hook pre-archive --change "" --json → If hooks returned, follow each instruction in order @@ -198,7 +198,7 @@ The same pattern applies to all skills: explore, new, continue, ff, apply, verif The `ff` skill has a nested pattern: it fires `pre-ff` at the start, then for each artifact creation it fires `pre-continue`/`post-continue` (reusing the continue hooks), and finally `post-ff` at the end: -``` +```text ff: pre-ff ├── pre-continue → create artifact 1 → post-continue From dd090313a4c898f3e526f9cf6f27452f0812104f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 12:55:53 +0100 Subject: [PATCH 30/34] fix: add --schema rejection scenario and clarify empty JSON output in spec --- .../add-lifecycle-hooks/specs/lifecycle-hooks/spec.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md index aca8d83a4..44a8f80c9 100644 --- a/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md +++ b/openspec/changes/add-lifecycle-hooks/specs/lifecycle-hooks/spec.md @@ -117,6 +117,11 @@ The system SHALL expose hooks via `openspec instructions --hook --hook ` - **THEN** the system exits with an error indicating that `--hook` cannot be used with an artifact argument +#### Scenario: Schema override not allowed in hook mode + +- **WHEN** executing `openspec instructions --hook --schema ` +- **THEN** the system exits with an error indicating that `--schema` cannot be used with `--hook` + #### Scenario: JSON output - **WHEN** executing with `--json` flag @@ -126,7 +131,7 @@ The system SHALL expose hooks via `openspec instructions --hook Date: Thu, 12 Feb 2026 12:56:09 +0100 Subject: [PATCH 31/34] fix: add fallback note for post-archive hooks in bulk-archive templates --- src/core/templates/skill-templates.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index be51e27a3..40da2783b 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -2585,6 +2585,10 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig \`\`\`bash openspec instructions --hook post-archive --change "" --json \`\`\` + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to: + \`\`\`bash + openspec instructions --hook post-archive --json + \`\`\` If hooks are returned, follow each instruction in order. e. **Track outcome** for each change: @@ -3393,6 +3397,10 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig \`\`\`bash openspec instructions --hook post-archive --change "" --json \`\`\` + **Note:** The change has been moved to archive, so the \`--change\` flag may not resolve. If this fails, fall back to: + \`\`\`bash + openspec instructions --hook post-archive --json + \`\`\` If hooks are returned, follow each instruction in order. e. **Track outcome** for each change: From 388d04a7ba13d35e900f90d58586d27e07212695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 13:02:55 +0100 Subject: [PATCH 32/34] test: add --schema + --hook mutual exclusivity test --- test/commands/artifact-workflow.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 2ff8f4c26..1c35b3eca 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -920,6 +920,17 @@ context: Updated context expect(output).toContain('--hook cannot be used with an artifact argument'); }, 60000); + it('should error when --schema used with --hook', async () => { + const result = await runCLI( + ['instructions', '--hook', 'pre-archive', '--schema', 'spec-driven'], + { cwd: tempDir, timeoutMs: 30000 } + ); + expect(result.exitCode).toBe(1); + + const output = getOutput(result); + expect(output).toContain('--schema cannot be used with --hook'); + }, 60000); + it('should accept pre-verify and post-verify as valid lifecycle points', async () => { // Run instructions --hook with pre-verify const result1 = await runCLI( From 699e43de4127f6978dbe21e3da8c369b5a333d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Thu, 12 Feb 2026 13:12:17 +0100 Subject: [PATCH 33/34] refactor: use it.each for lifecycle point tests, remove redundant verify test --- test/commands/artifact-workflow.test.ts | 36 +++++-------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 1c35b3eca..d337af6cd 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -931,37 +931,15 @@ context: Updated context expect(output).toContain('--schema cannot be used with --hook'); }, 60000); - it('should accept pre-verify and post-verify as valid lifecycle points', async () => { - // Run instructions --hook with pre-verify - const result1 = await runCLI( - ['instructions', '--hook', 'pre-verify', '--json'], - { cwd: tempDir, timeoutMs: 30000 } - ); - expect(result1.exitCode).toBe(0); - const json1 = JSON.parse(result1.stdout); - expect(json1.lifecyclePoint).toBe('pre-verify'); - - // Run instructions --hook with post-verify - const result2 = await runCLI( - ['instructions', '--hook', 'post-verify', '--json'], + it.each(VALID_LIFECYCLE_POINTS)('should accept %s as a valid lifecycle point', async (point) => { + const result = await runCLI( + ['instructions', '--hook', point, '--json'], { cwd: tempDir, timeoutMs: 30000 } ); - expect(result2.exitCode).toBe(0); - const json2 = JSON.parse(result2.stdout); - expect(json2.lifecyclePoint).toBe('post-verify'); - }, 60000); - - it('should accept all lifecycle points as valid', async () => { - for (const point of VALID_LIFECYCLE_POINTS) { - const result = await runCLI( - ['instructions', '--hook', point, '--json'], - { cwd: tempDir, timeoutMs: 30000 } - ); - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.lifecyclePoint).toBe(point); - } - }, 60000); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.lifecyclePoint).toBe(point); + }, 30000); it('should return schema hooks before config hooks', async () => { // Create a custom schema with hooks From 49a4a9849c6195daa9c7872f3c14acc707543034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Lozano=20Garci=CC=81a?= Date: Fri, 13 Feb 2026 10:25:46 +0100 Subject: [PATCH 34/34] refactor: move hook option into InstructionsOptions type Eliminates the type intersection `& { hook?: string }` in cli/index.ts by adding hook directly to InstructionsOptions. --- src/cli/index.ts | 4 +--- src/commands/workflow/instructions.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 4b3d1509a..45f5be22d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -34,8 +34,6 @@ import { } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; -type InstructionsActionOptions = InstructionsOptions & { hook?: string }; - const program = new Command(); const require = createRequire(import.meta.url); const { version } = require('../../package.json'); @@ -446,7 +444,7 @@ program .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--hook ', 'Retrieve lifecycle hooks for a given point (mutually exclusive with [artifact])') .option('--json', 'Output as JSON') - .action(async (artifactId: string | undefined, options: InstructionsActionOptions) => { + .action(async (artifactId: string | undefined, options: InstructionsOptions) => { try { // Mutual exclusivity: --hook cannot be used with an artifact argument if (options.hook && artifactId) { diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 0d501afec..a71b1947d 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -29,6 +29,7 @@ export interface InstructionsOptions { change?: string; schema?: string; json?: boolean; + hook?: string; } export interface ApplyInstructionsOptions {