Compare commits

..

7 Commits

Author SHA1 Message Date
603d509b8f cleanup: retire dead transitional residue (#270)
Some checks failed
Main Confidence / confidence (push) Failing after 58s
## Summary
- remove deprecated baseline profile status alias constants and keep baseline lifecycle semantics on the canonical enum path
- retire the dead tenant app-status badge/default-fixture residue from the active runtime support path
- add the `234-dead-transitional-residue` spec, plan, research, data-model, quickstart, checklist, and task artifacts plus focused regression assertions

## Validation
- not rerun during this PR creation step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #270
2026-04-23 16:54:48 +00:00
6fdd45fb02 feat: surface stale active operation runs (#269)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary
- keep stale active operation runs visible in the tenant progress overlay and polling state
- align tenant and canonical operation surfaces around the shared stale-active presentation contract
- add Spec 233 artifacts and clean the promoted-candidate backlog entries

## Validation
- browser smoke: `/admin/t/18000000-0000-4000-8000-000000000180` -> stale dashboard CTA -> `/admin/operations?tenant_id=7&activeTab=active_stale_attention&problemClass=active_stale_attention` -> `/admin/operations/15`
- verified healthy vs likely-stale tenant cards, canonical stale list row, and canonical run detail consistency

## Notes
- local smoke fixture seeded with one fresh and one stale running `baseline_compare` operation for browser validation
- Pest suite was not re-run in this session before opening this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #269
2026-04-23 15:10:06 +00:00
2bf53f6337 Enforce operation run link contract (#268)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- enforce shared operation run link generation across admin and system surfaces
- add guard coverage to block new raw operation route bypasses outside explicit exceptions
- harden Filament theme asset resolution so stale or wrong-stack hot files fall back to built assets

## Testing
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Unit/Filament/PanelThemeAssetTest.php

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #268
2026-04-23 13:09:53 +00:00
421261a517 feat: implement finding outcome taxonomy (#267)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary
- implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics
- align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics
- add focused Pest coverage and complete the spec artifacts for feature 231

## Details
- manual resolve is limited to the canonical `remediated` outcome
- close and reopen flows now use bounded canonical reasons
- trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths
- duplicate lifecycle backfill now closes findings canonically as `duplicate`
- accepted-risk recording now uses the canonical `accepted_risk` reason
- finding detail and list surfaces now expose terminal outcome and verification summaries
- review, snapshot, and review-pack consumers now propagate the same outcome buckets

## Filament / Platform Contract
- Livewire v4.0+ compatibility remains intact
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false
- lifecycle mutations still run through confirmed Filament actions with capability enforcement
- no new asset family was added; the existing `filament:assets` deploy step is unchanged

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`
- browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed

## Notes
- this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #267
2026-04-23 07:29:05 +00:00
76334cb096 chore: migrate repo to managed spec-kit (#266)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary

Selective migration to the managed Spec Kit project structure.

## Included

- add managed Spec Kit integration metadata under `.specify/`
- add bundled `speckit` workflow registry
- add bundled `git` extension, scripts, and config
- add new `speckit.git.*` command surfaces for Copilot, Gemini, and `.agents`
- add the Spec Kit plan marker block to `.github/copilot-instructions.md`

## Intentionally excluded

- no replacement of the existing customized core `speckit.*.agent.md` files
- no `.vscode/settings.json` commit; the copied manifest was adjusted accordingly
- no changes to the active `specs/231-finding-outcome-taxonomy` work

## Validation

- `specify integration list`
- `specify workflow list`
- `specify extension list`
- focused managed-file diff review

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #266
2026-04-22 22:29:05 +00:00
742d65f0d9 feat: converge findings notification presentation (#265)
Some checks failed
Main Confidence / confidence (push) Failing after 51s
## Summary
- converge finding, queued, and completed database notifications on one shared `OperationUxPresenter` presentation contract
- preserve existing finding and operation deep-link authorities while standardizing title, body, status/icon treatment, and single primary action
- add focused notification, findings, and guard coverage plus the full feature 230 spec artifacts

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`

## Filament / Platform Notes
- Livewire v4.0+ compliance preserved on Filament v5 primitives
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- no globally searchable resource behavior changed in this feature
- no destructive actions were introduced
- asset strategy is unchanged; the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #265
2026-04-22 20:26:18 +00:00
12fb5ebb30 feat: add findings hygiene report and control catalog layering (#264)
Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
## Summary
- add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work
- add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring
- align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model
- extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance

## Notes
- validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md`
- this PR targets `dev` from `225-assignment-hygiene`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #264
2026-04-22 12:26:18 +00:00
170 changed files with 12562 additions and 477 deletions

View File

@ -0,0 +1,53 @@
---
name: speckit-git-commit
description: Auto-commit changes after a Spec Kit command completes
compatibility: Requires spec-kit project structure with .specify/ directory
metadata:
author: github-spec-kit
source: git:commands/speckit.git.commit.md
---
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View File

@ -0,0 +1,72 @@
---
name: speckit-git-feature
description: Create a feature branch with sequential or timestamp numbering
compatibility: Requires spec-kit project structure with .specify/ directory
metadata:
author: github-spec-kit
source: git:commands/speckit.git.feature.md
---
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@ -0,0 +1,54 @@
---
name: speckit-git-initialize
description: Initialize a Git repository with an initial commit
compatibility: Requires spec-kit project structure with .specify/ directory
metadata:
author: github-spec-kit
source: git:commands/speckit.git.initialize.md
---
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `✓ Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View File

@ -0,0 +1,50 @@
---
name: speckit-git-remote
description: Detect Git remote URL for GitHub integration
compatibility: Requires spec-kit project structure with .specify/ directory
metadata:
author: github-spec-kit
source: git:commands/speckit.git.remote.md
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@ -0,0 +1,54 @@
---
name: speckit-git-validate
description: Validate current branch follows feature branch naming conventions
compatibility: Requires spec-kit project structure with .specify/ directory
metadata:
author: github-spec-kit
source: git:commands/speckit.git.validate.md
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@ -0,0 +1,50 @@
description = "Auto-commit changes after a Spec Kit command completes"
# Source: git
prompt = """
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message
"""

View File

@ -0,0 +1,69 @@
description = "Create a feature branch with sequential or timestamp numbering"
# Source: git
prompt = """
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
```text
{{args}}
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used
"""

View File

@ -0,0 +1,51 @@
description = "Initialize a Git repository with an initial commit"
# Source: git
prompt = """
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- ` Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository
"""

View File

@ -0,0 +1,47 @@
description = "Detect Git remote URL for GitHub integration"
# Source: git
prompt = """
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error other workflows should continue without Git remote information
"""

View File

@ -0,0 +1,51 @@
description = "Validate current branch follows feature branch naming conventions"
# Source: git
prompt = """
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: ` On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: ` Spec directory found: <path>`
- If spec directory missing: ` No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: ` Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning
"""

View File

@ -238,6 +238,14 @@ ## Active Technologies
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene) - PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning) - Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning) - Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -272,10 +280,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) - 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` - 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -0,0 +1,51 @@
---
description: Auto-commit changes after a Spec Kit command completes
---
<!-- Extension: git -->
<!-- Config: .specify/extensions/git/ -->
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View File

@ -0,0 +1,70 @@
---
description: Create a feature branch with sequential or timestamp numbering
---
<!-- Extension: git -->
<!-- Config: .specify/extensions/git/ -->
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@ -0,0 +1,52 @@
---
description: Initialize a Git repository with an initial commit
---
<!-- Extension: git -->
<!-- Config: .specify/extensions/git/ -->
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `✓ Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View File

@ -0,0 +1,48 @@
---
description: Detect Git remote URL for GitHub integration
---
<!-- Extension: git -->
<!-- Config: .specify/extensions/git/ -->
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@ -0,0 +1,52 @@
---
description: Validate current branch follows feature branch naming conventions
---
<!-- Extension: git -->
<!-- Config: .specify/extensions/git/ -->
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@ -673,3 +673,8 @@ ### Replaced Utilities
| decoration-slice | box-decoration-slice | | decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone | | decoration-clone | box-decoration-clone |
</laravel-boost-guidelines> </laravel-boost-guidelines>
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan
<!-- SPECKIT END -->

View File

@ -0,0 +1,3 @@
---
agent: speckit.git.commit
---

View File

@ -0,0 +1,3 @@
---
agent: speckit.git.feature
---

View File

@ -0,0 +1,3 @@
---
agent: speckit.git.initialize
---

View File

@ -0,0 +1,3 @@
---
agent: speckit.git.remote
---

View File

@ -0,0 +1,3 @@
---
agent: speckit.git.validate
---

148
.specify/extensions.yml Normal file
View File

@ -0,0 +1,148 @@
installed: []
settings:
auto_execute_hooks: true
hooks:
before_constitution:
- extension: git
command: speckit.git.initialize
enabled: true
optional: false
prompt: Execute speckit.git.initialize?
description: Initialize Git repository before constitution setup
condition: null
before_specify:
- extension: git
command: speckit.git.feature
enabled: true
optional: false
prompt: Execute speckit.git.feature?
description: Create feature branch before specification
condition: null
before_clarify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before clarification?
description: Auto-commit before spec clarification
condition: null
before_plan:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before planning?
description: Auto-commit before implementation planning
condition: null
before_tasks:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before task generation?
description: Auto-commit before task generation
condition: null
before_implement:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before implementation?
description: Auto-commit before implementation
condition: null
before_checklist:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before checklist?
description: Auto-commit before checklist generation
condition: null
before_analyze:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before analysis?
description: Auto-commit before analysis
condition: null
before_taskstoissues:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit outstanding changes before issue sync?
description: Auto-commit before tasks-to-issues conversion
condition: null
after_constitution:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit constitution changes?
description: Auto-commit after constitution update
condition: null
after_specify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit specification changes?
description: Auto-commit after specification
condition: null
after_clarify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit clarification changes?
description: Auto-commit after spec clarification
condition: null
after_plan:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit plan changes?
description: Auto-commit after implementation planning
condition: null
after_tasks:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit task changes?
description: Auto-commit after task generation
condition: null
after_implement:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit implementation changes?
description: Auto-commit after implementation
condition: null
after_checklist:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit checklist changes?
description: Auto-commit after checklist generation
condition: null
after_analyze:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit analysis results?
description: Auto-commit after analysis
condition: null
after_taskstoissues:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
prompt: Commit after syncing issues?
description: Auto-commit after tasks-to-issues conversion
condition: null

View File

@ -0,0 +1,44 @@
{
"schema_version": "1.0",
"extensions": {
"git": {
"version": "1.0.0",
"source": "local",
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
"enabled": true,
"priority": 10,
"registered_commands": {
"agy": [
"speckit.git.feature",
"speckit.git.validate",
"speckit.git.remote",
"speckit.git.initialize",
"speckit.git.commit"
],
"codex": [
"speckit.git.feature",
"speckit.git.validate",
"speckit.git.remote",
"speckit.git.initialize",
"speckit.git.commit"
],
"copilot": [
"speckit.git.feature",
"speckit.git.validate",
"speckit.git.remote",
"speckit.git.initialize",
"speckit.git.commit"
],
"gemini": [
"speckit.git.feature",
"speckit.git.validate",
"speckit.git.remote",
"speckit.git.initialize",
"speckit.git.commit"
]
},
"registered_skills": [],
"installed_at": "2026-04-22T21:58:03.029565+00:00"
}
}
}

View File

@ -0,0 +1,100 @@
# Git Branching Workflow Extension
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
## Overview
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
## Commands
| Command | Description |
|---------|-------------|
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
## Hooks
| Event | Command | Optional | Description |
|-------|---------|----------|-------------|
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
## Configuration
Configuration is stored in `.specify/extensions/git/git-config.yml`:
```yaml
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
default: false
after_specify:
enabled: true
message: "[Spec Kit] Add specification"
```
## Installation
```bash
# Install the bundled git extension (no network required)
specify extension add git
```
## Disabling
```bash
# Disable the git extension (spec creation continues without branching)
specify extension disable git
# Re-enable it
specify extension enable git
```
## Graceful Degradation
When Git is not installed or the directory is not a Git repository:
- Spec directories are still created under `specs/`
- Branch creation is skipped with a warning
- Branch validation is skipped with a warning
- Remote detection returns empty results
## Scripts
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@ -0,0 +1,48 @@
---
description: "Auto-commit changes after a Spec Kit command completes"
---
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View File

@ -0,0 +1,67 @@
---
description: "Create a feature branch with sequential or timestamp numbering"
---
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@ -0,0 +1,49 @@
---
description: "Initialize a Git repository with an initial commit"
---
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `✓ Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View File

@ -0,0 +1,45 @@
---
description: "Detect Git remote URL for GitHub integration"
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@ -0,0 +1,49 @@
---
description: "Validate current branch follows feature branch naming conventions"
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@ -0,0 +1,140 @@
schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
tools:
- name: git
required: false
provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
- name: speckit.git.remote
file: commands/speckit.git.remote.md
description: "Detect Git remote URL for GitHub integration"
- name: speckit.git.initialize
file: commands/speckit.git.initialize.md
description: "Initialize a Git repository with an initial commit"
- name: speckit.git.commit
file: commands/speckit.git.commit.md
description: "Auto-commit changes after a Spec Kit command completes"
config:
- name: "git-config.yml"
template: "config-template.yml"
description: "Git branching configuration"
required: false
hooks:
before_constitution:
command: speckit.git.initialize
optional: false
description: "Initialize Git repository before constitution setup"
before_specify:
command: speckit.git.feature
optional: false
description: "Create feature branch before specification"
before_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before clarification?"
description: "Auto-commit before spec clarification"
before_plan:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before planning?"
description: "Auto-commit before implementation planning"
before_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before task generation?"
description: "Auto-commit before task generation"
before_implement:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before implementation?"
description: "Auto-commit before implementation"
before_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before checklist?"
description: "Auto-commit before checklist generation"
before_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before analysis?"
description: "Auto-commit before analysis"
before_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before issue sync?"
description: "Auto-commit before tasks-to-issues conversion"
after_constitution:
command: speckit.git.commit
optional: true
prompt: "Commit constitution changes?"
description: "Auto-commit after constitution update"
after_specify:
command: speckit.git.commit
optional: true
prompt: "Commit specification changes?"
description: "Auto-commit after specification"
after_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit clarification changes?"
description: "Auto-commit after spec clarification"
after_plan:
command: speckit.git.commit
optional: true
prompt: "Commit plan changes?"
description: "Auto-commit after implementation planning"
after_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit task changes?"
description: "Auto-commit after task generation"
after_implement:
command: speckit.git.commit
optional: true
prompt: "Commit implementation changes?"
description: "Auto-commit after implementation"
after_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit checklist changes?"
description: "Auto-commit after checklist generation"
after_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit analysis results?"
description: "Auto-commit after analysis"
after_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit after syncing issues?"
description: "Auto-commit after tasks-to-issues conversion"
tags:
- "git"
- "branching"
- "workflow"
config:
defaults:
branch_numbering: sequential
init_commit_message: "[Spec Kit] Initial commit"

View File

@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Git extension: auto-commit.sh
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.sh <event_name>
# e.g.: auto-commit.sh after_specify
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
exit 1
fi
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
exit 0
fi
# Read per-command config from git-config.yml
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
if [ -f "$_config_file" ]; then
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
_in_auto_commit=false
_in_event=false
_default_enabled=false
while IFS= read -r _line; do
# Detect auto_commit: section
if echo "$_line" | grep -q '^auto_commit:'; then
_in_auto_commit=true
_in_event=false
continue
fi
# Exit auto_commit section on next top-level key
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
break
fi
if $_in_auto_commit; then
# Check default key
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _default_enabled=true
fi
# Detect our event subsection
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
_in_event=true
continue
fi
# Inside our event subsection
if $_in_event; then
# Exit on next sibling key (same indent level as event name)
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
_in_event=false
continue
fi
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _enabled=true
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
done < "$_config_file"
# If event-specific key not found, use default
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
# Only use default if the event wasn't explicitly set to false
# Check if event section existed at all
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
_enabled=true
fi
fi
else
# No config file — auto-commit disabled by default
exit 0
fi
if [ "$_enabled" != "true" ]; then
exit 0
fi
# Check if there are changes to commit
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "[specify] No changes to commit after $EVENT_NAME" >&2
exit 0
fi
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
# Use custom message if configured, otherwise default
if [ -z "$_commit_msg" ]; then
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
fi
# Stage and commit
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2

View File

@ -0,0 +1,453 @@
#!/usr/bin/env bash
# Git extension: create-new-feature.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
# Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers.
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo 'Error: --number must be a non-negative integer' >&2
exit 1
fi
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name without creating the branch"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches and return next available number.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# ---------------------------------------------------------------------------
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
#
# Search locations in priority order:
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root by walking up from the script location
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
_common_loaded=false
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
_common_loaded=true
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/scripts/bash/common.sh"
_common_loaded=true
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
_common_loaded=true
fi
if [ "$_common_loaded" != "true" ]; then
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
exit 1
fi
# Resolve repository root
if type get_repo_root >/dev/null 2>&1; then
REPO_ROOT=$(get_repo_root)
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
elif [ -n "$_PROJECT_ROOT" ]; then
REPO_ROOT="$_PROJECT_ROOT"
else
echo "Error: Could not determine repository root." >&2
exit 1
fi
# Check if git is available at this repo root
if type has_git >/dev/null 2>&1; then
if has_git "$REPO_ROOT"; then
HAS_GIT=true
else
HAS_GIT=false
fi
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
# Function to generate branch name with stop word filtering
generate_branch_name() {
local description="$1"
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
local meaningful_words=()
for word in $clean_name; do
[ -z "$word" ] && continue
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -qw -- "${word^^}"; then
meaningful_words+=("$word")
fi
fi
done
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
# GitHub enforces a 244-byte limit on branch names
MAX_BRANCH_LENGTH=244
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
fi
else
if type json_escape >/dev/null 2>&1; then
_je_branch=$(json_escape "$BRANCH_NAME")
_je_num=$(json_escape "$FEATURE_NUM")
else
_je_branch="$BRANCH_NAME"
_je_num="$FEATURE_NUM"
fi
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
else
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git-specific common functions for the git extension.
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.
# Check if we have git available at the repo root
has_git() {
local repo_root="${1:-$(pwd)}"
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
command -v git >/dev/null 2>&1 && \
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}

View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git extension: initialize-repo.sh
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
set -e
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Read commit message from extension config, fall back to default
COMMIT_MSG="[Spec Kit] Initial commit"
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
if [ -f "$_config_file" ]; then
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
if [ -n "$_msg" ]; then
COMMIT_MSG="$_msg"
fi
fi
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
exit 0
fi
# Check if already a git repo
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Git repository already initialized; skipping" >&2
exit 0
fi
# Initialize
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "✓ Git repository initialized" >&2

View File

@ -0,0 +1,169 @@
#!/usr/bin/env pwsh
# Git extension: auto-commit.ps1
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.ps1 <event_name>
# e.g.: auto-commit.ps1 after_specify
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
)
$ErrorActionPreference = 'Stop'
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
exit 0
}
# Temporarily relax ErrorActionPreference so git stderr warnings
# (e.g. CRLF notices on Windows) do not become terminating errors.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
$isRepo = $LASTEXITCODE -eq 0
} finally {
$ErrorActionPreference = $savedEAP
}
if (-not $isRepo) {
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
exit 0
}
# Read per-command config from git-config.yml
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
if (Test-Path $configFile) {
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
$defaultEnabled = $false
foreach ($line in Get-Content $configFile) {
# Detect auto_commit: section
if ($line -match '^auto_commit:') {
$inAutoCommit = $true
$inEvent = $false
continue
}
# Exit auto_commit section on next top-level key
if ($inAutoCommit -and $line -match '^[a-z]') {
break
}
if ($inAutoCommit) {
# Check default key
if ($line -match '^\s+default:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $defaultEnabled = $true }
}
# Detect our event subsection
if ($line -match "^\s+${EventName}:") {
$inEvent = $true
continue
}
# Inside our event subsection
if ($inEvent) {
# Exit on next sibling key (2-space indent, not 4+)
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
$inEvent = $false
continue
}
if ($line -match '\s+enabled:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $enabled = $true }
if ($val -eq 'false') { $enabled = $false }
}
if ($line -match '\s+message:\s*(.+)$') {
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
}
}
}
}
# If event-specific key not found, use default
if (-not $enabled -and $defaultEnabled) {
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
if (-not $hasEventKey) {
$enabled = $true
}
}
} else {
# No config file — auto-commit disabled by default
exit 0
}
if (-not $enabled) {
exit 0
}
# Check if there are changes to commit
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
$untracked = git ls-files --others --exclude-standard 2>$null
} finally {
$ErrorActionPreference = $savedEAP
}
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
exit 0
}
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
# Use custom message if configured, otherwise default
if (-not $commitMsg) {
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
}
# Stage and commit
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
# while still allowing redirected error output to be captured for diagnostics.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
} finally {
$ErrorActionPreference = $savedEAP
}
Write-Host "[OK] Changes committed $phase $commandName"

View File

@ -0,0 +1,403 @@
#!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
# Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers.
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name without creating the branch"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
exit 0
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
}
return $highest
}
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
$maxNum = [Math]::Max($highestBranch, $highestSpec)
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# ---------------------------------------------------------------------------
# Source common.ps1 from the project's installed scripts.
# Search locations in priority order:
# 1. .specify/scripts/powershell/common.ps1 under the project root
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
# 3. git-common.ps1 next to this script (minimal fallback)
# ---------------------------------------------------------------------------
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
$commonLoaded = $false
if ($projectRoot) {
$candidates = @(
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
(Join-Path $projectRoot "scripts/powershell/common.ps1")
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
. $candidate
$commonLoaded = $true
break
}
}
}
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
. "$PSScriptRoot/git-common.ps1"
$commonLoaded = $true
}
if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}
# Resolve repository root
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
$repoRoot = Get-RepoRoot
} elseif ($projectRoot) {
$repoRoot = $projectRoot
} else {
throw "Could not determine repository root."
}
# Check if git is available
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
# and git-common.ps1 (has -RepoRoot param with default).
$hasGit = Test-HasGit
} else {
try {
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
$hasGit = ($LASTEXITCODE -eq 0)
} catch {
$hasGit = $false
}
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
function Get-BranchName {
param([string]$Description)
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
$meaningfulWords = @()
foreach ($word in $words) {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
$meaningfulWords += $word
}
}
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
$branchSuffix = Get-BranchName -Description $featureDesc
}
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
}
}
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if (-not $DryRun) {
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
}
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
if ($currentBranch -eq $branchName) {
# Already on the target branch
} else {
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
exit 1
}
}
} else {
if ($Json) {
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
}
$env:SPECIFY_FEATURE = $branchName
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

View File

@ -0,0 +1,51 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
# branch validation and detection logic.
function Test-HasGit {
param([string]$RepoRoot = (Get-Location))
try {
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}

View File

@ -0,0 +1,69 @@
#!/usr/bin/env pwsh
# Git extension: initialize-repo.ps1
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
$ErrorActionPreference = 'Stop'
# Find project root
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Read commit message from extension config, fall back to default
$commitMsg = "[Spec Kit] Initial commit"
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
if (Test-Path $configFile) {
foreach ($line in Get-Content $configFile) {
if ($line -match '^init_commit_message:\s*(.+)$') {
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
if ($val) { $commitMsg = $val }
break
}
}
}
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
exit 0
}
# Check if already a git repo
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Warning "[specify] Git repository already initialized; skipping"
exit 0
}
} catch { }
# Initialize
try {
$out = git init -q 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "✓ Git repository initialized"

View File

@ -0,0 +1,10 @@
{
"ai": "copilot",
"branch_numbering": "sequential",
"context_file": ".github/copilot-instructions.md",
"here": true,
"integration": "copilot",
"preset": null,
"script": "sh",
"speckit_version": "0.7.4"
}

View File

@ -0,0 +1,4 @@
{
"integration": "copilot",
"version": "0.7.4"
}

View File

@ -0,0 +1,25 @@
{
"integration": "copilot",
"version": "0.7.4",
"installed_at": "2026-04-22T21:58:02.962169+00:00",
"files": {
".github/agents/speckit.analyze.agent.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda",
".github/agents/speckit.checklist.agent.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde",
".github/agents/speckit.clarify.agent.md": "0cc766dcc5cab233ccdf3bc4cfb5759a6d7d1e13e29f611083046f818f5812bb",
".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7",
".github/agents/speckit.implement.agent.md": "83628415c86ba487b3a083c7a2c0f016c9073abd02c1c7f4a30cff949b6602c0",
".github/agents/speckit.plan.agent.md": "2ad128b81ccd8f5bfa78b3b43101f377dfddd8f800fa0856f85bf53b1489b783",
".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b",
".github/agents/speckit.tasks.agent.md": "a58886f29f75e1a14840007772ddd954742aafb3e03d9d1231bee033e6c1626b",
".github/agents/speckit.taskstoissues.agent.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee",
".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77",
".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c",
".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4",
".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d",
".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80",
".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e",
".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98",
".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f",
".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d"
}
}

View File

@ -0,0 +1,8 @@
{
"integration": "speckit",
"version": "0.7.4",
"installed_at": "2026-04-22T21:58:02.965809+00:00",
"files": {
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
}
}

View File

@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->

View File

@ -0,0 +1,63 @@
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
speckit_version: ">=0.7.2"
integrations:
any: ["copilot", "claude", "gemini"]
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "copilot"
prompt: "Integration to use (e.g. claude, copilot, gemini)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"

View File

@ -0,0 +1,13 @@
{
"schema_version": "1.0",
"workflows": {
"speckit": {
"name": "Full SDD Cycle",
"version": "1.0.0",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"source": "bundled",
"installed_at": "2026-04-22T21:58:03.039039+00:00",
"updated_at": "2026-04-22T21:58:03.039046+00:00"
}
}
}

View File

@ -67,7 +67,6 @@ public function handle(): int
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'), 'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey, 'tenant_id' => $tenantRouteKey,
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => Tenant::STATUS_ACTIVE, 'status' => Tenant::STATUS_ACTIVE,
'environment' => 'dev', 'environment' => 'dev',

View File

@ -20,6 +20,7 @@
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -535,7 +536,7 @@ public function basisRunSummary(): array
: 'The coverage basis is current, but your role cannot open the cited run detail.', : 'The coverage basis is current, but your role cannot open the cited run detail.',
'badgeLabel' => $badge->label, 'badgeLabel' => $badge->label,
'badgeColor' => $badge->color, 'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, 'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]; ];
@ -560,13 +561,6 @@ protected function coverageTruth(): ?TenantCoverageTruth
private function inventorySyncHistoryUrl(Tenant $tenant): string private function inventorySyncHistoryUrl(Tenant $tenant): string
{ {
return route('admin.operations.index', [ return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
} }
} }

View File

@ -110,14 +110,14 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_operations') $actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations') ->label('Back to Operations')
->color('gray') ->color('gray')
->url(fn (): string => route('admin.operations.index')); ->url(fn (): string => OperationRunLinks::index());
} }
if ($activeTenant instanceof Tenant) { if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_show_all_operations') $actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations') ->label('Show all operations')
->color('gray') ->color('gray')
->url(fn (): string => route('admin.operations.index')); ->url(fn (): string => OperationRunLinks::index());
} }
$actions[] = Action::make('refresh') $actions[] = Action::make('refresh')
@ -126,7 +126,7 @@ protected function getHeaderActions(): array
->color('primary') ->color('primary')
->url(fn (): string => isset($this->run) ->url(fn (): string => isset($this->run)
? OperationRunLinks::tenantlessView($this->run, $navigationContext) ? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: route('admin.operations.index')); : OperationRunLinks::index());
if (! isset($this->run)) { if (! isset($this->run)) {
return $actions; return $actions;

View File

@ -14,6 +14,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
@ -352,7 +353,14 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
private function reviewOutcomeDescription(TenantReview $record): ?string private function reviewOutcomeDescription(TenantReview $record): ?string
{ {
return $this->reviewOutcome($record)->primaryReason; $primaryReason = $this->reviewOutcome($record)->primaryReason;
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
} }
private function reviewOutcomeNextStep(TenantReview $record): string private function reviewOutcomeNextStep(TenantReview $record): string
@ -373,4 +381,16 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
SurfaceCompressionContext::reviewRegister(), SurfaceCompressionContext::reviewRegister(),
); );
} }
private function findingOutcomeSummary(TenantReview $record): ?string
{
$summary = is_array($record->summary) ? $record->summary : [];
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
} }

View File

@ -21,6 +21,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
@ -156,6 +157,14 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) ->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextEntry::make('finding_terminal_outcome')
->label('Terminal outcome')
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
TextEntry::make('finding_verification_state')
->label('Verification')
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
TextEntry::make('severity') TextEntry::make('severity')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
@ -292,9 +301,15 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'), TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'), TextEntry::make('resolved_reason')
->label('Resolved reason')
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
->placeholder('—'),
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'), TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'), TextEntry::make('closed_reason')
->label('Closed/risk reason')
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
->placeholder('—'),
TextEntry::make('closed_by_user_id') TextEntry::make('closed_by_user_id')
->label('Closed by') ->label('Closed by')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')), ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
@ -726,7 +741,7 @@ public static function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) ->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
->description(fn (Finding $record): string => static::primaryNarrative($record)), ->description(fn (Finding $record): string => static::statusDescription($record)),
Tables\Columns\TextColumn::make('governance_validity') Tables\Columns\TextColumn::make('governance_validity')
->label('Governance') ->label('Governance')
->badge() ->badge()
@ -820,6 +835,14 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(FilterOptionCatalog::findingStatuses()) ->options(FilterOptionCatalog::findingStatuses())
->label('Status'), ->label('Status'),
Tables\Filters\SelectFilter::make('terminal_outcome')
->label('Terminal outcome')
->options(FilterOptionCatalog::findingTerminalOutcomes())
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
Tables\Filters\SelectFilter::make('verification_state')
->label('Verification')
->options(FilterOptionCatalog::findingVerificationStates())
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
Tables\Filters\SelectFilter::make('workflow_family') Tables\Filters\SelectFilter::make('workflow_family')
->label('Workflow family') ->label('Workflow family')
->options(FilterOptionCatalog::findingWorkflowFamilies()) ->options(FilterOptionCatalog::findingWorkflowFamilies())
@ -1092,16 +1115,20 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('resolve_selected') BulkAction::make('resolve_selected')
->label('Resolve selected') ->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
->form([ ->form([
Textarea::make('resolved_reason') Select::make('resolved_reason')
->label('Resolution reason') ->label('Resolution outcome')
->rows(3) ->options(static::resolveReasonOptions())
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -1145,7 +1172,7 @@ public static function table(Table $table): Table
} }
} }
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.'; $body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
if ($skippedCount > 0) { if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}."; $body .= " Skipped {$skippedCount}.";
} }
@ -1167,18 +1194,20 @@ public static function table(Table $table): Table
UiEnforcement::forBulkAction( UiEnforcement::forBulkAction(
BulkAction::make('close_selected') BulkAction::make('close_selected')
->label('Close selected') ->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('warning') ->color('warning')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading) ->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription) ->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Select::make('closed_reason')
->label('Close reason') ->label('Close reason')
->rows(3) ->options(static::closeReasonOptions())
->helperText('Use the canonical administrative closure outcome for this finding.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void { ->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -1448,24 +1477,30 @@ public static function assignAction(): Actions\Action
public static function resolveAction(): Actions\Action public static function resolveAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('resolve_finding');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('resolve') Actions\Action::make('resolve')
->label('Resolve') ->label($rule->canonicalLabel)
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('resolved_reason') Select::make('resolved_reason')
->label('Resolution reason') ->label('Resolution outcome')
->rows(3) ->options(static::resolveReasonOptions())
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
record: $record, record: $record,
successTitle: 'Finding resolved', successTitle: $rule->successTitle,
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve( callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
$finding, $finding,
$tenant, $tenant,
@ -1495,11 +1530,13 @@ public static function closeAction(): Actions\Action
->modalHeading($rule->modalHeading) ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription) ->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Select::make('closed_reason')
->label('Close reason') ->label('Close reason')
->rows(3) ->options(static::closeReasonOptions())
->helperText('Use the canonical administrative closure outcome for this finding.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -1694,12 +1731,17 @@ public static function reopenAction(): Actions\Action
->modalHeading($rule->modalHeading) ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription) ->modalDescription($rule->modalDescription)
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->fillForm([
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
])
->form([ ->form([
Textarea::make('reopen_reason') Select::make('reopen_reason')
->label('Reopen reason') ->label('Reopen reason')
->rows(3) ->options(static::reopenReasonOptions())
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
->native(false)
->required() ->required()
->maxLength(255), ->selectablePlaceholder(false),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -2138,6 +2180,150 @@ private static function governanceValidityState(Finding $finding): ?string
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding)); ->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
} }
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
{
return app(FindingOutcomeSemantics::class);
}
/**
* @return array{
* terminal_outcome_key: ?string,
* label: ?string,
* verification_state: string,
* verification_label: ?string,
* report_bucket: ?string
* }
*/
private static function findingOutcome(Finding $finding): array
{
return static::findingOutcomeSemantics()->describe($finding);
}
/**
* @return array<string, string>
*/
private static function resolveReasonOptions(): array
{
return [
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
];
}
/**
* @return array<string, string>
*/
private static function closeReasonOptions(): array
{
return [
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
];
}
/**
* @return array<string, string>
*/
private static function reopenReasonOptions(): array
{
return [
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
];
}
private static function resolveReasonLabel(?string $reason): ?string
{
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
default => null,
};
}
private static function closeReasonLabel(?string $reason): ?string
{
return static::closeReasonOptions()[$reason] ?? match ($reason) {
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
default => null,
};
}
private static function reopenReasonLabel(?string $reason): ?string
{
return static::reopenReasonOptions()[$reason] ?? null;
}
private static function terminalOutcomeLabel(Finding $finding): ?string
{
return static::findingOutcome($finding)['label'] ?? null;
}
private static function verificationStateLabel(Finding $finding): ?string
{
return static::findingOutcome($finding)['verification_label'] ?? null;
}
private static function statusDescription(Finding $finding): string
{
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
}
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || $value === '') {
return $query;
}
return match ($value) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
->where('status', Finding::STATUS_CLOSED)
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
->where('status', Finding::STATUS_RISK_ACCEPTED),
default => $query,
};
}
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || $value === '') {
return $query;
}
return match ($value) {
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
->where('status', Finding::STATUS_RESOLVED)
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
$verificationQuery
->where('status', '!=', Finding::STATUS_RESOLVED)
->orWhereNull('resolved_reason')
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
}),
default => $query,
};
}
private static function primaryNarrative(Finding $finding): string private static function primaryNarrative(Finding $finding): string
{ {
return app(FindingRiskGovernanceResolver::class) return app(FindingRiskGovernanceResolver::class)

View File

@ -17,6 +17,7 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -148,7 +149,13 @@ public static function infolist(Schema $schema): Schema
return null; return null;
} }
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]); $tenant = $record->tenant;
if ($tenant instanceof Tenant) {
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
}
return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id);
}) })
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('support_restore') TextEntry::make('support_restore')

View File

@ -13,6 +13,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
@ -199,9 +200,19 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'), ->placeholder('—'),
TextEntry::make('operationRun.id') TextEntry::make('operationRun.id')
->label('Operation') ->label('Operation')
->url(fn (ReviewPack $record): ?string => $record->operation_run_id ->url(function (ReviewPack $record): ?string {
? route('admin.operations.view', ['run' => (int) $record->operation_run_id]) if (! $record->operation_run_id) {
: null) return null;
}
$tenant = $record->tenant;
if ($tenant instanceof Tenant) {
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
}
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
})
->openUrlInNewTab() ->openUrlInNewTab()
->placeholder('—'), ->placeholder('—'),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'), TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),

View File

@ -18,6 +18,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -540,12 +541,19 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record); $truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class); $reasonPresenter = app(ReasonPresenter::class);
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) {
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
}
return [ return [
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(), 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'compressed_outcome' => static::compressedOutcome($record)->toArray(), 'compressed_outcome' => static::compressedOutcome($record)->toArray(),
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()), 'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => $highlights,
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record), 'context_links' => static::summaryContextLinks($record),
@ -554,6 +562,8 @@ private static function summaryPresentation(TenantReview $record): array
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -655,4 +665,18 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
SurfaceCompressionContext::tenantReview(), SurfaceCompressionContext::tenantReview(),
); );
} }
/**
* @param array<string, mixed> $summary
*/
private static function findingOutcomeSummary(array $summary): ?string
{
$outcomeCounts = $summary['finding_outcomes'] ?? [];
if (! is_array($outcomeCounts)) {
return null;
}
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
}
} }

View File

@ -41,7 +41,7 @@ protected function getViewData(): array
return [ return [
'tenant' => null, 'tenant' => null,
'runs' => collect(), 'runs' => collect(),
'operationsIndexUrl' => route('admin.operations.index'), 'operationsIndexUrl' => OperationRunLinks::index(),
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
]; ];
@ -68,7 +68,7 @@ protected function getViewData(): array
return [ return [
'tenant' => $tenant, 'tenant' => $tenant,
'runs' => $runs, 'runs' => $runs,
'operationsIndexUrl' => route('admin.operations.index'), 'operationsIndexUrl' => OperationRunLinks::index($tenant),
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(), 'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(), 'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
]; ];

View File

@ -345,9 +345,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
} }
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_CLOSED,
'resolved_at' => $backfillStartedAt, 'resolved_at' => null,
'resolved_reason' => 'consolidated_duplicate', 'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null, 'recurrence_key' => null,
])->save(); ])->save();

View File

@ -325,9 +325,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
} }
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_CLOSED,
'resolved_at' => $backfillStartedAt, 'resolved_at' => null,
'resolved_reason' => 'consolidated_duplicate', 'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null, 'recurrence_key' => null,
])->save(); ])->save();

View File

@ -86,7 +86,7 @@ public function refreshRuns(): void
$query = OperationRun::query() $query = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->healthyActive() ->active()
->orderByDesc('created_at'); ->orderByDesc('created_at');
$activeCount = (clone $query)->count(); $activeCount = (clone $query)->count();

View File

@ -20,21 +20,6 @@ class BaselineProfile extends Model
{ {
use HasFactory; use HasFactory;
/**
* @deprecated Use BaselineProfileStatus::Draft instead.
*/
public const string STATUS_DRAFT = 'draft';
/**
* @deprecated Use BaselineProfileStatus::Active instead.
*/
public const string STATUS_ACTIVE = 'active';
/**
* @deprecated Use BaselineProfileStatus::Archived instead.
*/
public const string STATUS_ARCHIVED = 'archived';
/** @var list<string> */ /** @var list<string> */
protected $fillable = [ protected $fillable = [
'workspace_id', 'workspace_id',

View File

@ -47,6 +47,32 @@ class Finding extends Model
public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability'; public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned'; public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
@ -160,6 +186,113 @@ public static function highSeverityValues(): array
]; ];
} }
/**
* @return array<int, string>
*/
public static function manualResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_REMEDIATED,
];
}
/**
* @return array<int, string>
*/
public static function systemResolveReasonKeys(): array
{
return [
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
self::RESOLVE_REASON_PERMISSION_GRANTED,
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
];
}
/**
* @return array<int, string>
*/
public static function resolveReasonKeys(): array
{
return [
...self::manualResolveReasonKeys(),
...self::systemResolveReasonKeys(),
];
}
/**
* @return array<int, string>
*/
public static function closeReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
self::CLOSE_REASON_ACCEPTED_RISK,
];
}
/**
* @return array<int, string>
*/
public static function manualCloseReasonKeys(): array
{
return [
self::CLOSE_REASON_FALSE_POSITIVE,
self::CLOSE_REASON_DUPLICATE,
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
];
}
/**
* @return array<int, string>
*/
public static function reopenReasonKeys(): array
{
return [
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
self::REOPEN_REASON_VERIFICATION_FAILED,
self::REOPEN_REASON_MANUAL_REASSESSMENT,
];
}
public static function isResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
}
public static function isManualResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
}
public static function isSystemResolveReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
}
public static function isCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
}
public static function isManualCloseReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
}
public static function isRiskAcceptedReason(?string $reason): bool
{
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
}
public static function isReopenReason(?string $reason): bool
{
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
}
public static function canonicalizeStatus(?string $status): ?string public static function canonicalizeStatus(?string $status): ?string
{ {
if ($status === self::STATUS_ACKNOWLEDGED) { if ($status === self::STATUS_ACKNOWLEDGED) {

View File

@ -4,11 +4,9 @@
namespace App\Notifications\Findings; namespace App\Notifications\Findings;
use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Actions\Action; use App\Support\OpsUx\OperationUxPresenter;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@ -38,20 +36,11 @@ public function via(object $notifiable): array
*/ */
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
$message = FilamentNotification::make() $message = OperationUxPresenter::findingDatabaseNotificationMessage(
->title($this->title()) $this->finding,
->body($this->body()) $this->tenant,
->actions([ $this->event,
Action::make('open_finding') );
->label('Open finding')
->url(FindingResource::getUrl(
'view',
['record' => $this->finding],
panel: 'tenant',
tenant: $this->tenant,
)),
])
->getDatabaseMessage();
$message['finding_event'] = [ $message['finding_event'] = [
'event_type' => (string) ($this->event['event_type'] ?? ''), 'event_type' => (string) ($this->event['event_type'] ?? ''),
@ -65,29 +54,4 @@ public function toDatabase(object $notifiable): array
return $message; return $message;
} }
private function title(): string
{
$title = trim((string) ($this->event['title'] ?? 'Finding update'));
return $title !== '' ? $title : 'Finding update';
}
private function body(): string
{
$body = trim((string) ($this->event['body'] ?? 'A finding needs follow-up.'));
$recipientReason = $this->recipientReasonCopy((string) data_get($this->event, 'metadata.recipient_reason', ''));
return trim($body.' '.$recipientReason);
}
private function recipientReasonCopy(string $reason): string
{
return match ($reason) {
'new_assignee' => 'You are the new assignee.',
'current_assignee' => 'You are the current assignee.',
'current_owner' => 'You are the accountable owner.',
default => '',
};
}
} }

View File

@ -3,12 +3,8 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\System\SystemOperationRunLinks;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@ -27,25 +23,7 @@ public function via(object $notifiable): array
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
$tenant = $this->run->tenant; $message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
$runUrl = match (true) {
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
default => OperationRunLinks::tenantlessView($this->run),
};
$notification = OperationUxPresenter::terminalDatabaseNotification(
run: $this->run,
tenant: $tenant instanceof Tenant ? $tenant : null,
);
$notification->actions([
\Filament\Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
]);
$message = $notification->getDatabaseMessage();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
if ($reasonEnvelope !== null) { if ($reasonEnvelope !== null) {

View File

@ -3,10 +3,7 @@
namespace App\Notifications; namespace App\Notifications;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@ -31,31 +28,6 @@ public function via(object $notifiable): array
*/ */
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
$tenant = $this->run->tenant; return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
$context = is_array($this->run->context) ? $this->run->context : [];
$wizard = $context['wizard'] ?? null;
$isManagedTenantOnboardingWizardRun = is_array($wizard)
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
$operationLabel = OperationCatalog::label((string) $this->run->type);
$runUrl = match (true) {
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
default => null,
};
return FilamentNotification::make()
->title("{$operationLabel} queued")
->body('Queued for execution. Open the operation for progress and next steps.')
->info()
->actions([
\Filament\Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->getDatabaseMessage();
} }
} }

View File

@ -213,6 +213,12 @@ public function buildSnapshotPayload(Tenant $tenant): array
'state' => $item['state'], 'state' => $item['state'],
'required' => $item['required'], 'required' => $item['required'],
], $items), ], $items),
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
? $findingsSummary['outcome_counts']
: [],
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
? $findingsSummary['report_bucket_counts']
: [],
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null) 'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
? $findingsSummary['risk_acceptance'] ? $findingsSummary['risk_acceptance']
: [ : [

View File

@ -8,12 +8,14 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Evidence\Contracts\EvidenceSourceProvider; use App\Services\Evidence\Contracts\EvidenceSourceProvider;
use App\Services\Findings\FindingRiskGovernanceResolver; use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
final class FindingsSummarySource implements EvidenceSourceProvider final class FindingsSummarySource implements EvidenceSourceProvider
{ {
public function __construct( public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver, private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {} ) {}
public function key(): string public function key(): string
@ -33,6 +35,7 @@ public function collect(Tenant $tenant): array
$entries = $findings->map(function (Finding $finding): array { $entries = $findings->map(function (Finding $finding): array {
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding);
return [ return [
'id' => (int) $finding->getKey(), 'id' => (int) $finding->getKey(),
@ -43,10 +46,42 @@ public function collect(Tenant $tenant): array
'description' => $finding->description, 'description' => $finding->description,
'created_at' => $finding->created_at?->toIso8601String(), 'created_at' => $finding->created_at?->toIso8601String(),
'updated_at' => $finding->updated_at?->toIso8601String(), 'updated_at' => $finding->updated_at?->toIso8601String(),
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
'terminal_outcome_label' => $outcome['label'],
'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [
'key' => $outcome['terminal_outcome_key'],
'label' => $outcome['label'],
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'governance_state' => $governanceState,
] : null,
'governance_state' => $governanceState, 'governance_state' => $governanceState,
'governance_warning' => $governanceWarning, 'governance_warning' => $governanceWarning,
]; ];
}); });
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);
$reportBucketCounts = [
'remediation_pending_verification' => 0,
'remediation_verified' => 0,
'administrative_closure' => 0,
'accepted_risk' => 0,
];
foreach ($entries as $entry) {
$terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null;
$reportBucket = $entry['report_bucket'] ?? null;
if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) {
$outcomeCounts[$terminalOutcomeKey]++;
}
if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) {
$reportBucketCounts[$reportBucket]++;
}
}
$riskAcceptedEntries = $entries->filter( $riskAcceptedEntries = $entries->filter(
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED, static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
); );
@ -78,6 +113,8 @@ public function collect(Tenant $tenant): array
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(), 'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(), 'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
], ],
'outcome_counts' => $outcomeCounts,
'report_bucket_counts' => $reportBucketCounts,
'entries' => $entries->all(), 'entries' => $entries->all(),
]; ];

View File

@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
private function findingRiskAcceptedReason(string $approvalReason): string private function findingRiskAcceptedReason(string $approvalReason): string
{ {
return mb_substr($approvalReason, 0, 255); return Finding::CLOSE_REASON_ACCEPTED_RISK;
} }
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable

View File

@ -7,11 +7,16 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\FindingExceptionDecision; use App\Models\FindingExceptionDecision;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
final class FindingRiskGovernanceResolver final class FindingRiskGovernanceResolver
{ {
public function __construct(
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {}
public function resolveWorkflowFamily(Finding $finding): string public function resolveWorkflowFamily(Finding $finding): string
{ {
return match (Finding::canonicalizeStatus((string) $finding->status)) { return match (Finding::canonicalizeStatus((string) $finding->status)) {
@ -218,11 +223,7 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy' 'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
? 'Accepted risk remains visible because current governance is still valid.' ? 'Accepted risk remains visible because current governance is still valid.'
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.', : 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
'historical' => match ((string) $finding->status) { 'historical' => $this->historicalPrimaryNarrative($finding),
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
default => 'This finding is historical workflow context.',
},
default => match ($finding->responsibilityState()) { default => match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.', Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.', Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
@ -253,8 +254,14 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
}; };
} }
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) { if ((string) $finding->status === Finding::STATUS_RESOLVED) {
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.'; return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING
? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.'
: 'Keep the finding closed unless later trusted evidence shows the issue has returned.';
}
if ((string) $finding->status === Finding::STATUS_CLOSED) {
return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.';
} }
return match ($finding->responsibilityState()) { return match ($finding->responsibilityState()) {
@ -340,23 +347,33 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
private function resolvedHistoricalContext(Finding $finding): ?string private function resolvedHistoricalContext(Finding $finding): ?string
{ {
$reason = (string) ($finding->resolved_reason ?? ''); return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.',
return match ($reason) { FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.',
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
'permission_granted',
'permission_removed_from_registry',
'role_assignment_removed',
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.', default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
}; };
} }
private function closedHistoricalContext(Finding $finding): ?string private function closedHistoricalContext(Finding $finding): ?string
{ {
return match ((string) ($finding->closed_reason ?? '')) { return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.', FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.',
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.', default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
}; };
} }
private function historicalPrimaryNarrative(Finding $finding): string
{
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.',
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.',
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE,
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE,
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.',
default => 'This finding is historical workflow context.',
};
}
} }

View File

@ -14,6 +14,7 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorType; use App\Support\Audit\AuditActorType;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -28,6 +29,7 @@ public function __construct(
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly CapabilityResolver $capabilityResolver, private readonly CapabilityResolver $capabilityResolver,
private readonly FindingNotificationService $findingNotificationService, private readonly FindingNotificationService $findingNotificationService,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {} ) {}
/** /**
@ -273,7 +275,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
throw new InvalidArgumentException('Only open findings can be resolved.'); throw new InvalidArgumentException('Only open findings can be resolved.');
} }
$reason = $this->validatedReason($reason, 'resolved_reason'); $reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -299,7 +301,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]); $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
$reason = $this->validatedReason($reason, 'closed_reason'); $reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -342,7 +344,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.'); throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
} }
$reason = $this->validatedReason($reason, 'closed_reason'); $reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
return $this->mutateAndAudit( return $this->mutateAndAudit(
@ -376,7 +378,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');
} }
$reason = $this->validatedReason($reason, 'reopen_reason'); $reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
@ -418,11 +420,11 @@ public function resolveBySystem(
): Finding { ): Finding {
$this->assertFindingOwnedByTenant($finding, $tenant); $this->assertFindingOwnedByTenant($finding, $tenant);
if (! $finding->hasOpenStatus()) { if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
throw new InvalidArgumentException('Only open findings can be resolved.'); throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
} }
$reason = $this->validatedReason($reason, 'resolved_reason'); $reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
return $this->mutateAndAudit( return $this->mutateAndAudit(
finding: $finding, finding: $finding,
@ -456,6 +458,7 @@ public function reopenBySystem(
CarbonImmutable $reopenedAt, CarbonImmutable $reopenedAt,
?int $operationRunId = null, ?int $operationRunId = null,
?callable $mutate = null, ?callable $mutate = null,
?string $reason = null,
): Finding { ): Finding {
$this->assertFindingOwnedByTenant($finding, $tenant); $this->assertFindingOwnedByTenant($finding, $tenant);
@ -463,6 +466,11 @@ public function reopenBySystem(
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');
} }
$reason = $this->validatedReason(
$reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
'reopen_reason',
Finding::reopenReasonKeys(),
);
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
@ -474,6 +482,7 @@ public function reopenBySystem(
context: [ context: [
'metadata' => [ 'metadata' => [
'reopened_at' => $reopenedAt->toIso8601String(), 'reopened_at' => $reopenedAt->toIso8601String(),
'reopened_reason' => $reason,
'sla_days' => $slaDays, 'sla_days' => $slaDays,
'due_at' => $dueAt->toIso8601String(), 'due_at' => $dueAt->toIso8601String(),
'system_origin' => true, 'system_origin' => true,
@ -574,7 +583,10 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
} }
} }
private function validatedReason(string $reason, string $field): string /**
* @param array<int, string> $allowedReasons
*/
private function validatedReason(string $reason, string $field, array $allowedReasons): string
{ {
$reason = trim($reason); $reason = trim($reason);
@ -586,6 +598,14 @@ private function validatedReason(string $reason, string $field): string
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field)); throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
} }
if (! in_array($reason, $allowedReasons, true)) {
throw new InvalidArgumentException(sprintf(
'%s must be one of: %s.',
$field,
implode(', ', $allowedReasons),
));
}
return $reason; return $reason;
} }
@ -637,12 +657,17 @@ private function mutateAndAudit(
$record->save(); $record->save();
$after = $this->auditSnapshot($record); $after = $this->auditSnapshot($record);
$outcome = $this->findingOutcomeSemantics->describe($record);
$auditMetadata = array_merge($metadata, [ $auditMetadata = array_merge($metadata, [
'finding_id' => (int) $record->getKey(), 'finding_id' => (int) $record->getKey(),
'before_status' => $before['status'] ?? null, 'before_status' => $before['status'] ?? null,
'after_status' => $after['status'] ?? null, 'after_status' => $after['status'] ?? null,
'before' => $before, 'before' => $before,
'after' => $after, 'after' => $after,
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
'terminal_outcome_label' => $outcome['label'],
'verification_state' => $outcome['verification_state'],
'report_bucket' => $outcome['report_bucket'],
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType), '_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
]); ]);
@ -713,6 +738,7 @@ private function dedupeKey(
'owner_user_id' => $metadata['owner_user_id'] ?? null, 'owner_user_id' => $metadata['owner_user_id'] ?? null,
'resolved_reason' => $metadata['resolved_reason'] ?? null, 'resolved_reason' => $metadata['resolved_reason'] ?? null,
'closed_reason' => $metadata['closed_reason'] ?? null, 'closed_reason' => $metadata['closed_reason'] ?? null,
'reopened_reason' => $metadata['reopened_reason'] ?? null,
]; ];
$encoded = json_encode($payload); $encoded = json_encode($payload);

View File

@ -83,6 +83,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
'status' => ReviewPackStatus::Queued->value, 'status' => ReviewPackStatus::Queued->value,
'options' => $options, 'options' => $options,
'summary' => [ 'summary' => [
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
? $snapshot->summary['finding_outcomes']
: [],
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
? $snapshot->summary['finding_report_buckets']
: [],
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null) 'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
? $snapshot->summary['risk_acceptance'] ? $snapshot->summary['risk_acceptance']
: [], : [],
@ -168,6 +174,12 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
'review_status' => (string) $review->status, 'review_status' => (string) $review->status,
'review_completeness_state' => (string) $review->completeness_state, 'review_completeness_state' => (string) $review->completeness_state,
'section_count' => $review->sections->count(), 'section_count' => $review->sections->count(),
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
? $review->summary['finding_outcomes']
: [],
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
? $review->summary['finding_report_buckets']
: [],
'evidence_resolution' => [ 'evidence_resolution' => [
'outcome' => 'resolved', 'outcome' => 'resolved',
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => (int) $snapshot->getKey(),

View File

@ -59,6 +59,12 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
'publish_blockers' => $blockers, 'publish_blockers' => $blockers,
'has_ready_export' => false, 'has_ready_export' => false,
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0), 'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
? data_get($sections, '0.summary_payload.finding_outcomes')
: [],
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'report_count' => 2, 'report_count' => 2,
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0), 'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []), 'highlights' => data_get($sections, '0.render_payload.highlights', []),

View File

@ -6,12 +6,17 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewCompletenessState;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
final class TenantReviewSectionFactory final class TenantReviewSectionFactory
{ {
public function __construct(
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
) {}
/** /**
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
@ -47,6 +52,8 @@ private function executiveSummarySection(
$rolesSummary = $this->summary($rolesItem); $rolesSummary = $this->summary($rolesItem);
$baselineSummary = $this->summary($baselineItem); $baselineSummary = $this->summary($baselineItem);
$operationsSummary = $this->summary($operationsItem); $operationsSummary = $this->summary($operationsItem);
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : []; $riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
$openCount = (int) ($findingsSummary['open_count'] ?? 0); $openCount = (int) ($findingsSummary['open_count'] ?? 0);
@ -55,9 +62,11 @@ private function executiveSummarySection(
$postureScore = $permissionSummary['posture_score'] ?? null; $postureScore = $permissionSummary['posture_score'] ?? null;
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0); $operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0); $partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
$highlights = array_values(array_filter([ $highlights = array_values(array_filter([
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount), sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.', $postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
sprintf('%d baseline drift findings remain open.', $driftCount), sprintf('%d baseline drift findings remain open.', $driftCount),
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations), sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
@ -81,6 +90,8 @@ private function executiveSummarySection(
'summary_payload' => [ 'summary_payload' => [
'finding_count' => $findingCount, 'finding_count' => $findingCount,
'open_risk_count' => $openCount, 'open_risk_count' => $openCount,
'finding_outcomes' => $findingOutcomes,
'finding_report_buckets' => $findingReportBuckets,
'posture_score' => $postureScore, 'posture_score' => $postureScore,
'baseline_drift_count' => $driftCount, 'baseline_drift_count' => $driftCount,
'failed_operation_count' => $operationFailures, 'failed_operation_count' => $operationFailures,

View File

@ -38,7 +38,6 @@ final class BadgeCatalog
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class, BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class, BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class, BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class, BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class, BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class, BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,

View File

@ -29,7 +29,6 @@ enum BadgeDomain: string
case BooleanEnabled = 'boolean_enabled'; case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors'; case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status'; case TenantStatus = 'tenant_status';
case TenantAppStatus = 'tenant_app_status';
case TenantRbacStatus = 'tenant_rbac_status'; case TenantRbacStatus = 'tenant_rbac_status';
case TenantPermissionStatus = 'tenant_permission_status'; case TenantPermissionStatus = 'tenant_permission_status';
case PolicySnapshotMode = 'policy_snapshot_mode'; case PolicySnapshotMode = 'policy_snapshot_mode';

View File

@ -1,24 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantAppStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
@ -142,6 +143,22 @@ public static function findingWorkflowFamilies(): array
]; ];
} }
/**
* @return array<string, string>
*/
public static function findingTerminalOutcomes(): array
{
return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions();
}
/**
* @return array<string, string>
*/
public static function findingVerificationStates(): array
{
return app(FindingOutcomeSemantics::class)->verificationStateOptions();
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -6,19 +6,89 @@
class PanelThemeAsset class PanelThemeAsset
{ {
/**
* @var array<string, bool>
*/
private static array $hotAssetReachability = [];
public static function resolve(string $entry): ?string public static function resolve(string $entry): ?string
{ {
if (app()->runningUnitTests()) { if (app()->runningUnitTests()) {
return static::resolveFromManifest($entry); return static::resolveFromManifest($entry);
} }
if (is_file(public_path('hot'))) { if (static::shouldUseHotAsset($entry)) {
return Vite::asset($entry); return Vite::asset($entry);
} }
return static::resolveFromManifest($entry); return static::resolveFromManifest($entry);
} }
private static function shouldUseHotAsset(string $entry): bool
{
$hotFile = public_path('hot');
if (! is_file($hotFile)) {
return false;
}
$hotUrl = trim((string) file_get_contents($hotFile));
if ($hotUrl === '') {
return false;
}
$assetUrl = Vite::asset($entry);
if ($assetUrl === '') {
return false;
}
if (array_key_exists($assetUrl, static::$hotAssetReachability)) {
return static::$hotAssetReachability[$assetUrl];
}
$parts = parse_url($assetUrl);
if (! is_array($parts)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
$host = $parts['host'] ?? null;
if (! is_string($host) || $host === '') {
return static::$hotAssetReachability[$assetUrl] = false;
}
$scheme = $parts['scheme'] ?? 'http';
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$transport = $scheme === 'https' ? 'ssl://' : '';
$connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2);
if (! is_resource($connection)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
$hostHeader = isset($parts['port']) ? $host.':'.$port : $host;
stream_set_timeout($connection, 0, 200000);
fwrite(
$connection,
"HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n",
);
$statusLine = fgets($connection);
fclose($connection);
if (! is_string($statusLine)) {
return static::$hotAssetReachability[$assetUrl] = false;
}
return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1;
}
private static function resolveFromManifest(string $entry): ?string private static function resolveFromManifest(string $entry): ?string
{ {
$manifest = public_path('build/manifest.json'); $manifest = public_path('build/manifest.json');

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Support\Findings;
use App\Models\Finding;
final class FindingOutcomeSemantics
{
public const string VERIFICATION_PENDING = 'pending_verification';
public const string VERIFICATION_VERIFIED = 'verified_cleared';
public const string VERIFICATION_NOT_APPLICABLE = 'not_applicable';
public const string OUTCOME_RESOLVED_PENDING_VERIFICATION = 'resolved_pending_verification';
public const string OUTCOME_VERIFIED_CLEARED = 'verified_cleared';
public const string OUTCOME_CLOSED_FALSE_POSITIVE = 'closed_false_positive';
public const string OUTCOME_CLOSED_DUPLICATE = 'closed_duplicate';
public const string OUTCOME_CLOSED_NO_LONGER_APPLICABLE = 'closed_no_longer_applicable';
public const string OUTCOME_RISK_ACCEPTED = 'risk_accepted';
/**
* @return array{
* terminal_outcome_key: ?string,
* label: ?string,
* verification_state: string,
* verification_label: ?string,
* report_bucket: ?string
* }
*/
public function describe(Finding $finding): array
{
$terminalOutcomeKey = $this->terminalOutcomeKey($finding);
$verificationState = $this->verificationState($finding);
return [
'terminal_outcome_key' => $terminalOutcomeKey,
'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null,
'verification_state' => $verificationState,
'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE
? $this->verificationStateLabel($verificationState)
: null,
'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null,
];
}
public function terminalOutcomeKey(Finding $finding): ?string
{
return match ((string) $finding->status) {
Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')),
Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')),
Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED,
default => null,
};
}
public function verificationState(Finding $finding): string
{
if ((string) $finding->status !== Finding::STATUS_RESOLVED) {
return self::VERIFICATION_NOT_APPLICABLE;
}
$reason = (string) ($finding->resolved_reason ?? '');
if (Finding::isSystemResolveReason($reason)) {
return self::VERIFICATION_VERIFIED;
}
if (Finding::isManualResolveReason($reason)) {
return self::VERIFICATION_PENDING;
}
return self::VERIFICATION_NOT_APPLICABLE;
}
public function systemReopenReasonFor(Finding $finding): string
{
return $this->verificationState($finding) === self::VERIFICATION_PENDING
? Finding::REOPEN_REASON_VERIFICATION_FAILED
: Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION;
}
/**
* @return array<string, string>
*/
public function terminalOutcomeOptions(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION),
self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED),
self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE),
self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE),
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE),
self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED),
];
}
/**
* @return array<string, string>
*/
public function verificationStateOptions(): array
{
return [
self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING),
self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED),
self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE),
];
}
public function terminalOutcomeLabel(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification',
self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared',
self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive',
self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate',
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable',
self::OUTCOME_RISK_ACCEPTED => 'Risk accepted',
default => 'Unknown outcome',
};
}
public function verificationStateLabel(string $verificationState): string
{
return match ($verificationState) {
self::VERIFICATION_PENDING => 'Pending verification',
self::VERIFICATION_VERIFIED => 'Verified cleared',
default => 'Not applicable',
};
}
public function reportBucket(string $terminalOutcomeKey): string
{
return match ($terminalOutcomeKey) {
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification',
self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified',
self::OUTCOME_RISK_ACCEPTED => 'accepted_risk',
default => 'administrative_closure',
};
}
public function compactOutcomeSummary(array $counts): ?string
{
$parts = [];
foreach ($this->orderedOutcomeKeys() as $outcomeKey) {
$count = (int) ($counts[$outcomeKey] ?? 0);
if ($count < 1) {
continue;
}
$parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey)));
}
return $parts === [] ? null : implode(', ', $parts);
}
/**
* @return array<int, string>
*/
public function orderedOutcomeKeys(): array
{
return [
self::OUTCOME_RESOLVED_PENDING_VERIFICATION,
self::OUTCOME_VERIFIED_CLEARED,
self::OUTCOME_CLOSED_FALSE_POSITIVE,
self::OUTCOME_CLOSED_DUPLICATE,
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
self::OUTCOME_RISK_ACCEPTED,
];
}
private function resolvedTerminalOutcomeKey(string $reason): ?string
{
if (Finding::isSystemResolveReason($reason)) {
return self::OUTCOME_VERIFIED_CLEARED;
}
if (Finding::isManualResolveReason($reason)) {
return self::OUTCOME_RESOLVED_PENDING_VERIFICATION;
}
return null;
}
private function closedTerminalOutcomeKey(string $reason): ?string
{
return match ($reason) {
Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE,
Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE,
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
default => null,
};
}
}

View File

@ -202,7 +202,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId) ->whereKey($resourceId)
->where('workspace_id', (int) $workspace->getKey()) ->where('workspace_id', (int) $workspace->getKey())
->exists() ->exists()
? ['label' => OperationRunLinks::openLabel(), 'url' => route('admin.operations.view', ['run' => $resourceId])] ? ['label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::tenantlessView($resourceId)]
: null, : null,
'baseline_profile' => $workspace instanceof Workspace 'baseline_profile' => $workspace instanceof Workspace
&& $this->workspaceCapabilityResolver->isMember($user, $workspace) && $this->workspaceCapabilityResolver->isMember($user, $workspace)

View File

@ -81,6 +81,7 @@ public static function index(
?string $activeTab = null, ?string $activeTab = null,
bool $allTenants = false, bool $allTenants = false,
?string $problemClass = null, ?string $problemClass = null,
?string $operationType = null,
): string { ): string {
$parameters = $context?->toQuery() ?? []; $parameters = $context?->toQuery() ?? [];
@ -106,6 +107,10 @@ public static function index(
} }
} }
if (is_string($operationType) && $operationType !== '') {
$parameters['tableFilters']['type']['value'] = $operationType;
}
return route('admin.operations.index', $parameters); return route('admin.operations.index', $parameters);
} }

View File

@ -22,7 +22,7 @@ public static function existForTenantId(?int $tenantId): bool
return OperationRun::query() return OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->healthyActive() ->active()
->exists(); ->exists();
} }

View File

@ -4,7 +4,11 @@
namespace App\Support\OpsUx; namespace App\Support\OpsUx;
use App\Filament\Resources\FindingResource;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -12,11 +16,13 @@
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\System\SystemOperationRunLinks;
use App\Support\Ui\DerivedState\DerivedStateFamily; use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey; use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore; use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification; use Filament\Notifications\Notification as FilamentNotification;
final class OperationUxPresenter final class OperationUxPresenter
@ -81,6 +87,48 @@ public static function scopeBusyToast(
->duration(self::QUEUED_TOAST_DURATION_MS); ->duration(self::QUEUED_TOAST_DURATION_MS);
} }
/**
* @param array<string, mixed> $event
* @return array<string, mixed>
*/
public static function findingDatabaseNotificationMessage(Finding $finding, Tenant $tenant, array $event): array
{
return self::databaseNotificationMessage(
title: self::findingNotificationTitle($event),
body: self::findingNotificationBody($event),
status: self::findingNotificationStatus($event),
actionName: 'open_finding',
actionLabel: 'Open finding',
actionUrl: FindingResource::getUrl(
'view',
['record' => $finding],
panel: 'tenant',
tenant: $tenant,
),
actionTarget: 'finding_detail',
supportingLines: self::findingNotificationSupportingLines($event),
);
}
/**
* @return array<string, mixed>
*/
public static function queuedDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
{
$operationLabel = OperationCatalog::label((string) $run->type);
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
return self::databaseNotificationMessage(
title: "{$operationLabel} queued",
body: 'Queued for execution. Open the operation for progress and next steps.',
status: 'info',
actionName: 'view_run',
actionLabel: $primaryAction['label'],
actionUrl: $primaryAction['url'],
actionTarget: $primaryAction['target'],
);
}
/** /**
* Terminal DB notification payload. * Terminal DB notification payload.
* *
@ -89,44 +137,40 @@ public static function scopeBusyToast(
*/ */
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
{ {
$operationLabel = OperationCatalog::label((string) $run->type); $payload = self::terminalNotificationPayload($run);
$presentation = self::terminalPresentation($run); $actionUrl = $tenant instanceof Tenant
$bodyLines = [$presentation['body']]; ? OperationRunUrl::view($run, $tenant)
: OperationRunLinks::tenantlessView($run);
$failureMessage = self::surfaceFailureDetail($run); return self::makeDatabaseNotification(
if ($failureMessage !== null) { title: $payload['title'],
$bodyLines[] = $failureMessage; body: $payload['body'],
} status: $payload['status'],
actionName: 'view_run',
actionLabel: OperationRunLinks::openLabel(),
actionUrl: $actionUrl,
supportingLines: $payload['supportingLines'],
);
}
$guidance = self::surfaceGuidance($run); /**
if ($guidance !== null) { * @return array<string, mixed>
$bodyLines[] = $guidance; */
} public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
{
$payload = self::terminalNotificationPayload($run);
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []); return self::databaseNotificationMessage(
if ($summary !== null) { title: $payload['title'],
$bodyLines[] = $summary; body: $payload['body'],
} status: $payload['status'],
actionName: 'view_run',
$integritySummary = RedactionIntegrity::noteForRun($run); actionLabel: $primaryAction['label'],
if (is_string($integritySummary) && trim($integritySummary) !== '') { actionUrl: $primaryAction['url'],
$bodyLines[] = trim($integritySummary); actionTarget: $primaryAction['target'],
} supportingLines: $payload['supportingLines'],
);
$notification = FilamentNotification::make()
->title("{$operationLabel} {$presentation['titleSuffix']}")
->body(implode("\n", $bodyLines))
->status($presentation['status']);
if ($tenant instanceof Tenant) {
$notification->actions([
\Filament\Actions\Action::make('view')
->label(OperationRunLinks::openLabel())
->url(OperationRunUrl::view($run, $tenant)),
]);
}
return $notification;
} }
public static function surfaceGuidance(OperationRun $run): ?string public static function surfaceGuidance(OperationRun $run): ?string
@ -345,6 +389,59 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
}; };
} }
/**
* @param array<string, mixed> $event
* @return list<string>
*/
private static function findingNotificationSupportingLines(array $event): array
{
$recipientReason = self::findingRecipientReasonCopy((string) data_get($event, 'metadata.recipient_reason', ''));
return $recipientReason !== '' ? [$recipientReason] : [];
}
/**
* @param array<string, mixed> $event
*/
private static function findingNotificationTitle(array $event): string
{
$title = trim((string) ($event['title'] ?? 'Finding update'));
return $title !== '' ? $title : 'Finding update';
}
/**
* @param array<string, mixed> $event
*/
private static function findingNotificationBody(array $event): string
{
$body = trim((string) ($event['body'] ?? 'A finding needs follow-up.'));
return $body !== '' ? $body : 'A finding needs follow-up.';
}
/**
* @param array<string, mixed> $event
*/
private static function findingNotificationStatus(array $event): string
{
return match ((string) ($event['event_type'] ?? '')) {
AlertRule::EVENT_FINDINGS_DUE_SOON => 'warning',
AlertRule::EVENT_FINDINGS_OVERDUE => 'danger',
default => 'info',
};
}
private static function findingRecipientReasonCopy(string $reason): string
{
return match ($reason) {
'new_assignee' => 'You are the new assignee.',
'current_assignee' => 'You are the current assignee.',
'current_owner' => 'You are the accountable owner.',
default => '',
};
}
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
{ {
return self::resolveGovernanceOperatorExplanation($run); return self::resolveGovernanceOperatorExplanation($run);
@ -377,7 +474,7 @@ private static function terminalPresentation(OperationRun $run): array
if ($freshnessState->isReconciledFailed()) { if ($freshnessState->isReconciledFailed()) {
return [ return [
'titleSuffix' => 'was automatically reconciled', 'titleSuffix' => 'was automatically reconciled',
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.', 'body' => 'Automatically reconciled after infrastructure failure.',
'status' => 'danger', 'status' => 'danger',
]; ];
} }
@ -395,17 +492,198 @@ private static function terminalPresentation(OperationRun $run): array
], ],
'blocked' => [ 'blocked' => [
'titleSuffix' => 'blocked by prerequisite', 'titleSuffix' => 'blocked by prerequisite',
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.', 'body' => 'Blocked by prerequisite.',
'status' => 'warning', 'status' => 'warning',
], ],
default => [ default => [
'titleSuffix' => 'execution failed', 'titleSuffix' => 'execution failed',
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.', 'body' => 'Execution failed.',
'status' => 'danger', 'status' => 'danger',
], ],
}; };
} }
/**
* @return array{
* title:string,
* body:string,
* status:string,
* supportingLines:list<string>
* }
*/
private static function terminalNotificationPayload(OperationRun $run): array
{
$operationLabel = OperationCatalog::label((string) $run->type);
$presentation = self::terminalPresentation($run);
return [
'title' => "{$operationLabel} {$presentation['titleSuffix']}",
'body' => $presentation['body'],
'status' => $presentation['status'],
'supportingLines' => self::terminalSupportingLines($run),
];
}
/**
* @return list<string>
*/
private static function terminalSupportingLines(OperationRun $run): array
{
$lines = [];
$reasonLabel = trim((string) (self::reasonEnvelope($run)?->operatorLabel ?? ''));
if ($reasonLabel !== '') {
$lines[] = $reasonLabel;
}
$failureMessage = self::surfaceFailureDetail($run);
if ($failureMessage !== null) {
$lines[] = $failureMessage;
}
$guidance = self::surfaceGuidance($run);
if ($guidance !== null) {
$lines[] = $guidance;
}
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
if ($summary !== null) {
$lines[] = $summary;
}
$integritySummary = RedactionIntegrity::noteForRun($run);
if (is_string($integritySummary) && trim($integritySummary) !== '') {
$lines[] = trim($integritySummary);
}
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
}
/**
* @return array{label:string, url:?string, target:string}
*/
private static function operationRunPrimaryAction(OperationRun $run, object $notifiable): array
{
if ($notifiable instanceof PlatformUser) {
return [
'label' => OperationRunLinks::openLabel(),
'url' => SystemOperationRunLinks::view($run),
'target' => 'system_operation_run',
];
}
if (self::isManagedTenantOnboardingWizardRun($run)) {
return [
'label' => OperationRunLinks::openLabel(),
'url' => OperationRunLinks::tenantlessView($run),
'target' => 'tenantless_operation_run',
];
}
if ($run->tenant instanceof Tenant) {
return [
'label' => OperationRunLinks::openLabel(),
'url' => OperationRunLinks::view($run, $run->tenant),
'target' => 'admin_operation_run',
];
}
return [
'label' => OperationRunLinks::openLabel(),
'url' => OperationRunLinks::tenantlessView($run),
'target' => 'tenantless_operation_run',
];
}
private static function isManagedTenantOnboardingWizardRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];
$wizard = $context['wizard'] ?? null;
return is_array($wizard)
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
}
/**
* @param list<string> $supportingLines
*/
private static function makeDatabaseNotification(
string $title,
string $body,
string $status,
string $actionName,
string $actionLabel,
?string $actionUrl,
array $supportingLines = [],
): FilamentNotification {
return FilamentNotification::make()
->title($title)
->body(self::composeDatabaseNotificationBody($body, $supportingLines))
->status($status)
->actions([
Action::make($actionName)
->label($actionLabel)
->url($actionUrl),
]);
}
/**
* @param list<string> $supportingLines
* @return array<string, mixed>
*/
private static function databaseNotificationMessage(
string $title,
string $body,
string $status,
string $actionName,
string $actionLabel,
?string $actionUrl,
string $actionTarget,
array $supportingLines = [],
): array {
$message = self::makeDatabaseNotification(
title: $title,
body: $body,
status: $status,
actionName: $actionName,
actionLabel: $actionLabel,
actionUrl: $actionUrl,
supportingLines: $supportingLines,
)->getDatabaseMessage();
$message['supporting_lines'] = array_values(array_filter(
$supportingLines,
static fn (string $line): bool => trim($line) !== '',
));
if (is_array($message['actions'][0] ?? null)) {
$message['actions'][0]['target'] = $actionTarget;
}
return $message;
}
/**
* @param list<string> $supportingLines
*/
private static function composeDatabaseNotificationBody(string $body, array $supportingLines): string
{
$lines = [trim($body)];
foreach ($supportingLines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$lines[] = $line;
}
return implode("\n", array_filter($lines, static fn (string $line): bool => $line !== ''));
}
private static function requiresFollowUp(OperationRun $run): bool private static function requiresFollowUp(OperationRun $run): bool
{ {
if (self::firstNextStepLabel($run) !== null) { if (self::firstNextStepLabel($run) !== null) {

View File

@ -70,7 +70,7 @@ public static function families(): array
'canonicalObject' => 'finding', 'canonicalObject' => 'finding',
'panels' => ['tenant'], 'panels' => ['tenant'],
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'], 'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
'defaultActionOrder' => ['close_finding', 'reopen_finding'], 'defaultActionOrder' => ['resolve_finding', 'close_finding', 'reopen_finding'],
'supportsDocumentedDeviation' => false, 'supportsDocumentedDeviation' => false,
'defaultMutationScopeSource' => 'finding lifecycle', 'defaultMutationScopeSource' => 'finding lifecycle',
], ],
@ -260,6 +260,20 @@ public static function rules(): array
serviceOwner: 'OperationRunTriageService', serviceOwner: 'OperationRunTriageService',
surfaceKeys: ['system_view_run'], surfaceKeys: ['system_view_run'],
), ),
'resolve_finding' => new GovernanceActionRule(
actionKey: 'resolve_finding',
familyKey: 'finding_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Resolve',
modalHeading: 'Resolve finding',
modalDescription: 'Resolve this finding for the current tenant. TenantPilot records a canonical remediation outcome and keeps the finding in a pending-verification state until trusted evidence later confirms it is actually clear.',
successTitle: 'Finding resolved pending verification',
auditVerb: 'resolve finding',
serviceOwner: 'FindingWorkflowService',
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
),
'close_finding' => new GovernanceActionRule( 'close_finding' => new GovernanceActionRule(
actionKey: 'close_finding', actionKey: 'close_finding',
familyKey: 'finding_lifecycle', familyKey: 'finding_lifecycle',
@ -268,7 +282,7 @@ public static function rules(): array
dangerPolicy: 'none', dangerPolicy: 'none',
canonicalLabel: 'Close', canonicalLabel: 'Close',
modalHeading: 'Close finding', modalHeading: 'Close finding',
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.', modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.',
successTitle: 'Finding closed', successTitle: 'Finding closed',
auditVerb: 'close finding', auditVerb: 'close finding',
serviceOwner: 'FindingWorkflowService', serviceOwner: 'FindingWorkflowService',
@ -282,7 +296,7 @@ public static function rules(): array
dangerPolicy: 'none', dangerPolicy: 'none',
canonicalLabel: 'Reopen', canonicalLabel: 'Reopen',
modalHeading: 'Reopen finding', modalHeading: 'Reopen finding',
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.', modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.',
successTitle: 'Finding reopened', successTitle: 'Finding reopened',
auditVerb: 'reopen finding', auditVerb: 'reopen finding',
serviceOwner: 'FindingWorkflowService', serviceOwner: 'FindingWorkflowService',
@ -489,6 +503,17 @@ public static function surfaceBindings(): array
'uiFieldKey' => 'reason', 'uiFieldKey' => 'reason',
'auditChannel' => 'system_audit', 'auditChannel' => 'system_audit',
], ],
[
'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
'actionName' => 'resolve',
'familyKey' => 'finding_lifecycle',
'statePredicate' => 'finding has open status',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'tenant_findings.resolve',
'uiFieldKey' => 'resolved_reason',
'auditChannel' => 'tenant_audit',
],
[ [
'surfaceKey' => 'view_finding', 'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding', 'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',

View File

@ -120,7 +120,16 @@ public function resolved(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(), 'resolved_at' => now(),
'resolved_reason' => 'permission_granted', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
]);
}
public function verifiedCleared(string $reason = Finding::RESOLVE_REASON_NO_LONGER_DRIFTING): static
{
return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(),
'resolved_reason' => $reason,
]); ]);
} }
@ -176,7 +185,7 @@ public function closed(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_CLOSED, 'status' => Finding::STATUS_CLOSED,
'closed_at' => now(), 'closed_at' => now(),
'closed_reason' => 'duplicate', 'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
]); ]);
} }
@ -188,7 +197,7 @@ public function riskAccepted(): static
return $this->state(fn (array $attributes): array => [ return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_RISK_ACCEPTED, 'status' => Finding::STATUS_RISK_ACCEPTED,
'closed_at' => now(), 'closed_at' => now(),
'closed_reason' => 'accepted_risk', 'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
]); ]);
} }

View File

@ -42,7 +42,6 @@ public function definition(): array
'app_client_id' => fake()->uuid(), 'app_client_id' => fake()->uuid(),
'app_client_secret' => null, // Skip encryption in tests 'app_client_secret' => null, // Skip encryption in tests
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => 'active', 'status' => 'active',
'environment' => 'other', 'environment' => 'other',

View File

@ -1,6 +1,8 @@
@php($runs = $runs ?? collect()) @php
@php($overflowCount = (int) ($overflowCount ?? 0)) $runs = $runs ?? collect();
@php($tenant = $tenant ?? null) $overflowCount = (int) ($overflowCount ?? 0);
$tenant = $tenant ?? null;
@endphp
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}} {{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
@ -16,6 +18,17 @@
@if($runs->isNotEmpty()) @if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;"> <div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@foreach ($runs->take(5) as $run) @foreach ($runs->take(5) as $run)
@php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300" <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}"> wire:key="run-{{ $run->id }}">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
@ -30,6 +43,21 @@
Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
@endif @endif
</p> </p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$statusSpec->color" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div>
@if ($guidance)
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $guidance }}
</p>
@endif
</div> </div>
@if ($tenant) @if ($tenant)

View File

@ -7,6 +7,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -63,7 +64,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('Operations') ->assertSee('Operations')
->assertSee(route('admin.operations.index'), false) ->assertSee(OperationRunLinks::index(), false)
->assertDontSee('View restore run'); ->assertDontSee('View restore run');
} }

View File

@ -75,6 +75,34 @@ public function test_trusts_notification_style_run_links_with_no_selected_tenant
->assertSee('Canonical workspace view'); ->assertSee('Canonical workspace view');
} }
public function test_uses_canonical_collection_link_for_default_back_and_show_all_fallbacks(): void
{
$runTenant = Tenant::factory()->create();
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
]);
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id,
'tenant_id' => (int) $runTenant->getKey(),
'type' => 'inventory_sync',
]);
Filament::setTenant($otherTenant, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Back to Operations')
->assertSee('Show all operations')
->assertSee(OperationRunLinks::index(), false);
}
public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void
{ {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();

View File

@ -1100,7 +1100,7 @@
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_RESOLVED, 'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now()->subMinute(), 'resolved_at' => now()->subMinute(),
'resolved_reason' => 'manually_resolved', 'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
])->save(); ])->save();
$firstRun->update(['completed_at' => now()->subMinute()]); $firstRun->update(['completed_at' => now()->subMinute()]);

View File

@ -43,6 +43,10 @@
it('archives baseline profiles for authorized workspace members', function (): void { it('archives baseline profiles for authorized workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void { it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
@ -23,6 +25,11 @@
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void { it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => BaselineProfileStatus::Archived->value,
]);
$this->actingAs($user) $this->actingAs($user)
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); ->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
@ -32,5 +39,8 @@
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles"); expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
$this->get($workspaceUrl)->assertOk(); $this->get($workspaceUrl)->assertOk();
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))->assertOk();
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound(); $this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
expect($profile->fresh()->status)->toBe(BaselineProfileStatus::Archived);
}); });

View File

@ -14,6 +14,8 @@
it('filters baseline profiles by status inside the current workspace', function (): void { it('filters baseline profiles by status inside the current workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse();
$active = BaselineProfile::factory()->active()->create([ $active = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -45,7 +46,7 @@
expect($profile->scope_jsonb)->toBe([ expect($profile->scope_jsonb)->toBe([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'], 'foundation_types' => ['assignmentFilter'],
]); ])->and($profile->status)->toBe(BaselineProfileStatus::Draft);
expect($profile->canonicalScopeJsonb())->toBe([ expect($profile->canonicalScopeJsonb())->toBe([
'version' => 2, 'version' => 2,
@ -83,7 +84,7 @@
'name' => 'Legacy baseline profile', 'name' => 'Legacy baseline profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => [], 'policy_types' => [],
@ -178,7 +179,7 @@
'name' => 'Legacy lineage profile', 'name' => 'Legacy lineage profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],

View File

@ -63,3 +63,52 @@
->assertSee('Baseline compare') ->assertSee('Baseline compare')
->assertSee('Operation #'.$run->getKey()); ->assertSee('Operation #'.$run->getKey());
}); });
it('shows canonical manual terminal outcome and verification labels on finding detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
'resolved_at' => now()->subHour(),
]);
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Terminal outcome')
->assertSee('Resolved pending verification')
->assertSee('Verification')
->assertSee('Pending verification')
->assertSee('Resolved reason')
->assertSee('Remediated');
});
it('shows verified clear and administrative closure labels on finding detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$verifiedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_RESOLVED,
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
'resolved_at' => now()->subHour(),
]);
$closedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_CLOSED,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_at' => now()->subHour(),
]);
$this->get(FindingResource::getUrl('view', ['record' => $verifiedFinding], tenant: $tenant))
->assertOk()
->assertSee('Verified cleared')
->assertSee('No longer drifting');
$this->get(FindingResource::getUrl('view', ['record' => $closedFinding], tenant: $tenant))
->assertOk()
->assertSee('Closed as duplicate')
->assertSee('Duplicate');
});

View File

@ -4,9 +4,11 @@
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -40,21 +42,14 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
$run = seedCoverageBasisRun($tenant); $run = seedCoverageBasisRun($tenant);
$historyUrl = route('admin.operations.index', [ $historyUrl = OperationRunLinks::index($tenant, operationType: 'inventory_sync');
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
$this->actingAs($user) $this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant)) ->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('Latest coverage-bearing sync completed') ->assertSee('Latest coverage-bearing sync completed')
->assertSee('Open basis run') ->assertSee('Open basis run')
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false) ->assertSee(OperationRunLinks::view($run, $tenant), false)
->assertSee($historyUrl, false) ->assertSee($historyUrl, false)
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.'); ->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
}); });
@ -78,6 +73,26 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
->assertDontSee('Open basis run'); ->assertDontSee('Open basis run');
}); });
it('shows the last inventory sync as a canonical admin operation detail link', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory_sync',
]);
$item = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
$this->actingAs($user)
->get(InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant))
->assertOk()
->assertSee('Last inventory sync')
->assertSee(OperationRunLinks::view($run, $tenant), false);
});
it('keeps the no-basis fallback explicit on the inventory items list', function (): void { it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -16,7 +16,7 @@
$this->actingAs($user); $this->actingAs($user);
OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check', 'type' => 'provider.connection.check',
@ -32,6 +32,8 @@
->assertSee('Open operation') ->assertSee('Open operation')
->assertSee(OperationRunLinks::openCollectionLabel()) ->assertSee(OperationRunLinks::openCollectionLabel())
->assertSee(OperationRunLinks::collectionScopeDescription()) ->assertSee(OperationRunLinks::collectionScopeDescription())
->assertSee(OperationRunLinks::index($tenant), false)
->assertSee(OperationRunLinks::tenantlessView($run), false)
->assertSee('No action needed.') ->assertSee('No action needed.')
->assertDontSee('No operations yet.'); ->assertDontSee('No operations yet.');
}); });

View File

@ -25,6 +25,8 @@
role: 'owner', role: 'owner',
); );
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -33,11 +35,14 @@
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed'); ->assertSee('Failed');
}); });
it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void { it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->onboarding()->raw()))->toBeFalse();
$tenant = Tenant::factory()->onboarding()->create([ $tenant = Tenant::factory()->onboarding()->create([
'name' => 'Viewer Separation Tenant', 'name' => 'Viewer Separation Tenant',
]); ]);

View File

@ -38,6 +38,8 @@
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('ok');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -61,6 +63,17 @@
->and($visibleColumnNames)->not->toContain('provider_connection_state'); ->and($visibleColumnNames)->not->toContain('provider_connection_state');
}); });
it('keeps legacy app status as opt-in test setup instead of a factory default', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->raw()))->toBeFalse();
$tenant = Tenant::factory()->create([
'name' => 'Explicit Historical App Status Tenant',
'app_status' => 'error',
]);
expect($tenant->fresh()->app_status)->toBe('error');
});
it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void { it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING, 'status' => Tenant::STATUS_ONBOARDING,
@ -86,6 +99,8 @@
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -97,6 +112,7 @@
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed') ->assertSee('Failed')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('Truth Cleanup Connection') ->assertSee('Truth Cleanup Connection')
->assertSee('Lifecycle') ->assertSee('Lifecycle')
->assertSee('Disabled') ->assertSee('Disabled')

View File

@ -20,9 +20,9 @@
$service->triage($finding, $tenant, $user); $service->triage($finding, $tenant, $user);
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey()); $service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
$service->resolve($finding->refresh(), $tenant, $user, 'patched'); $service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
$service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.'); $service->reopen($finding->refresh(), $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
$service->close($finding->refresh(), $tenant, $user, 'duplicate'); $service->close($finding->refresh(), $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
expect(AuditLog::query() expect(AuditLog::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -37,14 +37,14 @@
->and($closedAudit->targetDisplayLabel())->toContain('finding') ->and($closedAudit->targetDisplayLabel())->toContain('finding')
->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED) ->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED)
->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED) ->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED)
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate') ->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe(Finding::CLOSE_REASON_DUPLICATE)
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull() ->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull(); ->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened); $reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
expect($reopenedAudit)->not->toBeNull() expect($reopenedAudit)->not->toBeNull()
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.'); ->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
}); });
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void { it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {

Some files were not shown because too many files have changed in this diff Show More