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:
co-authored by
Claude Opus 4.8
parent
7101af9fd4
commit
36a4812ef3
@@ -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> | 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 | Auto-generated — do not edit manually | $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"
|
||||
Reference in New Issue
Block a user