feat: migrate missing harnesses, templates, and workflows from agent-runtimes
Brings the framework CRS repo up to date with all content that was living in agent-runtimes (local-dev fallback) but hadn't been promoted. New composites: feature-delivery-loop, integration-direct, scaffolding-repo, sonnet-impl-narrow, sonnet-manager, test-writing-repo New contexts: integration/v1, scaffolding/v1, sonnet-manager/v1, z-ai/v1, airouter/v1/bin (anthropic-compat-wrapper.sh), cp-harness/v1/init.sh New task-templates: sonnet-integrator.yaml, workflow/* (17 typed workflow task templates for the Epic 1 pipeline) Updated: agent-repo/v1/finalize.sh — adds AR-38/F97 empty-deliverable audit (SKIP_BRANCH_PUSH support, boilerplate-path filtering, ci_metadata.json flag) Also adds MEMORY.md index and memory/ topic files for the framework repo.
This commit is contained in:
13
MEMORY.md
Normal file
13
MEMORY.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Memory Index — agent-runtime-framework
|
||||
|
||||
Thin index. Read topic files for detail.
|
||||
|
||||
## Gotchas
|
||||
- [memory/gotchas-gitea.md](memory/gotchas-gitea.md) — Gitea API auth quirks: no `/login`, Basic Auth not Bearer, merged-as-closed, fork Actions independence
|
||||
- [memory/gotchas-tokens.md](memory/gotchas-tokens.md) — `~/.config/agent-runtimes/tokens.json` flat structure
|
||||
|
||||
## Decisions
|
||||
- [memory/decisions.md](memory/decisions.md) — Fork cleanup workflow, shallow clones, pre-test hook, workflow output validation, push retry
|
||||
|
||||
## Process
|
||||
- [memory/process-lessons.md](memory/process-lessons.md) — Verifying PR state, fork workflow placement, fetch-depth for date inspection, wrong-path smoke testing
|
||||
10
harnesses/composites/feature-delivery-loop/v1/harness.yaml
Normal file
10
harnesses/composites/feature-delivery-loop/v1/harness.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
kind: composite
|
||||
name: feature-delivery-loop
|
||||
version: 1
|
||||
description: "Orchestrator harness for feature-delivery-loop@1: drives the eligibility-pick-dispatch-wait loop on a planning item; reads ACL inbox + planning state; calls CP API"
|
||||
|
||||
layers:
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: gitea-ssh/v1
|
||||
- context: agent-repo/v1
|
||||
- context: planning/v1
|
||||
10
harnesses/composites/integration-direct/v1/harness.yaml
Normal file
10
harnesses/composites/integration-direct/v1/harness.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
kind: composite
|
||||
name: integration-direct
|
||||
version: 1
|
||||
description: "Integration harness — cherry-picks from agent fork and pushes directly to main after test verification"
|
||||
|
||||
layers:
|
||||
- context: integration/v1
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: gitea-ssh/v1
|
||||
- context: direct-push/v1
|
||||
10
harnesses/composites/scaffolding-repo/v1/harness.yaml
Normal file
10
harnesses/composites/scaffolding-repo/v1/harness.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
kind: composite
|
||||
name: scaffolding-repo
|
||||
version: 1
|
||||
description: "Model-agnostic scaffolding harness — writes stubs for coding agents to implement against"
|
||||
|
||||
layers:
|
||||
- context: scaffolding/v1
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: gitea-ssh/v1
|
||||
- context: agent-repo/v1
|
||||
11
harnesses/composites/sonnet-impl-narrow/v1/harness.yaml
Normal file
11
harnesses/composites/sonnet-impl-narrow/v1/harness.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
kind: composite
|
||||
name: sonnet-impl-narrow
|
||||
version: 1
|
||||
description: "Sonnet impl agent for narrow single-file workflows: code methodology + Anthropic cloud (OAuth) + repo clone + TDD-protect (tests read-only)"
|
||||
|
||||
layers:
|
||||
- context: code-methodology/v1
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: gitea-ssh/v1
|
||||
- context: agent-repo/v1
|
||||
- context: tdd-protect/v1
|
||||
12
harnesses/composites/sonnet-manager/v1/harness.yaml
Normal file
12
harnesses/composites/sonnet-manager/v1/harness.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
kind: composite
|
||||
name: sonnet-manager
|
||||
version: 1
|
||||
description: "Sonnet-driven per-project manager: ACL-typed-capable + planning + cp-cli + agent-repo"
|
||||
|
||||
layers:
|
||||
- context: cp-harness/v1
|
||||
- context: agent-communication/v1
|
||||
- context: agent-repo/v1
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: planning/v1
|
||||
- context: sonnet-manager/v1
|
||||
10
harnesses/composites/test-writing-repo/v1/harness.yaml
Normal file
10
harnesses/composites/test-writing-repo/v1/harness.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
kind: composite
|
||||
name: test-writing-repo
|
||||
version: 1
|
||||
description: "Model-agnostic test writing harness — credential layer swappable as model scores evolve"
|
||||
|
||||
layers:
|
||||
- context: test-writing/v1
|
||||
- context: anthropic-cloud-paul-oauth/v1
|
||||
- context: gitea-ssh/v1
|
||||
- context: agent-repo/v1
|
||||
@@ -1,8 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Agent repo finalize script — auto-commit and push changes
|
||||
# AR-19, AR-20, AR-8, AR-32, F66, BUG-5, BUG-20
|
||||
# AR-19, AR-20, AR-8, AR-32, AR-38, F66, F97, BUG-5, BUG-20
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AR-38 / F97: Post-commit empty-deliverable audit configuration
|
||||
# Paths matching this regex are treated as boilerplate / metadata only —
|
||||
# a commit containing ONLY paths matching this pattern is rejected.
|
||||
# Overridable so harnesses with a different notion of "trivial" can adjust
|
||||
# without forking this script.
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_BOILERPLATE_PATHS_REGEX='^(\.gitignore|AGENTS\.md|ci_metadata\.json|memory/log/.*|\.agent-output/.*)$'
|
||||
BOILERPLATE_PATHS_REGEX="${AGENT_BOILERPLATE_PATHS_REGEX:-$DEFAULT_BOILERPLATE_PATHS_REGEX}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F66: Content-based secret scanning
|
||||
# Scans staged diff for credential patterns before committing.
|
||||
@@ -59,6 +69,9 @@ BRANCH="${AGENT_BRANCH:-}"
|
||||
REPO_URL="${AGENT_REPO_URL:-}"
|
||||
# BUG-20: Push retry
|
||||
PUSH_RETRIES="${AGENT_PUSH_RETRIES:-1}"
|
||||
# When true, skip the branch push entirely (e.g. direct-push integrators that
|
||||
# push to origin main themselves rather than using branch persistence).
|
||||
SKIP_BRANCH_PUSH="${AGENT_SKIP_BRANCH_PUSH:-false}"
|
||||
|
||||
mkdir -p "$AGENT_OUTPUT_DIR"
|
||||
|
||||
@@ -146,6 +159,16 @@ fi
|
||||
# Check again after potential moves
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No changes to commit — skipping push (AR-8)"
|
||||
# AR-38 / F97: AR-8 no-commit path is semantically equivalent to
|
||||
# "empty deliverable" — the agent produced no work product at all.
|
||||
# When the audit is enabled, surface this as empty_deliverable=true so
|
||||
# operators triaging via ci_metadata.json can distinguish "agent did
|
||||
# nothing" from "audit disabled by operator config" (the only path
|
||||
# that produces an absent key).
|
||||
AR8_EMPTY_DELIVERABLE_ENABLED="true"
|
||||
if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then
|
||||
AR8_EMPTY_DELIVERABLE_ENABLED="false"
|
||||
fi
|
||||
python3 -c "
|
||||
import json, os
|
||||
out = '$METADATA_FILE'
|
||||
@@ -159,6 +182,8 @@ if os.path.isfile(out):
|
||||
meta['agent_branch'] = '$BRANCH'
|
||||
meta['agent_repo_url'] = '$REPO_URL'
|
||||
meta['agent_branch_pushed'] = False
|
||||
if '$AR8_EMPTY_DELIVERABLE_ENABLED' == 'true':
|
||||
meta['empty_deliverable'] = True
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json (no-op: no changes)')
|
||||
@@ -238,6 +263,44 @@ git commit -F "$COMMIT_MSG_FILE"
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
echo "Committed as: $COMMIT_SHA"
|
||||
|
||||
# AR-38 / F97: Post-commit empty-deliverable audit ------------------------
|
||||
# Inspect the committed diff. If every changed path is boilerplate
|
||||
# (.gitignore, AGENTS.md, ci_metadata.json, memory/log/*, .agent-output/*),
|
||||
# flag the commit as empty_deliverable so the operator can distinguish a
|
||||
# real successful run from a no-op that happened to advance HEAD.
|
||||
#
|
||||
# Sentinel values for EMPTY_DELIVERABLE:
|
||||
# "true" — audit ran, every committed path was boilerplate
|
||||
# "false" — audit ran, at least one path was substantive
|
||||
# "skipped" — audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false
|
||||
#
|
||||
# Wrapped in `set +eo pipefail` because a piped grep that finds nothing
|
||||
# returns 1, which would otherwise abort the script (see gotchas-agent-repo
|
||||
# "AR-21 set-e/pipefail aborts finalize on diff-verify pipeline").
|
||||
EMPTY_DELIVERABLE="false"
|
||||
if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then
|
||||
echo "AR-38: empty-deliverable audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false"
|
||||
EMPTY_DELIVERABLE="skipped"
|
||||
else
|
||||
set +eo pipefail
|
||||
COMMITTED_PATHS=$(git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA")
|
||||
SUBSTANTIVE_PATHS=$(printf '%s\n' "$COMMITTED_PATHS" \
|
||||
| grep -v '^[[:space:]]*$' \
|
||||
| grep -vxE "$BOILERPLATE_PATHS_REGEX")
|
||||
set -eo pipefail
|
||||
if [ -z "$SUBSTANTIVE_PATHS" ]; then
|
||||
echo "ERROR: AR-38 empty-deliverable audit FAILED for commit $COMMIT_SHA" >&2
|
||||
echo "ERROR: every committed path matched the boilerplate regex:" >&2
|
||||
echo "ERROR: regex: $BOILERPLATE_PATHS_REGEX" >&2
|
||||
echo "ERROR: committed paths:" >&2
|
||||
printf '%s\n' "$COMMITTED_PATHS" | sed 's/^/ERROR: /' >&2
|
||||
EMPTY_DELIVERABLE="true"
|
||||
else
|
||||
echo "AR-38: audit passed — substantive path(s) found in commit:"
|
||||
printf '%s\n' "$SUBSTANTIVE_PATHS" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
|
||||
# BUG-20: Output validation — check expected output file exists and is non-empty
|
||||
# AGENT_EXPECTED_OUTPUT: absolute path to required output file (e.g., /workspace/project/spec/f94-auth.md)
|
||||
# AGENT_MIN_OUTPUT_BYTES: minimum size in bytes (default 0 = any non-empty)
|
||||
@@ -260,79 +323,31 @@ if [ -n "${AGENT_EXPECTED_OUTPUT:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# AR-21 (2026-05-08): Diff-against-upstream verification.
|
||||
# AGENT_EXPECTED_CHANGED_FILES: comma-separated list of paths that MUST appear
|
||||
# in the working-tree diff vs upstream main. Catches the failure mode where
|
||||
# an agent reports "succeeded" but produced no actual change to the target
|
||||
# file (real incident: 2026-05-08 dogfood batch, gotchas item 30 — three
|
||||
# "succeeded" tasks merged nothing actionable).
|
||||
# AGENT_FORBIDDEN_CHANGED_FILES: comma-separated list of paths that MUST NOT
|
||||
# appear in the diff. Catches the inverse: an agent silently destroying or
|
||||
# refactoring files outside the task scope (real incident: 2026-05-08 task
|
||||
# 4a2f2988 — agent stripped 9 unrelated functions; gotchas item 17/21).
|
||||
#
|
||||
# Whole block runs with set +e (and pipefail off) to ensure no diagnostic
|
||||
# pipeline failure aborts finalize before metadata can be written. We
|
||||
# explicitly check exit codes where they matter.
|
||||
DIFF_VERIFIED=true
|
||||
DIFF_MISMATCH=""
|
||||
DIFF_SUMMARY=""
|
||||
set +eo pipefail
|
||||
if [ -d /workspace/reference/main/.git ]; then
|
||||
REF_HEAD=$(git -C /workspace/reference/main rev-parse HEAD 2>/dev/null)
|
||||
if [ -n "$REF_HEAD" ]; then
|
||||
# Files modified/added/deleted by the agent vs the upstream HEAD seed.
|
||||
# Use git diff (working-tree style) plus committed changes — the
|
||||
# agent commits via finalize.sh later, so the diff against REF_HEAD
|
||||
# reflects total scope. `|| true` belt-and-braces against unreachable
|
||||
# SHAs (e.g., if init.sh fell back to fork main without seeding from
|
||||
# upstream).
|
||||
DIFF_RAW=$(git diff --name-only "$REF_HEAD"..HEAD 2>/dev/null || true)
|
||||
# If the .. range fails (rev-parse error) the substitution returns "".
|
||||
# Fall back to the simpler diff against working-tree HEAD-1 (no good
|
||||
# answer; just emit empty).
|
||||
DIFF_SUMMARY=$(printf '%s\n' "$DIFF_RAW" | tr '\n' ',' | sed 's/,$//' || true)
|
||||
|
||||
if [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}" ]; then
|
||||
echo "Verifying required changed files: $AGENT_EXPECTED_CHANGED_FILES"
|
||||
IFS=',' read -ra _REQUIRED <<< "$AGENT_EXPECTED_CHANGED_FILES"
|
||||
for required in "${_REQUIRED[@]:-}"; do
|
||||
required="${required#"${required%%[![:space:]]*}"}"
|
||||
required="${required%"${required##*[![:space:]]}"}"
|
||||
[ -z "$required" ] && continue
|
||||
if ! printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$required,"; then
|
||||
echo "ERROR: Required change to '$required' missing from agent's diff"
|
||||
DIFF_VERIFIED=false
|
||||
DIFF_MISMATCH="$DIFF_MISMATCH missing:$required"
|
||||
# AGENT_SKIP_BRANCH_PUSH: direct-push agents (e.g. integrators) push to origin
|
||||
# main themselves and don't want a task branch created as a side-effect.
|
||||
if [ "$SKIP_BRANCH_PUSH" = "true" ]; then
|
||||
echo "AGENT_SKIP_BRANCH_PUSH=true — skipping task branch push"
|
||||
python3 -c "
|
||||
import json, os
|
||||
out = '$METADATA_FILE'
|
||||
meta = {}
|
||||
if os.path.isfile(out):
|
||||
try:
|
||||
with open(out) as f:
|
||||
meta = json.load(f)
|
||||
except Exception:
|
||||
meta = {}
|
||||
meta['agent_branch'] = '$BRANCH'
|
||||
meta['agent_repo_url'] = '$REPO_URL'
|
||||
meta['agent_branch_pushed'] = False
|
||||
meta['direct_push_mode'] = True
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json (direct_push_mode: branch push skipped)')
|
||||
"
|
||||
echo "=== agent-repo/v1 finalize.sh complete (direct_push_mode) ==="
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
|
||||
echo "Verifying forbidden files unchanged: $AGENT_FORBIDDEN_CHANGED_FILES"
|
||||
IFS=',' read -ra _FORBIDDEN <<< "$AGENT_FORBIDDEN_CHANGED_FILES"
|
||||
for forbidden in "${_FORBIDDEN[@]:-}"; do
|
||||
forbidden="${forbidden#"${forbidden%%[![:space:]]*}"}"
|
||||
forbidden="${forbidden%"${forbidden##*[![:space:]]}"}"
|
||||
[ -z "$forbidden" ] && continue
|
||||
if printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$forbidden,"; then
|
||||
echo "ERROR: Forbidden file '$forbidden' was modified by agent"
|
||||
DIFF_VERIFIED=false
|
||||
DIFF_MISMATCH="$DIFF_MISMATCH forbidden:$forbidden"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$DIFF_VERIFIED" = "true" ] && [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
|
||||
echo "Diff verification passed (changed: $DIFF_SUMMARY)"
|
||||
fi
|
||||
else
|
||||
echo "WARNING: /workspace/reference/main has no commits; skipping diff verification"
|
||||
fi
|
||||
else
|
||||
echo "Note: /workspace/reference/main not present; skipping AR-21 diff verification"
|
||||
fi
|
||||
set -eo pipefail
|
||||
|
||||
# AR-19: Push with retry — attempt up to PUSH_RETRIES+1 times (default 2: initial + 1 retry)
|
||||
echo "Pushing branch $BRANCH to $REPO_URL..."
|
||||
@@ -344,23 +359,13 @@ for attempt in $(seq 1 $((PUSH_RETRIES + 1))); do
|
||||
sleep 5
|
||||
echo "Retry $attempt: git push $REPO_URL HEAD:refs/heads/$BRANCH --force"
|
||||
fi
|
||||
# Capture push output explicitly to stderr so it shows in finalize-error
|
||||
# capture (the previous `if cmd 2>&1; then` form let git stderr go to
|
||||
# stdout where it was lost — the actual push error message wasn't
|
||||
# visible in CP logs, hiding e.g. "Permission denied (publickey)" or
|
||||
# "remote: error: ..." rejections. Real incident: 2026-05-08 probe 6
|
||||
# silently failed for 6 retries with no visible reason.
|
||||
set +e
|
||||
PUSH_OUT=$(timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1)
|
||||
PUSH_EXIT=$?
|
||||
set -e
|
||||
echo "git push attempt $attempt exit=$PUSH_EXIT, output:" >&2
|
||||
echo "$PUSH_OUT" >&2
|
||||
if [ "$PUSH_EXIT" -eq 0 ]; then
|
||||
if timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1; then
|
||||
echo "Push succeeded (attempt $attempt)"
|
||||
PUSHED=true
|
||||
PUSH_EXIT=0
|
||||
break
|
||||
else
|
||||
PUSH_EXIT=$?
|
||||
echo "Push attempt $attempt failed with exit code $PUSH_EXIT"
|
||||
if [ "$attempt" -lt $((PUSH_RETRIES + 1)) ]; then
|
||||
echo "Will retry..."
|
||||
@@ -395,28 +400,30 @@ meta['diff_kb'] = float('$DIFF_KB') if '$DIFF_KB' else 0.0
|
||||
meta['output_validated'] = $( [ '$OUTPUT_VALIDATED' = 'true' ] && echo 'True' || echo 'False' )
|
||||
if '$OUTPUT_MISSING':
|
||||
meta['output_missing'] = '$OUTPUT_MISSING'
|
||||
# AR-21: diff verification metadata
|
||||
meta['diff_verified'] = $( [ '$DIFF_VERIFIED' = 'true' ] && echo 'True' || echo 'False' )
|
||||
if '$DIFF_MISMATCH'.strip():
|
||||
meta['diff_mismatch'] = '$DIFF_MISMATCH'.strip()
|
||||
if '$DIFF_SUMMARY':
|
||||
meta['diff_changed_files'] = [f for f in '$DIFF_SUMMARY'.split(',') if f]
|
||||
# AR-38 / F97: record empty-deliverable audit outcome.
|
||||
# Only emit the key when the audit ran. Skipped state omits the key so that
|
||||
# downstream consumers can tell apart ran-and-passed from did-not-run.
|
||||
ed = '$EMPTY_DELIVERABLE'
|
||||
if ed == 'true':
|
||||
meta['empty_deliverable'] = True
|
||||
elif ed == 'false':
|
||||
meta['empty_deliverable'] = False
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json')
|
||||
"
|
||||
|
||||
# AR-38 / F97: exit non-zero (dedicated code 2) when audit flagged the commit.
|
||||
# Take precedence over push failure (code 1) — empty deliverable is the more
|
||||
# actionable signal for the operator. Exit only after push attempt above so
|
||||
# the commit is still pushed for forensics.
|
||||
if [ "$EMPTY_DELIVERABLE" = "true" ]; then
|
||||
echo "ERROR: exiting 2 — AR-38 empty-deliverable audit failed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$PUSHED" = "false" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# AR-21: fail the task if diff verification didn't pass — even if push succeeded.
|
||||
# Branch is preserved on the agent repo for forensics, but the task ends as
|
||||
# failed so the operator + CP know it shouldn't be merged.
|
||||
if [ "$DIFF_VERIFIED" = "false" ]; then
|
||||
echo "ERROR: Diff verification failed:$DIFF_MISMATCH" >&2
|
||||
echo "Branch $BRANCH is pushed for forensics, but the task is being marked failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== agent-repo/v1 finalize.sh complete ==="
|
||||
10
harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh
Executable file
10
harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Anthropic-compat wrapper for airouter harness (Phase 9 ESO-managed secrets)
|
||||
# Reads airouter credentials from /run/agent/secrets/airouter/ and passes them
|
||||
# as env vars to the underlying Claude runner.
|
||||
set -euo pipefail
|
||||
|
||||
exec env \
|
||||
ANTHROPIC_AUTH_TOKEN="$(cat /run/agent/secrets/airouter/auth_token)" \
|
||||
ANTHROPIC_BASE_URL="$(cat /run/agent/secrets/airouter/base_url)" \
|
||||
claude "$@"
|
||||
121
harnesses/contexts/cp-harness/v1/init.sh
Executable file
121
harnesses/contexts/cp-harness/v1/init.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# cp-harness init — CPH-4/5/6 startup verification.
|
||||
#
|
||||
# Verifies five dispatcher-injected files exist with correct modes,
|
||||
# validates cp_url (https:// prefix, no whitespace/newlines), and checks
|
||||
# the not_after RFC 3339 timestamp is not expired.
|
||||
#
|
||||
# CPH-4: file existence + mode checks (tls.key must be 0400)
|
||||
# CPH-5: cp_url must start with https://, no whitespace/newlines
|
||||
# CPH-6: not_after must be a valid RFC 3339 timestamp in the future
|
||||
#
|
||||
# RUN_DIR: defaults to /run; tests pass a tmpdir path via env.
|
||||
# PROFILE_D_DIR: defaults to /etc/profile.d; override in tests if needed.
|
||||
set -euo pipefail
|
||||
|
||||
RUN_DIR="${RUN_DIR:-/run}"
|
||||
PROFILE_D_DIR="${PROFILE_D_DIR:-/etc/profile.d}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-4: verify file existence and modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TLS_CRT="$RUN_DIR/cp-client/tls.crt"
|
||||
TLS_KEY="$RUN_DIR/cp-client/tls.key"
|
||||
CA_CRT="$RUN_DIR/cp-client/ca.crt"
|
||||
CP_URL_FILE="$RUN_DIR/cp-harness/cp_url"
|
||||
NOT_AFTER_FILE="$RUN_DIR/cp-harness/not_after"
|
||||
|
||||
# Check all five files exist and are readable.
|
||||
for f in "$TLS_CRT" "$TLS_KEY" "$CA_CRT" "$CP_URL_FILE" "$NOT_AFTER_FILE"; do
|
||||
if [ ! -r "$f" ]; then
|
||||
echo "ERROR: missing required file $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# tls.key must be strictly 0400 (private key — defence in depth).
|
||||
key_mode=$(stat -c %a "$TLS_KEY")
|
||||
if [ "$key_mode" != "400" ]; then
|
||||
# Attempt to tighten the mode.
|
||||
if ! chmod 0400 "$TLS_KEY" 2>/tmp/cp_harness_chmod_err; then
|
||||
chmod_err=$(cat /tmp/cp_harness_chmod_err 2>/dev/null || true)
|
||||
echo "ERROR: tls.key mode $key_mode is broader than 0400; chmod failed: $chmod_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Re-check after chmod.
|
||||
key_mode=$(stat -c %a "$TLS_KEY")
|
||||
if [ "$key_mode" != "400" ]; then
|
||||
echo "ERROR: tls.key mode $key_mode remains broader than 0400 after chmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-5: validate cp_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cp_url=$(cat "$CP_URL_FILE")
|
||||
|
||||
# Must start with https:// (case-sensitive, literal).
|
||||
if [[ "$cp_url" != https://* ]]; then
|
||||
echo "ERROR: cp_url does not start with https:// prefix" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Must not contain carriage return, newline, or any whitespace.
|
||||
# Use explicit byte checks plus [[:space:]] guard.
|
||||
if printf '%s' "$cp_url" | grep -qP '\r|\n'; then
|
||||
echo "ERROR: cp_url contains invalid whitespace/newline" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$cp_url" =~ [[:space:]] ]]; then
|
||||
echo "ERROR: cp_url contains invalid whitespace/newline" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export for downstream processes. Fail silently if /etc/profile.d is unwritable
|
||||
# (test environments may not have it).
|
||||
mkdir -p "$PROFILE_D_DIR" 2>/dev/null || true
|
||||
printf 'export CP_URL=%s\n' "$cp_url" > "$PROFILE_D_DIR/cp-url.sh" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-6: validate not_after RFC 3339 timestamp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
not_after=$(cat "$NOT_AFTER_FILE")
|
||||
|
||||
# Parse and validate: exit 1 if expired or unparseable.
|
||||
if ! python3 -c "
|
||||
import datetime, sys
|
||||
raw = sys.argv[1].strip()
|
||||
try:
|
||||
t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00'))
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=datetime.timezone.utc)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
delta = (t - now).total_seconds()
|
||||
sys.exit(0 if delta > 0 else 1)
|
||||
" "$not_after" 2>/dev/null; then
|
||||
echo "ERROR: not_after expired or unparseable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warn if expiry is within 300s.
|
||||
warn_seconds=$(python3 -c "
|
||||
import datetime, sys
|
||||
raw = sys.argv[1].strip()
|
||||
t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00'))
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
print(int((t - now).total_seconds()))
|
||||
" "$not_after" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$warn_seconds" -lt 300 ]; then
|
||||
echo "WARNING: cert expires in ${warn_seconds}s" >&2
|
||||
fi
|
||||
|
||||
echo "cp-harness: all checks passed"
|
||||
87
harnesses/contexts/integration/v1/CLAUDE.md
Normal file
87
harnesses/contexts/integration/v1/CLAUDE.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Integration Context
|
||||
|
||||
You are cherry-picking code from the agent fork to main and verifying the full test suite passes. This is the integration gate — code only reaches main through you.
|
||||
|
||||
## Your task
|
||||
|
||||
1. **Identify the coding agent's branch** (see Branch Discovery below)
|
||||
2. **Cherry-pick commits** from the agent fork branch onto main
|
||||
3. **Run the target test files** — must pass, not just collect
|
||||
4. **Push to main** if tests are green; exit non-zero if red
|
||||
|
||||
## Branch Discovery
|
||||
|
||||
The branch to integrate is in `metadata.automation.last_coder_branch`. If that is empty or absent (F58 fix pending), discover the branch:
|
||||
|
||||
```bash
|
||||
AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
ITEM_UUID="${item.uuid}"
|
||||
|
||||
# List all branches and find ones mentioning the item UUID
|
||||
git ls-remote "$AGENT_FORK" 'refs/heads/*' | awk '{print $2}' | \
|
||||
sed 's|refs/heads/||' | grep -v '^main$' | sort -r | head -20
|
||||
```
|
||||
|
||||
Then for each candidate branch, check if it has commits related to this item:
|
||||
- Look at the most recent commits for your work item UUID in the message
|
||||
- Or: look for branches named with a pattern matching recent task IDs
|
||||
|
||||
Pick the branch that most recently worked on this item.
|
||||
|
||||
## Cherry-pick procedure
|
||||
|
||||
```bash
|
||||
AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
BRANCH="${metadata.automation.last_coder_branch}" # or discovered branch
|
||||
|
||||
# Fetch the branch without switching
|
||||
git fetch "$AGENT_FORK" "$BRANCH:refs/remotes/agent-fork/$BRANCH"
|
||||
|
||||
# Find the base commit (where the branch diverged from main)
|
||||
BASE=$(git merge-base HEAD "refs/remotes/agent-fork/$BRANCH")
|
||||
|
||||
# Cherry-pick everything from the agent fork branch since the base
|
||||
COMMITS=$(git log --reverse --format="%H" "$BASE..refs/remotes/agent-fork/$BRANCH")
|
||||
for COMMIT in $COMMITS; do
|
||||
git cherry-pick "$COMMIT" || {
|
||||
echo "Cherry-pick conflict on $COMMIT — aborting"
|
||||
git cherry-pick --abort
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
```
|
||||
|
||||
If conflicts occur: use the spec IDs (`${metadata.automation.spec_ids}`) as authority. Never silently discard test changes.
|
||||
|
||||
## Test verification
|
||||
|
||||
After cherry-pick:
|
||||
```bash
|
||||
python -m pytest ${metadata.automation.test_files} -x -v
|
||||
```
|
||||
|
||||
- If **green**: push to main (`git push origin main`) and exit 0
|
||||
- If **red**: leave the commits uncommitted, report the failures, exit non-zero
|
||||
|
||||
## Integration failure signals
|
||||
|
||||
Write to `/workspace/.agent-output/ci_metadata.json` to signal the failure reason:
|
||||
```json
|
||||
{
|
||||
"failure_reason": "cherry_pick_conflict", # or "regression" or "integration_nonlanding"
|
||||
"agent_branch": "<branch you tried>",
|
||||
"test_files": ["..."]
|
||||
}
|
||||
```
|
||||
|
||||
Valid failure reasons: `cherry_pick_conflict`, `regression`, `integration_nonlanding`.
|
||||
|
||||
## Commit message format
|
||||
|
||||
```
|
||||
feat(<spec-ids>): integrate ${item.uuid[:8]}
|
||||
|
||||
Integrates coding work for: ${item.title}
|
||||
Spec IDs: ${metadata.automation.spec_ids}
|
||||
Work item: ${item.uuid}
|
||||
```
|
||||
9
harnesses/contexts/integration/v1/harness.yaml
Normal file
9
harnesses/contexts/integration/v1/harness.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
kind: context
|
||||
name: integration
|
||||
version: 1
|
||||
description: "Integration methodology — cherry-pick coding output from agent fork to main and verify full test suite"
|
||||
provides: [integrator]
|
||||
|
||||
context_files:
|
||||
- source: ./CLAUDE.md
|
||||
target: /opt/harness/context/integration/CLAUDE.md
|
||||
48
harnesses/contexts/scaffolding/v1/CLAUDE.md
Normal file
48
harnesses/contexts/scaffolding/v1/CLAUDE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Scaffolding Context
|
||||
|
||||
You are writing STUB IMPLEMENTATIONS. Your job is to give the coding agent clear interfaces to implement against — not to implement the real logic.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Read the test files first.** Identify every function, class, and module the tests import.
|
||||
|
||||
2. **Write stubs with correct signatures and type hints.** Match what the tests expect exactly.
|
||||
|
||||
3. **Bodies must be minimal:**
|
||||
- For functions: `raise NotImplementedError("spec-id: <id>")` where spec-id matches the failing test's xfail reason
|
||||
- For Pydantic models: define all required fields with correct types, use minimal defaults
|
||||
- For abstract base classes: define the interface with `@abstractmethod` stubs
|
||||
- Never implement real logic
|
||||
|
||||
4. **Verify stubs compile and tests collect:**
|
||||
```bash
|
||||
python -m py_compile <file>
|
||||
python -m pytest --collect-only <test_file>
|
||||
```
|
||||
Fix any ImportError or collection errors before committing.
|
||||
|
||||
5. **Do NOT make tests pass.** Tests should remain `xfail` (expected failure). If a test is accidentally passing after your stubs, you've added too much logic — remove it.
|
||||
|
||||
6. **Commit and push to the work branch.** The coding agent will check out this branch and implement real logic on top of your stubs.
|
||||
|
||||
## Common patterns
|
||||
|
||||
```python
|
||||
# Function stub
|
||||
def compute_agent_branch(payload: dict, task_id: str) -> str:
|
||||
raise NotImplementedError("AR-25: compute branch from payload + task_id")
|
||||
|
||||
# Class stub
|
||||
class TriggerRegistry:
|
||||
def __init__(self, rules_path: str) -> None:
|
||||
raise NotImplementedError("WT-REG-1: load rules from YAML")
|
||||
|
||||
def evaluate(self, event: dict) -> list[str]:
|
||||
raise NotImplementedError("WT-REG-2: evaluate trigger rules against event")
|
||||
|
||||
# Pydantic model stub
|
||||
class WorkflowInput(BaseModel):
|
||||
state: str
|
||||
tags_required: list[str] = []
|
||||
tags_forbidden: list[str] = []
|
||||
```
|
||||
9
harnesses/contexts/scaffolding/v1/harness.yaml
Normal file
9
harnesses/contexts/scaffolding/v1/harness.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
kind: context
|
||||
name: scaffolding
|
||||
version: 1
|
||||
description: "Stub implementation methodology — write skeleton code that satisfies test signatures without real logic"
|
||||
provides: [scaffolder]
|
||||
|
||||
context_files:
|
||||
- source: ./CLAUDE.md
|
||||
target: /opt/harness/context/scaffolding/CLAUDE.md
|
||||
0
harnesses/contexts/sonnet-manager/__init__.py
Normal file
0
harnesses/contexts/sonnet-manager/__init__.py
Normal file
7
harnesses/contexts/sonnet-manager/v1/CLAUDE.md
Normal file
7
harnesses/contexts/sonnet-manager/v1/CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Sonnet Manager — Project Orchestrator
|
||||
|
||||
You are the **per-project manager**. Your job: poll the ACL inbox, poll the eligibility queue, and dispatch eligible workflows through `cp-cli`. Idle-exit when both inbox and eligibility have been empty for `settle_threshold` consecutive polls.
|
||||
|
||||
Full runtime contract: `spec/manager-sonnet.md`.
|
||||
|
||||
(Detailed manager instructions will be added in a follow-up.)
|
||||
0
harnesses/contexts/sonnet-manager/v1/__init__.py
Normal file
0
harnesses/contexts/sonnet-manager/v1/__init__.py
Normal file
17
harnesses/contexts/sonnet-manager/v1/harness.yaml
Normal file
17
harnesses/contexts/sonnet-manager/v1/harness.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
kind: context
|
||||
name: sonnet-manager
|
||||
version: 1
|
||||
description: "Sonnet-driven manager: per-project orchestrator that polls ACL inbox + eligibility and dispatches workflows"
|
||||
|
||||
requires: []
|
||||
provides: []
|
||||
provides_tags: [orchestrator]
|
||||
requires_tags: [cp-client, agent-communication-typed-capable]
|
||||
requires_project_id: true
|
||||
|
||||
scripts:
|
||||
init: "./init.sh"
|
||||
finalize: "./finalize.sh"
|
||||
|
||||
env: {}
|
||||
secrets_required: []
|
||||
3
harnesses/contexts/sonnet-manager/v1/manager-policy.yaml
Normal file
3
harnesses/contexts/sonnet-manager/v1/manager-policy.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
version: 1
|
||||
defaults: {}
|
||||
rules: []
|
||||
301
harnesses/contexts/sonnet-manager/v1/runner.py
Normal file
301
harnesses/contexts/sonnet-manager/v1/runner.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Sonnet-manager runner — per-project orchestrator.
|
||||
|
||||
Drives planning items toward terminal states by polling the ACL inbox,
|
||||
checking eligibility, and dispatching workflows. Idle-exits when both
|
||||
inbox and eligibility have been empty for settle_threshold consecutive
|
||||
polls.
|
||||
|
||||
Implements MS-18..MS-25 from spec/manager-sonnet.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
__all__ = [
|
||||
"IdleExitConfig",
|
||||
"manager_loop",
|
||||
"handle_inbox_message",
|
||||
"fallback_escalation",
|
||||
]
|
||||
|
||||
_logger = logging.getLogger("manager.runner")
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class IdleExitConfig:
|
||||
"""Idle-exit configuration.
|
||||
|
||||
settle_threshold: number of consecutive empty iterations before exiting.
|
||||
poll_interval_seconds: sleep between iterations when idle.
|
||||
"""
|
||||
settle_threshold: int
|
||||
poll_interval_seconds: float = 30.0
|
||||
|
||||
|
||||
# ── Main loop (MS-18, MS-19, MS-20, MS-24, MS-25) ──────────────────────────
|
||||
|
||||
def manager_loop(*, client, project_id, idle_config: IdleExitConfig):
|
||||
"""Outer loop: inbox → eligibility → dispatch → idle-exit.
|
||||
|
||||
MS-18: inbox polled BEFORE eligibility each iteration.
|
||||
MS-19: exit 0 after settle_threshold consecutive empty iterations.
|
||||
MS-20: tag_revoke before sys.exit(0).
|
||||
MS-24: revoke → final poll → re-advertise-if-nonempty.
|
||||
MS-25: acl_send exit 3 triggers fallback, not crash.
|
||||
MS-9: tag_advertise exit 3 is fatal.
|
||||
"""
|
||||
# Startup: advertise orchestrator tag
|
||||
try:
|
||||
client.tag_advertise(project_id=project_id)
|
||||
except CalledProcessError as exc:
|
||||
# MS-9: tag_advertise exit 3 is fatal
|
||||
_logger.error("tag_advertise failed (exit %s) — fatal", exc.returncode)
|
||||
sys.exit(exc.returncode if exc.returncode else 1)
|
||||
|
||||
idle_settle_count = 0
|
||||
|
||||
while True:
|
||||
# ── MS-18: inbox poll FIRST ──────────────────────────────────────
|
||||
inbox = client.acl_inbox_poll(project_id=project_id)
|
||||
|
||||
if inbox:
|
||||
# Non-empty inbox → reset settle counter
|
||||
idle_settle_count = 0
|
||||
# Process each message
|
||||
for msg in inbox:
|
||||
_process_inbox_message_safe(client, msg, project_id)
|
||||
# After processing, go back to top of loop
|
||||
continue
|
||||
|
||||
# ── Eligibility check ─────────────────────────────────────────────
|
||||
eligible = client.list_eligible_workflows(project_id=project_id)
|
||||
|
||||
if not eligible:
|
||||
idle_settle_count += 1
|
||||
else:
|
||||
idle_settle_count = 0
|
||||
# Per-item dispatch would go here (MS-14..MS-16, not yet implemented)
|
||||
# for item in eligible:
|
||||
# rule = policy.match(item, item.get('eligible', []))
|
||||
# if rule:
|
||||
# client.dispatch_workflow(...)
|
||||
|
||||
if idle_settle_count >= idle_config.settle_threshold:
|
||||
# ── MS-20 / MS-24: idle-exit sequence ─────────────────────────
|
||||
# MS-20: revoke before exit
|
||||
client.tag_revoke(project_id=project_id)
|
||||
|
||||
# MS-24: final poll after revoke
|
||||
final_inbox = client.acl_inbox_poll(project_id=project_id)
|
||||
if final_inbox:
|
||||
# Re-advertise and reset counter
|
||||
client.tag_advertise(project_id=project_id)
|
||||
idle_settle_count = 0
|
||||
continue
|
||||
|
||||
# Final poll empty → clean exit
|
||||
sys.exit(0)
|
||||
|
||||
if idle_config.poll_interval_seconds > 0:
|
||||
time.sleep(idle_config.poll_interval_seconds)
|
||||
|
||||
|
||||
def _process_inbox_message_safe(client, message, project_id):
|
||||
"""Process a single inbox message, catching exceptions.
|
||||
|
||||
MS-25: acl_send exit 3 should not crash the manager.
|
||||
"""
|
||||
message_type = message.get("message_type")
|
||||
if message_type is None:
|
||||
# Plain message — log and continue
|
||||
_logger.info("Received plain message (msg_id=%s): %s",
|
||||
message.get("msg_id"), message.get("body", ""))
|
||||
return
|
||||
|
||||
try:
|
||||
# We need item_uuid from the message; fall back to None
|
||||
item_uuid = message.get("item_uuid")
|
||||
handle_inbox_message(
|
||||
client=client,
|
||||
message=message,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
# MS-25: exit 3 from acl_send → fallback escalation
|
||||
payload = message.get("typed_payload") or {}
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type=message_type,
|
||||
cp_error_code="typed_message_unroutable",
|
||||
typed_payload=payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
_logger.error("acl_send failed with exit %s: %s", exc.returncode, exc)
|
||||
|
||||
|
||||
# ── Inbox message handler (MS-21, MS-22) ─────────────────────────────────────
|
||||
|
||||
def handle_inbox_message(*, client, message, item_uuid, project_id):
|
||||
"""Route a typed inbox message according to its message_type.
|
||||
|
||||
MS-21: request-handoff → escalation map dispatch or human fallback.
|
||||
MS-22: request-clarification → policy-driven routing.
|
||||
MS-25: acl_send exit 3 → fallback_escalation (caught by caller).
|
||||
"""
|
||||
msg_type = message.get("message_type")
|
||||
typed_payload = message.get("typed_payload") or {}
|
||||
|
||||
if msg_type == "request-handoff":
|
||||
_handle_request_handoff(client, message, typed_payload, item_uuid, project_id)
|
||||
elif msg_type == "request-clarification":
|
||||
_handle_request_clarification(client, message, typed_payload, item_uuid, project_id)
|
||||
else:
|
||||
_logger.info("Unhandled message type '%s' (msg_id=%s)", msg_type, message.get("msg_id"))
|
||||
|
||||
|
||||
def _handle_request_handoff(client, message, typed_payload, item_uuid, project_id):
|
||||
"""MS-21: dispatch escalation workflow or fall back to human."""
|
||||
reason = typed_payload.get("reason", "other")
|
||||
|
||||
# "other" always goes to human
|
||||
if reason == "other":
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
return
|
||||
|
||||
# Look up escalation map from workflow template
|
||||
try:
|
||||
template = client.get_workflow_template(item_uuid=item_uuid, project_id=project_id)
|
||||
except Exception:
|
||||
template = {}
|
||||
|
||||
escalation_map = template.get("escalation") or {}
|
||||
target_workflow = escalation_map.get(reason)
|
||||
|
||||
if target_workflow:
|
||||
# MS-21: dispatch configured escalation workflow
|
||||
try:
|
||||
client.dispatch_workflow(
|
||||
item_uuid=item_uuid,
|
||||
workflow=target_workflow,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type="request-handoff",
|
||||
cp_error_code=getattr(exc, "cp_error_code", "dispatch_failed"),
|
||||
typed_payload=typed_payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# No escalation for this reason → human fallback
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
|
||||
|
||||
def _handle_request_clarification(client, message, typed_payload, item_uuid, project_id):
|
||||
"""MS-22: request-clarification is routed to human for policy decision."""
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
|
||||
|
||||
def _escalate_to_human(client, message, typed_payload, item_uuid, project_id):
|
||||
"""Send a request-clarification to tag: human."""
|
||||
try:
|
||||
client.acl_send(
|
||||
to="tag:human",
|
||||
message_type="request-clarification",
|
||||
body=_build_escalation_body(message, typed_payload),
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type="request-clarification",
|
||||
cp_error_code=getattr(exc, "cp_error_code", "acl_send_failed"),
|
||||
typed_payload=typed_payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def _build_escalation_body(message, typed_payload):
|
||||
"""Build a human-readable escalation body from a message."""
|
||||
context_ref = typed_payload.get("context_ref")
|
||||
context_digest = ""
|
||||
if context_ref:
|
||||
context_digest = f" Context: {repr(context_ref)}"
|
||||
return (
|
||||
f"Handoff/clarification request: reason={typed_payload.get('reason', 'unknown')}."
|
||||
f"{context_digest}"
|
||||
)
|
||||
|
||||
|
||||
# ── Fallback escalation (MS-25) ──────────────────────────────────────────────
|
||||
|
||||
def fallback_escalation(*, client, original_message_type, cp_error_code,
|
||||
typed_payload, item_uuid, project_id):
|
||||
"""MS-25: when a typed send fails, send a plain escalation to human.
|
||||
|
||||
If the fallback send also fails, log a warning and continue.
|
||||
The manager MUST NOT crash.
|
||||
"""
|
||||
_logger.warning(
|
||||
"fallback escalation: typed send failed — type=%s, error=%s",
|
||||
original_message_type, cp_error_code,
|
||||
)
|
||||
|
||||
body = _build_fallback_body(original_message_type, cp_error_code, typed_payload)
|
||||
|
||||
try:
|
||||
client.acl_send(
|
||||
to="tag:human",
|
||||
body=body,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
_logger.info(
|
||||
"fallback escalation sent successfully for item %s", item_uuid,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
_logger.warning(
|
||||
"fallback_escalation_failed: original_type=%s, cp_error=%s, "
|
||||
"fallback_exit=%s",
|
||||
original_message_type, cp_error_code, exc.returncode,
|
||||
)
|
||||
|
||||
|
||||
def _build_fallback_body(original_message_type, cp_error_code, typed_payload):
|
||||
"""Build the fallback plain-message body (MS-25 format).
|
||||
|
||||
Includes original message type, CP error code, truncated payload summary,
|
||||
and a SERIALIZER_VERSION drift note.
|
||||
"""
|
||||
reason = typed_payload.get("reason", "unknown") if isinstance(typed_payload, dict) else "unknown"
|
||||
# Truncate payload summary to 500 chars
|
||||
payload_summary = repr(typed_payload)[:500]
|
||||
|
||||
body = (
|
||||
f"Manager could not route typed message: type={original_message_type}, "
|
||||
f"reason={reason}.\n"
|
||||
f"CP rejected with: {cp_error_code}. "
|
||||
f"Possible cause: SERIALIZER_VERSION drift (AC-43).\n"
|
||||
f"Payload summary: {payload_summary}"
|
||||
)
|
||||
return body
|
||||
29
harnesses/contexts/z-ai/v1/harness.yaml
Normal file
29
harnesses/contexts/z-ai/v1/harness.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
kind: context
|
||||
name: z-ai
|
||||
version: 1
|
||||
description: "Z.ai — Anthropic-API-compatible endpoint"
|
||||
requires: []
|
||||
provides: [claude-code]
|
||||
|
||||
# Auth is wired by init.sh via Claude Code's apiKeyHelper (settings.json).
|
||||
# No credential env vars: the secret stays in the mounted file and is read
|
||||
# only by the helper command at request time.
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/v1"
|
||||
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1"
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
||||
DISABLE_PROMPT_CACHING: "1"
|
||||
|
||||
secrets_required:
|
||||
- name: z-ai
|
||||
account_ref: "z-ai"
|
||||
mount_path: /run/agent/secrets/z-ai
|
||||
# 0400 (root-only) — defense in depth. The agent user CANNOT read this
|
||||
# mount. init.sh runs as root and `install`s a per-secret copy into the
|
||||
# agent's home with mode 0600 owned by agent; only that copy is exposed
|
||||
# to the runtime. Matches the gitea-ssh pattern. If the ESO Secret
|
||||
# later grows additional keys, they remain inaccessible by default.
|
||||
mode: "0400"
|
||||
|
||||
scripts:
|
||||
init: ./init.sh
|
||||
76
harnesses/contexts/z-ai/v1/init.sh
Executable file
76
harnesses/contexts/z-ai/v1/init.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# z-ai init — stage the auth_token for the agent user and wire apiKeyHelper.
|
||||
#
|
||||
# Threat model: keep the ESO mount root-only (mode 0400) so the agent user
|
||||
# cannot directly `cat` /run/agent/secrets/z-ai/auth_token. init.sh runs as
|
||||
# root (in uid-wrapper.sh, before the gosu drop) and stages a per-secret
|
||||
# copy into the agent's home with mode 0600 owned by agent. apiKeyHelper
|
||||
# points at the COPY. This is the gitea-ssh pattern — only the file the
|
||||
# harness explicitly grants is reachable by the runtime.
|
||||
#
|
||||
# Rotation handling: this is a one-shot copy at container start. For
|
||||
# ephemeral container agents (one task = one container) every task starts
|
||||
# with the latest secret. Long-running sessions don't refresh the copy
|
||||
# until a future scripts.control_loop hook lands (planning E1-M3).
|
||||
#
|
||||
# Auth wire-up: apiKeyHelper output is sent as `Authorization: Bearer
|
||||
# <value>` when ANTHROPIC_BASE_URL is non-anthropic.com — exactly what
|
||||
# api.z.ai/v1 requires. The secret value never enters this process' env,
|
||||
# the claude subprocess' env, or /proc/<pid>/environ.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ESO_AUTH_TOKEN="/run/agent/secrets/z-ai/auth_token"
|
||||
|
||||
if [ ! -r "$ESO_AUTH_TOKEN" ]; then
|
||||
echo "ERROR: $ESO_AUTH_TOKEN not readable. Check ESO ExternalSecret acct-<z-ai-id>." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the agent user's home (init.sh's $HOME is /root before gosu drop).
|
||||
AGENT_USER="${AGENT_USER:-agent}"
|
||||
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
||||
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
||||
AGENT_HOME="/home/$AGENT_USER"
|
||||
fi
|
||||
|
||||
# Stage the auth_token into a per-secret path owned by agent, mode 0600.
|
||||
# install(1) handles ownership/mode atomically; the destination is outside
|
||||
# the read-only ESO mount so we can chmod/chown freely.
|
||||
STAGED_KEY_DIR="$AGENT_HOME/.claude/secrets"
|
||||
STAGED_KEY="$STAGED_KEY_DIR/z-ai-token"
|
||||
mkdir -p "$STAGED_KEY_DIR"
|
||||
chown "$AGENT_USER:" "$STAGED_KEY_DIR" 2>/dev/null || true
|
||||
chmod 0700 "$STAGED_KEY_DIR"
|
||||
install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_AUTH_TOKEN" "$STAGED_KEY"
|
||||
|
||||
# Wire apiKeyHelper to the staged copy in the agent's settings.json.
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
||||
chmod 0755 "$CONFIG_DIR"
|
||||
|
||||
SETTINGS_FILE="$CONFIG_DIR/settings.json"
|
||||
|
||||
# Merge into an existing settings.json (from another harness layer) when
|
||||
# possible; otherwise create a fresh one.
|
||||
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
||||
TMP=$(mktemp)
|
||||
jq --arg helper "cat $STAGED_KEY" \
|
||||
'. + {apiKeyHelper: $helper}' \
|
||||
"$SETTINGS_FILE" > "$TMP"
|
||||
mv "$TMP" "$SETTINGS_FILE"
|
||||
else
|
||||
cat > "$SETTINGS_FILE" <<EOF
|
||||
{
|
||||
"apiKeyHelper": "cat $STAGED_KEY"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# settings.json holds a command (a path), not a credential value.
|
||||
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
||||
chmod 0644 "$SETTINGS_FILE"
|
||||
|
||||
echo "z-ai auth_token staged at $STAGED_KEY (0600 $AGENT_USER:$AGENT_USER)"
|
||||
echo "z-ai apiKeyHelper wired in $SETTINGS_FILE"
|
||||
21
memory/decisions.md
Normal file
21
memory/decisions.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Decisions
|
||||
|
||||
## Weekly fork cleanup workflow on `agent-runtimes-agents`
|
||||
|
||||
A weekly cron in the fork's `.gitea/workflows/cleanup.yaml` deletes `task-*` branches that have been inactive for 7+ days, and resets `main` to an orphan commit if there have been no commits for 7+ days. Supports a dry-run via `workflow_dispatch`. Runs on the fork (not the parent) because Gitea Actions is per-repo and the fork enables Actions independently. Merged 2026-05-04 (PR #1, opened by ai_enablement, merged by admin).
|
||||
|
||||
## Shallow clones (`--depth 1`) for task branches in fork cleanup
|
||||
|
||||
The fork-cleanup workflow uses `--depth 1` when cloning individual task branches it is about to delete — no history is needed beyond the tip. Keeps the runner cheap. The exception is the cleanup job itself, which uses `fetch-depth: 0` so it can inspect commit dates across `main`.
|
||||
|
||||
## Workspace pre-test hook reverts agent test edits before each test run
|
||||
|
||||
`pre_test.sh` at the harness level reverts any agent modifications to test folders before each test run. The hook writes `{"reverted": N}` to `/workspace/.agent-output/.pre-test-result.json` atomically (mktemp + mv). Exit non-zero → that test attempt is skipped. Closes BUG-5 (`.agent-output/` silent drops) and prevents agents from tampering with the tests they are being judged against.
|
||||
|
||||
## Workflow nodes declare `output: {path, min_bytes}` for validation
|
||||
|
||||
All 17 nodes in the spec-planning workflow now carry an `output:` block. The finalize step validates that the declared file exists and meets the minimum size before declaring the node successful. Pairs with wrong-path detection: if the agent writes the right content to the wrong path, a `.correction-prompt.txt` sentinel is written and the entrypoint re-invokes the agent with the correction prompt. Closes BUG-20 (MiniMax finalize no-push).
|
||||
|
||||
## Push retry with debug tracing in finalize
|
||||
|
||||
`finalize.sh` now runs with `set -x` tracing and retries the final git push up to `AGENT_PUSH_RETRIES` times. Output validation gates the push: if `AGENT_EXPECTED_OUTPUT` is set and the file is missing or under-sized, the finalize fails loudly rather than silently pushing an empty branch.
|
||||
25
memory/gotchas-gitea.md
Normal file
25
memory/gotchas-gitea.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Gitea Gotchas
|
||||
|
||||
## `/login` returns 404 — use Basic Auth or `/users/<username>/access_tokens`
|
||||
|
||||
Symptom: hitting `/api/v1/login` (or any `/login`-style endpoint) returns `404 Not Found`. Easy to mistake for a misconfigured Gitea instance.
|
||||
|
||||
Fix: Gitea has no session-login API endpoint. Either (a) use HTTP Basic Auth directly against any `/api/v1/` endpoint with `auth=("user","pass")` in `httpx`/`requests`, or (b) create an access token by POSTing to `/api/v1/users/<username>/access_tokens` (also Basic-Auth'd). Bearer token auth is supported on subsequent calls *after* you have a token.
|
||||
|
||||
## API access tokens require HTTP Basic Auth, not Bearer
|
||||
|
||||
Symptom: Bearer token auth (`Authorization: Bearer <token>`) returns `401 Unauthorized` even with a valid access token against Gitea API endpoints used for token creation.
|
||||
|
||||
Fix: use `httpx.get(url, auth=("user","pass"))` (Basic Auth) for token-creation calls. Once you hold a personal access token, subsequent API calls accept it as the `password` half of Basic Auth (with the username as the user) — still NOT Bearer-style. Encode this in any helper library wrapping the Gitea API.
|
||||
|
||||
## PR state `closed` with `merged: true` means merged, not abandoned
|
||||
|
||||
Symptom: PR appears `state: closed` in the API response; easy to assume the PR was cancelled.
|
||||
|
||||
Fix: always check `merged` alongside `state`. Gitea encodes merged PRs as `state: closed, merged: true`. Parse both fields. When verifying a PR's outcome programmatically, dump the full JSON (`json.dumps(..., indent=2)`) and read both rather than asserting on `state` alone.
|
||||
|
||||
## Fork actions run on the fork, not the parent
|
||||
|
||||
Symptom: A workflow file lives on a fork repo and you expect the parent repo's Actions runner to see it — or vice versa. The workflow never fires (or fires on the wrong runner).
|
||||
|
||||
Fix: Gitea Actions is enabled per-repo. `has_actions=true` on the parent does NOT propagate to forks — forks must enable Actions independently. Fork-cleanup workflows must be committed to the fork's own `.gitea/workflows/` and run on the fork. Parent-repo workflows ignore fork branches entirely.
|
||||
7
memory/gotchas-tokens.md
Normal file
7
memory/gotchas-tokens.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Token Gotchas
|
||||
|
||||
## `~/.config/agent-runtimes/tokens.json` uses flat structure
|
||||
|
||||
Symptom: code expecting a nested `{provider: {access_token, expires_at}}` shape fails to find the token.
|
||||
|
||||
Fix: the file is flat — `{"access_token": "...", "expires_at": <unix_seconds>}`. There is no provider key. Read it as a flat dict. When debugging "is my token expired?", compare `expires_at` against `int(time.time())` directly — both are Unix epoch seconds.
|
||||
27
memory/log/2026-05-04.225112.md
Normal file
27
memory/log/2026-05-04.225112.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Session Log -- 2026-05-04
|
||||
|
||||
## Summary
|
||||
Implemented 5 planned fixes for BUG-5 (`.agent-output/` silent drops) and BUG-20 (MiniMax finalize no-push): pre_test.sh harness hook, output validation in finalize, push retry with debug tracing, spec-planning workflow output tags, and wrong-path detection with agent correction re-invoke. Fork cleanup PR merged, agent-monitor PR #44 merged.
|
||||
|
||||
## Decisions
|
||||
- Fork cleanup workflow (PR #1 on agent-runtimes-agents): weekly cron deletes `task-*` branches inactive for 7+ days; main reset to orphan commit if no commits for 7+ days. Supports dry-run via workflow_dispatch. **Merged** (2026-05-04 10:39 UTC by admin).
|
||||
- Gitea API token auth requires HTTP Basic Auth (`auth=("user","pass")`) not Bearer token — Bearer returns 401 even with valid access token. Use `httpx.get(..., auth=("user","pass"))` pattern.
|
||||
- Shallow clone (`--depth 1`) for task branches in fork cleanup to avoid bloating with full history.
|
||||
|
||||
## Gotchas Discovered
|
||||
- **[Gitea Gotchas]** Gitea API endpoint `/login` is `404 Not Found` — use `/users/<username>/access_tokens` to create tokens, or use HTTP Basic Auth directly against any `/api/v1/` endpoint with `auth=("user","pass")`.
|
||||
- **[Gitea Gotchas]** PR state `closed` with `merged: true` means the PR was merged (not just closed). Gitea distinguishes merged vs closed states.
|
||||
- **[Gitea Gotchas]** Fork PRs use the parent repo's Actions (has_actions=true on parent, `has_actions: false` on fork). Fork cleanup workflow runs on the fork's GHA because fork enables Actions independently.
|
||||
- **[tokens]** Token file at `~/.config/agent-runtimes/tokens.json` uses flat structure: `{"access_token": "...", "expires_at": N}`. Token was valid (not expired at session continuation time: now 1777891716 vs expiry 1777891804).
|
||||
|
||||
## Key Context
|
||||
- **agent-runtime-framework** `8bbf6cb`: pre_test.sh created (harness-level test folder revert), finalize.sh gains `set -x` tracing, push retry (`AGENT_PUSH_RETRIES`), output validation (`AGENT_EXPECTED_OUTPUT`), wrong-path detection + `.correction-prompt.txt` sentinel for entrypoint re-invoke. All 17 spec-planning workflow nodes tagged with `output: {path, min_bytes}`.
|
||||
- **agent-runtimes-agents** fork: `fork-cleanup` branch merged to main (PR #1 by ai_enablement, merged by admin 2026-05-04). Workflow file at `.gitea/workflows/cleanup.yaml`.
|
||||
- **agent-runtimes** `148d9d3`: PR #44 merged — `scripts/agent-monitor` gains `provider` and `model_full` columns.
|
||||
- **agent-runtimes** local uncommitted: M22 Phase 3 manifests work (`controlplane/api/manifests.py`, `controlplane/db/manifest_store.py`, migration `20260504_0017_m16_phase3_manifests.py`, `controlplane/manifests/`). Not related to this session's focus.
|
||||
- Two ops gates pending before M22 Phase 9 live cutover: CI build `agent-runtimes-init:1.0.0` image; `agent-session` Role deployed via `homelab/agent-runtimes-deploy` + ArgoCD sync.
|
||||
|
||||
## Process Notes
|
||||
- When verifying PR state via Gitea API: parse JSON directly (don't assume specific keys — use `json.dumps(..., indent=2)` to inspect full structure).
|
||||
- Fork cleanup workflow commits to `agent-runtimes-agents` fork main; parent `agent-runtimes` main unchanged. Fork's main tracked via `agent-runtimes-agents` remote in local `agent-runtime-framework` checkout.
|
||||
- Fork cleanup's `fetch-depth: 0` is needed for commit date inspection even with shallow clones elsewhere.
|
||||
17
memory/process-lessons.md
Normal file
17
memory/process-lessons.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Process Lessons
|
||||
|
||||
## When verifying PR state via the Gitea API, dump the full JSON
|
||||
|
||||
Do not assert on a single key like `state`. Dump the response (`json.dumps(..., indent=2)`) and read `state`, `merged`, `merged_at`, and `merge_commit_sha` together. PRs that look "closed" may have been merged; PRs that look "open" may have a stale `head` SHA pointing at a deleted branch.
|
||||
|
||||
## Fork-cleanup workflows must live on the fork, not the parent
|
||||
|
||||
When the goal is "clean up branches on a fork", commit the workflow to the fork's `.gitea/workflows/`. The parent repo's Actions runner does not see fork branches. Verify Gitea Actions is enabled on the fork (`has_actions: true` in the fork's repo metadata).
|
||||
|
||||
## Use `fetch-depth: 0` in cleanup jobs that inspect commit dates
|
||||
|
||||
Shallow clones omit the timestamps needed to decide "this branch has been inactive for 7 days". The cleanup job itself must use `fetch-depth: 0` even if every other clone in the workflow is shallow.
|
||||
|
||||
## Test the wrong-path detection by writing to the wrong path on purpose
|
||||
|
||||
When adding output validation + correction prompt, do a smoke test that deliberately writes the expected content to the wrong path. Confirm the sentinel `.correction-prompt.txt` is written and the entrypoint re-invokes the agent. Don't rely on accidental coverage.
|
||||
14
task-templates/sonnet-integrator.yaml
Normal file
14
task-templates/sonnet-integrator.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: sonnet-integrator
|
||||
description: "Sonnet integrator — cherry-picks from coding fork and pushes directly to main. No fork persistence."
|
||||
model: sonnet
|
||||
harness: code-sonnet-direct/v1
|
||||
requires_tags: [claude-code, coding-agent, git-access]
|
||||
required_params: [repo_url]
|
||||
defaults:
|
||||
timeout: 1800
|
||||
pre_actions:
|
||||
- type: clone
|
||||
repo: "{{ repo_url }}"
|
||||
branch: main
|
||||
depth: 1
|
||||
clone_path: /workspace/project
|
||||
25
task-templates/workflow/airouter-impl-narrow@1.yaml
Normal file
25
task-templates/workflow/airouter-impl-narrow@1.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: airouter-impl-narrow
|
||||
version: 1
|
||||
description: Single-file impl of a tagged spec requirement against existing tests, airouter in worktree
|
||||
runtime: code-airouter-tdd-repo
|
||||
input:
|
||||
state: test-validated
|
||||
tags_required: [airouter-eligible]
|
||||
tags_forbidden: [security-sensitive, multi-file, algorithmic-large]
|
||||
required_artifacts: [test_file, impl_skeleton, prompt_pack]
|
||||
output:
|
||||
state: impl-green
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 80
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: code-airouter-tdd-repo
|
||||
prompt: "Implement the failing test. Test file: ${artifacts.test_file}. Skeleton: ${artifacts.impl_skeleton}."
|
||||
escalation:
|
||||
fixture_broken: fix-test-fixture-sonnet
|
||||
contract_ambiguous: review-spec-opus
|
||||
scope_exceeded: replan-spec-opus
|
||||
agent_bailed: airouter-impl-narrow
|
||||
26
task-templates/workflow/airouter-impl-ready@1.yaml
Normal file
26
task-templates/workflow/airouter-impl-ready@1.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: airouter-impl-ready
|
||||
version: 1
|
||||
description: Single-file impl of a tagged spec requirement against pre-validated tests, airouter in worktree
|
||||
runtime: code-airouter-tdd-repo
|
||||
input:
|
||||
state: scaffolded
|
||||
tags_required: [airouter-eligible]
|
||||
tags_forbidden: [security-sensitive, multi-file, algorithmic-large]
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: impl-green
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 80
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: code-airouter-tdd-repo
|
||||
labels: [airouter]
|
||||
prompt: "Implement the failing test. Test files: ${metadata.automation.test_files}. Spec IDs: ${metadata.automation.spec_ids}."
|
||||
escalation:
|
||||
fixture_broken: fix-test-fixture-sonnet
|
||||
contract_ambiguous: review-spec-opus
|
||||
scope_exceeded: replan-spec-opus
|
||||
agent_bailed: airouter-impl-ready
|
||||
36
task-templates/workflow/feature-delivery-loop@1.yaml
Normal file
36
task-templates/workflow/feature-delivery-loop@1.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: feature-delivery-loop
|
||||
version: 1
|
||||
description: |
|
||||
Orchestrator workflow for delivering a feature-tagged item end-to-end. Reads
|
||||
the item's workflow_state and workflow_history, lists eligible child workflows,
|
||||
picks one via policy-pick, dispatches it, waits for completion, then loops.
|
||||
Emits ACL request-handoff when policy-pick returns no match or when an outcome
|
||||
triggers an escalation that is not yet implemented.
|
||||
runtime: feature-delivery-loop
|
||||
input:
|
||||
state: ready
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: impl-green
|
||||
scope_budget:
|
||||
# The orchestrator runner does not directly modify code; child workflows do.
|
||||
# The schema requires positive minimums, so set the loop's own budget to 1/1
|
||||
# — child workflow scope budgets are enforced separately during their dispatch.
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 1
|
||||
wall_clock_seconds_max: 7200
|
||||
nodes:
|
||||
- id: policy-pick
|
||||
kind: script
|
||||
cmd: scripts/workflow/policy-pick
|
||||
on_fail: escalate
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: feature-delivery-loop
|
||||
runtime_config:
|
||||
runner: feature_delivery_loop
|
||||
prompt: "Drive the eligibility-pick-dispatch-wait loop for item ${item.uuid} until it reaches a terminal state."
|
||||
escalation:
|
||||
policy_pick_no_match: blocked-pending-handoff
|
||||
scope_exceeded: replan-spec-opus
|
||||
agent_bailed: feature-delivery-loop
|
||||
23
task-templates/workflow/frozen-fixture-emit@1.yaml
Normal file
23
task-templates/workflow/frozen-fixture-emit@1.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
name: frozen-fixture-emit
|
||||
version: 1
|
||||
description: Emit frozen test fixtures for test-needed items
|
||||
runtime: script
|
||||
input:
|
||||
state: scoped-tagged
|
||||
tags_required: [test-needed]
|
||||
required_artifacts: [impl_skeleton]
|
||||
output:
|
||||
state: test-skeleton-ready
|
||||
scope_budget:
|
||||
files_modified_max: 2
|
||||
lines_diff_max: 200
|
||||
wall_clock_seconds_max: 600
|
||||
nodes:
|
||||
- id: frozen-fixture-emit
|
||||
kind: script
|
||||
cmd: scripts/workflow/frozen-fixture-emit
|
||||
on_fail: refuse_dispatch
|
||||
- id: state-tx
|
||||
kind: script
|
||||
cmd: scripts/workflow/state-tx
|
||||
on_fail: continue
|
||||
32
task-templates/workflow/impl-review-sonnet@1.yaml
Normal file
32
task-templates/workflow/impl-review-sonnet@1.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: impl-review-sonnet
|
||||
version: 1
|
||||
description: Code review of implementation using Sonnet code-review
|
||||
runtime: best-practices-airouter-repo
|
||||
input:
|
||||
state: integrated
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: impl-reviewed
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 80
|
||||
wall_clock_seconds_max: 1200
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: best-practices-airouter-repo
|
||||
prompt: |
|
||||
Code-review the implementation for the following task.
|
||||
|
||||
**Title**: ${artifacts.item_title}
|
||||
|
||||
Review all changed Python files in the workspace (compare against main branch).
|
||||
Check for:
|
||||
1. Correctness against the spec requirements
|
||||
2. Test coverage for new code paths
|
||||
3. Security issues (injection, auth bypass, data leaks)
|
||||
4. Performance or concurrency bugs
|
||||
|
||||
Write your review to /workspace/.agent-output/review.md with a PASS or
|
||||
REWORK verdict. If REWORK, list specific changes required.
|
||||
39
task-templates/workflow/integration@1.yaml
Normal file
39
task-templates/workflow/integration@1.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: integration
|
||||
version: 1
|
||||
description: Cherry-pick coding output from agent fork to main and verify test suite (WT-PIPE-5)
|
||||
runtime: integration-direct
|
||||
input:
|
||||
state: impl-green
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: integrated
|
||||
scope_budget:
|
||||
files_modified_max: 20
|
||||
lines_diff_max: 2000
|
||||
wall_clock_seconds_max: 3600
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: integration-direct
|
||||
prompt: |
|
||||
You are the integration gate for this work item.
|
||||
|
||||
Work item UUID: ${item.uuid}
|
||||
Work item title: ${item.title}
|
||||
Spec IDs: ${metadata.automation.spec_ids}
|
||||
Test files: ${metadata.automation.test_files}
|
||||
Coding agent branch: ${metadata.automation.last_coder_branch}
|
||||
Agent fork: git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git
|
||||
|
||||
Task:
|
||||
1. Fetch and cherry-pick commits from the coding agent's branch (see integration context)
|
||||
2. Run: python -m pytest ${metadata.automation.test_files} -x -v
|
||||
3. If green: git push origin main, then exit 0
|
||||
4. If red or conflict: write failure reason to ci_metadata.json and exit non-zero
|
||||
|
||||
Branch discovery: if last_coder_branch is empty, list agent fork branches
|
||||
and find the one most recently working on this item (UUID in commit message or
|
||||
branch name matches known patterns for this project's task IDs).
|
||||
|
||||
NEVER push if tests are red. Better to fail here than to break main.
|
||||
27
task-templates/workflow/merge@1.yaml
Normal file
27
task-templates/workflow/merge@1.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: merge
|
||||
version: 1
|
||||
description: Prepare a reviewed implementation for merge — runs pre-merge checks and marks item merge-ready
|
||||
runtime: best-practices-airouter-repo
|
||||
input:
|
||||
state: impl-reviewed
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: merge-ready
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 200
|
||||
wall_clock_seconds_max: 600
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: best-practices-airouter-repo
|
||||
cli: agentic
|
||||
model: Qwen3.6
|
||||
labels: [airouter]
|
||||
runtime_env:
|
||||
AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
AGENT_BRANCH: "spec/auto-draft"
|
||||
AGENT_SKIP_BRANCH_PUSH: "true"
|
||||
AGENT_EMPTY_DELIVERABLE_CHECK: "false"
|
||||
prompt: "Review the implementation for '${item.title}' (project: ${item.project_id}). Write a concise merge checklist to /workspace/.agent-output/merge-checklist.md covering: (1) tests pass, (2) scope matches spec, (3) no regressions. End with MERGE_READY or NEEDS_REWORK."
|
||||
49
task-templates/workflow/review-spec-arch-opus@1.yaml
Normal file
49
task-templates/workflow/review-spec-arch-opus@1.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: review-spec-arch-opus
|
||||
version: 1
|
||||
description: Architecture review of a spec draft
|
||||
runtime: best-practices-airouter-repo
|
||||
input:
|
||||
state: spec-draft
|
||||
tags_forbidden: [security-sensitive]
|
||||
required_artifacts: [spec_file]
|
||||
output:
|
||||
state: spec-reviewed-arch
|
||||
scope_budget:
|
||||
files_modified_max: 2
|
||||
lines_diff_max: 150
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: best-practices-airouter-repo
|
||||
cli: agentic
|
||||
model: Qwen3.6
|
||||
labels: [airouter]
|
||||
runtime_env:
|
||||
AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
AGENT_BRANCH: "spec/auto-draft"
|
||||
prompt: |
|
||||
The agent-runtimes project repo is cloned at /workspace/project.
|
||||
Change to that directory before doing any work.
|
||||
|
||||
Review the spec draft for the following concept.
|
||||
|
||||
**Title**: ${item.title}
|
||||
|
||||
**Original concept**:
|
||||
${item.body}
|
||||
|
||||
Steps:
|
||||
1. cd /workspace/project
|
||||
2. Find the spec file: ls spec/ and look for a file matching the concept title slug
|
||||
3. Read the spec file completely
|
||||
4. Review it against architectural best practices:
|
||||
- Are requirements testable and unambiguous?
|
||||
- Are there security implications not addressed?
|
||||
- Are interfaces and data contracts clearly defined?
|
||||
- Are edge cases and failure modes covered?
|
||||
5. Write your review findings to /workspace/.agent-output/review.md with a PASS or
|
||||
REWORK verdict and specific findings
|
||||
6. Call task_complete with "PASS" or "REWORK: <one-line reason>"
|
||||
escalation:
|
||||
contract_ambiguous: review-spec-opus
|
||||
18
task-templates/workflow/review-spec-security-opus@1.yaml
Normal file
18
task-templates/workflow/review-spec-security-opus@1.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: review-spec-security-opus
|
||||
version: 1
|
||||
description: Security review of a spec draft using Opus security-review
|
||||
runtime: opus-security-review
|
||||
input:
|
||||
state: spec-draft
|
||||
tags_required: [security-sensitive]
|
||||
required_artifacts: [spec_file]
|
||||
output:
|
||||
state: spec-reviewed-security
|
||||
scope_budget:
|
||||
files_modified_max: 5
|
||||
lines_diff_max: 300
|
||||
wall_clock_seconds_max: 3600
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: opus-security-review
|
||||
38
task-templates/workflow/scaffold@1.yaml
Normal file
38
task-templates/workflow/scaffold@1.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: scaffold
|
||||
version: 1
|
||||
description: Write stub implementations satisfying test signatures before coding begins (WT-PIPE-3)
|
||||
runtime: scaffolding-repo
|
||||
input:
|
||||
state: tests-written
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: scaffolded
|
||||
scope_budget:
|
||||
files_modified_max: 5
|
||||
lines_diff_max: 300
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: scaffolding-repo
|
||||
prompt: |
|
||||
Write stub implementations for the following task.
|
||||
|
||||
Work item UUID: ${item.uuid}
|
||||
Test files: ${metadata.automation.test_files}
|
||||
Spec IDs: ${metadata.automation.spec_ids}
|
||||
Task description: ${item.body}
|
||||
|
||||
Read the test files on the work branch to identify what functions, classes,
|
||||
and modules need to exist. Write minimal stub implementations:
|
||||
- Correct signatures and type hints
|
||||
- Bodies: raise NotImplementedError("spec-id: description") — no real logic
|
||||
- Minimal Pydantic model fields where needed
|
||||
|
||||
Verify stubs compile and tests still collect (they should remain xfail):
|
||||
python -m py_compile <files>
|
||||
python -m pytest --collect-only ${metadata.automation.test_files}
|
||||
|
||||
Commit and push to the work branch. The coding agent will implement
|
||||
real logic on top of these stubs.
|
||||
68
task-templates/workflow/scope-decompose-sonnet@1.yaml
Normal file
68
task-templates/workflow/scope-decompose-sonnet@1.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: scope-decompose-sonnet
|
||||
version: 1
|
||||
description: Scope and decompose a spec into tagged tasks using Sonnet planning
|
||||
runtime: spec-writing-airouter-repo
|
||||
input:
|
||||
state: spec-reviewed-arch
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: scoped-tagged
|
||||
scope_budget:
|
||||
files_modified_max: 10
|
||||
lines_diff_max: 500
|
||||
wall_clock_seconds_max: 2400
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: spec-writing-airouter-repo
|
||||
cli: agentic
|
||||
model: Qwen3.6
|
||||
labels: [airouter]
|
||||
runtime_env:
|
||||
AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
AGENT_BRANCH: "spec/auto-draft"
|
||||
CP_URL: "http://controlplane.agent-runtimes.svc.cluster.local:8100"
|
||||
prompt: |
|
||||
The agent-runtimes project repo is cloned at /workspace/project.
|
||||
Change to that directory before doing any work.
|
||||
|
||||
Find the spec file in /workspace/project/spec/ (the spec was
|
||||
written in an earlier pipeline stage — look for a file whose name matches
|
||||
the concept title '${item.title}' as a slug, e.g. spec/<slug>.md).
|
||||
Read that spec carefully.
|
||||
|
||||
Write a decomposition plan to /workspace/.agent-output/decompose.json with
|
||||
exactly this structure:
|
||||
{
|
||||
"project_id": "${item.project_id}",
|
||||
"space_id": "${item.space_id}",
|
||||
"parent_item_uuid": "${item.uuid}",
|
||||
"tasks": [
|
||||
{
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"flow_state": "needs-tests",
|
||||
"spec_ids": ["WT-EVAL-1", "..."],
|
||||
"test_files": ["tests/triggers/test_schema.py", "..."],
|
||||
"tags": ["airouter-eligible"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Include "airouter-eligible" in tags only for single-file, self-contained
|
||||
tasks that Qwen3.6 can handle alone. Omit tags for complex multi-file tasks.
|
||||
|
||||
After writing decompose.json, run this Python command to create the work items:
|
||||
|
||||
python3 -c "
|
||||
import sys, os
|
||||
sys.path.insert(0, '/opt/agent')
|
||||
cp_url = os.environ.get('CP_URL', 'http://controlplane.agent-runtimes.svc.cluster.local:8100').rstrip('/')
|
||||
from actions.decompose_work_items import decompose_work_items_action
|
||||
decompose_work_items_action(
|
||||
{'cp_url': cp_url, 'plan_path': '/workspace/.agent-output/decompose.json'},
|
||||
{}
|
||||
)
|
||||
print('decompose_work_items: done')
|
||||
"
|
||||
22
task-templates/workflow/sonnet-impl-narrow@1.yaml
Normal file
22
task-templates/workflow/sonnet-impl-narrow@1.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: sonnet-impl-narrow
|
||||
version: 1
|
||||
description: Single-file impl of a tagged spec requirement against existing tests, sonnet in worktree
|
||||
runtime: sonnet-impl-narrow
|
||||
input:
|
||||
state: test-validated
|
||||
tags_forbidden: [security-sensitive, multi-file, algorithmic-large]
|
||||
required_artifacts: [test_file, impl_skeleton, prompt_pack]
|
||||
output:
|
||||
state: impl-green
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 80
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: code-sonnet-tdd-repo
|
||||
prompt: "Implement the failing test. Test file: ${artifacts.test_file}. Skeleton: ${artifacts.impl_skeleton}."
|
||||
escalation:
|
||||
scope_exceeded: replan-spec-opus
|
||||
agent_bailed: sonnet-impl-narrow
|
||||
19
task-templates/workflow/sonnet-impl-ready@1.yaml
Normal file
19
task-templates/workflow/sonnet-impl-ready@1.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: sonnet-impl-ready
|
||||
version: 1
|
||||
description: Single-file impl of a tagged spec requirement against pre-validated tests, Sonnet in worktree (escalation from airouter)
|
||||
runtime: code-sonnet-tdd-repo
|
||||
input:
|
||||
state: scaffolded
|
||||
tags_forbidden: [security-sensitive, multi-file, algorithmic-large]
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: impl-green
|
||||
scope_budget:
|
||||
files_modified_max: 3
|
||||
lines_diff_max: 200
|
||||
wall_clock_seconds_max: 1800
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: code-sonnet-tdd-repo
|
||||
prompt: "Implement the task. ${metadata.automation.test_files}${metadata.automation.spec_ids}Task description: ${artifacts.item_title}. ${artifacts.item_body}"
|
||||
47
task-templates/workflow/spec-draft-opus@1.yaml
Normal file
47
task-templates/workflow/spec-draft-opus@1.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: spec-draft-opus
|
||||
version: 1
|
||||
description: Draft a spec from an idea using spec-writer agent
|
||||
runtime: spec-writing-airouter-repo
|
||||
input:
|
||||
state: idea
|
||||
tags_required: []
|
||||
tags_forbidden: [security-sensitive]
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: spec-draft
|
||||
scope_budget:
|
||||
files_modified_max: 5
|
||||
lines_diff_max: 300
|
||||
wall_clock_seconds_max: 3600
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: spec-writing-airouter-repo
|
||||
cli: agentic
|
||||
model: Qwen3.6
|
||||
labels: [airouter]
|
||||
runtime_env:
|
||||
AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
AGENT_BRANCH: "spec/auto-draft"
|
||||
prompt: |
|
||||
The agent-runtimes project repo is cloned at /workspace/project.
|
||||
Change to that directory before doing any work.
|
||||
|
||||
Draft a spec for the following concept and commit it to the repo.
|
||||
|
||||
**Title**: ${item.title}
|
||||
|
||||
${item.body}
|
||||
|
||||
Steps:
|
||||
1. cd /workspace/project
|
||||
2. Read existing specs in spec/ to understand the format
|
||||
3. Create a slug from the title (lowercase, hyphens instead of spaces)
|
||||
4. Write the spec to spec/<slug>.md following the project spec format
|
||||
5. git add spec/<slug>.md && git commit -m "spec: auto-draft ${item.title}" && git push
|
||||
6. Call task_complete with a one-sentence summary
|
||||
|
||||
The spec should have numbered requirement IDs, clear acceptance criteria,
|
||||
and error handling. Follow the format of existing specs in spec/.
|
||||
escalation:
|
||||
scope_exceeded: replan-spec-opus
|
||||
31
task-templates/workflow/test-validate@1.yaml
Normal file
31
task-templates/workflow/test-validate@1.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: test-validate
|
||||
version: 1
|
||||
description: Validate tests are runnable and non-failing by default
|
||||
runtime: script
|
||||
input:
|
||||
state: test-write-pending
|
||||
tags_required: []
|
||||
required_artifacts: [test_file]
|
||||
output:
|
||||
state: test-validated
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 50
|
||||
wall_clock_seconds_max: 600
|
||||
nodes:
|
||||
- id: preflight-test-collection
|
||||
kind: script
|
||||
cmd: scripts/workflow/preflight-test-collection
|
||||
on_fail: refuse_dispatch
|
||||
- id: preflight-fixture-lint
|
||||
kind: script
|
||||
cmd: scripts/workflow/preflight-fixture-lint
|
||||
on_fail: refuse_dispatch
|
||||
- id: preflight-test-fail-mode
|
||||
kind: script
|
||||
cmd: scripts/workflow/preflight-test-fail-mode
|
||||
on_fail: continue
|
||||
- id: state-tx
|
||||
kind: script
|
||||
cmd: scripts/workflow/state-tx
|
||||
on_fail: continue
|
||||
18
task-templates/workflow/test-write-minimax@2.yaml
Normal file
18
task-templates/workflow/test-write-minimax@2.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: test-write-minimax
|
||||
version: 2
|
||||
description: Write tests from skeletons using MiniMax test-writer
|
||||
runtime: minimax-test-writer
|
||||
input:
|
||||
state: needs-tests
|
||||
tags_required: [test-needed]
|
||||
required_artifacts: [test_file]
|
||||
output:
|
||||
state: test-write-pending
|
||||
scope_budget:
|
||||
files_modified_max: 1
|
||||
lines_diff_max: 150
|
||||
wall_clock_seconds_max: 1200
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: minimax-test-writer
|
||||
36
task-templates/workflow/test-write@1.yaml
Normal file
36
task-templates/workflow/test-write@1.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: test-write
|
||||
version: 1
|
||||
description: Write xfail tests from spec before coding begins (model-agnostic, WT-PIPE-2)
|
||||
runtime: test-writing-repo
|
||||
input:
|
||||
state: needs-tests
|
||||
tags_forbidden: []
|
||||
required_artifacts: []
|
||||
output:
|
||||
state: tests-written
|
||||
scope_budget:
|
||||
files_modified_max: 3
|
||||
lines_diff_max: 400
|
||||
wall_clock_seconds_max: 2400
|
||||
nodes:
|
||||
- id: agent
|
||||
kind: agent
|
||||
template: test-writing-repo
|
||||
prompt: |
|
||||
You are writing pytest tests for the following task.
|
||||
|
||||
Work item UUID: ${item.uuid}
|
||||
Spec IDs: ${metadata.automation.spec_ids}
|
||||
Expected test files: ${metadata.automation.test_files}
|
||||
Task description: ${item.body}
|
||||
|
||||
Rules:
|
||||
- Write tests to exactly the files listed in test_files (create if missing, append if existing)
|
||||
- Mark EVERY test @pytest.mark.xfail(strict=True, reason="<spec-id>: <description>")
|
||||
- Tests must FAIL when the implementation does not exist yet
|
||||
- Write minimal import stubs if needed to prevent ImportError — no real logic
|
||||
- Run: python -m pytest --collect-only <test_file> to verify collection before committing
|
||||
- Commit and push to the work branch
|
||||
|
||||
After writing tests, run the specified test files to confirm they are collected
|
||||
and fail appropriately (xfail with strict=True).
|
||||
Reference in New Issue
Block a user