refactor(structure): promote app to repo root + remove redundant workspace cruft

Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.

- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
  .specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
  active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
  launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
  tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
  README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
  artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
  - .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
    working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
    -> packages/casan-harness/... (.specify/logs state kept)
  - .claude/launch.json, .gitea/*-runbook.md: path prefixes
  - CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
  - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.

Full gate from the new root: PASS=64 FAIL=0 SKIP=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-08 13:26:36 +09:00
co-authored by Claude Opus 4.8
parent 7101af9fd4
commit 36a4812ef3
925 changed files with 410 additions and 18001 deletions
@@ -0,0 +1,196 @@
#!/usr/bin/env pwsh
# CASAN H6 AgentOps Harness - PowerShell port of agent-metrics.sh
# Usage:
# agent-metrics.ps1 <input-file> <output-file> [-- <command> [args...]]
#
# If command omitted: pass-through copy.
# If command provided: runs under timing/cost wrapper.
# Exit codes mirror the wrapped command's exit code.
param(
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
[Parameter(ValueFromRemainingArguments=$true)][string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$logDir = Join-Path $projectRoot ".specify/logs"
$traceDir = Join-Path $logDir "trace"
$metricsDir = Join-Path $logDir "cost"
$alertLog = Join-Path $projectRoot ".specify/agentops/alerts.log"
$metricsLog = Join-Path $metricsDir "metrics.jsonl"
$toolAudit = Join-Path $logDir "audit/tool-calls.jsonl"
foreach ($d in @($traceDir, $metricsDir, (Split-Path $OutputFile -Parent), (Split-Path $alertLog -Parent), (Split-Path $toolAudit -Parent))) {
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
}
if (!(Test-Path $InputFile)) {
Write-Error "AGENTOPS_FAILED: input file not found: $InputFile"
exit 1
}
function New-TraceId {
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
}
function Get-Sha256 ([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
}
function Get-WordCount ([string]$text) {
return ($text -split '\s+' | Where-Object { $_ -ne "" }).Count
}
function ConvertTo-JsonArray ([string[]]$arr) {
if (!$arr -or $arr.Count -eq 0) { return "[]" }
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
return "[" + ($escaped -join ",") + "]"
}
$traceId = New-TraceId
$startTs = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$startMs = [long]([System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
$status = "success"
$errorMsg = ""
$exitCode = 0
$retryCount = if ($env:CASAN_RETRY_COUNT) { [int]$env:CASAN_RETRY_COUNT } else { 0 }
$agentName = if ($env:CASAN_AGENT_NAME) { $env:CASAN_AGENT_NAME } else { "unknown-agent" }
$stepName = if ($env:CASAN_STEP_NAME) { $env:CASAN_STEP_NAME } else { "unknown-step" }
$modelName = if ($env:CASAN_MODEL_NAME) { $env:CASAN_MODEL_NAME } else { "claude-opus-4-8" }
$inputContent = Get-Content $InputFile -Raw -Encoding UTF8
if (!$inputContent) { $inputContent = "" }
$inputTokens = Get-WordCount $inputContent
# ── Strip leading "--" separator from remaining args ──────────────────────
$cmdArgs = $RemainingArgs
if ($cmdArgs -and $cmdArgs[0] -eq "--") { $cmdArgs = $cmdArgs[1..($cmdArgs.Count-1)] }
# ── Execute wrapped command ────────────────────────────────────────────────
if ($cmdArgs -and $cmdArgs.Count -gt 0) {
$env:CASAN_INPUT = $InputFile
$env:CASAN_OUTPUT = $OutputFile
try {
& $cmdArgs[0] $cmdArgs[1..($cmdArgs.Count-1)]
$exitCode = $LASTEXITCODE
} catch {
$exitCode = 1
$errorMsg = $_.Exception.Message
}
if ($exitCode -ne 0) {
$status = "failed"
if (!$errorMsg) { $errorMsg = "command exited with code $exitCode" }
}
# Tool call audit log (H2 per-call audit)
$cmdStr = ($cmdArgs -join " ") -replace '"','\"'
$toolLine = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"agent`":`"$agentName`",`"step`":`"$stepName`",`"tool`":`"Bash`",`"command`":`"$cmdStr`",`"exit_code`":$exitCode,`"status`":`"$status`"}"
Add-Content -Path $toolAudit -Value $toolLine -Encoding UTF8
} else {
Copy-Item -Path $InputFile -Destination $OutputFile -Force
}
$endMs = [long]([System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
$latencyMs = $endMs - $startMs
if (!(Test-Path $OutputFile)) {
$status = "failed"
if (!$errorMsg) { $errorMsg = "output file not produced" }
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
}
$outputContent = Get-Content $OutputFile -Raw -Encoding UTF8
if (!$outputContent) { $outputContent = "" }
$outputTokens = Get-WordCount $outputContent
$totalTokens = $inputTokens + $outputTokens
# ── Token estimate v2: word-ratio model (more accurate than pure word count) ─
$modelRatios = @{ "claude-opus" = 1.35; "claude-sonnet" = 1.32; "gpt-4" = 1.30 }
$modelFamily = if ($modelName -match "opus") { "claude-opus" }
elseif ($modelName -match "sonnet") { "claude-sonnet" }
else { "claude-opus" }
$tokenRatio = $modelRatios[$modelFamily]
$tokensEstimated = [int]($totalTokens * $tokenRatio)
# Cost per 1M tokens (blended input+output estimate)
$costPer1M = @{ "claude-opus" = 45.0; "claude-sonnet" = 9.0 }
$costRate = $costPer1M[$modelFamily]
$costEstimate = [math]::Round($tokensEstimated / 1000000 * $costRate, 8)
$costPerKOverride = if ($env:CASAN_COST_PER_1K) { [double]$env:CASAN_COST_PER_1K } else { $null }
if ($costPerKOverride) { $costEstimate = [math]::Round($totalTokens * $costPerKOverride / 1000, 8) }
$inputHash = Get-Sha256 $inputContent
$outputHash = Get-Sha256 $outputContent
# ── Alerts ─────────────────────────────────────────────────────────────────
$latencyAlertMs = if ($env:CASAN_LATENCY_ALERT_MS) { [int]$env:CASAN_LATENCY_ALERT_MS } else { 5000 }
$retryAlertThresh = if ($env:CASAN_RETRY_ALERT_THRESHOLD) { [int]$env:CASAN_RETRY_ALERT_THRESHOLD } else { 2 }
$tokenAlertThresh = if ($env:CASAN_TOKEN_ALERT_THRESHOLD) { [int]$env:CASAN_TOKEN_ALERT_THRESHOLD } else { 5000 }
$alerts = [System.Collections.Generic.List[string]]::new()
if ($latencyMs -gt $latencyAlertMs) { $alerts.Add("high-latency") }
if ($retryCount -gt $retryAlertThresh) { $alerts.Add("high-retry") }
if ($status -eq "failed") { $alerts.Add("execution-failed") }
if ($totalTokens -gt $tokenAlertThresh){ $alerts.Add("token-overuse") }
$alertsJson = ConvertTo-JsonArray ($alerts.ToArray())
# ── Trace JSON ─────────────────────────────────────────────────────────────
$traceFile = Join-Path $traceDir "agentops-$traceId.json"
@"
{
"trace_id": "$traceId",
"timestamp": "$startTs",
"harness": "H6-agentops",
"agent": "$agentName",
"step": "$stepName",
"model": "$modelName",
"status": "$status",
"exit_code": $exitCode,
"latency_ms": $latencyMs,
"retry_count": $retryCount,
"input_tokens": $inputTokens,
"output_tokens": $outputTokens,
"total_tokens": $totalTokens,
"tokens_estimated": $tokensEstimated,
"token_estimate_method": "word_ratio_v2",
"cost_estimate": $costEstimate,
"alerts": $alertsJson,
"input_hash": "$inputHash",
"output_hash": "$outputHash",
"error": "$errorMsg"
}
"@ | Set-Content -Path $traceFile -Encoding UTF8
# ── Metrics JSONL ──────────────────────────────────────────────────────────
$metricsLine = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"harness`":`"H6-agentops`",`"agent`":`"$agentName`",`"step`":`"$stepName`",`"status`":`"$status`",`"exit_code`":$exitCode,`"latency_ms`":$latencyMs,`"retry_count`":$retryCount,`"input_tokens`":$inputTokens,`"output_tokens`":$outputTokens,`"total_tokens`":$totalTokens,`"tokens_estimated`":$tokensEstimated,`"cost_estimate`":$costEstimate,`"alerts`":$alertsJson,`"input_hash`":`"$inputHash`",`"output_hash`":`"$outputHash`"}"
Add-Content -Path $metricsLog -Value $metricsLine -Encoding UTF8
# ── Alert log + multi-channel notification ────────────────────────────────
foreach ($alert in $alerts) {
$alertEntry = "{`"timestamp`":`"$startTs`",`"trace_id`":`"$traceId`",`"severity`":`"WARN`",`"resource`":{`"service.name`":`"$agentName`",`"service.version`":`"1.0.0`"},`"body`":{`"message`":`"Alert triggered: $alert`",`"alert.type`":`"$alert`",`"step.name`":`"$stepName`"},`"attributes`":{`"latency_ms`":$latencyMs,`"status`":`"$status`"}}"
Add-Content -Path $alertLog -Value $alertEntry -Encoding UTF8
# Console notification (always on for interactive runs)
Write-Warning "[CASAN ALERT] $alert | step=$stepName agent=$agentName latency=${latencyMs}ms"
# Webhook (if configured)
if ($env:CASAN_ALERT_WEBHOOK_URL) {
try {
$body = @{ text = "CASAN Alert [$alert]: $stepName — $agentName" } | ConvertTo-Json
Invoke-RestMethod -Uri $env:CASAN_ALERT_WEBHOOK_URL -Method POST -Body $body -ContentType "application/json" -ErrorAction SilentlyContinue
} catch { <# fire-and-forget #> }
}
}
Write-Output "AGENTOPS_RECORDED trace_id=$traceId status=$status latency_ms=$latencyMs tokens=$totalTokens estimated_tokens=$tokensEstimated cost=$costEstimate output=$OutputFile"
exit $exitCode
@@ -0,0 +1,105 @@
#!/usr/bin/env pwsh
# CASAN unified harness wrapper - PowerShell port of casan-harness.sh
# Usage:
# casan-harness.ps1 <input-file> <output-file> [action-name] [-- <command> [args...]]
#
# Runs: H4-input → H5-governance → H6-metrics(real cmd) → H4-output
# Includes idempotency caching without bypassing H4/H5/H4-output gates.
param(
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
[Parameter(Mandatory=$true, Position=1)][string]$FinalOutput,
[Parameter(Position=2)][string]$ActionName = "agent_step",
[Parameter(ValueFromRemainingArguments=$true)][string[]]$RemainingArgs
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$tmpDir = Join-Path $projectRoot ".specify/logs/tmp"
$cacheDir = Join-Path $projectRoot ".specify/logs/idempotency"
foreach ($d in @($tmpDir, $cacheDir, (Split-Path $FinalOutput -Parent))) {
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
}
function Get-Sha256 ([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
}
# Strip "--" separator
$cmdArgs = $RemainingArgs
if ($cmdArgs -and $cmdArgs[0] -eq "--") { $cmdArgs = $cmdArgs[1..($cmdArgs.Count-1)] }
# ── Idempotency check ──────────────────────────────────────────────────────
$inputContent = Get-Content $InputFile -Raw -Encoding UTF8
if (!$inputContent) { $inputContent = "" }
$cmdStr = if ($cmdArgs) { $cmdArgs -join " " } else { "no_cmd" }
$inputHash = Get-Sha256 $inputContent
$cmdHash = Get-Sha256 $cmdStr
$idemKey = Get-Sha256 "$inputHash|$cmdHash|$ActionName"
$cacheMeta = Join-Path $cacheDir "$idemKey.json"
$cacheOut = Join-Path $cacheDir "$idemKey.output"
# ── Normal flow ────────────────────────────────────────────────────────────
$suffix = "$(Get-Date -Format 'yyyyMMddHHmmss')-$PID"
$safeInput = Join-Path $tmpDir "security-input-$suffix.txt"
$approvedIn = Join-Path $tmpDir "governance-approved-$suffix.txt"
$rawOutput = Join-Path $tmpDir "raw-output-$suffix.txt"
# H4 input security
& "$scriptDir/security-check.ps1" $InputFile $safeInput input
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# H5 governance
& "$scriptDir/governance-check.ps1" $safeInput $approvedIn $ActionName
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# H6 metrics around real command or cached output.
if ((Test-Path $cacheMeta) -and (Test-Path $cacheOut)) {
Copy-Item -Path $cacheOut -Destination $rawOutput -Force
$metricsExit = 0
$cacheStatus = "cached"
} elseif ($cmdArgs -and $cmdArgs.Count -gt 0) {
& "$scriptDir/agent-metrics.ps1" $approvedIn $rawOutput "--" @cmdArgs
$metricsExit = $LASTEXITCODE
$cacheStatus = "stored"
} else {
& "$scriptDir/agent-metrics.ps1" $approvedIn $rawOutput
$metricsExit = $LASTEXITCODE
$cacheStatus = "stored"
}
# H4 output security
& "$scriptDir/security-check.ps1" $rawOutput $FinalOutput output
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── Save idempotency cache ─────────────────────────────────────────────────
$outputContent = Get-Content $FinalOutput -Raw -Encoding UTF8
if (!$outputContent) { $outputContent = "" }
$outputHash = Get-Sha256 $outputContent
if ($cacheStatus -eq "stored") {
@"
{
"idempotency_key": "$idemKey",
"timestamp": "$($(Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))",
"action": "$ActionName",
"command": "$(($cmdStr -replace '"','\"'))",
"output_hash": "$outputHash"
}
"@ | Set-Content -Path $cacheMeta -Encoding UTF8
Copy-Item -Path $FinalOutput -Destination $cacheOut -Force
}
# Clean up tmp files
foreach ($f in @($safeInput, $approvedIn, $rawOutput)) {
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
}
Write-Output "CASAN_HARNESS_COMPLETE cache=$cacheStatus key=$idemKey output=$FinalOutput"
exit $metricsExit
@@ -0,0 +1,148 @@
#!/usr/bin/env pwsh
# Consolidated prerequisite checking script (PowerShell)
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.ps1 [OPTIONS]
#
# OPTIONS:
# -Json Output in JSON format
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Help, -h Show help message
[CmdletBinding()]
param(
[switch]$Json,
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output @"
Usage: check-prerequisites.ps1 [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
-Json Output in JSON format
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
"@
exit 0
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
if ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
BRANCH = $paths.CURRENT_BRANCH
FEATURE_DIR = $paths.FEATURE_DIR
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
}
exit 0
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.specify first to create the feature structure."
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.plan first to create the implementation plan."
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.tasks first to create the task list."
exit 1
}
# Build list of available documents
$docs = @()
# Always check these optional docs
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}
}
@@ -0,0 +1,137 @@
#!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh
function Get-RepoRoot {
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Fall back to script location for non-git repos
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# Then check git if available
try {
$result = git rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# For non-git repos, try to find the latest feature directory
$repoRoot = Get-RepoRoot
$specsDir = Join-Path $repoRoot "specs"
if (Test-Path $specsDir) {
$latestFeature = ""
$highest = 0
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3})-') {
$num = [int]$matches[1]
if ($num -gt $highest) {
$highest = $num
$latestFeature = $_.Name
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
function Test-HasGit {
try {
git rev-parse --show-toplevel 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
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
}
if ($Branch -notmatch '^[0-9]{3}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name"
return $false
}
return $true
}
function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output " ✓ $Description"
return $true
} else {
Write-Output " ✗ $Description"
return $false
}
}
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
Write-Output " ✓ $Description"
return $true
} else {
Write-Output " ✗ $Description"
return $false
}
}
@@ -0,0 +1,305 @@
#!/usr/bin/env pwsh
# Create a new feature
[CmdletBinding()]
param(
[switch]$Json,
[string]$ShortName,
[int]$Number = 0,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
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 " -Help Show this help message"
Write-Host ""
Write-Host "Examples:"
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
exit 0
}
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
# Validate description is not empty after trimming (e.g., user passed only whitespace)
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialized with --no-git.
function Find-RepositoryRoot {
param(
[string]$StartDir,
[string[]]$Markers = @('.git', '.specify')
)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in $Markers) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) {
# Reached filesystem root without finding markers
return $null
}
$current = $parent
}
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d+)') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
$highest = 0
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0) {
foreach ($branch in $branches) {
# Clean branch name: remove leading markers and remote prefixes
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
# Extract feature number if branch matches pattern ###-*
if ($cleanBranch -match '^(\d+)-') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
} catch {
# If git command fails, return 0
Write-Verbose "Could not check Git branches: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir
)
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
try {
git fetch --all --prune 2>$null | Out-Null
} catch {
# Ignore fetch errors
}
# Get highest number from ALL branches (not just matching short name)
$highestBranch = Get-HighestNumberFromBranches
# Get highest number from ALL specs (not just matching short name)
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
# Take the maximum of both
$maxNum = [Math]::Max($highestBranch, $highestSpec)
# Return next number
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
if (-not $fallbackRoot) {
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
exit 1
}
try {
$repoRoot = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
$hasGit = $true
} else {
throw "Git not available"
}
} catch {
$repoRoot = $fallbackRoot
$hasGit = $false
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
# Common stop words to filter out
$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'
)
# Convert to lowercase and extract words (alphanumeric only)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
$meaningfulWords = @()
foreach ($word in $words) {
# Skip stop words
if ($stopWords -contains $word) { continue }
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
$meaningfulWords += $word
}
}
# If we have meaningful words, use first 3-4 of them
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
# Fallback to original logic if no meaningful words found
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Generate branch name
if ($ShortName) {
# Use provided short name, just clean it up
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
# Generate from description with smart filtering
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Determine branch number
if ($Number -eq 0) {
if ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for: feature number (3) + hyphen (1) = 4 chars
$maxSuffixLength = $maxBranchLength - 4
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$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 ($hasGit) {
$branchCreated = $false
try {
git checkout -b $branchName 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
# Exception during git command
}
if (-not $branchCreated) {
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
exit 1
}
}
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
$specFile = Join-Path $featureDir 'spec.md'
if (Test-Path $template) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile | Out-Null
}
# Set the SPECIFY_FEATURE environment variable for the current session
$env:SPECIFY_FEATURE = $branchName
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
@@ -0,0 +1,93 @@
#!/usr/bin/env pwsh
# CASAN L5 Drift Detector - PowerShell port of drift-detect.sh
# Usage:
# drift-detect.ps1 <golden-file> <candidate-file> <report-json>
#
# Exit codes: 0=pass/warn, 2=drift-fail
param(
[Parameter(Mandatory=$true, Position=0)][string]$GoldenFile,
[Parameter(Mandatory=$true, Position=1)][string]$CandidateFile,
[Parameter(Mandatory=$true, Position=2)][string]$ReportFile
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$l5LogDir = Join-Path $projectRoot ".specify/logs/level5"
foreach ($d in @($l5LogDir, (Split-Path $ReportFile -Parent))) {
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
}
if (!(Test-Path $GoldenFile)) { Write-Error "Golden file not found: $GoldenFile"; exit 1 }
if (!(Test-Path $CandidateFile)) { Write-Error "Candidate file not found: $CandidateFile"; exit 1 }
function Get-Sha256 ([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
}
# Levenshtein-inspired similarity via longest common subsequence on words
function Get-SimilarityRatio ([string]$a, [string]$b) {
$wordsA = $a -split '\s+' | Where-Object { $_ }
$wordsB = $b -split '\s+' | Where-Object { $_ }
if ($wordsA.Count -eq 0 -and $wordsB.Count -eq 0) { return 1.0 }
if ($wordsA.Count -eq 0 -or $wordsB.Count -eq 0) { return 0.0 }
# Simple Jaccard similarity on word sets (fast, portable, no python needed)
$setA = [System.Collections.Generic.HashSet[string]]$wordsA
$setB = [System.Collections.Generic.HashSet[string]]$wordsB
$intersection = ($setA | Where-Object { $setB.Contains($_) }).Count
$union = ($setA + $setB | Sort-Object -Unique).Count
return [math]::Round($intersection / [math]::Max($union, 1), 4)
}
$golden = Get-Content $GoldenFile -Raw -Encoding UTF8
$candidate = Get-Content $CandidateFile -Raw -Encoding UTF8
if (!$golden) { $golden = "" }
if (!$candidate) { $candidate = "" }
$similarity = Get-SimilarityRatio $golden $candidate
$lenGolden = [math]::Max($golden.Length, 1)
$lengthDelta = [math]::Round([math]::Abs($candidate.Length - $golden.Length) / $lenGolden, 4)
$driftStatus = "pass"
$driftAction = "allow"
if ($similarity -lt 0.70 -or $lengthDelta -gt 0.50) {
$driftStatus = "fail"; $driftAction = "block_or_fallback"
} elseif ($similarity -lt 0.85 -or $lengthDelta -gt 0.30) {
$driftStatus = "warn"; $driftAction = "require_review"
}
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$goldenHash = Get-Sha256 $golden
$candidHash = Get-Sha256 $candidate
$report = @"
{
"timestamp": "$timestamp",
"harness": "L5-drift-detection",
"status": "$driftStatus",
"action": "$driftAction",
"similarity_ratio": $similarity,
"length_delta_ratio": $lengthDelta,
"golden_hash": "$goldenHash",
"candidate_hash": "$candidHash",
"golden_file": "$GoldenFile",
"candidate_file": "$CandidateFile"
}
"@
Set-Content -Path $ReportFile -Value $report -Encoding UTF8
# Append to L5 log
$logLine = "{`"timestamp`":`"$timestamp`",`"harness`":`"L5-drift-detection`",`"status`":`"$driftStatus`",`"similarity`":$similarity,`"length_delta`":$lengthDelta,`"golden`":`"$GoldenFile`",`"candidate`":`"$CandidateFile`"}"
Add-Content -Path (Join-Path $l5LogDir "drift.jsonl") -Value $logLine -Encoding UTF8
Write-Output "DRIFT_$($driftStatus.ToUpper()) similarity=$similarity length_delta=$lengthDelta report=$ReportFile"
if ($driftStatus -eq "fail") { exit 2 }
exit 0
@@ -0,0 +1,121 @@
#!/usr/bin/env pwsh
# CASAN H6 AgentOps Dashboard Generator (auto-refresh HTML)
# Usage:
# generate-agentops-dashboard.ps1 [-FeatureId <id>] [-OutputHtml <path>]
param(
[string]$FeatureId = "latest",
[string]$OutputHtml = ""
)
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$metricsLog = Join-Path $projectRoot ".specify/logs/cost/metrics.jsonl"
$alertLog = Join-Path $projectRoot ".specify/agentops/alerts.log"
if (!$OutputHtml) { $OutputHtml = Join-Path $projectRoot "docs/output/casan/agentops-dashboard.html" }
if (!(Test-Path (Split-Path $OutputHtml -Parent))) { New-Item -ItemType Directory -Force -Path (Split-Path $OutputHtml -Parent) | Out-Null }
$generatedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# ── Read metrics ───────────────────────────────────────────────────────────
$metrics = @()
if (Test-Path $metricsLog) {
$metrics = Get-Content $metricsLog | ForEach-Object {
try { $_ | ConvertFrom-Json } catch { $null }
} | Where-Object { $_ }
}
$alerts = @()
if (Test-Path $alertLog) {
$alerts = Get-Content $alertLog | ForEach-Object {
try { $_ | ConvertFrom-Json } catch { $null }
} | Where-Object { $_ }
}
# ── Aggregations ───────────────────────────────────────────────────────────
$totalRuns = $metrics.Count
$totalCost = [math]::Round(($metrics | Measure-Object -Property cost_estimate -Sum).Sum, 4)
$totalTokens = ($metrics | Measure-Object -Property tokens_estimated -Sum).Sum
$avgLatency = if ($totalRuns) { [math]::Round(($metrics | Measure-Object -Property latency_ms -Average).Average, 0) } else { 0 }
$failedRuns = ($metrics | Where-Object { $_.status -eq "failed" }).Count
$passRate = if ($totalRuns) { [math]::Round(($totalRuns - $failedRuns) / $totalRuns * 100, 1) } else { 100 }
$totalAlerts = $alerts.Count
# Per-step table rows
$stepRows = $metrics | Group-Object step | ForEach-Object {
$g = $_
$avgLat = [math]::Round(($g.Group | Measure-Object latency_ms -Average).Average, 0)
$totCost = [math]::Round(($g.Group | Measure-Object cost_estimate -Sum).Sum, 4)
$runs = $g.Group.Count
$fails = ($g.Group | Where-Object { $_.status -eq "failed" }).Count
"<tr><td>$($g.Name)</td><td>$runs</td><td>${avgLat}ms</td><td>$$totCost</td><td>$fails</td></tr>"
}
# Alert rows
$alertRows = $alerts | Select-Object -Last 20 | ForEach-Object {
$sev = if ($_.severity -eq "WARN") { "style='color:orange'" } else { "style='color:red'" }
$msg = $_.body.message
"<tr><td $sev>$($_.severity)</td><td>$($_.timestamp)</td><td>$msg</td></tr>"
}
$html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CASAN AgentOps Dashboard — $FeatureId</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background:#f5f7fa; margin:0; padding:20px; color:#333 }
h1 { color:#1a56db; margin-bottom:4px }
.meta { color:#6b7280; font-size:0.85em; margin-bottom:24px }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:16px; margin-bottom:28px }
.card { background:#fff; border-radius:12px; padding:20px; box-shadow:0 1px 4px rgba(0,0,0,.08); text-align:center }
.card .val { font-size:2em; font-weight:700; color:#1a56db }
.card .lbl { font-size:0.8em; color:#6b7280; margin-top:4px }
.card.warn .val { color:#d97706 }
.card.fail .val { color:#dc2626 }
table { width:100%; border-collapse:collapse; background:#fff; border-radius:12px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.08); margin-bottom:24px }
th { background:#1a56db; color:#fff; padding:10px 14px; text-align:left; font-size:0.85em }
td { padding:9px 14px; border-bottom:1px solid #f0f0f0; font-size:0.9em }
tr:last-child td { border-bottom:none }
h2 { color:#374151; margin:24px 0 8px }
.badge { display:inline-block; padding:2px 8px; border-radius:99px; font-size:0.75em; font-weight:600 }
.badge.pass { background:#d1fae5; color:#065f46 }
.badge.fail { background:#fee2e2; color:#991b1b }
footer { color:#9ca3af; font-size:0.78em; margin-top:32px }
</style>
</head>
<body>
<h1>CASAN AgentOps Dashboard</h1>
<div class="meta">Feature: <strong>$FeatureId</strong> &nbsp;|&nbsp; Generated: $generatedAt</div>
<div class="cards">
<div class="card"><div class="val">$totalRuns</div><div class="lbl">Total Runs</div></div>
<div class="card $(if($passRate -lt 80){'fail'} elseif($passRate -lt 95){'warn'} else {''})"><div class="val">${passRate}%</div><div class="lbl">Pass Rate</div></div>
<div class="card"><div class="val">${avgLatency}ms</div><div class="lbl">Avg Latency</div></div>
<div class="card"><div class="val">$totalTokens</div><div class="lbl">Total Tokens (est.)</div></div>
<div class="card $(if($totalCost -gt 1){'warn'} else {''})"><div class="val">\$$totalCost</div><div class="lbl">Est. Cost (USD)</div></div>
<div class="card $(if($totalAlerts -gt 0){'warn'} else {''})"><div class="val">$totalAlerts</div><div class="lbl">Alerts</div></div>
</div>
<h2>Per-Step Breakdown</h2>
<table>
<thead><tr><th>Step</th><th>Runs</th><th>Avg Latency</th><th>Est. Cost</th><th>Failures</th></tr></thead>
<tbody>$($stepRows -join "`n")</tbody>
</table>
<h2>Recent Alerts (last 20)</h2>
<table>
<thead><tr><th>Severity</th><th>Timestamp</th><th>Message</th></tr></thead>
<tbody>$(if($alertRows){$alertRows -join "`n"} else {"<tr><td colspan='3' style='color:#10b981;text-align:center'>No alerts — all clear ✓</td></tr>"})</tbody>
</table>
<footer>CASAN Level 4 AgentOps Harness &nbsp;|&nbsp; Auto-generated — do not edit manually &nbsp;|&nbsp; $generatedAt</footer>
</body>
</html>
"@
Set-Content -Path $OutputHtml -Value $html -Encoding UTF8
Write-Output "AGENTOPS_DASHBOARD_GENERATED: $OutputHtml"
@@ -0,0 +1,135 @@
#!/usr/bin/env pwsh
# CASAN H5 Compliance Report Generator
# Usage:
# generate-compliance-report.ps1 -FeatureId <id> -OutputPath <path>
param(
[Parameter(Mandatory=$true)][string]$FeatureId,
[string]$OutputPath = ""
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$auditLog = Join-Path $projectRoot ".specify/logs/audit/audit.jsonl"
$secAudit = Join-Path $projectRoot ".specify/logs/audit/security.jsonl"
$toolAudit = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
if (!$OutputPath) {
$outDir = Join-Path $projectRoot "docs/output/output_logs/$FeatureId"
if (!(Test-Path $outDir)) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
$OutputPath = Join-Path $outDir "compliance-report.md"
}
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# ── Read audit records ─────────────────────────────────────────────────────
function Read-Jsonl ([string]$path) {
if (!(Test-Path $path)) { return @() }
Get-Content $path | ForEach-Object {
try { $_ | ConvertFrom-Json } catch { $null }
} | Where-Object { $_ }
}
$govRecords = Read-Jsonl $auditLog
$secRecords = Read-Jsonl $secAudit
$toolRecords = Read-Jsonl $toolAudit
# ── Statistics ─────────────────────────────────────────────────────────────
$totalActions = $govRecords.Count
$highRisk = $govRecords | Where-Object { $_.risk_level -eq "high" }
$denied = $govRecords | Where-Object { $_.decision -eq "denied" }
$humanApproved = $govRecords | Where-Object { $_.approval_status -eq "human_approved" }
$secBlocked = $secRecords | Where-Object { $_.status -eq "blocked" }
$piiMasked = $secRecords | Where-Object { $_.action -eq "allow" -and $_.matched_rules }
$toolCalls = $toolRecords.Count
$toolDenied = $toolRecords | Where-Object { $_.decision -eq "denied" }
# ── Audit chain validation ─────────────────────────────────────────────────
$chainValid = $true
$prevHash = ""
foreach ($rec in $govRecords) {
if ($prevHash -and $rec.previous_record_hash -ne $prevHash) { $chainValid = $false; break }
$prevHash = $rec.record_hash
}
$chainStatus = if ($chainValid) { "VALID ✅" } else { "BROKEN ⚠️" }
# ── Report content ─────────────────────────────────────────────────────────
$highRiskTable = if ($highRisk) {
$highRisk | ForEach-Object { "| $($_.timestamp) | $($_.action) | $($_.decision) | $($_.approver) | $($_.approval_status) |" }
} else { "| — | — | — | — | — |" }
$deniedTable = if ($denied) {
$denied | ForEach-Object { "| $($_.timestamp) | $($_.action) | $($_.risk_level) | $($_.approval_status) |" }
} else { "| — | — | — | — |" }
$report = @"
# Compliance Report — $FeatureId
**Generated:** $timestamp
**Pipeline:** $FeatureId
**Audit chain status:** $chainStatus
---
## Action Summary
| Metric | Count |
|--------|------:|
| Total governance actions | $totalActions |
| High-risk actions | $($highRisk.Count) |
| Denied actions | $($denied.Count) |
| Human-approved (identity verified) | $($humanApproved.Count) |
| Security blocks (H4) | $($secBlocked.Count) |
| PII-masked inputs | $($piiMasked.Count) |
| Tool registry calls | $toolCalls |
| Tool registry denials | $($toolDenied.Count) |
---
## High-Risk Actions
| Timestamp | Action | Decision | Approver | Approval Status |
|-----------|--------|----------|----------|-----------------|
$($highRiskTable -join "`n")
---
## Denied Actions
| Timestamp | Action | Risk Level | Approval Status |
|-----------|--------|:----------:|-----------------|
$($deniedTable -join "`n")
---
## Security Events (H4)
- Prompt injections blocked: **$($secRecords | Where-Object { $_.status -eq "blocked" -and $_.mode -eq "input" } | Measure-Object | Select-Object -ExpandProperty Count)**
- Output redactions: **$($secRecords | Where-Object { $_.action -eq "redact" } | Measure-Object | Select-Object -ExpandProperty Count)**
- Hallucination risk flags: **$($secRecords | Where-Object { $_.action -eq "flag" } | Measure-Object | Select-Object -ExpandProperty Count)**
---
## Audit Chain Status
- Records checked: **$totalActions**
- Chain integrity: **$chainStatus**
- Storage: hash-chain JSONL at `.specify/logs/audit/audit.jsonl`
---
## Tool Registry Compliance (H2)
- Total tool calls logged: **$toolCalls**
- Denied (missing idempotency key): **$($toolDenied.Count)**
- Side-effecting actions: **$($toolRecords | Where-Object { $_.decision -eq "approved" } | Measure-Object | Select-Object -ExpandProperty Count)**
---
*Report auto-generated by CASAN H5 Governance Harness*
"@
Set-Content -Path $OutputPath -Value $report -Encoding UTF8
Write-Output "COMPLIANCE_REPORT_GENERATED: $OutputPath"
@@ -0,0 +1,149 @@
#!/usr/bin/env pwsh
# CASAN H5 Governance Harness - PowerShell port of governance-check.sh
# Usage:
# governance-check.ps1 <input-file> <output-file> [action-name]
#
# Non-interactive. High-risk denied unless env:CASAN_APPROVAL_DECISION=approve + env:CASAN_APPROVER set.
# Exit codes: 0=approved, 2=denied, 64=usage error
param(
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
[Parameter(Position=2)][string]$ActionName = "agent_step"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$logDir = Join-Path $projectRoot ".specify/logs"
$traceDir = Join-Path $logDir "trace"
$auditDir = Join-Path $logDir "audit"
$auditLog = Join-Path $auditDir "audit.jsonl"
foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) {
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
}
if (!(Test-Path $InputFile)) {
Write-Error "GOVERNANCE_DENIED: input file not found: $InputFile"
exit 2
}
function New-TraceId {
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
}
function Get-Sha256 ([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
}
function ConvertTo-JsonArray ([string[]]$arr) {
if (!$arr -or $arr.Count -eq 0) { return "[]" }
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
return "[" + ($escaped -join ",") + "]"
}
$traceId = New-TraceId
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$input = Get-Content $InputFile -Raw -Encoding UTF8
if (!$input) { $input = "" }
$lowerInput = $input.ToLower()
$actor = if ($env:CASAN_ACTOR) { $env:CASAN_ACTOR } else { "developer" }
$approver = if ($env:CASAN_APPROVER) { $env:CASAN_APPROVER } else { "" }
$approvalDecision = if ($env:CASAN_APPROVAL_DECISION) { $env:CASAN_APPROVAL_DECISION } else { "auto" }
$agentName = if ($env:CASAN_AGENT_NAME) { $env:CASAN_AGENT_NAME } else { "unknown" }
$riskLevel = "low"
$reasons = [System.Collections.Generic.List[string]]::new()
# ── Risk by action name ────────────────────────────────────────────────────
switch -Regex ($ActionName) {
"^(write_code|write_file|external_api|tool_call)$" {
$riskLevel = "medium"; $reasons.Add("sensitive-action:$ActionName")
}
"^(deploy|launch|migration|db_write)$" {
$riskLevel = "high"; $reasons.Add("high-risk-action:$ActionName")
}
}
# ── Tool registry agent whitelist check ───────────────────────────────────
if ($agentName -ne "unknown") {
if ($agentName -match "okr\.(srs|bd|reviewspec|reviewplan|reviewcode)" -or
$agentName -match "speckit\.(specify|clarify|plan|tasks)") {
if ($ActionName -match "^(deploy|migration|db_write)$") {
$riskLevel = "high"
$reasons.Add("unauthorized-action-for-agent:$ActionName")
}
}
}
# ── Risk by content keywords ───────────────────────────────────────────────
if ($lowerInput -match "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)") {
$riskLevel = "high"
if (!$reasons.Contains("high-risk-content")) { $reasons.Add("high-risk-content") }
} elseif ($lowerInput -match "(internal|config|system|policy|permission)") {
if ($riskLevel -eq "low") { $riskLevel = "medium"; $reasons.Add("medium-risk-content") }
}
# ── Approval decision ──────────────────────────────────────────────────────
$approvalStatus = "auto_approved"
$decision = "approved"
if ($riskLevel -eq "medium") { $approvalStatus = "policy_auto_approved_with_audit" }
if ($riskLevel -eq "high") {
if ($approvalDecision -eq "approve" -and $approver -ne "") {
$approvalStatus = "human_approved"; $decision = "approved"
} else {
$approvalStatus = "approval_required"; $decision = "denied"
}
}
# ── Hash + chain ───────────────────────────────────────────────────────────
$inputHash = Get-Sha256 $input
$prevHash = ""
if (Test-Path $auditLog) {
$lastLine = Get-Content $auditLog -Tail 1
if ($lastLine -match '"record_hash":"([^"]+)"') { $prevHash = $Matches[1] }
}
$recordCore = "$timestamp|$traceId|$ActionName|$actor|$riskLevel|$decision|$approvalStatus|$inputHash|$prevHash"
$recordHash = Get-Sha256 $recordCore
$reasonsJson = ConvertTo-JsonArray ($reasons.ToArray())
# ── Trace JSON ─────────────────────────────────────────────────────────────
$traceFile = Join-Path $traceDir "governance-$traceId.json"
@"
{
"trace_id": "$traceId",
"timestamp": "$timestamp",
"harness": "H5-governance",
"action": "$ActionName",
"actor": "$actor",
"risk_level": "$riskLevel",
"decision": "$decision",
"approval_status": "$approvalStatus",
"approver": "$approver",
"reasons": $reasonsJson,
"input_hash": "$inputHash",
"previous_record_hash": "$prevHash",
"record_hash": "$recordHash"
}
"@ | Set-Content -Path $traceFile -Encoding UTF8
# ── Audit JSONL (append-only) ──────────────────────────────────────────────
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H5-governance`",`"action`":`"$ActionName`",`"actor`":`"$actor`",`"risk_level`":`"$riskLevel`",`"decision`":`"$decision`",`"approval_status`":`"$approvalStatus`",`"approver`":`"$approver`",`"input_hash`":`"$inputHash`",`"previous_record_hash`":`"$prevHash`",`"record_hash`":`"$recordHash`"}"
Add-Content -Path $auditLog -Value $auditLine -Encoding UTF8
# ── Result ─────────────────────────────────────────────────────────────────
if ($decision -ne "approved") {
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
Write-Error "GOVERNANCE_DENIED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus"
exit 2
}
$input | Set-Content -Path $OutputFile -Encoding UTF8
Write-Output "GOVERNANCE_APPROVED trace_id=$traceId risk=$riskLevel approval_status=$approvalStatus output=$OutputFile"
exit 0
@@ -0,0 +1,62 @@
#!/usr/bin/env pwsh
# CASAN H5 Approval Request — identity-verified human checkpoint
# Usage:
# request-approval.ps1 -ActionName <name> -RiskLevel <low|medium|high> [-Description <text>] [-NonInteractive]
#
# Exit codes: 0=approved, 2=denied
param(
[Parameter(Mandatory=$true)][string]$ActionName,
[Parameter(Mandatory=$true)][ValidateSet("low","medium","high")][string]$RiskLevel,
[string]$Description = "",
[switch]$NonInteractive
)
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# Low/medium: auto-approve
if ($RiskLevel -ne "high") {
Write-Output "AUTO_APPROVED: $ActionName (risk=$RiskLevel) ts=$timestamp"
exit 0
}
# High risk — require explicit approval with identity
if ($NonInteractive) {
$decision = $env:CASAN_APPROVAL_DECISION
$approver = $env:CASAN_APPROVER
if ($decision -ne "approve") {
Write-Error "HIGH_RISK_DENIED: '$ActionName' requires CASAN_APPROVAL_DECISION=approve"
Write-Error "Set CASAN_APPROVER=<name> and CASAN_APPROVAL_DECISION=approve to proceed"
exit 2
}
if ([string]::IsNullOrWhiteSpace($approver)) {
Write-Error "HIGH_RISK_DENIED: CASAN_APPROVER must be set (cannot be empty)"
exit 2
}
Write-Output "APPROVED_WITH_IDENTITY: action=$ActionName approver=$approver ts=$timestamp"
exit 0
}
# Interactive mode
Write-Host ""
Write-Host "[GOVERNANCE] High-risk action requested" -ForegroundColor Yellow
Write-Host " Action: $ActionName" -ForegroundColor White
Write-Host " Risk level: $RiskLevel" -ForegroundColor Red
if ($Description) { Write-Host " Description: $Description" -ForegroundColor Gray }
Write-Host ""
$answer = Read-Host "Approve this action? (yes/no)"
if ($answer.Trim().ToLower() -ne "yes") {
Write-Error "HUMAN_DENIED: $ActionName denied by operator"
exit 2
}
$approver = Read-Host "Enter your name (for audit trail)"
if ([string]::IsNullOrWhiteSpace($approver)) {
Write-Error "HUMAN_DENIED: approver name cannot be empty"
exit 2
}
Write-Output "HUMAN_APPROVED: action=$ActionName approver=$approver ts=$timestamp"
exit 0
@@ -0,0 +1,89 @@
#!/usr/bin/env pwsh
# CASAN L5 Rollback Manager - PowerShell port of rollback-manager.sh
# Usage:
# rollback-manager.ps1 record <action> <rollback-command>
# rollback-manager.ps1 execute <transaction-id>
# rollback-manager.ps1 list
#
# Exit codes: 0=success, 1=error, 64=usage error
param(
[Parameter(Mandatory=$true, Position=0)][ValidateSet("record","execute","list")][string]$Mode,
[Parameter(Position=1)][string]$Arg1 = "",
[Parameter(Position=2)][string]$Arg2 = ""
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$logDir = Join-Path $projectRoot ".specify/logs/level5"
$txLog = Join-Path $logDir "rollback-transactions.jsonl"
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null }
function New-TraceId {
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "tx-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
}
switch ($Mode) {
"record" {
$action = $Arg1
$rollbackCommand = $Arg2
if (!$action -or !$rollbackCommand) {
Write-Error "Usage: rollback-manager.ps1 record <action> <rollback-command>"
exit 64
}
$txId = New-TraceId
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$gitRef = ""
try { $gitRef = (git rev-parse HEAD 2>$null).Trim() } catch {}
$line = "{`"timestamp`":`"$timestamp`",`"transaction_id`":`"$txId`",`"action`":`"$action`",`"rollback_command`":`"$(($rollbackCommand -replace '"','\"'))`",`"git_ref`":`"$gitRef`",`"status`":`"recorded`"}"
Add-Content -Path $txLog -Value $line -Encoding UTF8
Write-Output "ROLLBACK_RECORDED transaction_id=$txId"
exit 0
}
"execute" {
$txId = $Arg1
if (!$txId) { Write-Error "Usage: rollback-manager.ps1 execute <transaction-id>"; exit 64 }
if (!(Test-Path $txLog)) { Write-Error "ROLLBACK_NOT_FOUND: transaction log missing"; exit 1 }
$found = $false
$cmd = ""
foreach ($line in (Get-Content $txLog)) {
try {
$rec = $line | ConvertFrom-Json
if ($rec.transaction_id -eq $txId) {
$cmd = $rec.rollback_command
$found = $true
break
}
} catch {}
}
if (!$found -or !$cmd) {
Write-Error "ROLLBACK_NOT_FOUND transaction_id=$txId"
exit 1
}
Write-Output "Executing rollback: $cmd"
Invoke-Expression $cmd
$execExit = $LASTEXITCODE
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Add-Content -Path $txLog -Value "{`"timestamp`":`"$timestamp`",`"transaction_id`":`"$txId`",`"status`":`"rolled_back`",`"exit_code`":$execExit}" -Encoding UTF8
Write-Output "ROLLBACK_EXECUTED transaction_id=$txId exit_code=$execExit"
exit $execExit
}
"list" {
if (!(Test-Path $txLog)) { Write-Output "No rollback transactions recorded."; exit 0 }
$records = Get-Content $txLog | ForEach-Object {
try { $_ | ConvertFrom-Json } catch { $null }
} | Where-Object { $_ }
$records | Format-Table transaction_id, action, status, timestamp -AutoSize
exit 0
}
}
@@ -0,0 +1,218 @@
#!/usr/bin/env pwsh
# CASAN H4 Security Harness - PowerShell port of security-check.sh
# Usage:
# security-check.ps1 <input-file> <output-file> [input|output]
#
# input mode: blocks prompt injection / jailbreak / secrets, masks PII.
# output mode: redacts PII/secrets from generated output, flags risky language.
# Exit codes: 0=pass, 2=blocked, 64=usage error
param(
[Parameter(Mandatory=$true, Position=0)][string]$InputFile,
[Parameter(Mandatory=$true, Position=1)][string]$OutputFile,
[Parameter(Position=2)][ValidateSet("input","output")][string]$Mode = "input"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$logDir = Join-Path $projectRoot ".specify/logs"
$traceDir = Join-Path $logDir "trace"
$auditDir = Join-Path $logDir "audit"
$securityDir = Join-Path $projectRoot ".specify/security"
foreach ($d in @($traceDir, $auditDir, (Split-Path $OutputFile -Parent))) {
if ($d -and !(Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
}
if (!(Test-Path $InputFile)) {
Write-Error "SECURITY_BLOCKED: input file not found: $InputFile"
exit 2
}
function New-TraceId {
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "trace-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
}
function Get-Sha256 ([string]$text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
return ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
}
function ConvertTo-JsonArray ([string[]]$arr) {
if (!$arr -or $arr.Count -eq 0) { return "[]" }
$escaped = $arr | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }
return "[" + ($escaped -join ",") + "]"
}
$traceId = New-TraceId
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$content = Get-Content $InputFile -Raw -Encoding UTF8
if (!$content) { $content = "" }
$lower = $content.ToLower()
$status = "pass"
$action = "allow"
$riskLevel = "low"
$matchedRules = [System.Collections.Generic.List[string]]::new()
# ── Block patterns (prompt injection) ──────────────────────────────────────
$blockPatterns = @(
"ignore previous instruction",
"ignore system instruction",
"bypass system",
"bypass policy",
"system bypass",
"override policy",
"you are now",
"act as admin",
"pretend to be system",
"reveal system prompt",
"reveal original prompt",
"show hidden instruction",
"show internal prompt"
)
# ── Jailbreak patterns (Phase 2 addition) ──────────────────────────────────
$jailbreakPatterns = @(
"act as a different",
"act as an evil",
"act as an unconstrained",
"act as an unrestricted",
"pretend you are not an ai",
"pretend you're not an ai",
"pretend you are no longer",
"developer mode",
"dan mode",
"jailbreak mode",
"unrestricted mode",
"your real instructions are",
"your true instructions are",
"what are your system instructions",
"what were your original instructions"
)
# ── Load additional patterns from prompt-filter.yaml ───────────────────────
$filterYaml = Join-Path $securityDir "prompt-filter.yaml"
if (Test-Path $filterYaml) {
$yaml = Get-Content $filterYaml -Raw
$patternMatches = [regex]::Matches($yaml, 'pattern:\s*"([^"]+)"')
foreach ($m in $patternMatches) { $blockPatterns += $m.Groups[1].Value }
}
# ── Approval / alert patterns ──────────────────────────────────────────────
$approvalPatterns = @("delete ", "drop table", "shutdown system", "export secrets", "dump database")
$alertPatterns = @("show all data", "internal prompt", "system message", "hidden instruction")
# ── Regexes ────────────────────────────────────────────────────────────────
$emailRegex = '[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}'
$phoneRegex = '(\+?[0-9][0-9 .\-]{8,}[0-9])'
$personalIdReg = '\b[0-9]{9,12}\b'
$creditCardReg = '\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
$secretRegex = '(?i)(API[_\-]?KEY|ACCESS[_\-]?TOKEN|REFRESH[_\-]?TOKEN|PASSWORD|JWT[_\-]?SECRET|SECRET)\s*[:=]\s*\S+'
$credentialReg = '(?i)(postgres|mysql|mongodb)://[^@]+@' # connection strings
$awsKeyReg = 'AKIA[0-9A-Z]{16}'
if ($Mode -eq "input") {
# Block patterns check
foreach ($p in ($blockPatterns + $jailbreakPatterns)) {
if ($p -and $lower -match [regex]::Escape($p)) {
$status = "blocked"; $action = "block"; $riskLevel = "high"
$matchedRules.Add("prompt-injection:$p")
}
}
# Credit card PII
if ($content -match $creditCardReg) {
$status = "blocked"; $action = "block"; $riskLevel = "high"
$matchedRules.Add("pii-credit-card")
}
# Hardcoded secrets / credentials (Phase 2 addition)
if (($content -match $secretRegex) -or ($content -match $credentialReg) -or ($content -match $awsKeyReg)) {
$status = "blocked"; $action = "block"; $riskLevel = "high"
$matchedRules.Add("secret-in-input")
}
# Approval patterns (only if not already blocked)
if ($status -ne "blocked") {
foreach ($p in $approvalPatterns) {
if ($lower -match [regex]::Escape($p)) {
$status = "requires_approval"; $action = "require_approval"; $riskLevel = "high"
$matchedRules.Add("unsafe-action:$p")
}
}
}
# Alert patterns
if ($status -ne "blocked") {
foreach ($p in $alertPatterns) {
if ($lower -match [regex]::Escape($p)) {
if ($riskLevel -eq "low") { $riskLevel = "medium" }
$action = "alert"
$matchedRules.Add("suspicious:$p")
}
}
}
}
# ── PII masking + secret redaction (both modes) ───────────────────────────
$safeContent = $content
$safeContent = [regex]::Replace($safeContent, $emailRegex, '***MASKED_EMAIL***')
$safeContent = [regex]::Replace($safeContent, $phoneRegex, '***MASKED_PHONE***')
$safeContent = [regex]::Replace($safeContent, $personalIdReg, '***MASKED_ID***')
$safeContent = [regex]::Replace($safeContent, $secretRegex, '[REDACTED_SECRET]')
$safeContent = [regex]::Replace($safeContent, $credentialReg, '[REDACTED_CONNSTRING]://')
$safeContent = [regex]::Replace($safeContent, $awsKeyReg, '[REDACTED_AWSKEY]')
if ($Mode -eq "output") {
if ($content -match $secretRegex) {
$action = "redact"
if ($riskLevel -eq "low") { $riskLevel = "medium" }
$matchedRules.Add("secret-redacted-output")
}
if ($lower -match "(maybe|might be incorrect|i am not sure|uncertain)") {
$action = "flag"
if ($riskLevel -eq "low") { $riskLevel = "medium" }
$matchedRules.Add("hallucination-risk-language")
}
}
# ── Hash ───────────────────────────────────────────────────────────────────
$inputHash = Get-Sha256 $content
$outputHash = Get-Sha256 $safeContent
$rulesJson = ConvertTo-JsonArray ($matchedRules.ToArray())
# ── Trace JSON ─────────────────────────────────────────────────────────────
$traceFile = Join-Path $traceDir "security-$traceId.json"
@"
{
"trace_id": "$traceId",
"timestamp": "$timestamp",
"harness": "H4-security",
"mode": "$Mode",
"status": "$status",
"action": "$action",
"risk_level": "$riskLevel",
"matched_rules": $rulesJson,
"input_hash": "$inputHash",
"output_hash": "$outputHash"
}
"@ | Set-Content -Path $traceFile -Encoding UTF8
# ── Audit JSONL ────────────────────────────────────────────────────────────
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"H4-security`",`"mode`":`"$Mode`",`"status`":`"$status`",`"action`":`"$action`",`"risk_level`":`"$riskLevel`",`"input_hash`":`"$inputHash`",`"output_hash`":`"$outputHash`"}"
Add-Content -Path (Join-Path $auditDir "security.jsonl") -Value $auditLine -Encoding UTF8
# ── Result ─────────────────────────────────────────────────────────────────
if ($status -eq "blocked") {
Set-Content -Path $OutputFile -Value "" -Encoding UTF8
Write-Error "SECURITY_BLOCKED trace_id=$traceId risk=$riskLevel rules=$rulesJson"
exit 2
}
Set-Content -Path $OutputFile -Value $safeContent -Encoding UTF8
Write-Output "SECURITY_$($status.ToUpper()) trace_id=$traceId risk=$riskLevel action=$action output=$OutputFile"
exit 0
@@ -0,0 +1,61 @@
#!/usr/bin/env pwsh
# Setup implementation plan for a feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
Write-Output " -Json Output results in JSON format"
Write-Output " -Help Show this help message"
exit 0
}
# Load common functions
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
# Check if we're on a proper feature branch (only for git repos)
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if it exists, otherwise note it or create empty file
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
if (Test-Path $template) {
Copy-Item $template $paths.IMPL_PLAN -Force
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
} else {
Write-Warning "Plan template not found at $template"
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
# Output results
if ($Json) {
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
HAS_GIT = $paths.HAS_GIT
}
$result | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
}
@@ -0,0 +1,89 @@
#!/usr/bin/env pwsh
# CASAN L5 Tool Registry Gate - PowerShell port of tool-registry-gate.sh
# Usage:
# tool-registry-gate.ps1 <tool-id> [idempotency-key]
#
# Exit codes: 0=approved, 2=denied, 64=usage error
param(
[Parameter(Mandatory=$true, Position=0)][string]$ToolId,
[Parameter(Position=1)][string]$IdempotencyKey = ""
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$registry = Join-Path $projectRoot ".specify/level5/tool-registry.yaml"
$logDir = Join-Path $projectRoot ".specify/logs/level5"
$txLog = Join-Path $logDir "tool-registry.jsonl"
$toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null }
if (!(Test-Path (Split-Path $toolCallLog -Parent))) { New-Item -ItemType Directory -Force -Path (Split-Path $toolCallLog -Parent) | Out-Null }
function New-TraceId {
try { return [System.Guid]::NewGuid().ToString("D") } catch { return "tool-$(Get-Date -Format 'yyyyMMddHHmmss')-$PID" }
}
if (!(Test-Path $registry)) {
Write-Error "TOOL_REGISTRY_ERROR: registry not found at $registry"
exit 1
}
$traceId = New-TraceId
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
# ── Parse YAML registry (simple line-based parser) ─────────────────────────
$yamlText = Get-Content $registry -Raw
$toolBlocks = $yamlText -split "\n\s*-\s+id:\s+"
$tools = @{}
foreach ($block in $toolBlocks[1..($toolBlocks.Count-1)]) {
$lines = $block -split "`n"
$tid = $lines[0].Trim()
$attrs = @{ id = $tid }
foreach ($line in $lines[1..($lines.Count-1)]) {
if ($line -match '^\s{4}(\w[^:]+):\s+(.+)$') {
$k = $Matches[1].Trim(); $v = $Matches[2].Trim().Trim('"')
$attrs[$k] = $v
}
}
$tools[$tid] = $attrs
}
$tool = $tools[$ToolId]
if (!$tool) {
$line = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"L5-tool-registry`",`"tool_id`":`"$ToolId`",`"decision`":`"denied`",`"reason`":`"unknown_tool`"}"
Add-Content -Path $txLog -Value $line -Encoding UTF8
Write-Error "TOOL_DENIED unknown_tool=$ToolId"
exit 2
}
$sideEffect = $tool["side_effect"] -eq "true"
$idemRequired = $tool["idempotency_required"] -eq "true"
$riskLevel = $tool["risk_level"]
$owner = $tool["owner"]
$decision = "approved"
$reason = "registered"
if ($sideEffect -and $idemRequired -and [string]::IsNullOrEmpty($IdempotencyKey)) {
$decision = "denied"
$reason = "missing_idempotency_key"
}
$record = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"harness`":`"L5-tool-registry`",`"tool_id`":`"$ToolId`",`"owner`":`"$owner`",`"risk_level`":`"$riskLevel`",`"side_effect`":$($sideEffect.ToString().ToLower()),`"idempotency_required`":$($idemRequired.ToString().ToLower()),`"idempotency_key_present`":$(-not [string]::IsNullOrEmpty($IdempotencyKey) | ForEach-Object { $_.ToString().ToLower() }),`"decision`":`"$decision`",`"reason`":`"$reason`"}"
Add-Content -Path $txLog -Value $record -Encoding UTF8
# Per-call audit in tool-calls.jsonl (H2 audit requirement)
$auditLine = "{`"timestamp`":`"$timestamp`",`"trace_id`":`"$traceId`",`"tool`":`"$ToolId`",`"idempotency_key`":`"$IdempotencyKey`",`"decision`":`"$decision`",`"reason`":`"$reason`",`"risk_level`":`"$riskLevel`",`"owner`":`"$owner`"}"
Add-Content -Path $toolCallLog -Value $auditLine -Encoding UTF8
Write-Output "TOOL_$($decision.ToUpper()) tool=$ToolId reason=$reason"
if ($decision -ne "approved") {
Write-Error "TOOL_REGISTRY_DENIED: '$ToolId' — $reason. Set CASAN_IDEMPOTENCY_KEY env var."
exit 2
}
exit 0
@@ -0,0 +1,463 @@
#!/usr/bin/env pwsh
<#!
.SYNOPSIS
Update agent context files with information from plan.md (PowerShell version)
.DESCRIPTION
Mirrors the behavior of scripts/bash/update-agent-context.sh:
1. Environment Validation
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, kiro-cli, agy, bob, qodercli)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
.EXAMPLE
./update-agent-context.ps1 -AgentType claude
.EXAMPLE
./update-agent-context.ps1 # Updates all existing agent files
.NOTES
Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','kiro-cli','agy','bob','qodercli','generic')]
[string]$AgentType
)
$ErrorActionPreference = 'Stop'
# Import common helpers
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $ScriptDir 'common.ps1')
# Acquire environment paths
$envData = Get-FeaturePathsEnv
$REPO_ROOT = $envData.REPO_ROOT
$CURRENT_BRANCH = $envData.CURRENT_BRANCH
$HAS_GIT = $envData.HAS_GIT
$IMPL_PLAN = $envData.IMPL_PLAN
$NEW_PLAN = $IMPL_PLAN
# Agent file paths
$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md'
$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md'
$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md'
$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md'
$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
# Parsed plan data placeholders
$script:NEW_LANG = ''
$script:NEW_FRAMEWORK = ''
$script:NEW_DB = ''
$script:NEW_PROJECT_TYPE = ''
function Write-Info {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "INFO: $Message"
}
function Write-Success {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "$([char]0x2713) $Message"
}
function Write-WarningMsg {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Warning $Message
}
function Write-Err {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "ERROR: $Message" -ForegroundColor Red
}
function Validate-Environment {
if (-not $CURRENT_BRANCH) {
Write-Err 'Unable to determine current feature'
if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' }
exit 1
}
if (-not (Test-Path $NEW_PLAN)) {
Write-Err "No plan.md found at $NEW_PLAN"
Write-Info 'Ensure you are working on a feature with a corresponding spec directory'
if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' }
exit 1
}
if (-not (Test-Path $TEMPLATE_FILE)) {
Write-Err "Template file not found at $TEMPLATE_FILE"
Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.'
exit 1
}
}
function Extract-PlanField {
param(
[Parameter(Mandatory=$true)]
[string]$FieldPattern,
[Parameter(Mandatory=$true)]
[string]$PlanFile
)
if (-not (Test-Path $PlanFile)) { return '' }
# Lines like **Language/Version**: Python 3.12
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
if ($_ -match $regex) {
$val = $Matches[1].Trim()
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
}
} | Select-Object -First 1
}
function Parse-PlanData {
param(
[Parameter(Mandatory=$true)]
[string]$PlanFile
)
if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false }
Write-Info "Parsing plan data from $PlanFile"
$script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile
$script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile
$script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile
$script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile
if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' }
if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" }
if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" }
return $true
}
function Format-TechnologyStack {
param(
[Parameter(Mandatory=$false)]
[string]$Lang,
[Parameter(Mandatory=$false)]
[string]$Framework
)
$parts = @()
if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang }
if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework }
if (-not $parts) { return '' }
return ($parts -join ' + ')
}
function Get-ProjectStructure {
param(
[Parameter(Mandatory=$false)]
[string]$ProjectType
)
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
}
function Get-CommandsForLanguage {
param(
[Parameter(Mandatory=$false)]
[string]$Lang
)
switch -Regex ($Lang) {
'Python' { return "cd src; pytest; ruff check ." }
'Rust' { return "cargo test; cargo clippy" }
'JavaScript|TypeScript' { return "npm test; npm run lint" }
default { return "# Add commands for $Lang" }
}
}
function Get-LanguageConventions {
param(
[Parameter(Mandatory=$false)]
[string]$Lang
)
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
}
function New-AgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[string]$ProjectName,
[Parameter(Mandatory=$true)]
[datetime]$Date
)
if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false }
$temp = New-TemporaryFile
Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force
$projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE
$commands = Get-CommandsForLanguage -Lang $NEW_LANG
$languageConventions = Get-LanguageConventions -Lang $NEW_LANG
$escaped_lang = $NEW_LANG
$escaped_framework = $NEW_FRAMEWORK
$escaped_branch = $CURRENT_BRANCH
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
# Build the technology stack string safely
$techStackForTemplate = ""
if ($escaped_lang -and $escaped_framework) {
$techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)"
} elseif ($escaped_lang) {
$techStackForTemplate = "- $escaped_lang ($escaped_branch)"
} elseif ($escaped_framework) {
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
}
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
# For project structure we manually embed (keep newlines)
$escapedStructure = [Regex]::Escape($projectStructure)
$content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure
# Replace escaped newlines placeholder after all replacements
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
# Build the recent changes string safely
$recentChangesForTemplate = ""
if ($escaped_lang -and $escaped_framework) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}"
} elseif ($escaped_lang) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}"
} elseif ($escaped_framework) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
}
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
# Convert literal \n sequences introduced by Escape to real newlines
$content = $content -replace '\\n',[Environment]::NewLine
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
if ($TargetFile -match '\.mdc$') {
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine
$content = $frontmatter + $content
}
$parent = Split-Path -Parent $TargetFile
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
Remove-Item $temp -Force
return $true
}
function Update-ExistingAgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[datetime]$Date
)
if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) }
$techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK
$newTechEntries = @()
if ($techStack) {
$escapedTechStack = [Regex]::Escape($techStack)
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
}
}
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
$escapedDB = [Regex]::Escape($NEW_DB)
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
}
}
$newChangeEntry = ''
if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" }
elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" }
$lines = Get-Content -LiteralPath $TargetFile -Encoding utf8
$output = New-Object System.Collections.Generic.List[string]
$inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0
for ($i=0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -eq '## Active Technologies') {
$output.Add($line)
$inTech = $true
continue
}
if ($inTech -and $line -match '^##\s') {
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
$output.Add($line); $inTech = $false; continue
}
if ($inTech -and [string]::IsNullOrWhiteSpace($line)) {
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
$output.Add($line); continue
}
if ($line -eq '## Recent Changes') {
$output.Add($line)
if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true }
$inChanges = $true
continue
}
if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue }
if ($inChanges -and $line -match '^- ') {
if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ }
continue
}
if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') {
$output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd')))
continue
}
$output.Add($line)
}
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) {
$newTechEntries | ForEach-Object { $output.Add($_) }
}
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') {
$frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','')
$output.InsertRange(0, $frontmatter)
}
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8
return $true
}
function Update-AgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[string]$AgentName
)
if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false }
Write-Info "Updating $AgentName context file: $TargetFile"
$projectName = Split-Path $REPO_ROOT -Leaf
$date = Get-Date
$dir = Split-Path -Parent $TargetFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
if (-not (Test-Path $TargetFile)) {
if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false }
} else {
try {
if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false }
} catch {
Write-Err "Cannot access or update existing file: $TargetFile. $_"
return $false
}
}
return $true
}
function Update-SpecificAgent {
param(
[Parameter(Mandatory=$true)]
[string]$Type
)
switch ($Type) {
'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' }
'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' }
'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' }
'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' }
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' }
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic'; return $false }
}
}
function Update-AllExistingAgents {
$found = $false
$ok = $true
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true }
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...'
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
}
return $ok
}
function Print-Summary {
Write-Host ''
Write-Info 'Summary of changes:'
if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" }
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic]'
}
function Main {
Validate-Environment
Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 }
$success = $true
if ($AgentType) {
Write-Info "Updating specific agent: $AgentType"
if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false }
}
else {
Write-Info 'No agent specified, updating all existing agent files...'
if (-not (Update-AllExistingAgents)) { $success = $false }
}
Print-Summary
if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 }
}
Main
@@ -0,0 +1,43 @@
#!/usr/bin/env pwsh
# CASAN H5 Risk Registry Auto-Updater
# Usage:
# update-risk-registry.ps1 -ActionName <name> [-DefaultRisk <low|medium|high>]
#
# If action not in registry, adds it with DefaultRisk (default: high = fail-secure).
param(
[Parameter(Mandatory=$true)][string]$ActionName,
[ValidateSet("low","medium","high")][string]$DefaultRisk = "high"
)
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$registryPath = Join-Path $projectRoot ".specify/governance/risk-registry.yaml"
if (!(Test-Path $registryPath)) {
Write-Warning "Risk registry not found at $registryPath — skipping auto-update"
exit 0
}
$content = Get-Content $registryPath -Raw
if ($content -match "(?m)^\s+-\s+id:\s+$([regex]::Escape($ActionName))") {
Write-Output "RISK_REGISTRY_EXISTS: $ActionName already registered"
exit 0
}
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$newEntry = @"
- id: $ActionName
risk_level: $DefaultRisk
added_by: auto_update
added_at: $timestamp
note: "Auto-added (unknown action — defaulting to $DefaultRisk per fail-secure policy)"
"@
# Append before the last line (end of YAML file)
$lines = (Get-Content $registryPath) + $newEntry.Split("`n")
Set-Content -Path $registryPath -Value $lines -Encoding UTF8
Write-Warning "[RISK_REGISTRY] Auto-added unknown action '$ActionName' with risk_level=$DefaultRisk (fail-secure)"
Write-Output "RISK_REGISTRY_UPDATED: $ActionName risk=$DefaultRisk"