Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/dld-goal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
Expand Down
86 changes: 86 additions & 0 deletions .claude/skills/dld-goal/scripts/block-item.sh
Original file line number Diff line number Diff line change
@@ -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 <slug> <index> --reason <text> [--question <text>] [--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 <slug> <index> --reason <text> [--question <text>]}"
INDEX="${2:?Usage: block-item.sh <slug> <index> --reason <text> [--question <text>]}"
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."
146 changes: 146 additions & 0 deletions .claude/skills/dld-goal/scripts/guard-preconditions.sh
Original file line number Diff line number Diff line change
@@ -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 <DL-A,DL-B> [--base <ref>]
# guard-preconditions.sh resume <slug> [--base <ref>]
#
# 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 <start|resume> [...]" >&2; exit 1 ;;
esac

SLUG=""
DECISIONS=""
BASE=""

if [[ "$MODE" == "resume" ]]; then
SLUG="${1:?Usage: guard-preconditions.sh resume <slug> [--base <ref>]}"
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"
93 changes: 93 additions & 0 deletions .claude/skills/dld-goal/scripts/resolve-block.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Resolve a blocked item with the operator's answer.
#
# @decision(DL-004)
#
# Usage: resolve-block.sh <slug> <index> --answer <text> --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 <slug> <index> --answer <text> --action retry|skip}"
INDEX="${2:?Usage: resolve-block.sh <slug> <index> --answer <text> --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."
35 changes: 34 additions & 1 deletion .claude/skills/dld-goal/scripts/run-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
#
# run-state.sh add-item <slug> --decisions <DL-A,DL-B> [--check <cmd>]...
# [--annotation <path>]...
# Checks are stored as argv and run without a
# shell; shell operators are rejected.
# run-state.sh get-item <slug> <index> Print one item as JSON
# run-state.sh set-item-status <slug> <index> <status>
# run-state.sh add-evidence <slug> <index> <json>
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading