Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
that skill is installed beside it — a human committing outside any harness
gets the deterministic checks CI would report, first; a repository's first
commit skips it with a note (VST-310).
- orch: `reconcile-work-items` reports tracker state written once and never
re-read — parked containers, stale started items, Done items with unchecked
acceptance boxes; oversee's close-out and audit-issues' preflight run it, so
a skipped close step or a partial-scope `Closes` cannot stay silent (VST-318).

- settings templates: every key's comment condensed to one-line intent plus
landmines (922 → 545 lines across the root and skill templates, zero value
Expand Down
1 change: 1 addition & 0 deletions skills/orch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Invoke through your AI coding harness (`/orch <command>`, `/skill:orch <command>
| `ORCH_MERGE_AUTONOMY` | `auto` merges without asking once every merge gate is green; `ask` presents the merge decision. A `MERGE_READY = false` state never auto-merges | `ask` |
| `ORCH_OVERSEER_LANES` | Max concurrent lanes `oversee` keeps in flight | `3` |
| `QA_PERF_PATHS` | Space-separated path globs whose modification adds the `needs-perf-test` QA signal in `workflows/review-pr.md` § 5. Empty means the diff scan never raises it | empty |
| `RECONCILE_STALE_HOURS` | Hours before an In Progress / In Review item counts as started-stale in `reconcile-work-items` sweeps | `24` |
| Review-gate settings | `REVIEW_GATE_MODE`, `PR_REVIEW_GATE`, `PR_REVIEW_CHECK`, `PR_REVIEW_QUORUM`, `PR_REVIEW_ON_TIMEOUT`, `PR_REVIEW_NUDGE*`, `PR_REVIEW_WAIT_SECS` — [references/gates.md](references/gates.md) | — |
| Lane settings | `ORCH_LANE_DIRS`, `ORCH_LANE_ALIASES`, `ORCH_LANE_MAX_PCT`, `ORCH_TMUX_VERIFY_SECS` — `lanes --help`, `open-terminal --help` | — |

Expand Down
1 change: 1 addition & 0 deletions skills/orch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Route `<command> [args]` to its workflow and follow [Workflow Execution](#workfl
| `spawn-adapter` | Resolve Codex spawn parameters (`spawn`) and the runtime thread budget (`slots`) |
| `open-terminal` | Launch-only terminal handoff; model, effort, and permission flags come from `--launch-flags`. `--help` |
| `lanes` | Enumerate harness auth lanes, their live usage, and the launches already in flight on each; `pick` prints the launch env prefix for the qualifying lane with the fewest in-flight claims, headroom breaking the tie, exit 3 when none qualifies. `--help` |
| `reconcile-work-items` | Read-only tracker sweep: parked containers (children Done, parent open), stale started items (untouched past `RECONCILE_STALE_HOURS`, PR merged or absent), Done items with unchecked `- [ ]` boxes. Exit 1 on findings, mutates nothing. |
| `oversee-watch` | Block until the fleet needs the overseer, then print one `EVENT` line: a new pr-watch attention line, a live `--item`'s PR merged, a lane window gone, a lane whose harness exited under a live window, a lane whose account hit its limit with the harness still up, a lane pane at a question prompt, a lane idle at its prompt after a round, or a heartbeat. `--help` |

The three waiters share a bounded env-first GitHub auth ladder and exit `3` on hard auth failure — [references/gates.md](references/gates.md).
Expand Down
149 changes: 149 additions & 0 deletions skills/orch/scripts/reconcile-work-items
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# reconcile-work-items — report tracker state the workflows wrote once and
# never re-read. READ-ONLY: prints findings, mutates nothing; the caller
# decides what to fix.
#
# Three checks over the linear skill's cache (offline; sync first for
# freshness):
# container-parked a parent in a non-terminal state whose non-canceled
# children are all Done — the close that only merge-pr § 5
# performs never ran
# started-stale an In Progress / In Review item untouched for longer
# than the threshold whose branch-named PR is merged (or
# unverifiable) — the item outlived its work
# done-unchecked a Done item whose description still carries unchecked
# `- [ ]` acceptance criteria — a partial-scope PR's
# `Closes` magic word closed it
#
# Exit codes: 0 nothing to reconcile; 1 findings; 2 config/collection error.
#
# RECONCILE_STALE_HOURS started-stale threshold (default 24)
# RECONCILE_GH_CLI PR-probe command (default gh); absent/failing gh
# degrades that item to "PR state unverified", never
# to silence
set -euo pipefail

config_error() {
echo "::error::reconcile-work-items: $*" >&2
exit 2
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || config_error "not inside a git repository"
# shellcheck source=lib/vstack-env.sh
source "$SCRIPT_DIR/lib/vstack-env.sh"
vstack_load_project_env "$REPO_ROOT"

STALE_HOURS="${RECONCILE_STALE_HOURS:-24}"
Comment thread
bmethod marked this conversation as resolved.
case "$STALE_HOURS" in
"" | *[!0-9]* | 0*[0-9]) config_error "RECONCILE_STALE_HOURS must be a non-negative integer, got '$STALE_HOURS'" ;;
Comment thread
bmethod marked this conversation as resolved.
Comment thread
bmethod marked this conversation as resolved.
Comment thread
bmethod marked this conversation as resolved.
Comment thread
bmethod marked this conversation as resolved.
Comment thread
bmethod marked this conversation as resolved.
esac
GH_CLI="${RECONCILE_GH_CLI:-gh}"
Comment thread
bmethod marked this conversation as resolved.

command -v jq >/dev/null 2>&1 || config_error "jq is required"

CACHE="$REPO_ROOT/.cache/linear/issues.json"
[ -f "$CACHE" ] || config_error "no linear cache at $CACHE — run linear.sh sync first"
# Structural validation up front: an array of rows missing the fields the
# scans key on would otherwise be skipped silently and report clean.
jq -e 'type == "array" and all(.[];
type == "object"
and (.identifier | type == "string")
and (.state | type == "object")
and (.state.name | type == "string")
and (.state.type | type == "string"))' "$CACHE" >/dev/null 2>&1 \
Comment thread
bmethod marked this conversation as resolved.
|| config_error "linear cache at $CACHE is not an array of issue rows with identifier and state.name/state.type"

findings=0
note() { findings=$((findings + 1)); printf '%s\n' "$1"; }

# --- container-parked --------------------------------------------------------
# Non-terminal parents whose non-canceled children (by parent identifier) are
# all completed. Trashed/archived rows never count as children or parents.
TMPD="$(mktemp -d)" || config_error "could not create a scratch directory"
trap 'rm -rf -- "$TMPD"' EXIT

jq -r '
map(select((.trashed // false) == false and .archivedAt == null)) as $live
| ($live | map(select(.parent.identifier != null))) as $children
| ($children | group_by(.parent.identifier)) as $broods
Comment thread
bmethod marked this conversation as resolved.
Comment thread
bmethod marked this conversation as resolved.
| $broods[]
| .[0].parent.identifier as $pid
| (map(select(.state.type != "canceled"))) as $countable
| select(($countable | length) > 0 and ($countable | all(.state.type == "completed")))
Comment thread
bmethod marked this conversation as resolved.
| ($live[] | select(.identifier == $pid)) as $parent
| select($parent.state.type != "completed" and $parent.state.type != "canceled")
Comment thread
bmethod marked this conversation as resolved.
# A "(one PR)" root is a single-PR bundle, not a container: its children go
# Done while the root stays open until ITS PR merges, so an open root with
# Done children is that contract working, not a parked container.
| select($parent.title | test("\\(one PR\\)"; "i") | not)
| [$parent.identifier, $parent.title, $parent.state.name] | @tsv
' "$CACHE" >"$TMPD/containers" || config_error "container scan failed"
while IFS=$'\t' read -r pid ptitle pstate; do
[ -n "$pid" ] || continue
note "container-parked: $pid [$pstate] — every non-canceled child is Done; close it or say why it stays open ($ptitle)"
done <"$TMPD/containers"

# --- started-stale -----------------------------------------------------------
now_epoch="$(date -u +%s)" || config_error "could not read the clock"
jq -r '
.[] | select((.trashed // false) == false and .archivedAt == null)
| select(.state.type == "started")
| [.identifier, .title, .state.name, .updatedAt] | @tsv
' "$CACHE" >"$TMPD/started" || config_error "started scan failed"
while IFS=$'\t' read -r iid ititle istate iupdated; do
[ -n "$iid" ] || continue
updated_epoch="$(date -u -d "$iupdated" +%s 2>/dev/null)" || updated_epoch=""
if [ -z "$updated_epoch" ]; then
# BSD date has no -d; parse the ISO stamp with -j -f (fractional part
# and zone dropped — whole-second precision is plenty for an hours
# threshold).
trimmed="${iupdated%%.*}"
trimmed="${trimmed%Z}"
updated_epoch="$(date -j -u -f "%Y-%m-%dT%H:%M:%S" "$trimmed" +%s 2>/dev/null)" || updated_epoch=""
fi
# A timestamp neither form can read must never silently shrink the check.
[ -n "$updated_epoch" ] || config_error "could not parse updatedAt '$iupdated' for $iid — the started-stale check cannot run"
age_hours=$(((now_epoch - updated_epoch) / 3600))
[ "$age_hours" -ge "$STALE_HOURS" ] || continue
branch="$(printf '%s' "$iid" | tr '[:upper:]' '[:lower:]')"
pr_state="unverified"
if command -v "${GH_CLI%% *}" >/dev/null 2>&1; then
Comment thread
bmethod marked this conversation as resolved.
# This repo's branch only — a fork's same-named branch is someone
# else's PR and must not vouch for this item.
# The probes answer for THIS checkout: an inherited GH_REPO /
# GITHUB_REPOSITORY redirect would let another repository's same-named
# branch vouch for this item.
merged="$(env -u GH_REPO -u GITHUB_REPOSITORY $GH_CLI pr list --state merged --head "$branch" --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository | not)] | length' 2>/dev/null)" || merged=""
open="$(env -u GH_REPO -u GITHUB_REPOSITORY $GH_CLI pr list --state open --head "$branch" --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository | not)] | length' 2>/dev/null)" || open=""
Comment thread
bmethod marked this conversation as resolved.
if [ -n "$merged" ] && [ -n "$open" ]; then
if [ "$open" -gt 0 ]; then
continue # a live PR is a live item, whatever the clock says
elif [ "$merged" -gt 0 ]; then
pr_state="PR merged"
else
pr_state="no PR"
fi
fi
fi
note "started-stale: $iid [$istate] — untouched ${age_hours}h, $pr_state; finish the lifecycle or restate the scope ($ititle)"
done <"$TMPD/started"

# --- done-unchecked ----------------------------------------------------------
jq -r '
.[] | select((.trashed // false) == false and .archivedAt == null)
| select(.state.type == "completed")
| select((.description // "") | test("- \\[ \\]"))
| [.identifier, .title] | @tsv
' "$CACHE" >"$TMPD/done" || config_error "done scan failed"
while IFS=$'\t' read -r iid ititle; do
[ -n "$iid" ] || continue
note "done-unchecked: $iid [Done] — the description still carries unchecked \`- [ ]\` acceptance criteria; reopen or check them off with evidence ($ititle)"
done <"$TMPD/done"

if [ "$findings" -gt 0 ]; then
echo "reconcile-work-items: $findings finding(s) — the tracker disagrees with the work"
exit 1
fi
echo "reconcile-work-items: clean — every item's state matches its work"
146 changes: 146 additions & 0 deletions skills/orch/tests/reconcile-work-items.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Pins for reconcile-work-items (vstack #1388 / VST-318): the read-only sweep
# reports the three write-without-read-back shapes and stays quiet on their
# healthy twins. Fully offline: fixture cache + stubbed PR probe.
set -euo pipefail

TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$TEST_DIR/.." && pwd)"
RW="$SKILL_DIR/scripts/reconcile-work-items"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n %s\n' "$1" "${2:-}"; }

R="$TMP/repo"
mkdir -p "$R/.cache/linear"
git -C "$R" init -q -b main 2>/dev/null || git -C "$R" init -q

now="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
old="$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -j -u -v-3d +%Y-%m-%dT%H:%M:%S.000Z)"

issue() { # ID TITLE STATE_NAME STATE_TYPE UPDATED [PARENT] [DESC]
local parent="null"
[ -n "${6:-}" ] && parent="{\"identifier\":\"$6\"}"
jq -cn --arg id "$1" --arg t "$2" --arg sn "$3" --arg st "$4" --arg up "$5" --argjson p "$parent" --arg d "${7:-}" \
'{identifier:$id, title:$t, state:{name:$sn,type:$st}, updatedAt:$up, parent:$p, description:$d, trashed:false, archivedAt:null}'
}

{
issue "T-1" "parked container" "Todo" "unstarted" "$now"
issue "T-2" "done child a" "Done" "completed" "$now" "T-1"
issue "T-3" "done child b" "Done" "completed" "$now" "T-1"
issue "T-4" "canceled child" "Canceled" "canceled" "$now" "T-1"
issue "T-5" "healthy container" "Todo" "unstarted" "$now"
issue "T-6" "done child" "Done" "completed" "$now" "T-5"
issue "T-7" "pending child" "In Progress" "started" "$now" "T-5"
issue "T-8" "closed container" "Done" "completed" "$now"
issue "T-9" "done child of closed" "Done" "completed" "$now" "T-8"
issue "T-10" "stale started merged" "In Review" "started" "$old"
issue "T-11" "fresh started" "In Progress" "started" "$now"
issue "T-12" "stale started live pr" "In Progress" "started" "$old"
issue "T-13" "done with open boxes" "Done" "completed" "$now" "" "did:\n- [x] one\n- [ ] two"
issue "T-14" "done all checked" "Done" "completed" "$now" "" "did:\n- [x] one\n- [x] two"
issue "T-15" "trashed parked" "Todo" "unstarted" "$now"
issue "T-16" "ship the widget (One PR)" "In Review" "started" "$now"
issue "T-17" "done bundle child" "Done" "completed" "$now" "T-16"
} | jq -s 'map(if .identifier == "T-15" then .trashed = true else . end)' >"$R/.cache/linear/issues.json"

cat >"$TMP/gh-stub" <<'STUB'
#!/usr/bin/env bash
# args: pr list --state STATE --head BRANCH --json number --jq length
# A leaked repo redirect must never reach the probe.
if [ -n "${GH_REPO:-}" ] || [ -n "${GITHUB_REPOSITORY:-}" ]; then
echo "gh-stub: GH_REPO/GITHUB_REPOSITORY leaked into the probe" >&2
exit 9
fi
state=""; head=""
while [ $# -gt 0 ]; do
case "$1" in
--state) state="$2"; shift ;;
--head) head="$2"; shift ;;
esac
shift
done
case "$head:$state" in
t-10:merged) echo 1 ;;
t-12:open) echo 1 ;;
*) echo 0 ;;
esac
STUB
chmod +x "$TMP/gh-stub"

OUT=""; RC=0
OUT="$(cd "$R" && GH_REPO=elsewhere/other GITHUB_REPOSITORY=elsewhere/other RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?

[ "$RC" -eq 1 ] && ok "findings exit 1" || bad "exit code" "rc=$RC out=$OUT"
case "$OUT" in *"container-parked: T-1"*) ok "the parked container is reported" ;; *) bad "parked container" "$OUT" ;; esac
# A "(one PR)" root with Done children is the single-PR bundle contract
# working, never a parked container.
case "$OUT" in *"container-parked: T-16"*) bad "one-PR bundle flagged as parked" "$OUT" ;; *) ok "a (One PR) bundle root is not container-parked (case-insensitive marker)" ;; esac
case "$OUT" in *"container-parked: T-5"*) bad "healthy container reported" "$OUT" ;; *) ok "a container with a pending child stays quiet" ;; esac
case "$OUT" in *"T-8"*) bad "closed container reported" "$OUT" ;; *) ok "a closed container stays quiet" ;; esac
case "$OUT" in *"started-stale: T-10"*"PR merged"*) ok "the stale started item with a merged PR is reported" ;; *) bad "stale merged" "$OUT" ;; esac
case "$OUT" in *"T-11"*) bad "fresh started reported" "$OUT" ;; *) ok "a fresh started item stays quiet" ;; esac
case "$OUT" in *"T-12"*) bad "live-PR started reported" "$OUT" ;; *) ok "a stale item with a live PR stays quiet" ;; esac
case "$OUT" in *"done-unchecked: T-13"*) ok "the Done item with open boxes is reported" ;; *) bad "done unchecked" "$OUT" ;; esac
case "$OUT" in *"T-14"*) bad "all-checked reported" "$OUT" ;; *) ok "a Done item with every box checked stays quiet" ;; esac
case "$OUT" in *"T-15"*) bad "trashed reported" "$OUT" ;; *) ok "a trashed row stays out of every check" ;; esac

# Clean fixture: only healthy rows -> exit 0 with the clean line.
jq '[.[] | select(.identifier == "T-5" or .identifier == "T-6" or .identifier == "T-7" or .identifier == "T-14" or .identifier == "T-11")]' \
"$R/.cache/linear/issues.json" >"$R/.cache/linear/issues2.json"
mv "$R/.cache/linear/issues2.json" "$R/.cache/linear/issues.json"
OUT=""; RC=0
OUT="$(cd "$R" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 0 ] && case "$OUT" in *"clean"*) true ;; *) false ;; esac \
&& ok "a healthy tracker exits 0 with the clean line" || bad "clean run" "rc=$RC out=$OUT"

# A malformed row inside an array-shaped cache: the scan must die loudly,
# never end early as a clean pass.
printf '[{"identifier":"T-BAD"}, 42]' >"$R/.cache/linear/issues.json"
OUT=""; RC=0
OUT="$(cd "$R" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 2 ] && ok "a malformed cache row is a loud collection error" || bad "malformed row" "rc=$RC out=$OUT"

# Object-shaped but incomplete rows must not read as a clean tracker: a row
# without identifier/state carries nothing the scans can inspect.
printf '[{}]' >"$R/.cache/linear/issues.json"
OUT=""; RC=0
OUT="$(cd "$R" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 2 ] && ok "an empty-object row is a config error, never clean" || bad "empty-object row" "rc=$RC out=$OUT"
printf '[{"identifier":"T-1","state":{"name":"Todo"}}]' >"$R/.cache/linear/issues.json"
OUT=""; RC=0
OUT="$(cd "$R" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 2 ] && ok "a row missing state.type is a config error" || bad "missing state.type" "rc=$RC out=$OUT"

# Missing cache: loud config error, never a clean pass.
rm "$R/.cache/linear/issues.json"
OUT=""; RC=0
OUT="$(cd "$R" && "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 2 ] && ok "a missing cache is a config error, never clean" || bad "missing cache" "rc=$RC out=$OUT"

# --- settings-file threshold -------------------------------------------------
# RECONCILE_STALE_HOURS set in the project's vstack.settings.toml (not the
# environment) must reach the sweep: a 2h-old In Progress item is quiet at the
# 24h default and a finding at a 1h threshold.
R2="$TMP/settings-repo"
mkdir -p "$R2/.cache/linear"
git -C "$R2" init -q
TWO_H_AGO="$(date -u -d '2 hours ago' '+%Y-%m-%dT%H:%M:%S.000Z' 2>/dev/null || date -j -u -v-2H '+%Y-%m-%dT%H:%M:%S.000Z')"
cat >"$R2/.cache/linear/issues.json" <<JSON
[{"identifier":"VST-900","title":"stale candidate","state":{"name":"In Progress","type":"started"},"parent":null,"description":"","updatedAt":"$TWO_H_AGO"}]
JSON
RC=0
OUT="$(cd "$R2" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
[ "$RC" -eq 0 ] && ok "default 24h threshold stays quiet at 2h" || bad "default threshold" "rc=$RC out=$OUT"
printf '[env]\nRECONCILE_STALE_HOURS = "1"\n' >"$R2/vstack.settings.toml"
RC=0
OUT="$(cd "$R2" && RECONCILE_GH_CLI="$TMP/gh-stub" "$RW" 2>&1)" || RC=$?
{ [ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q "VST-900"; } && ok "settings-file RECONCILE_STALE_HOURS reaches the sweep" || bad "settings-file threshold" "rc=$RC out=$OUT"

printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
4 changes: 4 additions & 0 deletions skills/orch/vstack.settings.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,7 @@ ORCH_TMUX_VERIFY_SECS = "15"

# Max concurrent lanes the overseer keeps in flight.
ORCH_OVERSEER_LANES = "3"

# Hours before an In Progress / In Review item counts as started-stale in
# reconcile-work-items sweeps.
# RECONCILE_STALE_HOURS = "24"
Comment thread
bmethod marked this conversation as resolved.
Loading
Loading