Compare commits

...

16 Commits

Author SHA1 Message Date
fb32e9bfa5 feat: canonical operation type source of truth (#276)
Some checks failed
Main Confidence / confidence (push) Failing after 49s
## Summary
- implement the canonical operation type source-of-truth slice across operation writers, monitoring surfaces, onboarding flows, and supporting services
- add focused contract and regression coverage for canonical operation type handling
- include the generated spec 239 artifacts for the feature slice

## Validation
- browser smoke PASS for `/admin` -> workspace overview -> operations -> operation detail -> tenant-scoped operations drilldown
- spec/plan/tasks/quickstart artifact analysis cleaned up to a no-findings state
- automated test suite not run in this session

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #276
2026-04-25 18:11:23 +00:00
58f9bb7355 chore: commit all workspace changes (#275)
Some checks failed
Main Confidence / confidence (push) Failing after 1m34s
Auto-generated PR: commit all workspace changes (includes .github/skills addition).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #275
2026-04-25 09:13:54 +00:00
110245a9ec feat: neutralize provider connection target-scope surfaces (#274)
Some checks are pending
Main Confidence / confidence (push) Waiting to run
## Summary
- add a shared provider target-scope descriptor, normalizer, identity-context metadata, and surface-summary layer
- update provider connection list, detail, create, edit, and onboarding surfaces to use neutral target-scope vocabulary while keeping Microsoft identity contextual
- align provider connection audit and resolver output with the neutral target-scope contract and add focused guard/unit/feature coverage for regressions

## Validation
- browser smoke: opened the tenant-scoped provider connection list, drilled into detail, and verified the edit/create surfaces in local admin context

## Notes
- this PR comes from the session branch created for the active feature work
- no additional runtime or persistence layer was introduced in this slice

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #274
2026-04-25 09:07:40 +00:00
bd26e209de feat: harden provider boundaries (#273)
Some checks failed
Main Confidence / confidence (push) Failing after 57s
## Summary
- add the provider boundary catalog, boundary support types, and guardrails for platform-core versus provider-owned seams
- harden provider gateway, identity resolution, operation registry, and start-gate behavior to require explicit provider bindings
- add unit and feature coverage for boundary classification, runtime preservation, unsupported paths, and platform-core leakage guards
- add the full Spec Kit artifact set for spec 237 and update roadmap/spec-candidate tracking

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderGatewayTest.php tests/Unit/Providers/ProviderIdentityResolverTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- browser smoke: `http://localhost/admin/provider-connections?tenant_id=18000000-0000-4000-8000-000000000180` loaded with the local smoke user, the empty-state CTA reached the canonical create route, and cancel returned to the scoped list

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #273
2026-04-24 21:05:37 +00:00
6a5b8a3a11 feat: canonical control catalog foundation (#272)
Some checks failed
Main Confidence / confidence (push) Failing after 50s
## Summary
- add a config-seeded canonical control catalog plus shared resolution primitives and Microsoft subject bindings
- propagate canonical control references into findings-derived evidence snapshots and tenant review composition
- add the feature spec artifacts and focused Pest coverage, plus the supporting workspace and Sail helper adjustments included in this branch

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/PlatformRelocation/CommandModelSmokeTest.php
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #272
2026-04-24 12:26:02 +00:00
2752515da5 Spec 235: harden baseline truth and onboarding flows (#271)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
## Summary
- harden baseline capture truth, compare readiness, and monitoring explanations around latest inventory eligibility, blocked prerequisites, and zero-subject outcomes
- improve onboarding verification and bootstrap recovery handling, including admin-consent callback invalidation and queued execution legitimacy/report behavior
- align workspace findings/workspace overview signals and refresh the related spec, roadmap, and spec-candidate artifacts

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineSnapshotBackfillTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Operations/QueuedExecutionAuditTrailTest.php tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php`

## Notes
- browser validation was not re-run in this pass

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #271
2026-04-24 05:44:54 +00:00
603d509b8f cleanup: retire dead transitional residue (#270)
Some checks failed
Main Confidence / confidence (push) Failing after 58s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## 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
ccd4a17209 spec: finalize 226 astrodeck inventory planning artifacts (#263)
Some checks failed
Main Confidence / confidence (push) Failing after 1m36s
## Summary
- finalize Spec 226 artifacts for AstroDeck inventory planning
- include completed planning set: spec, plan, research, data model, quickstart, tasks, checklist, contracts, and inventory outputs
- apply consistency fixes from the project analysis review

## Included changes
- updated `.github/agents/copilot-instructions.md` from agent-context sync
- added/updated all files under `specs/226-astrodeck-inventory-planning/`

## Notes
- docs/spec workflow changes only
- no runtime code paths changed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #263
2026-04-22 11:52:09 +00:00
71f94c3afa spec: finalize 223 AstroDeck rebuild planning consistency (#262)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- finalize Spec 223 planning artifact set for AstroDeck website rebuild
- align `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and contract schema
- add/complete inventory, mapping, exception, drift-follow-up, and supersession artifacts
- mark legacy website-spec task references as superseded and wire follow-up ownership

## Key Outcomes
- no remaining cross-artifact consistency findings in the Spec 223 bundle
- explicit Spec 213 handling path added
- material-drift follow-up rules normalized
- exception register and documented exception model made explicit and schema-backed

## Validation
- Integrated browser smoke check passed for main website routes (`/`, `/product`, `/trust`, `/changelog`, `/contact`, `/privacy`, `/imprint`, `/legal`, `/security-trust`)
- no console errors/warnings observed during route smoke navigation
- YAML contract parses successfully

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #262
2026-04-22 07:52:32 +00:00
e15d80cca5 feat: implement findings notifications escalation (#261)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- implement Spec 224 findings notifications and escalation v1 on top of the existing alerts and Filament database notification infrastructure
- add finding assignment, reopen, due soon, and overdue event handling with direct recipient routing, dedupe, and optional external alert fan-out
- extend alert rule and alert delivery surfaces plus add the Spec 224 planning bundle and candidate-list promotion cleanup

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`

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

## Manual Smoke Note
- integrated-browser smoke testing confirmed the new alert rule event options, notification drawer entries, alert delivery history row, and tenant finding detail route on the active Sail host
- local notification deep links currently resolve from `APP_URL`, so a local `localhost` vs `127.0.0.1:8081` host mismatch can break the browser session if the app is opened on a different host/port combination

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #261
2026-04-22 00:54:38 +00:00
459 changed files with 40079 additions and 1460 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

@ -1,4 +1,4 @@
[mcp_servers.laravel-boost] [mcp_servers.laravel-boost]
command = "vendor/bin/sail" command = "./scripts/platform-sail"
args = ["artisan", "boost:mcp"] args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas" cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform"

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

@ -230,6 +230,32 @@ ## Active Technologies
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox) - PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
- 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` (222-findings-intake-team-queue) - 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` (222-findings-intake-team-queue)
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue) - PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
- 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` (224-findings-notifications-escalation)
- PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned (224-findings-notifications-escalation)
- 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 (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)
- 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, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation)
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -264,14 +290,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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` - 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` - 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders - 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
- 218-homepage-hero: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests
- 217-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
<!-- 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
---

295
.github/skills/browsertest/SKILL.md vendored Normal file
View File

@ -0,0 +1,295 @@
---
name: browsertest
description: Führe einen vollständigen Smoke-Browser-Test im Integrated Browser für das aktuelle Feature aus, inklusive Happy Path, zentraler Regressionen, Kontext-Prüfung und belastbarer Ergebniszusammenfassung.
license: MIT
metadata:
author: GitHub Copilot
---
# Browser Smoke Test
## What This Skill Does
Use this skill to validate the current feature end-to-end in the integrated browser.
This is a focused smoke test, not a full exploratory test session. The goal is to prove that the primary operator flow:
- loads in the correct auth, workspace, and tenant context
- exposes the expected controls and decision points
- completes the main happy path without blocking issues
- lands in the expected end state or canonical drilldown
- does not show obvious regressions such as broken navigation, missing data, or conflicting actions
The skill should produce a concrete pass or fail result with actionable evidence.
## When To Apply
Activate this skill when:
- the user asks to smoke test the current feature in the browser
- a new Filament page, dashboard signal, report, wizard, or detail flow was just added
- a UI regression fix needs confirmation in a real browser context
- the primary question is whether the feature works from an operator perspective
- you need a quick integration-level check without writing a full browser test suite first
## What Success Looks Like
A successful smoke test confirms all of the following:
- the target route opens successfully
- the visible context is correct
- the main flow is usable
- the expected result appears after interaction
- the route or drilldown destination is correct
- the surface does not obviously violate its intended interaction model
If the test cannot be completed, the output must clearly state whether the blocker is:
- authentication
- missing data or fixture state
- routing
- UI interaction failure
- server error
- an unclear expected behavior contract
Do not guess. If the route or state is blocked, report the blocker explicitly.
## Preconditions
Before running the browser smoke test, make sure you know:
- the canonical route or entry point for the feature
- the primary operator action or happy path
- the expected success state
- whether the feature depends on a specific tenant, workspace, or seeded record
When available, use the feature spec, quickstart, tasks, or current browser page as the source of truth.
## Standard Workflow
### 1. Define the smoke-test scope
Identify:
- the route to open
- the primary action to perform
- the expected end state
- one or two critical regressions that must not break
The smoke test should stay narrow. Prefer one complete happy path plus one critical boundary over broad exploratory clicking.
### 2. Establish the browser state
- Reuse the current browser page if it already matches the target feature.
- Otherwise open the canonical route.
- Confirm the current auth and scope context before interacting.
For this repo, that usually means checking whether the page is on:
- `/admin/...` for workspace-context surfaces
- `/admin/t/{tenant}/...` for tenant-context surfaces
### 3. Inspect before acting
- Use `read_page` before interacting so you understand the live controls, refs, headings, and route context.
- Prefer `read_page` over screenshots for actual interaction planning.
- Use screenshots only for visual evidence or when the user asks for them.
### 4. Execute the primary happy path
Run the smallest meaningful flow that proves the feature works.
Typical steps include:
- open the page
- verify heading or key summary text
- click the primary CTA or row
- fill the minimum required form fields
- confirm modal or dialog text when relevant
- submit or navigate
- verify the expected destination or changed state
After each meaningful action, re-read the page so the next step is based on current DOM state.
### 5. Validate the outcome
Check the exact result that matters for the feature.
Examples:
- a new row appears
- a status changes
- a success message appears
- a report filter changes the result set
- a row click lands on the canonical detail page
- a dashboard signal links to the correct report page
### 6. Check for obvious regressions
Even in a smoke test, verify a few core non-negotiables:
- the page is not blank or half-rendered
- the main action is present and usable
- the visible context is correct
- the drilldown destination is canonical
- no obviously duplicated primary actions exist
- no stuck modal, spinner, or blocked interaction remains onscreen
### 7. Capture evidence and summarize clearly
Your result should state:
- route tested
- context used
- steps executed
- pass or fail
- exact blocker or discrepancy if failed
Include a screenshot only when it adds value.
## Tool Usage Guidance
Use the browser tools in this order by default:
1. `read_page`
2. `click_element`
3. `type_in_page`
4. `handle_dialog` when needed
5. `navigate_page` or `open_browser_page` only when route changes are required
6. `run_playwright_code` only if the normal browser tools are insufficient
7. `screenshot_page` for evidence, not for primary navigation logic
## Repo-Specific Guidance For TenantPilot
### Workspace surfaces
For `/admin` pages and similar workspace-context surfaces:
- verify the page is reachable without forcing tenant-route assumptions
- confirm any summary signal or CTA lands on the canonical destination
- verify calm-state versus attention-state behavior when the feature defines both
### Tenant surfaces
For `/admin/t/{tenant}/...` pages:
- verify the tenant context is explicit and correct
- verify drilldowns stay in the intended tenant scope
- treat cross-tenant leakage or silent scope changes as failures
### Filament list or report surfaces
For Filament tables, reports, or registry-style pages:
- verify the heading and table shell render
- verify fixed filters or summary controls exist when the spec requires them
- verify row click or the primary inspect affordance behaves as designed
- verify empty-state messaging is specific rather than generic when the feature defines custom behavior
### Filament detail pages
For detail or view surfaces:
- verify the canonical record loads
- verify expected sections or summary content are present
- verify critical actions or drillbacks are usable
## Result Format
Use a compact result format like this:
```text
Browser smoke result: PASS
Route: /admin/findings/hygiene
Context: workspace member with visible hygiene issues
Steps: opened report -> verified filters -> clicked finding row -> landed on canonical finding detail
Verified: report rendered, primary interaction worked, drilldown route was correct
```
If the test fails:
```text
Browser smoke result: FAIL
Route: /admin/findings/hygiene
Context: authenticated workspace member
Failed step: clicking the summary CTA
Expected: navigate to /admin/findings/hygiene
Actual: remained on /admin with no route change
Blocker: CTA appears rendered but is not interactive
```
## Examples
### Example 1: Smoke test a new report page
Use this when the feature adds a new read-only report.
Steps:
- open the canonical report route
- verify the page heading and main controls
- confirm the table or defined empty state is visible
- click one row or primary inspect affordance
- verify navigation lands on the canonical detail route
Pass criteria:
- report loads
- intended controls exist
- primary inspect path works
### Example 2: Smoke test a dashboard signal
Use this when the feature adds a summary signal on `/admin`.
Steps:
- open `/admin`
- find the signal
- verify the visible count or summary text
- click the CTA
- confirm navigation lands on the canonical downstream surface
Pass criteria:
- signal is visible in the correct state
- CTA text is present
- CTA opens the correct route
### Example 3: Smoke test a tenant detail follow-up
Use this when a workspace-level surface should drill into a tenant-level detail page.
Steps:
- open the workspace-level surface
- trigger the drilldown
- verify the target route includes the correct tenant and record
- confirm the target page actually loads the expected detail content
Pass criteria:
- drilldown route is canonical
- tenant context is correct
- destination content matches the selected record
## Common Pitfalls
- Clicking before reading the page state and refs
- Treating a blocked auth session as a feature failure
- Confusing workspace-context routes with tenant-context routes
- Reporting visual impressions without validating the actual interaction result
- Forgetting to re-read the page after a modal opens or a route changes
- Claiming success without verifying the final destination or changed state
## Non-Goals
This skill does not replace:
- full exploratory QA
- formal Pest browser coverage
- accessibility review
- visual regression approval
- backend correctness tests
It is a fast, real-browser confidence pass for the current feature.

View File

@ -0,0 +1,398 @@
---
name: spec-kit-next-best-one-shot
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code.
---
# Skill: Spec Kit Next-Best One-Shot Preparation
## Purpose
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass:
1. select the next best spec candidate from roadmap and spec candidates
2. run the repository's Spec Kit `specify` flow for that selected candidate
3. run the repository's Spec Kit `plan` flow for the generated spec
4. run the repository's Spec Kit `tasks` flow for the generated plan
5. run the repository's Spec Kit `analyze` flow against the generated artifacts
6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required)
7. stop before implementation
8. provide a concise readiness summary for the user
This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation.
The intended workflow is:
```text
roadmap.md + spec-candidates.md
→ select next best spec
→ run Spec Kit specify
→ run Spec Kit plan
→ run Spec Kit tasks
→ run Spec Kit analyze
→ fix preparation-artifact issues
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks things like:
```text
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus.
```
```text
Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch.
```
```text
Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze.
```
```text
Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor.
```
```text
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren.
```
## Hard Rules
- Work strictly repo-based.
- Use the repository's actual GitHub Spec Kit workflow.
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them.
- Do not bypass Spec Kit branch mechanics.
- Run `analyze` after `tasks` when the repository supports it.
- Fix only issues found in Spec Kit preparation artifacts and planning metadata.
- Do not treat analyze findings as permission to implement product code.
- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not invent roadmap priorities not supported by repository documents.
- Do not pick a spec only because it is listed first.
- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate.
- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
- Prefer small, reviewable, implementation-ready specs over large ambiguous themes.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
- If no candidate is suitable, do not run Spec Kit commands and explain why.
## Required Repository Checks Before Selection
Before selecting the next spec, inspect:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `.specify/scripts/`
4. existing Spec Kit command usage or repository instructions, if present
5. `specs/`
6. `docs/product/spec-candidates.md`
7. roadmap documents under `docs/product/`, especially `roadmap.md` if present
8. nearby existing specs related to top candidate areas
9. current branch and git status
10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
Do not edit application code.
## Git and Branch Safety
Before running any Spec Kit command or script:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
7. Do not overwrite existing specs.
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
## Candidate Selection Criteria
Evaluate candidate specs using these criteria.
### 1. Roadmap Fit
Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer.
Examples:
- governance foundations before advanced compliance views
- evidence/snapshot foundations before auditor packs
- control catalog foundations before CIS/NIS2 mappings
- decision/workflow surfaces before autonomous governance
- provider/platform boundary cleanup before multi-provider expansion
### 2. Foundation Value
Prefer candidates that strengthen reusable platform foundations:
- RBAC and workspace/tenant isolation
- auditability
- evidence and snapshot truth
- operation observability
- provider boundary neutrality
- canonical vocabulary
- baseline/control/finding semantics
- enterprise detail-page or decision-surface patterns
### 3. Dependency Unblocking
Prefer specs that unblock multiple later candidates.
A good next spec should usually make future specs smaller, safer, or more consistent.
### 4. Scope Size
Prefer a candidate that can be implemented as a narrow, testable slice.
Avoid selecting:
- broad platform rewrites
- vague product themes
- multi-feature bundles
- speculative future-provider frameworks
- large UX redesigns without a clear first slice
### 5. Repo Readiness
Prefer candidates where the repository already has enough structure to implement the next slice safely.
Check whether related models, services, UI pages, tests, or concepts already exist.
### 6. Risk Reduction
Prefer candidates that reduce current architectural or product risk:
- legacy dual-world semantics
- unclear truth ownership
- inconsistent operator UX
- missing audit/evidence boundaries
- repeated manual workflow friction
- false-positive calmness in governance surfaces
### 7. User/Product Value
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
## Required Selection Output Before Spec Kit Execution
Before running the Spec Kit flow, identify:
- selected candidate title
- source location in roadmap/spec-candidates
- why it was selected
- why close alternatives were deferred
- roadmap relationship
- smallest viable implementation slice
- proposed concise feature description to feed into `specify`
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
## Spec Kit Execution Flow
After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes.
### Step 1: Determine the repository's Spec Kit command pattern
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
Common locations to inspect:
```text
.specify/scripts/
.specify/templates/
.specify/memory/constitution.md
.github/prompts/
.github/skills/
README.md
specs/
```
Use the repo-specific mechanism if present.
### Step 2: Run `specify`
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
The `specify` input should include:
- selected candidate title
- problem statement
- operator/user value
- roadmap relationship
- out-of-scope boundaries
- key acceptance criteria
- important enterprise constraints
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
### Step 3: Run `plan`
Run the repository's `plan` flow for the generated spec.
The `plan` input should keep the scope tight and should require repo-based alignment with:
- constitution
- existing architecture
- workspace/tenant isolation
- RBAC
- OperationRun/observability where relevant
- evidence/snapshot/truth semantics where relevant
- Filament/Livewire conventions where relevant
- test strategy
### Step 4: Run `tasks`
Run the repository's `tasks` flow for the generated plan.
The generated tasks must be:
- ordered
- small
- testable
- grouped by phase
- limited to the selected scope
- suitable for later manual analysis before implementation
### Step 5: Run `analyze`
Run the repository's `analyze` flow against the generated Spec Kit artifacts.
Analyze must check:
- consistency between `spec.md`, `plan.md`, and `tasks.md`
- constitution alignment
- roadmap alignment
- whether the selected candidate was narrowed safely
- whether tasks are complete enough for implementation
- whether tasks accidentally require scope not described in the spec
- whether plan details conflict with repository architecture or terminology
- whether implementation risks are documented instead of silently ignored
Do not use analyze as a trigger to implement application code.
### Step 6: Fix preparation-artifact issues only
If analyze finds issues, fix only Spec Kit preparation artifacts such as:
- `spec.md`
- `plan.md`
- `tasks.md`
- generated Spec Kit metadata files, if the repository uses them
Allowed fixes include:
- clarify requirements
- tighten scope
- move out-of-scope work into follow-up candidates
- correct terminology
- add missing tasks
- remove tasks not backed by the spec
- align plan language with repository architecture
- add missing acceptance criteria or validation tasks
Forbidden fixes include:
- modifying application code
- creating migrations
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands
- running implementation or test-fix loops
- changing runtime behavior
### Step 7: Stop
After `analyze` has passed or preparation-artifact issues have been fixed, stop.
Do not implement.
Do not modify application code.
Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation.
## Failure Handling
If a Spec Kit command or analyze phase fails:
1. Stop immediately.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before running Spec Kit commands.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
After the Spec Kit preparation flow completes, respond with:
1. Selected candidate
2. Why this candidate was selected
3. Why close alternatives were deferred
4. Current branch after Spec Kit execution
5. Generated spec path
6. Files created or updated by Spec Kit
7. Analyze result summary
8. Preparation-artifact fixes applied after analyze
9. Assumptions made
10. Open questions, if any
11. Recommended next implementation prompt
12. Explicit statement that no application implementation was performed
Keep the response concise, but include enough detail for the user to continue immediately.
## Required Next Implementation Prompt
Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed:
```markdown
Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas.
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
Wichtig:
- Arbeite task-sequenziell.
- Ändere nur Dateien, die für die jeweilige Task notwendig sind.
- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution.
- Keine Scope-Erweiterung.
- Keine Opportunistic Refactors.
- Führe passende Tests nach sinnvollen Task-Gruppen aus.
- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren.
- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-next-best-one-shot.
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
Keine Application-Implementierung.
```
Expected behavior:
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
2. Check branch and working tree safety.
3. Compare candidate suitability.
4. Select the next best candidate.
5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
6. Run the repository's real Spec Kit `plan` flow.
7. Run the repository's real Spec Kit `tasks` flow.
8. Run the repository's real Spec Kit `analyze` flow.
9. Fix analyze issues only in Spec Kit preparation artifacts.
10. Stop before application implementation.
11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt.
```

View File

@ -0,0 +1,294 @@
---
name: spec-kit-one-shot-prep
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
---
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
Define the functionality provided by this skill, including detailed instructions and examples
---
name: spec-kit-one-shot-prep
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
---
# Skill: Spec Kit One-Shot Preparation
## Purpose
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
1. `spec.md`
2. `plan.md`
3. `tasks.md`
This skill prepares implementation work, but it must not perform implementation.
The intended workflow is:
```text
feature idea / roadmap item / spec candidate
→ one-shot spec + plan + tasks preparation
→ manual repo-based analysis/review
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
- a feature idea
- a spec candidate
- a roadmap item
- a product or UX requirement
- a governance/platform improvement
- an architecture cleanup candidate
- a refactoring preparation request
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
Typical user prompts:
```text
Mach daraus spec, plan und tasks in einem Rutsch.
```
```text
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
```
```text
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
```
```text
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
```
## Hard Rules
- Work strictly repo-based.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not expand scope beyond the provided feature idea.
- Do not invent architecture that conflicts with repository truth.
- Do not create broad platform rewrites when a smaller implementable spec is possible.
- Prefer small, reviewable, implementation-ready specs.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
## Required Inputs
The user should provide at least one of:
- feature title and short goal
- full spec candidate
- roadmap item
- rough problem statement
- UX or architecture improvement idea
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
## Required Repository Checks
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
Always check:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `specs/`
4. `docs/product/spec-candidates.md`
5. relevant roadmap documents under `docs/product/`
6. nearby existing specs with related terminology or scope
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
## Spec Directory Rules
Create a new spec directory using the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update only these preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
## `spec.md` Requirements
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
Include:
- Feature title
- Problem statement
- Business/product value
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but still must not implement.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
The plan should clearly distinguish:
- execution truth
- artifact truth
- backup/snapshot truth
- recovery/evidence truth
- operator next action
Use those distinctions only where relevant to the feature.
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Scope Control
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
Do not force all follow-up candidates into the primary spec.
## Final Response Requirements
After creating or updating the artifacts, respond with:
1. Created or updated spec directory
2. Files created or updated
3. Important repo-based adjustments made
4. Assumptions made
5. Open questions, if any
6. Recommended next manual analysis prompt
7. Explicit statement that no implementation was performed
Keep the final response concise, but include enough detail for the user to continue immediately.
## Required Next Manual Analysis Prompt
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
## Example Invocation
User:
```text
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
```
Expected behavior:
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
2. Determine the next valid spec number.
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
4. Keep scope tight.
5. Do not implement.
6. Return the summary and next manual analysis prompt.

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

@ -1,19 +1,30 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.6.0 -> 2.7.0 - Version change: 2.9.0 -> 2.10.0
- Modified principles: None - Modified principles:
- Expanded Operations / Run Observability Standard so OperationRun
start UX is shared-contract-owned instead of surface-owned
- Expanded Governance review expectations for OperationRun-starting
features, explicit queued-notification policy, and bounded
exceptions
- Added sections: - Added sections:
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases, - OperationRun Start UX Contract (OPS-UX-START-001): centralizes
migration shims, dual-write logic, and compatibility fixtures in a queued toast/link/event/message semantics, run/artifact deep links,
pre-production codebase; includes AI-agent verification checklist, queued DB-notification policy, and tenant/workspace-safe operation
review rule, and explicit exit condition at first production deploy URL resolution behind one shared OperationRun UX layer
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- .specify/templates/spec-template.md: added "Compatibility posture" - .specify/templates/spec-template.md: add OperationRun UX Impact
default block ✅ section + start-contract prompts ✅
- .github/agents/copilot-instructions.md: added "Pre-production - .specify/templates/plan-template.md: add OperationRun UX Impact
compatibility check" agent checklist ✅ planning section + constitution checks ✅
- .specify/templates/tasks-template.md: add central start-UX reuse,
queued-notification policy, and exception tasks ✅
- .specify/templates/checklist-template.md: add OperationRun start
UX review checks ✅
- docs/product/standards/README.md: refresh constitution index for
the new ops-UX contract ✅
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present - N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None - Follow-up TODOs: None
@ -53,6 +64,15 @@ ### No Premature Abstraction (ABSTR-001)
- Test convenience alone is not sufficient justification for a new abstraction. - Test convenience alone is not sufficient justification for a new abstraction.
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness. - Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
### First Provider Is Not Platform Core (PROV-001)
- Microsoft is the current first provider, not the platform core.
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001) ### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view. - New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time. - Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
@ -70,6 +90,14 @@ ### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures. - Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture. - Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
### Shared Pattern First For Cross-Cutting Interaction Classes (XCUT-001)
- Cross-cutting interaction classes such as notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, and similar operator-facing infrastructure MUST first attach to an existing shared contract, presenter, builder, renderer, or other shared path when one already exists.
- New local or domain-specific implementations for an existing interaction class are allowed only when the current shared path is demonstrably insufficient for current-release truth.
- The active spec MUST name the shared path being reused or explicitly record the deviation, why the existing path is insufficient, what consistency must still be preserved, and what ownership or spread-control cost the deviation creates.
- The same interaction class MUST NOT develop parallel operator-facing UX languages for title/body/action structure, status semantics, action-label patterns, or deep-link behavior unless the deviation is explicit and justified.
- Reviews MUST treat undocumented bypass of an existing shared path as drift and block merge until the feature converges on the shared path or records a bounded exception.
- If the drift is discovered only after a feature is already implemented, the remedy is NOT to rewrite historical closed specs retroactively by default; instead the active work MUST record the issue as `document-in-feature` or escalate it as `follow-up-spec`, depending on whether the drift is contained or structural.
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001) ### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred. - For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary. - Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
@ -281,24 +309,57 @@ ### Operations / Run Observability Standard
even if implemented by multiple jobs/steps (“umbrella run”). even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure. - “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- Monitoring pages MUST be DB-only at render time (no external calls). - Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work, - Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
confirm + “View run”. authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared
OperationRun Start UX Contract.
### OperationRun Start UX Contract (OPS-UX-START-001)
- OperationRun UX MUST be contract-driven, not surface-driven.
- Any feature that creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun` MUST use
the central OperationRun Start UX Contract.
- Filament Pages, Resources, Widgets, Livewire Components, Actions, and Services MUST NOT independently compose
OperationRun start UX from local pieces.
- The shared OperationRun UX layer MUST own:
- local start notification / toast
- `Open operation` / `View run` link
- artifact link such as `View snapshot`, `View pack`, or `View restore`
- run-enqueued browser event
- queued DB-notification decision
- dedupe / already-available / already-running messaging
- blocked / failed-to-start messaging
- tenant/workspace-safe operation URL resolution
- Feature surfaces MAY initiate `OperationRun`s, but they MUST NOT define their own OperationRun UX semantics.
- `OperationRun` lifecycle state remains the canonical execution truth.
- Queued DB notifications MUST remain explicit opt-in unless the active spec defines a different policy.
- Terminal `OperationRun` notifications MUST be emitted through the central OperationRun lifecycle mechanism.
- Any exception MUST include:
1. an explicit spec decision,
2. a documented architecture note,
3. a test or guard-test exception with rationale,
4. a follow-up migration decision if the exception is temporary.
- New OperationRun-starting features MUST include an `OperationRun UX Impact` section in the active spec or plan.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE) ### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others: If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces.
Queued DB notifications are forbidden by default and MAY exist only when the active spec explicitly opts into them
through the OperationRun Start UX Contract:
1) Toast (intent only / queued-only) 1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback). - A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`. - The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts. - Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`. - A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
- Queued toast copy, action links, artifact links, start-state browser events, and dedupe/start-failure messaging MUST be
produced by the shared OperationRun Start UX Contract, not by local surface code.
2) Progress (active awareness only) 2) Progress (active awareness only)
- Live progress MUST exist only in: - Live progress MUST exist only in:
- the global active-ops widget, and - the global active-ops widget, and
- Monitoring → Operation Run Detail. - Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs. - These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Running DB notifications are forbidden.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values. - Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time. - Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed). - The widget MUST NOT show percentage text (optional `processed/total` is allowed).
@ -339,6 +400,10 @@ ### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if: The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`, - any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- feature code bypasses the central OperationRun Start UX Contract for queued/start-state operation UX where the repo's
guardable patterns can detect it,
- feature code emits queued DB notifications for operations without explicit spec-driven opt-in through the shared
OperationRun UX layer,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification), - jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again. - deprecated legacy operation notification classes are referenced again.
@ -1587,6 +1652,12 @@ ### Scope, Compliance, and Review Expectations
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001. - Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands. - Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge. - Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` MUST reuse the
central OperationRun Start UX Contract, keep queued DB notifications explicit opt-in unless the active spec states a
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
and migration decision.
- Specs and PRs that change operator-facing surfaces MUST classify each - Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change. Decision Surface or workflow-first navigation change.
@ -1604,4 +1675,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19 **Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24

View File

@ -40,9 +40,13 @@ mkdir -p "$FEATURE_DIR"
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
if [[ -f "$TEMPLATE" ]]; then if [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN" cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN" if ! $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN"
fi
else else
echo "Warning: Plan template not found at $TEMPLATE" if ! $JSON_MODE; then
echo "Warning: Plan template not found at $TEMPLATE"
fi
# Create a basic plan file if template doesn't exist # Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN" touch "$IMPL_PLAN"
fi fi

View File

@ -26,18 +26,36 @@ ## Native, Shared-Family, And State Ownership
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another. - [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class. - [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
## Shared Pattern Reuse
- [ ] CHK007 Any cross-cutting interaction class is explicitly marked, and the existing shared contract/presenter/builder/renderer path is named once.
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## OperationRun Start UX Contract
- [ ] CHK019 The change explicitly says whether it creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`, and the required `OperationRun UX Impact` section exists when applicable.
- [ ] CHK020 Queued toast/link/artifact-link/browser-event/dedupe-or-blocked messaging and tenant/workspace-safe operation URL resolution are delegated to the shared OperationRun UX contract instead of local surface code.
- [ ] CHK021 Any queued DB notification is explicit opt-in in the active spec or plan, running DB notifications remain absent, and terminal notifications still flow through the central lifecycle mechanism.
- [ ] CHK022 Any exception records the explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision.
## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
## Signals, Exceptions, And Test Depth ## Signals, Exceptions, And Test Depth
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`. - [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK008 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry. - [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`. - [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK010 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists. - [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Review Outcome ## Review Outcome
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`. - [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`. - [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK013 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes. - [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
## Notes ## Notes
@ -48,7 +66,7 @@ ## Notes
- `keep`: the current scope, guardrail handling, and proof depth are justified. - `keep`: the current scope, guardrail handling, and proof depth are justified.
- `split`: the intent is valid, but the scope should narrow before merge. - `split`: the intent is valid, but the scope should narrow before merge.
- `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly. - `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly.
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up. - `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up. For already-implemented historical drift, prefer a follow-up spec or active feature note instead of retroactively rewriting closed specs.
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed. - `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
- Check items off as completed: `[x]` - Check items off as completed: `[x]`
- Add comments or findings inline - Add comments or findings inline

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

@ -43,6 +43,40 @@ ## UI / Surface Guardrail Plan
- **Exception path and spread control**: [none / describe the named exception boundary] - **Exception path and spread control**: [none / describe the named exception boundary]
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A] - **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: [yes / no / N/A]
- **Systems touched**: [List the existing shared systems or `N/A`]
- **Shared abstractions reused**: [Named contracts / presenters / builders / renderers / helpers or `N/A`]
- **New abstraction introduced? why?**: [none / short explanation]
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: [yes / no / N/A]
- **Central contract reused**: [shared OperationRun UX layer / `N/A`]
- **Delegated UX behaviors**: [queued toast / run link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Surface-owned behavior kept local**: [initiation inputs only / none / short explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception path**: [none / spec decision + architecture note + test rationale + temporary migration follow-up]
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
- **Provider-owned seams**: [List or `N/A`]
- **Platform-core seams**: [List or `N/A`]
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
- **Retained provider-specific semantics and why**: [none / short explanation]
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
## Constitution Check ## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
@ -57,7 +91,8 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics) - RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked - Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun` - Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications - OperationRun start UX: any feature that creates, queues, deduplicates, resumes, blocks, completes, or links `OperationRun` reuses the central OperationRun Start UX Contract; no local composition of queued toast/link/event/start-state messaging; `OperationRun UX Impact` is present in the active spec or plan
- Ops-UX 3-surface feedback: if `OperationRun` is used, default feedback is toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); queued DB notifications remain explicit opt-in through the shared start UX contract; running DB notifications stay disallowed
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside - Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only - Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress - Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
@ -70,6 +105,8 @@ ## Constitution Check
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived - Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived - Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping - UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve - V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth - Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests

View File

@ -35,6 +35,38 @@ ## Spec Scope Fields *(mandatory)*
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant] - **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks] - **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: [yes/no]
- **Interaction class(es)**: [notifications / status messaging / header actions / dashboard signals / navigation / reports / etc.]
- **Systems touched**: [List shared systems, surfaces, or infrastructure paths]
- **Existing pattern(s) to extend**: [Name the existing shared path(s) or write `none`]
- **Shared contract / presenter / builder / renderer to reuse**: [Exact class, helper, or surface path, or `none`]
- **Why the existing shared path is sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Allowed deviation and why**: [none / bounded exception + why]
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: [yes/no]
- **Shared OperationRun UX contract/layer reused**: [Name it or `N/A`]
- **Delegated start/completion UX behaviors**: [queued toast / `Open operation` or `View run` link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Local surface-owned behavior that remains**: [initiation inputs only / none / bounded explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception required?**: [none / explicit spec decision + architecture note + test or guard-test rationale + temporary migration follow-up]
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: [yes/no]
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* ## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does Use this section to classify UI and surface risk once. If the feature does
@ -214,6 +246,21 @@ ## Requirements *(mandatory)*
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver, If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
or taxonomy/classification system, the Proportionality Review section above is mandatory. or taxonomy/classification system, the Proportionality Review section above is mandatory.
**Constitution alignment (XCUT-001):** If this feature touches a cross-cutting interaction class such as notifications, status messaging,
action links, header actions, dashboard signals/cards, alerts, navigation entry points, or evidence/report viewers, the spec MUST:
- state whether the feature is cross-cutting,
- name the existing shared pattern(s) and shared contract/presenter/builder/renderer to extend,
- explain why the existing shared path is sufficient or why it is insufficient for current-release truth,
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
- name the neutral platform terms or shared contracts being preserved,
- explain why any retained provider-specific semantics are the narrowest current-release truth,
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe: **Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose, - the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof, - the affected validation lane(s) and why they are the narrowest sufficient proof,
@ -226,12 +273,21 @@ ## Requirements *(mandatory)*
- and the exact minimal validation commands reviewers should run. - and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST: **Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification), - explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into,
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`), - state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules, - describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring), - clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI. - list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
- include the `OperationRun UX Impact` section,
- name the shared OperationRun UX contract/layer being reused,
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
- keep local surface code limited to initiation inputs and operation-specific data capture,
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
- route terminal notifications through the central lifecycle mechanism,
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: **Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`), - state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404), - ensure any cross-plane access is deny-as-not-found (404),

View File

@ -18,17 +18,22 @@ # Tasks: [FEATURE NAME]
- record budget, baseline, or trend follow-up when runtime cost shifts materially, - record budget, baseline, or trend follow-up when runtime cost shifts materially,
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`. - and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a **Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub. canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub through the shared OperationRun start UX path rather than local surface composition.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant). If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`. without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include: If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- reusing the central OperationRun Start UX Contract instead of composing local queued toast/link/event/dedupe/blocked/start-failure semantics,
- delegating `Open operation` / `View run`, artifact links, run-enqueued browser event, queued DB-notification policy, dedupe / already-available / already-running messaging, blocked / failed-to-start messaging, and tenant/workspace-safe URL resolution to the shared OperationRun UX layer,
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only), - enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code), - keeping queued DB notifications explicit opt-in in the active spec unless a different policy is intentionally approved, and ensuring running DB notifications do not exist,
- routing terminal notifications through the central lifecycle mechanism rather than feature-local notification code,
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`, - ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only, - ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress, - adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system). - clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system),
- documenting any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision,
- and ensuring the active spec or plan contains an `OperationRun UX Impact` section.
**RBAC**: If this feature introduces or changes authorization, tasks MUST include: **RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions, - explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics: - explicit 404 vs 403 semantics:
@ -46,6 +51,16 @@ # Tasks: [FEATURE NAME]
- using source/domain terms only where same-screen disambiguation is required, - using source/domain terms only where same-screen disambiguation is required,
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary, - aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
- removing implementation-first wording from primary operator-facing copy. - removing implementation-first wording from primary operator-facing copy.
**Cross-Cutting Shared Pattern Reuse (XCUT-001)**: If this feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or another shared interaction family, tasks MUST include:
- identifying the existing shared contract/presenter/builder/renderer before local implementation begins,
- extending the shared path when it is sufficient for current-release truth,
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
- classifying each touched seam as provider-owned or platform-core,
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include: **UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision, - carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`), - classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),

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

@ -1 +1 @@
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}} {"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}

View File

@ -1 +1 @@
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}} {"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}

View File

@ -1 +1 @@
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}} {"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}

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

@ -4,6 +4,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -50,7 +51,7 @@ public function handle(): int
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict( $opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant, tenant: $tenant,
type: 'entra_group_sync', type: OperationRunType::DirectoryGroupsSync->value,
identityInputs: [ identityInputs: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'slot_key' => $slotKey, 'slot_key' => $slotKey,

View File

@ -11,6 +11,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -168,12 +169,12 @@ private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
'tenant_id' => (int) $tenant->id, 'tenant_id' => (int) $tenant->id,
'user_id' => null, 'user_id' => null,
'initiator_name' => 'System', 'initiator_name' => 'System',
'type' => 'backup_schedule_purge', 'type' => OperationRunType::BackupSchedulePurge->value,
'status' => 'completed', 'status' => 'completed',
'outcome' => 'succeeded', 'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', implode(':', [ 'run_identity_hash' => hash('sha256', implode(':', [
(string) $tenant->id, (string) $tenant->id,
'backup_schedule_purge', OperationRunType::BackupSchedulePurge->value,
now()->toISOString(), now()->toISOString(),
Str::uuid()->toString(), Str::uuid()->toString(),
])), ])),

View File

@ -7,7 +7,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler; use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command class TenantpilotReconcileBackupScheduleOperationRuns extends Command
@ -28,7 +30,7 @@ public function handle(
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query() $query = OperationRun::query()
->where('type', 'backup_schedule_run') ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->whereIn('status', ['queued', 'running']); ->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) { if ($olderThanMinutes > 0) {

View File

@ -21,6 +21,7 @@
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
@ -489,7 +490,7 @@ private function compareNowAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare') OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value)
->actions($run instanceof OperationRun ? [ ->actions($run instanceof OperationRun ? [
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('Open operation')

View File

@ -18,6 +18,7 @@
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
@ -810,8 +811,8 @@ private function compareAssignedTenants(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0 $toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare') ? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
: OperationUxPresenter::alreadyQueuedToast('baseline_compare'); : OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
$toast $toast
->body($summary.' Open Operations for progress and next steps.') ->body($summary.' Open Operations for progress and next steps.')

View File

@ -0,0 +1,659 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Findings;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingsHygieneReport extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'Findings hygiene report';
protected static ?string $slug = 'findings/hygiene';
protected string $view = 'filament.pages.findings.findings-hygiene-report';
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleTenants = null;
private ?Workspace $workspace = null;
public string $reasonFilter = FindingAssignmentHygieneService::FILTER_ALL;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep the hygiene scope fixed and expose only fixed reason views plus tenant-prefilter recovery when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The hygiene report stays read-only and exposes row click as the only inspect path.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The hygiene report does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm and only offers a tenant-prefilter reset when the active tenant filter hides otherwise visible issues.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Repair remains on the existing tenant finding detail surface.');
}
public function mount(): void
{
$this->reasonFilter = $this->resolveRequestedReasonFilter();
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
[],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->normalizeTenantFilterState();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->issueBaseQuery())
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->columns([
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('subject_display_name')
->label('Finding')
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
->wrap(),
TextColumn::make('owner')
->label('Owner')
->state(fn (Finding $record): string => FindingResource::accountableOwnerDisplayFor($record)),
TextColumn::make('assignee')
->label('Assignee')
->state(fn (Finding $record): string => FindingResource::activeAssigneeDisplayFor($record))
->description(fn (Finding $record): ?string => $this->assigneeContext($record)),
TextColumn::make('due_at')
->label('Due')
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
TextColumn::make('hygiene_reasons')
->label('Hygiene reason')
->state(fn (Finding $record): string => implode(', ', $this->hygieneService()->reasonLabelsFor($record)))
->wrap(),
TextColumn::make('last_workflow_activity')
->label('Last workflow activity')
->state(fn (Finding $record): mixed => $this->hygieneService()->lastWorkflowActivityAt($record))
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($this->hygieneService()->lastWorkflowActivityAt($record))),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->actions([])
->bulkActions([])
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
->emptyStateActions($this->emptyStateActions());
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$tenant = $this->filteredTenant();
return [
'workspace_scoped' => true,
'fixed_scope' => 'visible_findings_hygiene_only',
'reason_filter' => $this->currentReasonFilter(),
'reason_filter_label' => $this->hygieneService()->filterLabel($this->currentReasonFilter()),
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
'tenant_label' => $tenant?->name,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableFilters(): array
{
return [
[
'key' => 'hygiene_scope',
'label' => 'Findings hygiene only',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => collect($this->visibleTenants())
->map(fn (Tenant $tenant): array => [
'value' => (string) $tenant->getKey(),
'label' => (string) $tenant->name,
])
->values()
->all(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableReasonFilters(): array
{
$summary = $this->summaryCounts();
$currentFilter = $this->currentReasonFilter();
return [
[
'key' => FindingAssignmentHygieneService::FILTER_ALL,
'label' => 'All issues',
'active' => $currentFilter === FindingAssignmentHygieneService::FILTER_ALL,
'badge_count' => $summary['unique_issue_count'],
'url' => $this->reportUrl(['reason' => null]),
],
[
'key' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
'label' => 'Broken assignment',
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
'badge_count' => $summary['broken_assignment_count'],
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT]),
],
[
'key' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
'label' => 'Stale in progress',
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
'badge_count' => $summary['stale_in_progress_count'],
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS]),
],
];
}
/**
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
*/
public function summaryCounts(): array
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return [
'unique_issue_count' => 0,
'broken_assignment_count' => 0,
'stale_in_progress_count' => 0,
];
}
return $this->hygieneService()->summary(
$workspace,
$user,
$this->currentTenantFilterId(),
);
}
/**
* @return array<string, mixed>
*/
public function emptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'No hygiene issues match this tenant scope',
'body' => 'Your current tenant filter is hiding hygiene issues that are still visible elsewhere in this workspace.',
'icon' => 'heroicon-o-funnel',
'action_name' => 'clear_tenant_filter_empty',
'action_label' => 'Clear tenant filter',
'action_kind' => 'clear_tenant_filter',
];
}
if ($this->reasonFilterAloneExcludesRows()) {
return [
'title' => 'No findings match this hygiene reason',
'body' => 'The current fixed reason view is narrower than the visible issue set in this workspace.',
'icon' => 'heroicon-o-adjustments-horizontal',
];
}
return [
'title' => 'No visible hygiene issues right now',
'body' => 'Visible broken assignments and stale in-progress work are currently calm across the entitled tenant scope.',
'icon' => 'heroicon-o-wrench-screwdriver',
];
}
public function updatedTableFilters(): void
{
$this->normalizeTenantFilterState();
}
public function clearTenantFilter(): void
{
$this->removeTableFilter('tenant_id');
$this->resetTable();
}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(): array
{
if ($this->visibleTenants !== null) {
return $this->visibleTenants;
}
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return $this->visibleTenants = [];
}
return $this->visibleTenants = $this->hygieneService()->visibleTenants($workspace, $user);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
if (! app(WorkspaceCapabilityResolver::class)->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return Builder<Finding>
*/
private function issueBaseQuery(): Builder
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return Finding::query()->whereRaw('1 = 0');
}
return $this->hygieneService()->issueQuery(
$workspace,
$user,
tenantId: null,
reasonFilter: $this->currentReasonFilter(),
applyOrdering: true,
);
}
/**
* @return Builder<Finding>
*/
private function filteredIssueQuery(bool $includeTenantFilter = true, ?string $reasonFilter = null): Builder
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return Finding::query()->whereRaw('1 = 0');
}
return $this->hygieneService()->issueQuery(
$workspace,
$user,
tenantId: $includeTenantFilter ? $this->currentTenantFilterId() : null,
reasonFilter: $reasonFilter ?? $this->currentReasonFilter(),
applyOrdering: true,
);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->visibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->visibleTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function normalizeTenantFilterState(): void
{
$configuredTenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
return;
}
if ($this->currentTenantFilterId() !== null) {
return;
}
$this->removeTableFilter('tenant_id');
}
/**
* @return array<string, mixed>
*/
private function currentFiltersState(): array
{
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
return array_replace_recursive(
is_array($persisted) ? $persisted : [],
$this->tableFilters ?? [],
);
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
return null;
}
$tenantId = (int) $tenantFilter;
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenantId;
}
}
return null;
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function activeVisibleTenant(): ?Tenant
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ($tenant->is($activeTenant)) {
return $tenant;
}
}
return null;
}
private function tenantPrefilterSource(): string
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return 'none';
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
return 'active_tenant_context';
}
return 'explicit_filter';
}
private function assigneeContext(Finding $record): ?string
{
if (! $this->hygieneService()->recordHasBrokenAssignment($record)) {
return null;
}
if ($record->assigneeUser?->trashed()) {
return 'Soft-deleted user';
}
return 'No current tenant membership';
}
private function tenantFilterAloneExcludesRows(): bool
{
if ($this->currentTenantFilterId() === null) {
return false;
}
if ((clone $this->filteredIssueQuery())->exists()) {
return false;
}
return (clone $this->filteredIssueQuery(includeTenantFilter: false))->exists();
}
private function reasonFilterAloneExcludesRows(): bool
{
if ($this->currentReasonFilter() === FindingAssignmentHygieneService::FILTER_ALL) {
return false;
}
if ((clone $this->filteredIssueQuery())->exists()) {
return false;
}
return (clone $this->filteredIssueQuery(includeTenantFilter: true, reasonFilter: FindingAssignmentHygieneService::FILTER_ALL))->exists();
}
private function findingDetailUrl(Finding $record): string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}
private function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'findings.hygiene',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->currentTenantFilterId(),
backLinkLabel: 'Back to findings hygiene',
backLinkUrl: $this->reportUrl(),
);
}
private function reportUrl(array $overrides = []): string
{
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: $this->filteredTenant()?->external_id;
$resolvedReason = array_key_exists('reason', $overrides)
? $overrides['reason']
: $this->currentReasonFilter();
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'reason' => is_string($resolvedReason) && $resolvedReason !== FindingAssignmentHygieneService::FILTER_ALL
? $resolvedReason
: null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
private function resolveRequestedReasonFilter(): string
{
$requestedFilter = request()->query('reason');
$availableFilters = $this->hygieneService()->filterOptions();
return is_string($requestedFilter) && array_key_exists($requestedFilter, $availableFilters)
? $requestedFilter
: FindingAssignmentHygieneService::FILTER_ALL;
}
private function currentReasonFilter(): string
{
$availableFilters = $this->hygieneService()->filterOptions();
return array_key_exists($this->reasonFilter, $availableFilters)
? $this->reasonFilter
: FindingAssignmentHygieneService::FILTER_ALL;
}
/**
* @return array<int, Action>
*/
private function emptyStateActions(): array
{
$emptyState = $this->emptyState();
if (($emptyState['action_kind'] ?? null) !== 'clear_tenant_filter') {
return [];
}
return [
Action::make((string) $emptyState['action_name'])
->label((string) $emptyState['action_label'])
->icon('heroicon-o-arrow-right')
->color('gray')
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
private function hygieneService(): FindingAssignmentHygieneService
{
return app(FindingAssignmentHygieneService::class);
}
}

View File

@ -20,6 +20,8 @@
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\OperationRunType;
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 +537,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 +562,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: OperationRunType::InventorySync->value);
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
} }
} }

View File

@ -16,7 +16,9 @@
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
@ -110,14 +112,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 +128,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;
@ -507,12 +509,14 @@ private function canResumeCapture(): bool
return false; return false;
} }
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) { $canonicalType = OperationCatalog::canonicalCode((string) $this->run->type);
if (! in_array($canonicalType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
return false; return false;
} }
$context = is_array($this->run->context) ? $this->run->context : []; $context = is_array($this->run->context) ? $this->run->context : [];
$tokenKey = (string) $this->run->type === 'baseline_capture' $tokenKey = $canonicalType === OperationRunType::BaselineCapture->value
? 'baseline_capture.resume_token' ? 'baseline_capture.resume_token'
: 'baseline_compare.resume_token'; : 'baseline_compare.resume_token';
$token = data_get($context, $tokenKey); $token = data_get($context, $tokenKey);

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

@ -42,6 +42,7 @@
use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingDraftStage; use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\Onboarding\OnboardingLifecycleState; use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -51,6 +52,9 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation; use App\Support\Tenants\TenantLifecyclePresentation;
@ -317,7 +321,7 @@ public function content(Schema $schema): Schema
Section::make('Tenant') Section::make('Tenant')
->schema([ ->schema([
TextInput::make('entra_tenant_id') TextInput::make('entra_tenant_id')
->label('Entra Tenant ID (GUID)') ->label('Tenant ID (GUID)')
->required() ->required()
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') ->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
->rules(['uuid']) ->rules(['uuid'])
@ -423,7 +427,8 @@ public function content(Schema $schema): Schema
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('entra_tenant_id') TextInput::make('entra_tenant_id')
->label('Directory (tenant) ID') ->label('Target scope ID')
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
->disabled() ->disabled()
->dehydrated(false), ->dehydrated(false),
Toggle::make('uses_dedicated_override') Toggle::make('uses_dedicated_override')
@ -461,6 +466,13 @@ public function content(Schema $schema): Schema
->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->required(fn (Get $get): bool => $get('connection_mode') === 'new')
->maxLength(255) ->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
TextInput::make('new_connection.target_scope_id')
->label('Target scope ID')
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
->disabled()
->dehydrated(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
->helperText('The provider connection will point to this tenant target scope.'),
TextInput::make('new_connection.connection_type') TextInput::make('new_connection.connection_type')
->label('Connection type') ->label('Connection type')
->default('Platform connection') ->default('Platform connection')
@ -598,7 +610,9 @@ public function content(Schema $schema): Schema
->tooltip(fn (): ?string => $this->canStartAnyBootstrap() ->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
? null ? null
: 'You do not have permission to start bootstrap actions.') : 'You do not have permission to start bootstrap actions.')
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), ->action(fn (Get $get) => $this->startBootstrap(
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
)),
]), ]),
Text::make(fn (): string => $this->bootstrapRunsLabel()) Text::make(fn (): string => $this->bootstrapRunsLabel())
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
@ -606,9 +620,11 @@ public function content(Schema $schema): Schema
]) ])
->afterValidation(function (): void { ->afterValidation(function (): void {
$types = $this->data['bootstrap_operation_types'] ?? []; $types = $this->data['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($types) $this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) is_array($types) ? $types : [],
: []; );
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
$this->touchOnboardingSessionStep('bootstrap'); $this->touchOnboardingSessionStep('bootstrap');
}), }),
@ -642,6 +658,10 @@ public function content(Schema $schema): Schema
->badge() ->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()), ->color(fn (): string => $this->completionSummaryBootstrapColor()),
]), ]),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
Callout::make('After completion') Callout::make('After completion')
->description('This action is recorded in the audit log and cannot be undone from this wizard.') ->description('This action is recorded in the audit log and cannot be undone from this wizard.')
->info() ->info()
@ -649,7 +669,7 @@ public function content(Schema $schema): Schema
UnorderedList::make([ UnorderedList::make([
'Tenant status will be set to Active.', 'Tenant status will be set to Active.',
'Backup, inventory, and compliance operations become available.', 'Backup, inventory, and compliance operations become available.',
'The provider connection will be used for all Graph API calls.', 'The provider connection will be used for provider API calls.',
]), ]),
]), ]),
Toggle::make('override_blocked') Toggle::make('override_blocked')
@ -733,10 +753,122 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? []; $bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) ? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
: []; : [];
} }
/**
* @param array<int|string, mixed> $operationTypes
* @return array<int, string>
*/
private function normalizeBootstrapOperationTypes(array $operationTypes): array
{
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
$normalized = [];
foreach ($operationTypes as $key => $value) {
if (is_string($value)) {
$normalizedValue = $this->normalizeBootstrapOperationType($value);
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
$normalized[] = $normalizedValue;
}
continue;
}
if (! is_string($key) || trim($key) === '') {
continue;
}
$isSelected = match (true) {
is_bool($value) => $value,
is_int($value) => $value === 1,
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
default => false,
};
$normalizedKey = $this->normalizeBootstrapOperationType($key);
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
$normalized[] = $normalizedKey;
}
}
return array_values(array_unique($normalized));
}
private function normalizeBootstrapOperationType(string $operationType): string
{
$operationType = trim($operationType);
if ($operationType === '') {
return '';
}
return OperationCatalog::canonicalCode($operationType);
}
/**
* @return array<string, string>
*/
private function supportedBootstrapCapabilities(): array
{
return [
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
];
}
/**
* @param array<int, string> $operationTypes
*/
private function persistBootstrapSelection(array $operationTypes): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
$existing = is_array($existing)
? $this->normalizeBootstrapOperationTypes($existing)
: [];
if ($normalized === $existing) {
return;
}
try {
$this->setOnboardingSession($this->mutationService()->mutate(
draft: $this->onboardingSession,
actor: $user,
expectedVersion: $this->expectedDraftVersion(),
incrementVersion: false,
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
$state = is_array($draft->state) ? $draft->state : [];
$state['bootstrap_operation_types'] = $normalized;
$draft->state = $state;
},
));
} catch (OnboardingDraftConflictException) {
$this->handleDraftConflict();
return;
} catch (OnboardingDraftImmutableException) {
$this->handleImmutableDraft();
return;
}
}
/** /**
* @return Collection<int, TenantOnboardingSession> * @return Collection<int, TenantOnboardingSession>
*/ */
@ -1464,6 +1596,7 @@ private function initializeWizardData(): void
// Ensure all entangled schema state paths exist at render time. // Ensure all entangled schema state paths exist at render time.
// Livewire v4 can throw when entangling to missing nested array keys. // Livewire v4 can throw when entangling to missing nested array keys.
$this->data['notes'] ??= ''; $this->data['notes'] ??= '';
$this->data['bootstrap_operation_types'] ??= [];
$this->data['override_blocked'] ??= false; $this->data['override_blocked'] ??= false;
$this->data['override_reason'] ??= ''; $this->data['override_reason'] ??= '';
$this->data['new_connection'] ??= []; $this->data['new_connection'] ??= [];
@ -1483,6 +1616,7 @@ private function initializeWizardData(): void
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id; $this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other'); $this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
$this->data['name'] ??= (string) $tenant->name; $this->data['name'] ??= (string) $tenant->name;
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? ''); $this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
@ -1534,7 +1668,7 @@ private function initializeWizardData(): void
$types = $draft->state['bootstrap_operation_types'] ?? null; $types = $draft->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) { if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); $this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
} }
} }
@ -1566,14 +1700,56 @@ private function providerConnectionOptions(): array
} }
return ProviderConnection::query() return ProviderConnection::query()
->with('tenant')
->where('workspace_id', (int) $this->workspace->getKey()) ->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->orderByDesc('is_default') ->orderByDesc('is_default')
->orderBy('display_name') ->orderBy('display_name')
->pluck('display_name', 'id') ->get()
->mapWithKeys(fn (ProviderConnection $connection): array => [
(int) $connection->getKey() => sprintf(
'%s — %s',
(string) $connection->display_name,
$this->providerConnectionTargetScopeSummary($connection),
),
])
->all(); ->all();
} }
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
{
try {
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
} catch (InvalidArgumentException) {
return 'Target scope needs review';
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
{
try {
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
} catch (InvalidArgumentException) {
return array_merge([
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'target_scope' => [
'provider' => (string) $connection->provider,
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
'scope_identifier' => (string) $connection->entra_tenant_id,
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
'shared_label' => 'Target scope',
'shared_help_text' => 'The platform scope this provider connection represents.',
],
'provider_identity_context' => [],
], $extra);
}
}
private function verificationStatusLabel(): string private function verificationStatusLabel(): string
{ {
return BadgeCatalog::spec( return BadgeCatalog::spec(
@ -2489,12 +2665,11 @@ public function selectProviderConnection(int $providerConnectionId): void
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value, action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
context: [ context: [
'metadata' => [ 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(), 'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(), 'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(), 'onboarding_session_id' => $this->onboardingSession?->getKey(),
], ]),
], ],
actor: $user, actor: $user,
status: 'success', status: 'success',
@ -2547,6 +2722,22 @@ public function createProviderConnection(array $data): void
abort(422); abort(422);
} }
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) $tenant->tenant_id,
scopeDisplayName: $displayName,
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) $tenant->tenant_id,
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'new_connection.target_scope_id' => $targetScope['message'],
]);
}
if ($usesDedicatedCredential) { if ($usesDedicatedCredential) {
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED); $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
} }
@ -2623,14 +2814,11 @@ public function createProviderConnection(array $data): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.created', action: 'provider_connection.created',
context: [ context: [
'metadata' => [ 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(), 'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value, 'connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create', 'source' => 'managed_tenant_onboarding_wizard.create',
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -2646,15 +2834,12 @@ public function createProviderConnection(array $data): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(), 'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $previousConnectionType->value, 'from_connection_type' => $previousConnectionType->value,
'to_connection_type' => $connection->connection_type->value, 'to_connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create', 'source' => 'managed_tenant_onboarding_wizard.create',
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -2966,7 +3151,7 @@ public function startBootstrap(array $operationTypes): void
} }
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); $types = $this->normalizeBootstrapOperationTypes($operationTypes);
$types = array_values(array_filter( $types = array_values(array_filter(
$types, $types,
@ -3170,7 +3355,7 @@ private function dispatchBootstrapJob(
OperationRun $run, OperationRun $run,
): void { ): void {
match ($operationType) { match ($operationType) {
'inventory_sync' => ProviderInventorySyncJob::dispatch( 'inventory.sync' => ProviderInventorySyncJob::dispatch(
tenantId: $tenantId, tenantId: $tenantId,
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
@ -3236,18 +3421,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
default => null,
};
} }
private function canStartAnyBootstrap(): bool private function canStartAnyBootstrap(): bool
{ {
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC) foreach ($this->supportedBootstrapCapabilities() as $capability) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC) if ($this->currentUserCan($capability)) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP); return true;
}
}
return false;
} }
private function currentUserCan(string $capability): bool private function currentUserCan(string $capability): bool
@ -3498,33 +3683,59 @@ private function completionSummaryVerificationDetail(): string
private function completionSummaryBootstrapLabel(): string private function completionSummaryBootstrapLabel(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'Skipped'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
}
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
return 'Action required';
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
if ($runs === []) { if ($runs !== []) {
return 'Skipped'; return 'Started';
} }
return 'Started'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
} }
private function completionSummaryBootstrapDetail(): string private function completionSummaryBootstrapDetail(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'No bootstrap actions selected'; $selectedTypes = $this->completionSummarySelectedBootstrapTypes();
return $selectedTypes === []
? 'No bootstrap actions selected'
: sprintf('%d action(s) selected', count($selectedTypes));
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
if ($runs === []) { if ($selectedTypes === []) {
return 'No bootstrap actions selected'; return 'No bootstrap actions selected';
} }
return sprintf('%d operation(s) started', count($runs)); if ($actionRequiredDetail !== null) {
return $actionRequiredDetail;
}
if ($runs === []) {
return sprintf('%d action(s) selected', count($selectedTypes));
}
if (count($runs) < count($selectedTypes)) {
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
}
return sprintf('%d action(s) started', count($runs));
} }
private function completionSummaryBootstrapSummary(): string private function completionSummaryBootstrapSummary(): string
@ -3536,11 +3747,130 @@ private function completionSummaryBootstrapSummary(): string
); );
} }
private function showCompletionSummaryBootstrapRecovery(): bool
{
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
}
private function completionSummaryBootstrapRecoveryMessage(): string
{
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
}
private function completionSummaryBootstrapColor(): string private function completionSummaryBootstrapColor(): string
{ {
return $this->completionSummaryBootstrapLabel() === 'Started' return match ($this->completionSummaryBootstrapLabel()) {
? 'info' 'Action required' => 'warning',
: 'gray'; 'Started' => 'info',
'Selected' => 'warning',
default => 'gray',
};
}
private function completionSummaryBootstrapActionRequiredDetail(): ?string
{
$reasonCode = $this->completionSummaryBootstrapReasonCode();
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
return null;
}
$run = $this->completionSummaryBootstrapFailedRun();
if (! $run instanceof OperationRun) {
return $reasonCode === 'bootstrap_partial_failure'
? 'A bootstrap action needs attention'
: 'A bootstrap action failed';
}
$context = is_array($run->context ?? null) ? $run->context : [];
$operatorLabel = data_get($context, 'reason_translation.operator_label');
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
return trim($operatorLabel);
}
return match ($run->outcome) {
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
default => 'A bootstrap action failed',
};
}
private function completionSummaryBootstrapReasonCode(): ?string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
return is_string($reasonCode) ? $reasonCode : null;
}
private function completionSummaryBootstrapFailedRun(): ?OperationRun
{
return once(function (): ?OperationRun {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
if (! is_array($runMap)) {
return null;
}
$runIds = array_values(array_filter(array_map(
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
$runMap,
)));
if ($runIds === []) {
return null;
}
return OperationRun::query()
->whereIn('id', $runIds)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->latest('id')
->first();
});
}
/**
* @return array<int, string>
*/
private function completionSummarySelectedBootstrapTypes(): array
{
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
if (is_array($selectedTypes)) {
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
if ($normalized !== []) {
return $normalized;
}
}
if ($this->selectedBootstrapOperationTypes !== []) {
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
return is_array($persistedTypes)
? $this->normalizeBootstrapOperationTypes($persistedTypes)
: [];
} }
public function completeOnboarding(): void public function completeOnboarding(): void
@ -4049,15 +4379,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
tenant: $this->managedTenant, tenant: $this->managedTenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(), 'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $existingType->value, 'from_connection_type' => $existingType->value,
'to_connection_type' => $targetType->value, 'to_connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit', 'source' => 'managed_tenant_onboarding_wizard.inline_edit',
], ]),
], ],
actorId: (int) $user->getKey(), actorId: (int) $user->getKey(),
actorEmail: (string) $user->email, actorEmail: (string) $user->email,
@ -4073,15 +4400,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
tenant: $this->managedTenant, tenant: $this->managedTenant,
action: 'provider_connection.updated', action: 'provider_connection.updated',
context: [ context: [
'metadata' => [ 'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(), 'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(), 'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'fields' => $changedFields,
'connection_type' => $targetType->value, 'connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit', 'source' => 'managed_tenant_onboarding_wizard.inline_edit',
], ]),
], ],
actorId: (int) $user->getKey(), actorId: (int) $user->getKey(),
actorEmail: (string) $user->email, actorEmail: (string) $user->email,
@ -4139,9 +4463,10 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
private function bootstrapOperationOptions(): array private function bootstrapOperationOptions(): array
{ {
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
return collect($registry->all()) return collect($registry->all())
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') ->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
->all(); ->all();
} }

View File

@ -234,7 +234,8 @@ public static function table(Table $table): Table
->searchable(), ->searchable(),
TextColumn::make('event_type') TextColumn::make('event_type')
->label('Event') ->label('Event')
->badge(), ->badge()
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
TextColumn::make('severity') TextColumn::make('severity')
->badge() ->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state)) ->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))

View File

@ -380,6 +380,10 @@ public static function eventTypeOptions(): array
AlertRule::EVENT_SLA_DUE => 'SLA due', AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing', AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)', AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
]; ];
} }

View File

@ -25,6 +25,7 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -457,7 +458,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule_run', type: OperationRunType::BackupScheduleExecute->value,
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -528,7 +529,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule_run', type: OperationRunType::BackupScheduleExecute->value,
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -755,7 +756,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule_run', type: OperationRunType::BackupScheduleExecute->value,
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,
@ -852,7 +853,7 @@ public static function table(Table $table): Table
$nonce = (string) Str::uuid(); $nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity( $operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'backup_schedule_run', type: OperationRunType::BackupScheduleExecute->value,
identityInputs: [ identityInputs: [
'backup_schedule_id' => (int) $record->getKey(), 'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce, 'nonce' => $nonce,

View File

@ -9,6 +9,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -31,6 +32,8 @@
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -840,7 +843,17 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
private static function profileNextStep(BaselineProfile $profile): string private static function profileNextStep(BaselineProfile $profile): string
{ {
return match (self::compareAvailabilityReason($profile)) { $compareAvailabilityReason = self::compareAvailabilityReason($profile);
if ($compareAvailabilityReason === null) {
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
}
return match ($compareAvailabilityReason) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE, BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE, BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.', BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
@ -858,6 +871,30 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile); return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
} }
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$run = OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCapture->value))
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', 'completed')
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
$reasonCode = data_get($run->context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{ {
$status = $profile->status instanceof BaselineProfileStatus $status = $profile->status instanceof BaselineProfileStatus

View File

@ -17,8 +17,10 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -105,15 +107,10 @@ private function captureAction(): Action
if (! $result['ok']) { if (! $result['ok']) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
$message = match ($reasonCode) { $message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.', ? trim($translation->shortExplanation)
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', : 'Reason: '.str_replace('.', ' ', $reasonCode);
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make() Notification::make()
->title('Cannot start capture') ->title('Cannot start capture')
@ -344,8 +341,8 @@ private function compareAssignedTenantsAction(): Action
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0 $toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare') ? OperationUxPresenter::queuedToast(OperationRunType::BaselineCompare->value)
: OperationUxPresenter::alreadyQueuedToast('baseline_compare'); : OperationUxPresenter::alreadyQueuedToast(OperationRunType::BaselineCompare->value);
$toast $toast
->body($summary.' Open Operations for progress and next steps.') ->body($summary.' Open Operations for progress and next steps.')

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

@ -15,6 +15,7 @@
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -175,7 +176,7 @@ protected function getHeaderActions(): array
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity( $opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'inventory_sync', type: OperationRunType::InventorySync->value,
identityInputs: [ identityInputs: [
'selection_hash' => $computed['selection_hash'], 'selection_hash' => $computed['selection_hash'],
], ],

View File

@ -27,6 +27,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
@ -230,7 +231,9 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value)); return $query->whereIn('type', OperationCatalog::rawValuesForCanonical(
OperationCatalog::canonicalCode($value),
));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())), ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
@ -411,7 +414,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
); );
} }
if ((string) $record->type === 'baseline_compare') { if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCompare->value) {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory); $baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record); $baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record); $gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
@ -466,7 +469,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
} }
} }
if ((string) $record->type === 'baseline_capture') { if (OperationCatalog::canonicalCode((string) $record->type) === OperationRunType::BaselineCapture->value) {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record); $baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($baselineCaptureEvidence !== []) { if ($baselineCaptureEvidence !== []) {
@ -1446,7 +1449,7 @@ private static function reconciliationPayload(OperationRun $record): array
*/ */
private static function inventorySyncCoverageSection(OperationRun $record): ?array private static function inventorySyncCoverageSection(OperationRun $record): ?array
{ {
if ((string) $record->type !== 'inventory_sync') { if (OperationCatalog::canonicalCode((string) $record->type) !== OperationRunType::InventorySync->value) {
return null; return null;
} }

View File

@ -20,12 +20,15 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
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;
@ -50,6 +53,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use InvalidArgumentException;
use UnitEnum; use UnitEnum;
class ProviderConnectionResource extends Resource class ProviderConnectionResource extends Resource
@ -484,6 +488,62 @@ private static function verificationStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
} }
private static function targetScopeHelpText(): string
{
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
}
private static function targetScopeSummary(?ProviderConnection $record): string
{
if (! $record instanceof ProviderConnection) {
return 'Target scope is set when this connection is saved.';
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
} catch (InvalidArgumentException) {
return 'Target scope needs review';
}
}
private static function providerIdentityContext(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
{
try {
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
} catch (InvalidArgumentException) {
return array_merge([
'provider_connection_id' => (int) $record->getKey(),
'provider' => (string) $record->provider,
'target_scope' => [
'provider' => (string) $record->provider,
'scope_kind' => 'tenant',
'scope_identifier' => (string) $record->entra_tenant_id,
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
'shared_label' => 'Target scope',
'shared_help_text' => static::targetScopeHelpText(),
],
'provider_identity_context' => [],
], $extra);
}
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -496,11 +556,17 @@ public static function form(Schema $schema): Schema
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255), ->maxLength(255),
TextInput::make('entra_tenant_id') TextInput::make('entra_tenant_id')
->label('Entra tenant ID') ->label('Target scope ID')
->required() ->required()
->maxLength(255) ->maxLength(255)
->helperText(static::targetScopeHelpText())
->validationAttribute('target scope ID')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE)) ->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']), ->rules(['uuid']),
Placeholder::make('target_scope_display')
->label('Target scope')
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
Placeholder::make('connection_type_display') Placeholder::make('connection_type_display')
->label('Connection type') ->label('Connection type')
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)), ->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
@ -563,8 +629,9 @@ public static function infolist(Schema $schema): Schema
->label('Display name'), ->label('Display name'),
Infolists\Components\TextEntry::make('provider') Infolists\Components\TextEntry::make('provider')
->label('Provider'), ->label('Provider'),
Infolists\Components\TextEntry::make('entra_tenant_id') Infolists\Components\TextEntry::make('target_scope')
->label('Entra tenant ID') ->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(), ->copyable(),
Infolists\Components\TextEntry::make('connection_type') Infolists\Components\TextEntry::make('connection_type')
->label('Connection type') ->label('Connection type')
@ -614,6 +681,11 @@ public static function infolist(Schema $schema): Schema
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('provider_identity_context')
->label('Provider identity details')
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
->placeholder('n/a')
->columnSpanFull(),
Infolists\Components\TextEntry::make('last_error_reason_code') Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason') ->label('Last error reason')
->placeholder('n/a'), ->placeholder('n/a'),
@ -671,9 +743,15 @@ public static function table(Table $table): Table
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'); return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}), }),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(), Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('provider')
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true), ->label('Provider')
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(), ->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
Tables\Columns\TextColumn::make('target_scope')
->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('connection_type') Tables\Columns\TextColumn::make('connection_type')
->label('Connection type') ->label('Connection type')
->badge() ->badge()
@ -872,7 +950,7 @@ public static function makeInventorySyncAction(): Actions\Action
static::handleProviderOperationAction( static::handleProviderOperationAction(
record: $record, record: $record,
gate: $gate, gate: $gate,
operationType: 'inventory_sync', operationType: OperationRunType::InventorySync->value,
blockedTitle: 'Inventory sync blocked', blockedTitle: 'Inventory sync blocked',
dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void { dispatcher: function (Tenant $tenant, User $user, ProviderConnection $connection, OperationRun $operationRun): void {
ProviderInventorySyncJob::dispatch( ProviderInventorySyncJob::dispatch(
@ -949,10 +1027,7 @@ public static function makeSetDefaultAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.default_set', action: 'provider_connection.default_set',
context: [ context: [
'metadata' => [ 'metadata' => static::targetScopeAuditMetadata($record),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1014,15 +1089,12 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => static::targetScopeAuditMetadata($record, [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value, 'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value, 'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'], 'client_id' => (string) $data['client_id'],
'source' => $source, 'source' => $source,
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1161,14 +1233,11 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.connection_type_changed', action: 'provider_connection.connection_type_changed',
context: [ context: [
'metadata' => [ 'metadata' => static::targetScopeAuditMetadata($record, [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value, 'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value, 'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => $source, 'source' => $source,
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1233,14 +1302,12 @@ public static function makeEnableConnectionAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.enabled', action: 'provider_connection.enabled',
context: [ context: [
'metadata' => [ 'metadata' => static::targetScopeAuditMetadata($record, [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'enabled', 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -1302,12 +1369,10 @@ public static function makeDisableConnectionAction(): Actions\Action
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.disabled', action: 'provider_connection.disabled',
context: [ context: [
'metadata' => [ 'metadata' => static::targetScopeAuditMetadata($record, [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled', 'to_lifecycle' => 'disabled',
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -9,9 +9,12 @@
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateProviderConnection extends CreateRecord class CreateProviderConnection extends CreateRecord
{ {
@ -28,6 +31,21 @@ protected function mutateFormDataBeforeCreate(array $data): array
} }
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return [ return [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -70,11 +88,9 @@ protected function afterCreate(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.created', action: 'provider_connection.created',
context: [ context: [
'metadata' => [ 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'connection_type' => $record->connection_type->value, 'connection_type' => $record->connection_type->value,
], ]),
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -19,6 +19,8 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -26,6 +28,7 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class EditProviderConnection extends EditRecord class EditProviderConnection extends EditRecord
{ {
@ -77,6 +80,22 @@ protected function mutateFormDataBeforeSave(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']); unset($data['is_default']);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return $data; return $data;
} }
@ -119,11 +138,9 @@ protected function afterSave(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.updated', action: 'provider_connection.updated',
context: [ context: [
'metadata' => [ 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'provider' => $record->provider, 'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
'entra_tenant_id' => $record->entra_tenant_id, ]),
'fields' => $changedFields,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,
@ -139,10 +156,7 @@ protected function afterSave(): void
tenant: $tenant, tenant: $tenant,
action: 'provider_connection.default_set', action: 'provider_connection.default_set',
context: [ context: [
'metadata' => [ 'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
], ],
actorId: $actorId, actorId: $actorId,
actorEmail: $actorEmail, actorEmail: $actorEmail,

View File

@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string public function getTableEmptyStateHeading(): ?string
{ {
return 'No Microsoft connections found'; return 'No provider connections found';
} }
public function getTableEmptyStateDescription(): ?string public function getTableEmptyStateDescription(): ?string
{ {
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.'; return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
} }
public function getTableEmptyStateActions(): array public function getTableEmptyStateActions(): array

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

@ -14,7 +14,9 @@
use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\TenantCoverageTruth; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
@ -56,7 +58,7 @@ protected function getStats(): array
$inventoryOps = (int) OperationRun::query() $inventoryOps = (int) OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync') ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
->active() ->active()
->count(); ->count();

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

@ -4,6 +4,7 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
@ -54,6 +55,8 @@ public function __invoke(
error: $error, error: $error,
); );
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
$legacyStatus = $status === 'ok' ? 'success' : 'failed'; $legacyStatus = $status === 'ok' ? 'success' : 'failed';
$auditMetadata = [ $auditMetadata = [
'source' => 'admin.consent.callback', 'source' => 'admin.consent.callback',
@ -98,6 +101,7 @@ public function __invoke(
'status' => $status, 'status' => $status,
'error' => $error, 'error' => $error,
'consentGranted' => $consentGranted, 'consentGranted' => $consentGranted,
'verificationStateLabel' => $this->verificationStateLabel($connection),
]); ]);
} }
@ -197,4 +201,48 @@ private function parseState(?string $state): ?string
return $state; return $state;
} }
private function verificationStateLabel(ProviderConnection $connection): string
{
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
? $connection->verification_status
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
return $connection->consent_status === ProviderConsentStatus::Granted
? 'Needs verification'
: 'Not verified';
}
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
}
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
{
TenantOnboardingSession::query()
->where('tenant_id', (int) $tenant->getKey())
->resumable()
->each(function (TenantOnboardingSession $draft) use ($connection): void {
$state = is_array($draft->state) ? $draft->state : [];
$providerConnectionId = $state['provider_connection_id'] ?? null;
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
return;
}
unset(
$state['verification_operation_run_id'],
$state['verification_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
$state['bootstrap_run_ids'],
);
$state['connection_recently_updated'] = true;
$draft->state = $state;
$draft->save();
});
}
} }

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService; use App\Services\Alerts\AlertDispatchService;
use App\Services\Findings\FindingNotificationService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -21,6 +22,7 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Throwable; use Throwable;
class EvaluateAlertsJob implements ShouldQueue class EvaluateAlertsJob implements ShouldQueue
@ -32,7 +34,11 @@ public function __construct(
public ?int $operationRunId = null, public ?int $operationRunId = null,
) {} ) {}
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void public function handle(
AlertDispatchService $dispatchService,
OperationRunService $operationRuns,
FindingNotificationService $findingNotificationService,
): void
{ {
$workspace = Workspace::query()->whereKey($this->workspaceId)->first(); $workspace = Workspace::query()->whereKey($this->workspaceId)->first();
@ -67,6 +73,8 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart), ...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart), ...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
]; ];
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
$createdDeliveries = 0; $createdDeliveries = 0;
@ -74,13 +82,33 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event); $createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
} }
foreach ($dueSoonFindings as $finding) {
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$createdDeliveries += $result['external_delivery_count'];
if ($result['direct_delivery_status'] === 'sent') {
$createdDeliveries++;
}
}
foreach ($overdueFindings as $finding) {
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
$createdDeliveries += $result['external_delivery_count'];
if ($result['direct_delivery_status'] === 'sent') {
$createdDeliveries++;
}
}
$processedEventCount = count($events) + $dueSoonFindings->count() + $overdueFindings->count();
$operationRuns->updateRun( $operationRuns->updateRun(
$operationRun, $operationRun,
status: OperationRunStatus::Completed->value, status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value, outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [ summaryCounts: [
'total' => count($events), 'total' => $processedEventCount,
'processed' => count($events), 'processed' => $processedEventCount,
'created' => $createdDeliveries, 'created' => $createdDeliveries,
], ],
); );
@ -101,6 +129,45 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
} }
} }
/**
* @return Collection<int, Finding>
*/
private function dueSoonFindings(int $workspaceId): Collection
{
$now = CarbonImmutable::now('UTC');
return Finding::query()
->with('tenant')
->withSubjectDisplayName()
->where('workspace_id', $workspaceId)
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '>', $now)
->where('due_at', '<=', $now->addHours(24))
->orderBy('due_at')
->orderBy('id')
->get();
}
/**
* @return Collection<int, Finding>
*/
private function overdueFindings(int $workspaceId): Collection
{
$now = CarbonImmutable::now('UTC');
return Finding::query()
->with('tenant')
->withSubjectDisplayName()
->where('workspace_id', $workspaceId)
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '<', $now)
->orderBy('due_at')
->orderBy('id')
->get();
}
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
{ {
if (is_int($this->operationRunId) && $this->operationRunId > 0) { if (is_int($this->operationRunId) && $this->operationRunId > 0) {

View File

@ -8,8 +8,10 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -36,10 +38,10 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
'tenant_id' => (int) $schedule->tenant_id, 'tenant_id' => (int) $schedule->tenant_id,
'user_id' => null, 'user_id' => null,
'initiator_name' => 'System', 'initiator_name' => 'System',
'type' => 'backup_schedule_retention', 'type' => OperationRunType::BackupScheduleRetention->value,
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()), 'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':'.OperationRunType::BackupScheduleRetention->value.':'.$schedule->id.':'.Str::uuid()->toString()),
'context' => [ 'context' => [
'backup_schedule_id' => (int) $schedule->id, 'backup_schedule_id' => (int) $schedule->id,
], ],
@ -88,7 +90,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $keepBackupSetIds */ /** @var Collection<int, int> $keepBackupSetIds */
$keepBackupSetIds = OperationRun::query() $keepBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id) ->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run') ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id) ->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id') ->whereNotNull('context->backup_set_id')
@ -103,7 +105,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
/** @var Collection<int, int> $allBackupSetIds */ /** @var Collection<int, int> $allBackupSetIds */
$allBackupSetIds = OperationRun::query() $allBackupSetIds = OperationRun::query()
->where('tenant_id', (int) $schedule->tenant_id) ->where('tenant_id', (int) $schedule->tenant_id)
->where('type', 'backup_schedule_run') ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BackupScheduleExecute->value))
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where('context->backup_schedule_id', (int) $schedule->id) ->where('context->backup_schedule_id', (int) $schedule->id)
->whereNotNull('context->backup_set_id') ->whereNotNull('context->backup_set_id')

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

@ -11,6 +11,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotItemNormalizer; use App\Services\Baselines\BaselineSnapshotItemNormalizer;
@ -29,7 +30,6 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -71,13 +71,24 @@ public function handle(
InventoryMetaContract $metaContract, InventoryMetaContract $metaContract,
AuditLogger $auditLogger, AuditLogger $auditLogger,
OperationRunService $operationRunService, OperationRunService $operationRunService,
?CurrentStateHashResolver $hashResolver = null, mixed $arg5 = null,
?BaselineContentCapturePhase $contentCapturePhase = null, mixed $arg6 = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null, ?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineFullContentRolloutGate $rolloutGate = null, ?BaselineFullContentRolloutGate $rolloutGate = null,
): void { ): void {
$hashResolver ??= app(CurrentStateHashResolver::class); $captureService = $arg5 instanceof BaselineCaptureService
$contentCapturePhase ??= app(BaselineContentCapturePhase::class); ? $arg5
: app(BaselineCaptureService::class);
$hashResolver = $arg5 instanceof CurrentStateHashResolver
? $arg5
: ($arg6 instanceof CurrentStateHashResolver
? $arg6
: app(CurrentStateHashResolver::class));
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
? $arg5
: ($arg6 instanceof BaselineContentCapturePhase
? $arg6
: app(BaselineContentCapturePhase::class));
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class); $snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -118,10 +129,124 @@ public function handle(
$rolloutGate->assertEnabled(); $rolloutGate->assertEnabled();
} }
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant); $previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun $previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
? (int) $latestInventorySyncRun->getKey() ? (int) $previousCurrentSnapshot->getKey()
: null; : null;
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
? data_get($context, 'baseline_capture.eligibility')
: [];
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
? (int) $inventoryEligibility['inventory_sync_run_id']
: null;
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
&& ! ($inventoryEligibility['ok'] ?? false);
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
? (int) $preflightEligibility['inventory_sync_run_id']
: null;
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
? (string) $preflightEligibility['reason_code']
: null;
$context['baseline_capture'] = array_merge(
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
[
'inventory_sync_run_id' => $latestInventorySyncRunId,
'eligibility' => $eligibilityContext,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
if (! ($inventoryEligibility['ok'] ?? false)) {
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
? (string) $inventoryEligibility['reason_code']
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$blockedContext = $context;
$blockedContext['reason_code'] = $reasonCode;
$blockedContext['baseline_capture'] = array_merge(
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
[
'reason_code' => $reasonCode,
'subjects_total' => 0,
'current_baseline_changed' => false,
],
);
$blockedContext['result'] = array_merge(
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
[
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $blockedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
inventorySyncRunId: $latestInventorySyncRunId,
);
$operationRunService->finalizeBlockedRun(
run: $this->operationRun,
reasonCode: $reasonCode,
message: $this->blockedInventoryMessage(
$reasonCode,
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
),
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: null,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: false,
evidenceCaptureStats: [
'requested' => 0,
'succeeded' => 0,
'skipped' => 0,
'failed' => 0,
'throttled' => 0,
],
gaps: [
'count' => 0,
'by_reason' => [],
],
currentBaselineChanged: false,
reasonCode: $reasonCode,
);
return;
}
$inventoryResult = $this->collectInventorySubjects( $inventoryResult = $this->collectInventorySubjects(
sourceTenant: $sourceTenant, sourceTenant: $sourceTenant,
@ -154,6 +279,7 @@ public function handle(
'failed' => 0, 'failed' => 0,
'throttled' => 0, 'throttled' => 0,
]; ];
$phaseResult = [];
$phaseGaps = []; $phaseGaps = [];
$resumeToken = null; $resumeToken = null;
@ -222,6 +348,91 @@ public function handle(
], ],
]; ];
if ($subjectsTotal === 0) {
$snapshotResult = $this->captureNoDataSnapshotArtifact(
$profile,
$identityHash,
$snapshotSummary,
);
$snapshot = $snapshotResult['snapshot'];
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
$updatedContext['baseline_capture'] = array_merge(
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
[
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'subjects_total' => 0,
'inventory_sync_run_id' => $latestInventorySyncRunId,
'evidence_capture' => $phaseStats,
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
'current_baseline_changed' => false,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$updatedContext['result'] = array_merge(
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => 0,
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $updatedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: $snapshot,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: $wasNewSnapshot,
evidenceCaptureStats: $phaseStats,
gaps: [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
],
currentBaselineChanged: false,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return;
}
$snapshotResult = $this->captureSnapshotArtifact( $snapshotResult = $this->captureSnapshotArtifact(
$profile, $profile,
$identityHash, $identityHash,
@ -236,6 +447,9 @@ public function handle(
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
} }
$profile->refresh();
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null; $warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0); $warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
@ -269,6 +483,9 @@ public function handle(
: null, : null,
], ],
'resume_token' => $resumeToken, 'resume_token' => $resumeToken,
'current_baseline_changed' => $currentBaselineChanged,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
], ],
); );
$updatedContext['result'] = [ $updatedContext['result'] = [
@ -277,6 +494,7 @@ public function handle(
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $snapshotItems['items_count'], 'items_captured' => $snapshotItems['items_count'],
'snapshot_lifecycle' => $snapshot->lifecycleState()->value, 'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => $currentBaselineChanged,
]; ];
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
@ -295,6 +513,8 @@ public function handle(
'count' => $gapsCount, 'count' => $gapsCount,
'by_reason' => $gapsByReason, 'by_reason' => $gapsByReason,
], ],
currentBaselineChanged: $currentBaselineChanged,
reasonCode: null,
); );
} }
@ -651,6 +871,51 @@ private function captureSnapshotArtifact(
} }
} }
/**
* @param array<string, mixed> $summaryJsonb
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
*/
private function captureNoDataSnapshotArtifact(
BaselineProfile $profile,
string $identityHash,
array $summaryJsonb,
): array {
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
'expected_identity_hash' => $identityHash,
'expected_items' => 0,
'persisted_items' => 0,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => true,
]);
$snapshot->refresh();
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return [
'snapshot' => $snapshot,
'was_new_snapshot' => true,
];
}
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
{ {
$existing = BaselineSnapshot::query() $existing = BaselineSnapshot::query()
@ -783,6 +1048,32 @@ private function countByPolicyType(array $items): array
return $counts; return $counts;
} }
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
{
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
? (int) $currentSnapshot->getKey()
: null;
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
}
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
{
return match ($reasonCode) {
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
: 'Capture blocked because the latest inventory sync was blocked.',
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
: 'Capture blocked because the latest inventory sync failed.',
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
default => 'Capture blocked because no credible inventory basis was available.',
};
}
private function auditStarted( private function auditStarted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
@ -820,7 +1111,7 @@ private function auditCompleted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
BaselineProfile $profile, BaselineProfile $profile,
BaselineSnapshot $snapshot, ?BaselineSnapshot $snapshot,
?User $initiator, ?User $initiator,
BaselineCaptureMode $captureMode, BaselineCaptureMode $captureMode,
int $subjectsTotal, int $subjectsTotal,
@ -828,6 +1119,8 @@ private function auditCompleted(
bool $wasNewSnapshot, bool $wasNewSnapshot,
array $evidenceCaptureStats, array $evidenceCaptureStats,
array $gaps, array $gaps,
bool $currentBaselineChanged,
?string $reasonCode,
): void { ): void {
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -841,8 +1134,10 @@ private function auditCompleted(
'capture_mode' => $captureMode->value, 'capture_mode' => $captureMode->value,
'inventory_sync_run_id' => $inventorySyncRunId, 'inventory_sync_run_id' => $inventorySyncRunId,
'subjects_total' => $subjectsTotal, 'subjects_total' => $subjectsTotal,
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => $snapshot?->getKey(),
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, 'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
'reason_code' => $reasonCode,
'current_baseline_changed' => $currentBaselineChanged,
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'evidence_capture' => $evidenceCaptureStats, 'evidence_capture' => $evidenceCaptureStats,
'gaps' => $gaps, 'gaps' => $gaps,
@ -878,17 +1173,4 @@ private function mergeGapCounts(array ...$gaps): array
return $merged; return $merged;
} }
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
{
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
return $run instanceof OperationRun ? $run : null;
}
} }

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