feat: harden OpenSpec agent resilience
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
schema: wealth-ledger-sdd
|
||||
goal: Validate every supported Delta Spec operation without an empty section.
|
||||
affected_areas:
|
||||
- specs
|
||||
@@ -0,0 +1,19 @@
|
||||
## Why
|
||||
|
||||
Controlled fixture validates operation-neutral Delta Spec behavior.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Exercise every supported delta operation in one controlled artifact.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `workspace-artifact-hygiene`: controlled operation grammar fixture.
|
||||
|
||||
## Impact
|
||||
|
||||
Test fixture only; application behavior is not delivered.
|
||||
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Controlled fixture requirement
|
||||
|
||||
The fixture SHALL provide one complete added requirement.
|
||||
|
||||
#### Scenario: Added operation is complete
|
||||
|
||||
- **WHEN** the controlled delta is validated
|
||||
- **THEN** the added requirement has observable behavior
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Рабочая frontend configuration не является versioned input
|
||||
|
||||
Frontend SHALL поставлять versioned `.env.example`, достаточный для создания
|
||||
локального `.env`, но MUST NOT хранить рабочий `.env` или другой локальный
|
||||
`.env*` файл в source control. Ignore policy MUST исключать локальные frontend
|
||||
configuration files, сохраняя явно разрешённый example template.
|
||||
|
||||
#### Scenario: Участник создаёт local frontend configuration
|
||||
|
||||
- **WHEN** участник копирует `.env.example` в `.env` для запуска frontend
|
||||
- **THEN** локальный файл доступен runtime, но не появляется среди tracked
|
||||
файлов Git
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Runtime SQLite-данные не являются versioned artifact
|
||||
|
||||
**Reason**: Controlled fixture exercises the removal grammar.
|
||||
|
||||
**Migration**: Existing behavior remains authoritative outside this fixture.
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Dependency advisory получает проверяемое решение`
|
||||
- TO: `### Requirement: Dependency advisory получает контролируемое решение`
|
||||
@@ -58,7 +58,7 @@ require_file_contains "$config_file" 'schema: wealth-ledger-sdd'
|
||||
|
||||
schema_file="$canonical_root/schemas/wealth-ledger-sdd/schema.yaml"
|
||||
require_file_contains "$schema_file" 'name: wealth-ledger-sdd'
|
||||
require_file_contains "$schema_file" 'version: 2'
|
||||
require_file_contains "$schema_file" 'version: 3'
|
||||
require_schema_edge "$schema_file" 'specs' 'proposal'
|
||||
require_schema_edge "$schema_file" 'design' 'specs'
|
||||
require_schema_edge "$schema_file" 'tasks' 'design'
|
||||
|
||||
@@ -116,7 +116,7 @@ if run_verifier; then
|
||||
fi
|
||||
write_file "specs/openspec/config.yaml" 'schema: wealth-ledger-sdd'
|
||||
|
||||
perl -0pi -e 's/^version: 2$/version: 1/m' "$fixture/specs/openspec/schemas/wealth-ledger-sdd/schema.yaml"
|
||||
perl -0pi -e 's/^version: 3$/version: 2/m' "$fixture/specs/openspec/schemas/wealth-ledger-sdd/schema.yaml"
|
||||
if run_verifier; then
|
||||
echo "expected obsolete project schema version to fail" >&2
|
||||
exit 1
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
printf 'Usage: %s <change-name>\n' "${0##*/}" >&2
|
||||
invocation_fail() {
|
||||
printf 'Planning invocation failed: %s\n' "$1" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
environment_fail() {
|
||||
printf 'Planning environment failed: %s\n' "$1" >&2
|
||||
exit 3
|
||||
}
|
||||
|
||||
artifact_fail() {
|
||||
printf 'Planning artifact-validation failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
invocation_fail "usage: ${0##*/} <change-name>"
|
||||
fi
|
||||
|
||||
change=$1
|
||||
if [[ ! "$change" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
printf 'Invalid OpenSpec change name: %s\n' "$change" >&2
|
||||
exit 2
|
||||
invocation_fail "invalid OpenSpec change name: $change"
|
||||
fi
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
@@ -26,8 +39,7 @@ tasks_file="$change_dir/tasks.md"
|
||||
source "$script_dir/lib/delivery-map.sh"
|
||||
|
||||
fail() {
|
||||
printf 'Planning verification failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
artifact_fail "$1"
|
||||
}
|
||||
|
||||
require_file() {
|
||||
@@ -38,7 +50,10 @@ require_heading() {
|
||||
grep -Fqx -- "$2" "$1" || fail "${1#$workspace/} must contain heading: $2"
|
||||
}
|
||||
|
||||
bash "$script_dir/verify-openspec-version.sh" "$openspec_root/.cli-version" >/dev/null
|
||||
if ! version_output=$(bash "$script_dir/verify-openspec-version.sh" "$openspec_root/.cli-version" 2>&1); then
|
||||
environment_fail "$version_output"
|
||||
fi
|
||||
[[ -d "$change_dir" ]] || invocation_fail "change $change is absent from canonical store wealth-ledger-specs"
|
||||
require_file "$metadata_file"
|
||||
require_file "$proposal_file"
|
||||
|
||||
@@ -49,15 +64,17 @@ if ! grep -Eq '^goal: .+' "$metadata_file"; then
|
||||
fail '.openspec.yaml must contain a non-empty goal'
|
||||
fi
|
||||
|
||||
if ! (cd "$specs_root" && openspec status --change "$change" --json --store wealth-ledger-specs >/dev/null); then
|
||||
fail "change $change is not resolvable through canonical store wealth-ledger-specs"
|
||||
if ! status_output=$(cd "$specs_root" && openspec status --change "$change" --json --store wealth-ledger-specs 2>&1); then
|
||||
invocation_fail "change $change is not resolvable through canonical store wealth-ledger-specs: $status_output"
|
||||
fi
|
||||
|
||||
for heading in '## Why' '## What Changes' '## Capabilities' '### New Capabilities' '### Modified Capabilities' '## Impact'; do
|
||||
require_heading "$proposal_file" "$heading"
|
||||
done
|
||||
|
||||
temporary=$(mktemp -d)
|
||||
if ! temporary=$(mktemp -d); then
|
||||
environment_fail 'cannot create temporary planning-verification directory'
|
||||
fi
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
declared_capabilities="$temporary/declared-capabilities.tsv"
|
||||
actual_capabilities="$temporary/actual-capabilities.txt"
|
||||
@@ -133,7 +150,10 @@ if [[ -f "$design_file" ]]; then
|
||||
for heading in '## Context' '## Goals / Non-Goals' '## Decisions' '## Delivery Map' '### Contract Gate Decision' '## Risks / Trade-offs' '## Migration Plan' '### Rollback Contract'; do
|
||||
require_heading "$design_file" "$heading"
|
||||
done
|
||||
wl_parse_delivery_map "$metadata_file" "$design_file" || exit 1
|
||||
delivery_map_error="$temporary/delivery-map-error.txt"
|
||||
if ! wl_parse_delivery_map "$metadata_file" "$design_file" 2>"$delivery_map_error"; then
|
||||
artifact_fail "$(<"$delivery_map_error")"
|
||||
fi
|
||||
|
||||
rollback_status_count=$(awk -F'|' '$2 ~ /^[[:space:]]*Status[[:space:]]*$/ { count++ } END { print count + 0 }' "$design_file")
|
||||
[[ "$rollback_status_count" -eq 1 ]] || fail 'Rollback Contract must contain exactly one Status row'
|
||||
@@ -203,6 +223,7 @@ if [[ -f "$tasks_file" ]]; then
|
||||
if [[ "$payload" == \`N/A\`\ —\ * ]]; then
|
||||
reason=${payload#\`N/A\`\ —\ }
|
||||
[[ -n "$reason" && "$reason" != '-' && "$reason" != '—' ]] || fail "task $task_number has empty N/A rationale"
|
||||
[[ "$reason" != *'<!--'* && "$reason" != *'-->'* ]] || fail "task $task_number has unresolved N/A rationale placeholder"
|
||||
covers_is_na=1
|
||||
else
|
||||
IFS=';' read -r -a references <<< "$payload"
|
||||
|
||||
@@ -40,12 +40,26 @@ run_verifier() {
|
||||
expect_failure() {
|
||||
local change=$1
|
||||
local expected=$2
|
||||
local expected_status=${3:-1}
|
||||
local output
|
||||
local status
|
||||
|
||||
if output=$(run_verifier "$change" 2>&1); then
|
||||
set +e
|
||||
output=$(run_verifier "$change" 2>&1)
|
||||
status=$?
|
||||
set -e
|
||||
if [[ $status -eq 0 ]]; then
|
||||
printf 'expected planning verification to fail for %s\n' "$change" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $status -ne $expected_status ]]; then
|
||||
printf 'expected exit %s for %s, got %s:\n%s\n' "$expected_status" "$change" "$status" "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *'Planning artifact-validation failed:'* ]]; then
|
||||
printf 'expected artifact-validation category for %s, got:\n%s\n' "$change" "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *"$expected"* ]]; then
|
||||
printf 'expected failure containing %q, got:\n%s\n' "$expected" "$output" >&2
|
||||
exit 1
|
||||
@@ -60,6 +74,33 @@ proposal_output=$(run_verifier "$proposal_change")
|
||||
[[ "$proposal_output" == *'Planning stage verified: proposal'* ]]
|
||||
[[ "$proposal_output" == *'Full change validation pending: specs, design, tasks'* ]]
|
||||
|
||||
set +e
|
||||
invocation_output=$(bash "$verifier" 'INVALID CHANGE' 2>&1)
|
||||
invocation_status=$?
|
||||
set -e
|
||||
if [[ $invocation_status -ne 2 || "$invocation_output" != *'Planning invocation failed:'* ]]; then
|
||||
printf 'expected invalid-name invocation failure, got exit %s:\n%s\n' "$invocation_status" "$invocation_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
missing_change_output=$(bash "$verifier" "$prefix-missing" 2>&1)
|
||||
missing_change_status=$?
|
||||
set -e
|
||||
if [[ $missing_change_status -ne 2 || "$missing_change_output" != *'Planning invocation failed:'* ]]; then
|
||||
printf 'expected missing-change invocation failure, got exit %s:\n%s\n' "$missing_change_status" "$missing_change_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
environment_output=$(PATH=/usr/bin:/bin bash "$verifier" "$proposal_change" 2>&1)
|
||||
environment_status=$?
|
||||
set -e
|
||||
if [[ $environment_status -ne 3 || "$environment_output" != *'Planning environment failed:'* ]]; then
|
||||
printf 'expected environment failure, got exit %s:\n%s\n' "$environment_status" "$environment_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make_change skip-conflict
|
||||
skip_conflict=$fixture_change
|
||||
rm -rf "$changes_root/$skip_conflict/specs"
|
||||
@@ -103,6 +144,56 @@ tasks_output=$(run_verifier "$tasks_change")
|
||||
[[ "$tasks_output" == *'Delta requirement coverage verified: 9/9'* ]]
|
||||
[[ "$tasks_output" == *'Full planning structure verified; semantic approval remains human.'* ]]
|
||||
|
||||
for artifact in proposal specs design tasks; do
|
||||
instructions=$(cd "$workspace/specs" && openspec instructions "$artifact" --change "$tasks_change" --store wealth-ledger-specs --json)
|
||||
node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
if (!result.instruction.trimStart().startsWith("Critical Rules:")) process.exit(1);
|
||||
' <<< "$instructions"
|
||||
done
|
||||
|
||||
specs_instructions=$(cd "$workspace/specs" && openspec instructions specs --change "$tasks_change" --store wealth-ledger-specs --json)
|
||||
node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
const activeOperation = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/m;
|
||||
if (activeOperation.test(result.template)) process.exit(1);
|
||||
for (const token of ["Purpose", "ADDED", "MODIFIED", "REMOVED", "RENAMED"]) {
|
||||
if (!result.template.includes(token)) process.exit(1);
|
||||
}
|
||||
' <<< "$specs_instructions"
|
||||
|
||||
operation_change="$prefix-operation-neutral"
|
||||
cp -R "$script_dir/fixtures/operation-neutral-change" "$changes_root/$operation_change"
|
||||
created_changes+=("$operation_change")
|
||||
operation_output=$(run_verifier "$operation_change")
|
||||
if [[ "$operation_output" != *'Planning stage verified: specs'* ]]; then
|
||||
printf 'expected controlled operation fixture to verify, got:\n%s\n' "$operation_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
for operation in ADDED MODIFIED REMOVED RENAMED; do
|
||||
if [[ $(grep -Fxc "## $operation Requirements" "$changes_root/$operation_change/specs/workspace-artifact-hygiene/spec.md") -ne 1 ]]; then
|
||||
printf 'expected exactly one controlled %s operation section\n' "$operation" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# A completed fixture receives OpenSpec's terminal archive message instead of
|
||||
# the schema's Apply instruction, so leave one task pending for this contract.
|
||||
perl -0pi -e 's/^- \[[xX]\]/- [ ]/m' "$changes_root/$tasks_change/tasks.md"
|
||||
apply_instructions=$(cd "$workspace/specs" && openspec instructions apply --change "$tasks_change" --store wealth-ledger-specs --json)
|
||||
node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
for (const token of ["contextFiles", "AGENTS.md", "planned Verification", "Feedback routing", "Механическая правка", "Requirement wording"]) {
|
||||
if (!result.instruction.includes(token)) process.exit(1);
|
||||
}
|
||||
' <<< "$apply_instructions"
|
||||
|
||||
design_instructions=$(cd "$workspace/specs" && openspec instructions design --change "$tasks_change" --store wealth-ledger-specs --json)
|
||||
node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
if (!result.instruction.includes("инвалидируй downstream approvals")) process.exit(1);
|
||||
' <<< "$design_instructions"
|
||||
|
||||
make_change invalid-area
|
||||
invalid_area=$fixture_change
|
||||
perl -0pi -e 's/\[specs\]/[frontend]/' "$changes_root/$invalid_area/tasks.md"
|
||||
@@ -123,6 +214,21 @@ missing_metadata=$fixture_change
|
||||
perl -0pi -e 's/^ - \*\*Verification:\*\*.*\n//m' "$changes_root/$missing_metadata/tasks.md"
|
||||
expect_failure "$missing_metadata" 'task 1.1 must contain exactly one Verification line'
|
||||
|
||||
make_change unresolved-na-placeholder
|
||||
unresolved_na_placeholder=$fixture_change
|
||||
perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — <!-- unresolved reason -->/m' "$changes_root/$unresolved_na_placeholder/tasks.md"
|
||||
expect_failure "$unresolved_na_placeholder" 'task 1.1 has unresolved N/A rationale placeholder'
|
||||
|
||||
make_change empty-na
|
||||
empty_na=$fixture_change
|
||||
perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — /m' "$changes_root/$empty_na/tasks.md"
|
||||
expect_failure "$empty_na" 'task 1.1 has empty N/A rationale'
|
||||
|
||||
make_change separator-na
|
||||
separator_na=$fixture_change
|
||||
perl -0pi -e 's/^ - \*\*Covers:\*\*.*$/ - **Covers:** `N\/A` — —/m' "$changes_root/$separator_na/tasks.md"
|
||||
expect_failure "$separator_na" 'task 1.1 has empty N/A rationale'
|
||||
|
||||
make_change malformed-rollback
|
||||
malformed_rollback=$fixture_change
|
||||
sed -i '' '/| Status | `N\/A` |/d' "$changes_root/$malformed_rollback/design.md"
|
||||
|
||||
Reference in New Issue
Block a user