chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root() {
|
||||
git rev-parse --show-toplevel 2>/dev/null || pwd
|
||||
}
|
||||
|
||||
ensure_repo_root() {
|
||||
cd "$(repo_root)"
|
||||
}
|
||||
|
||||
has_remote() {
|
||||
git remote get-url origin >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "[agent-framework] $label"
|
||||
"$@"
|
||||
}
|
||||
|
||||
load_repo_hooks() {
|
||||
local hooks_file=".mosaic/repo-hooks.sh"
|
||||
if [[ -f "$hooks_file" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$hooks_file"
|
||||
fi
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
ensure_repo_root
|
||||
load_repo_hooks
|
||||
|
||||
if declare -F mosaic_hook_critical >/dev/null 2>&1; then
|
||||
run_step "Run repo critical hook" mosaic_hook_critical
|
||||
else
|
||||
echo "[agent-framework] No repo critical hook configured (.mosaic/repo-hooks.sh)"
|
||||
echo "[agent-framework] Define mosaic_hook_critical() for project-specific priority scans"
|
||||
fi
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TITLE="${1:-}"
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
echo "Usage: $0 \"Short limitation title\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE="EVOLUTION.md"
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "[agent-framework] $FILE not found. Create project-specific limitations log if needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
last_num=$(rg -o "^### L-[0-9]{3}" "$FILE" | sed 's/^### L-//' | sort -n | tail -1)
|
||||
else
|
||||
last_num=$(grep -E "^### L-[0-9]{3}" "$FILE" | sed 's/^### L-//' | sort -n | tail -1)
|
||||
fi
|
||||
|
||||
if [[ -z "$last_num" ]]; then
|
||||
next_num="001"
|
||||
else
|
||||
next_num=$(printf "%03d" $((10#$last_num + 1)))
|
||||
fi
|
||||
|
||||
entry_id="L-$next_num"
|
||||
|
||||
cat <<EOF2
|
||||
|
||||
### $entry_id: $TITLE
|
||||
|
||||
| Aspect | Details |
|
||||
|--------|---------|
|
||||
| **Pain** | TODO |
|
||||
| **Impact** | TODO |
|
||||
| **Frequency** | TODO |
|
||||
| **Current Workaround** | TODO |
|
||||
| **Proposed Solution** | TODO |
|
||||
| **Platform Implication** | TODO |
|
||||
EOF2
|
||||
|
||||
echo "[agent-framework] Suggested limitation ID: $entry_id"
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
ensure_repo_root
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
ORCH_DIR=".mosaic/orchestrator"
|
||||
PID_FILE="$ORCH_DIR/orchestrator.pid"
|
||||
LOG_FILE="$ORCH_DIR/logs/daemon.log"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") <start|drain|stop|status> [--poll-sec N] [--no-sync]
|
||||
|
||||
Commands:
|
||||
start Run orchestrator drain loop in background (detached)
|
||||
drain Run orchestrator drain loop in foreground (until queue drained)
|
||||
stop Stop background orchestrator if running
|
||||
status Show background orchestrator status
|
||||
|
||||
Options:
|
||||
--poll-sec N Poll interval (default: 15)
|
||||
--no-sync Skip docs/TASKS.md -> orchestrator queue sync before run
|
||||
USAGE
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
if [[ $# -gt 0 ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
poll_sec=15
|
||||
sync_arg=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--poll-sec)
|
||||
poll_sec="${2:-15}"
|
||||
shift 2
|
||||
;;
|
||||
--no-sync)
|
||||
sync_arg="--no-sync"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[agent-framework] unknown argument: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$ORCH_DIR/logs" "$ORCH_DIR/results"
|
||||
|
||||
is_running() {
|
||||
[[ -f "$PID_FILE" ]] || return 1
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||
[[ -n "$pid" ]] || return 1
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
start)
|
||||
if is_running; then
|
||||
echo "[agent-framework] orchestrator already running (pid=$(cat "$PID_FILE"))"
|
||||
exit 0
|
||||
fi
|
||||
nohup "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg >"$LOG_FILE" 2>&1 &
|
||||
echo "$!" > "$PID_FILE"
|
||||
echo "[agent-framework] orchestrator started (pid=$!, log=$LOG_FILE)"
|
||||
;;
|
||||
drain)
|
||||
exec "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg
|
||||
;;
|
||||
stop)
|
||||
if ! is_running; then
|
||||
echo "[agent-framework] orchestrator not running"
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
fi
|
||||
pid="$(cat "$PID_FILE")"
|
||||
kill "$pid" || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "[agent-framework] orchestrator stopped (pid=$pid)"
|
||||
;;
|
||||
status)
|
||||
if is_running; then
|
||||
echo "[agent-framework] orchestrator running (pid=$(cat "$PID_FILE"), log=$LOG_FILE)"
|
||||
else
|
||||
echo "[agent-framework] orchestrator not running"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
task_file="${1:-}"
|
||||
if [[ -z "$task_file" || ! -f "$task_file" ]]; then
|
||||
echo "[orchestrator-worker] missing task file argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
worker_exec="${MOSAIC_WORKER_EXEC:-}"
|
||||
if [[ -z "$worker_exec" ]]; then
|
||||
if command -v codex >/dev/null 2>&1; then
|
||||
worker_exec="codex -p"
|
||||
elif command -v opencode >/dev/null 2>&1; then
|
||||
worker_exec="opencode -p"
|
||||
else
|
||||
echo "[orchestrator-worker] set MOSAIC_WORKER_EXEC to your worker command (example: 'codex -p' or 'opencode -p')" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
prompt="$(python3 - "$task_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
task = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
task_id = str(task.get("id", "TASK"))
|
||||
title = str(task.get("title", ""))
|
||||
description = str(task.get("description", ""))
|
||||
meta = task.get("metadata", {}) or {}
|
||||
issue = str(meta.get("issue", ""))
|
||||
repo = str(meta.get("repo", ""))
|
||||
branch = str(meta.get("branch", ""))
|
||||
depends = task.get("depends_on", [])
|
||||
if isinstance(depends, list):
|
||||
depends_str = ", ".join(str(x) for x in depends)
|
||||
else:
|
||||
depends_str = str(depends)
|
||||
|
||||
print(
|
||||
f"""Read ~/.config/mosaic/STANDARDS.md, then AGENTS.md and SOUL.md (if present).
|
||||
Complete this queued task fully.
|
||||
|
||||
Task ID: {task_id}
|
||||
Title: {title}
|
||||
Description: {description}
|
||||
Issue: {issue}
|
||||
Repo hint: {repo}
|
||||
Branch hint: {branch}
|
||||
Depends on: {depends_str}
|
||||
|
||||
Requirements:
|
||||
- Implement and verify the task end-to-end.
|
||||
- Keep changes scoped to this task.
|
||||
- Run project checks and tests relevant to touched code.
|
||||
- Return with a concise summary of what changed and verification results.
|
||||
"""
|
||||
)
|
||||
PY
|
||||
)"
|
||||
|
||||
PROMPT="$prompt" bash -lc "$worker_exec \"\$PROMPT\""
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
ensure_repo_root
|
||||
load_repo_hooks
|
||||
|
||||
# ─── Mission session cleanup (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||
ORCH_DIR=".mosaic/orchestrator"
|
||||
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||
SESSION_LOCK="$ORCH_DIR/session.lock"
|
||||
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||
|
||||
if [[ -f "$SESSION_LOCK" ]] && [[ -f "$COORD_LIB" ]] && command -v jq &>/dev/null; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$COORD_LIB"
|
||||
|
||||
sess_id="$(jq -r '.session_id // ""' "$SESSION_LOCK")"
|
||||
if [[ -n "$sess_id" && -f "$MISSION_JSON" ]]; then
|
||||
# Update mission.json: mark session ended
|
||||
updated="$(jq \
|
||||
--arg sid "$sess_id" \
|
||||
--arg ts "$(iso_now)" \
|
||||
--arg reason "completed" \
|
||||
'(.sessions[] | select(.session_id == $sid)) |= . + {
|
||||
ended_at: $ts,
|
||||
ended_reason: $reason
|
||||
}' "$MISSION_JSON")"
|
||||
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||
echo "[agent-framework] Session $sess_id recorded in mission state"
|
||||
fi
|
||||
|
||||
session_lock_clear "."
|
||||
fi
|
||||
|
||||
if declare -F mosaic_hook_session_end >/dev/null 2>&1; then
|
||||
run_step "Run repo end hook" mosaic_hook_session_end
|
||||
else
|
||||
echo "[agent-framework] No repo end hook configured (.mosaic/repo-hooks.sh)"
|
||||
fi
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
run_step "Show status" git status --short
|
||||
run_step "Show diff summary" git diff --stat
|
||||
fi
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
ensure_repo_root
|
||||
load_repo_hooks
|
||||
|
||||
# ─── Update check (non-blocking) ────────────────────────────────────────────
|
||||
if command -v mosaic &>/dev/null; then
|
||||
if mosaic update --check 2>/dev/null; then
|
||||
: # up to date
|
||||
elif [[ $? -eq 2 ]]; then
|
||||
echo ""
|
||||
echo "[agent-framework] ⚠ A newer version of Mosaic CLI is available."
|
||||
echo "[agent-framework] Run: mosaic update or bash tools/install.sh"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
run_step "Pull latest changes" git pull --rebase
|
||||
else
|
||||
echo "[agent-framework] Skip pull: working tree has local changes"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Mission state detection (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||
ORCH_DIR=".mosaic/orchestrator"
|
||||
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||
|
||||
if [[ -f "$MISSION_JSON" ]] && command -v jq &>/dev/null; then
|
||||
mission_status="$(jq -r '.status // "inactive"' "$MISSION_JSON")"
|
||||
|
||||
if [[ "$mission_status" == "active" || "$mission_status" == "paused" ]]; then
|
||||
mission_name="$(jq -r '.name // "unnamed"' "$MISSION_JSON")"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "ACTIVE MISSION DETECTED"
|
||||
echo "========================================="
|
||||
echo " Mission: $mission_name"
|
||||
|
||||
# Extract key fields from manifest if present
|
||||
manifest="docs/MISSION-MANIFEST.md"
|
||||
if [[ -f "$manifest" ]]; then
|
||||
phase="$(grep -m1 '^\*\*Phase:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Phase:\*\* //' || true)"
|
||||
milestone="$(grep -m1 '^\*\*Current Milestone:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Current Milestone:\*\* //' || true)"
|
||||
progress="$(grep -m1 '^\*\*Progress:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Progress:\*\* //' || true)"
|
||||
[[ -n "$phase" ]] && echo " Phase: $phase"
|
||||
[[ -n "$milestone" ]] && echo " Milestone: $milestone"
|
||||
[[ -n "$progress" ]] && echo " Progress: $progress"
|
||||
fi
|
||||
|
||||
# Task counts
|
||||
if [[ -f "docs/TASKS.md" ]]; then
|
||||
total="$(grep -c '^|' "docs/TASKS.md" 2>/dev/null || true)"
|
||||
total="${total:-0}"
|
||||
done_count="$(grep -ci '| done \|| completed ' "docs/TASKS.md" 2>/dev/null || true)"
|
||||
done_count="${done_count:-0}"
|
||||
approx_total=$(( total > 2 ? total - 2 : 0 ))
|
||||
echo " Tasks: ~${done_count} done of ~${approx_total} total"
|
||||
fi
|
||||
|
||||
# Scratchpad
|
||||
if [[ -d "docs/scratchpads" ]]; then
|
||||
latest_sp="$(ls -t docs/scratchpads/*.md 2>/dev/null | head -1 || true)"
|
||||
[[ -n "$latest_sp" ]] && echo " Scratchpad: $latest_sp"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Resume: Read manifest + scratchpad before taking action."
|
||||
echo " Protocol: ~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Register session if coordinator lib is available
|
||||
if [[ -f "$COORD_LIB" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$COORD_LIB"
|
||||
sess_id="$(next_session_id ".")"
|
||||
runtime="${MOSAIC_RUNTIME:-unknown}"
|
||||
session_lock_write "." "$sess_id" "$runtime" "$$"
|
||||
|
||||
# Append session to mission.json
|
||||
updated="$(jq \
|
||||
--arg sid "$sess_id" \
|
||||
--arg rt "$runtime" \
|
||||
--arg ts "$(iso_now)" \
|
||||
'.sessions += [{"session_id":$sid,"runtime":$rt,"started_at":$ts,"ended_at":"","ended_reason":"","milestone_at_end":"","tasks_completed":[],"last_task_id":""}]' \
|
||||
"$MISSION_JSON")"
|
||||
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if declare -F mosaic_hook_session_start >/dev/null 2>&1; then
|
||||
run_step "Run repo start hook" mosaic_hook_session_start
|
||||
else
|
||||
echo "[agent-framework] No repo start hook configured (.mosaic/repo-hooks.sh)"
|
||||
fi
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# reflect-board-history.sh — Phase-0 experiment P3 (outcome detectability)
|
||||
#
|
||||
# Question: for completed tasks, how often does a machine-detectable
|
||||
# correct/wrong outcome signal appear within a follow-up window (default 30d)?
|
||||
# If the base rate is too low, predicted-vs-actual calibration (design §7) has
|
||||
# nothing to score against, so the kernel should capture caveat-notes only.
|
||||
#
|
||||
# Method: consume a board/task export (JSONL, one task object per line) OR fall
|
||||
# back to scanning the git history of a `data/` task directory. For each task
|
||||
# that reached a "done"-like state, decide whether a later signal marks it
|
||||
# correct or wrong (reopen, revert, follow-up "fix"/"regression", explicit
|
||||
# outcome field). Emit the detectable-outcome base rate. HARNESS + RUBRIC.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/analysis/reflect-board-history.sh --jsonl FILE [--window-days N] [--json|--md]
|
||||
# scripts/analysis/reflect-board-history.sh --data-dir DIR [--window-days N] [--json|--md]
|
||||
#
|
||||
# JSONL fields used (best-effort): .id .status .completed_at .outcome
|
||||
# .reopened_at .followups[] (free-form). Missing fields are tolerated.
|
||||
#
|
||||
# Requirements: jq (for --jsonl), git (for --data-dir), awk.
|
||||
#
|
||||
# PRE-REGISTERED KILL CONDITION:
|
||||
# detectable-outcome base rate < 20% ⇒ do NOT build §7 calibration loop;
|
||||
# capture caveat-notes only.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
JSONL=""
|
||||
DATA_DIR=""
|
||||
WINDOW_DAYS=30
|
||||
FORMAT="json"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--jsonl) JSONL="$2"; shift 2 ;;
|
||||
--data-dir) DATA_DIR="$2"; shift 2 ;;
|
||||
--window-days) WINDOW_DAYS="$2"; shift 2 ;;
|
||||
--json) FORMAT="json"; shift ;;
|
||||
--md) FORMAT="md"; shift ;;
|
||||
-h|--help) sed -n '2,32p' "$0"; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
KILL_CONDITION='detectable-outcome base rate < 20% ⇒ do NOT build §7 calibration loop'
|
||||
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||
|
||||
done_total=0
|
||||
detectable=0
|
||||
|
||||
if [[ -n "$JSONL" ]]; then
|
||||
command -v jq >/dev/null 2>&1 || { echo "jq required for --jsonl" >&2; exit 3; }
|
||||
[[ -r "$JSONL" ]] || { echo "cannot read $JSONL" >&2; exit 3; }
|
||||
# Count done tasks and those with a machine-detectable outcome signal.
|
||||
done_total="$(jq -rs '[.[] | select((.status // "") | test("done|complete|closed"; "i"))] | length' "$JSONL" 2>/dev/null || echo 0)"
|
||||
detectable="$(jq -rs '
|
||||
[ .[]
|
||||
| select((.status // "") | test("done|complete|closed"; "i"))
|
||||
| select(
|
||||
(.outcome // null) != null
|
||||
or (.reopened_at // null) != null
|
||||
or ((.followups // []) | length) > 0
|
||||
)
|
||||
] | length' "$JSONL" 2>/dev/null || echo 0)"
|
||||
elif [[ -n "$DATA_DIR" ]]; then
|
||||
command -v git >/dev/null 2>&1 || { echo "git required for --data-dir" >&2; exit 3; }
|
||||
[[ -d "$DATA_DIR" ]] || { echo "no such dir: $DATA_DIR" >&2; exit 3; }
|
||||
# Proxy: a task file later touched by a commit whose subject signals a
|
||||
# correction is a "detectable outcome".
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
done_total=$((done_total + 1))
|
||||
history_rc=0
|
||||
history="$(git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null)" || history_rc=$?
|
||||
if [[ "$history_rc" -eq 0 ]] && grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo' <<<"$history"; then
|
||||
detectable=$((detectable + 1))
|
||||
fi
|
||||
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
||||
else
|
||||
echo "provide --jsonl FILE or --data-dir DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rate="$(awk "BEGIN{ if ($done_total==0) print \"0.0\"; else printf \"%.1f\", 100*$detectable/$done_total }")"
|
||||
verdict="$(awk "BEGIN{print ($rate < 20.0) ? \"KILL §7 — caveat-notes only\" : \"signal present — proceed\"}")"
|
||||
|
||||
if [[ "$FORMAT" == "md" ]]; then
|
||||
cat <<EOF
|
||||
## P3 — outcome detectability
|
||||
|
||||
- done-like tasks: **${done_total}**
|
||||
- with machine-detectable outcome (window ${WINDOW_DAYS}d): **${detectable}**
|
||||
- base rate: **${rate}%**
|
||||
- kill condition: ${KILL_CONDITION}
|
||||
- verdict: **${verdict}**
|
||||
EOF
|
||||
else
|
||||
awk -v dt="$done_total" -v d="$detectable" -v r="$rate" -v w="$WINDOW_DAYS" \
|
||||
-v v="$verdict" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||
printf "{\n"
|
||||
printf " \"experiment\": \"P3-board-history\",\n"
|
||||
printf " \"window_days\": %d,\n", w
|
||||
printf " \"done_tasks\": %d,\n", dt
|
||||
printf " \"detectable_outcomes\": %d,\n", d
|
||||
printf " \"base_rate_pct\": %s,\n", r
|
||||
printf " \"kill_condition\": \"%s\",\n", kc
|
||||
printf " \"verdict\": \"%s\"\n", v
|
||||
printf "}\n"
|
||||
}'
|
||||
fi
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# reflect-calibration.sh — Phase-0 experiment P1 (confidence signal)
|
||||
#
|
||||
# Question: does an agent's self-reported confidence discriminate correct from
|
||||
# incorrect work — especially on the self-rated-HIGH subset, where a closed
|
||||
# loop would actually trust it? If confidence ≈ chance on the high subset, the
|
||||
# signal is useless and design §7–§8 should not be built.
|
||||
#
|
||||
# Method: consume a labelled corpus — JSONL of {confidence: 0..1, correct:
|
||||
# true|false}. Compute discrimination as ROC AUC over all rows, plus the
|
||||
# correct-rate (lift) on the high-confidence subset (>= threshold), and compare
|
||||
# to the pre-registered chance baseline (the overall correct-rate). HARNESS +
|
||||
# RUBRIC; the labelled corpus is supplied later.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/analysis/reflect-calibration.sh --jsonl FILE [--high 0.8] [--json|--md]
|
||||
#
|
||||
# Requirements: jq, awk.
|
||||
#
|
||||
# PRE-REGISTERED KILL CONDITION:
|
||||
# AUC <= 0.60 OR high-subset lift <= +5pp over base rate
|
||||
# ⇒ confidence is not a usable routing signal; do NOT build §7–§8.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
JSONL=""
|
||||
HIGH=0.8
|
||||
FORMAT="json"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--jsonl) JSONL="$2"; shift 2 ;;
|
||||
--high) HIGH="$2"; shift 2 ;;
|
||||
--json) FORMAT="json"; shift ;;
|
||||
--md) FORMAT="md"; shift ;;
|
||||
-h|--help) sed -n '2,27p' "$0"; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
KILL_CONDITION='AUC <= 0.60 OR high-subset lift <= +5pp ⇒ do NOT build §7–§8'
|
||||
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 3; }
|
||||
[[ -r "$JSONL" ]] || { echo "provide a readable --jsonl FILE" >&2; exit 2; }
|
||||
|
||||
# Normalise to "<confidence> <0|1>" rows; tolerate bad lines.
|
||||
ROWS="$(jq -rs '
|
||||
[ .[] | select((.confidence|type)=="number") |
|
||||
"\(.confidence) \((.correct==true) | if . then 1 else 0 end)" ]
|
||||
| .[]' "$JSONL" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$ROWS" ]]; then
|
||||
echo '{ "experiment": "P1-calibration", "error": "no usable rows" }'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# AUC via the Mann–Whitney U relation (rank-based); base rate; high-subset lift.
|
||||
read -r N POS BASE AUC HIGH_N HIGH_CORRECT HIGH_RATE LIFT <<EOF
|
||||
$(printf '%s\n' "$ROWS" | awk -v high="$HIGH" '
|
||||
{ c=$1; y=$2; conf[NR]=c; lab[NR]=y; n++;
|
||||
if (y==1) pos++; else neg++;
|
||||
if (c>=high) { hn++; if (y==1) hc++ } }
|
||||
END{
|
||||
base = (n>0)? pos/n : 0;
|
||||
# Rank-sum AUC: average ranks (ties → average rank).
|
||||
# sort indices by confidence
|
||||
for (i=1;i<=n;i++) idx[i]=i;
|
||||
for (i=1;i<=n;i++) for (j=i+1;j<=n;j++) if (conf[idx[i]]>conf[idx[j]]) { t=idx[i]; idx[i]=idx[j]; idx[j]=t }
|
||||
i=1;
|
||||
while (i<=n) {
|
||||
j=i; while (j<n && conf[idx[j+1]]==conf[idx[i]]) j++;
|
||||
avg=(i+j)/2.0;
|
||||
for (k=i;k<=j;k++) rank[idx[k]]=avg;
|
||||
i=j+1;
|
||||
}
|
||||
rsum=0; for (i=1;i<=n;i++) if (lab[i]==1) rsum+=rank[i];
|
||||
if (pos>0 && neg>0) auc=(rsum - pos*(pos+1)/2.0)/(pos*neg); else auc=0.5;
|
||||
hrate=(hn>0)? hc/hn : 0;
|
||||
lift=hrate-base;
|
||||
printf "%d %d %.4f %.4f %d %d %.4f %.4f", n, pos, base, auc, hn, hc, hrate, lift
|
||||
}')
|
||||
EOF
|
||||
|
||||
verdict="$(awk -v auc="$AUC" -v lift="$LIFT" 'BEGIN{
|
||||
print (auc <= 0.60 || lift <= 0.05) ? "KILL §7–§8 — confidence not usable" : "signal present — proceed"
|
||||
}')"
|
||||
|
||||
if [[ "$FORMAT" == "md" ]]; then
|
||||
cat <<EOF
|
||||
## P1 — confidence calibration
|
||||
|
||||
- rows: **${N}** (positives ${POS}) · base correct-rate **$(awk "BEGIN{printf \"%.1f\", 100*${BASE}}")%**
|
||||
- ROC AUC: **${AUC}**
|
||||
- high-confidence subset (>= ${HIGH}): n=${HIGH_N}, correct=${HIGH_CORRECT}, rate=$(awk "BEGIN{printf \"%.1f\", 100*${HIGH_RATE}}")%
|
||||
- lift over base: **$(awk "BEGIN{printf \"%+.1f\", 100*${LIFT}}")pp**
|
||||
- kill condition: ${KILL_CONDITION}
|
||||
- verdict: **${verdict}**
|
||||
EOF
|
||||
else
|
||||
awk -v n="$N" -v pos="$POS" -v base="$BASE" -v auc="$AUC" -v hn="$HIGH_N" \
|
||||
-v hc="$HIGH_CORRECT" -v hr="$HIGH_RATE" -v lift="$LIFT" -v high="$HIGH" \
|
||||
-v v="$verdict" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||
printf "{\n"
|
||||
printf " \"experiment\": \"P1-calibration\",\n"
|
||||
printf " \"rows\": %d,\n", n
|
||||
printf " \"positives\": %d,\n", pos
|
||||
printf " \"base_rate\": %.4f,\n", base
|
||||
printf " \"auc\": %.4f,\n", auc
|
||||
printf " \"high_threshold\": %s,\n", high
|
||||
printf " \"high_subset\": { \"n\": %d, \"correct\": %d, \"rate\": %.4f },\n", hn, hc, hr
|
||||
printf " \"lift_over_base\": %.4f,\n", lift
|
||||
printf " \"kill_condition\": \"%s\",\n", kc
|
||||
printf " \"verdict\": \"%s\"\n", v
|
||||
printf "}\n"
|
||||
}'
|
||||
fi
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# reflect-git-history.sh — Phase-0 experiment P2 ("only-self-reflection" bucket)
|
||||
#
|
||||
# Question: of the failures visible in git history, what fraction would ONLY
|
||||
# have been caught by end-of-run self-reflection — i.e. NOT by CI and NOT by
|
||||
# independent human review? If that bucket is near-empty, the closed
|
||||
# calibration / skill-synthesis loop (design §7–§8) is not worth building.
|
||||
#
|
||||
# Method: scan `git log` over a window for failure signals (reverts, and
|
||||
# fix:/hotfix commits landing shortly after a feature merge). Classify each by
|
||||
# the gate most likely to have caught it, using a pre-registered heuristic.
|
||||
# This is a HARNESS + RUBRIC; the classifier is deliberately simple and the
|
||||
# real corpus/labelling is wired later. It emits a structured tally.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/analysis/reflect-git-history.sh [--repo PATH] [--since SINCE] [--json|--md]
|
||||
#
|
||||
# Options:
|
||||
# --repo PATH repo to analyse (default: current repo)
|
||||
# --since SINCE git log --since value (default: "6 months ago")
|
||||
# --json emit JSON (default)
|
||||
# --md emit markdown
|
||||
#
|
||||
# Requirements: git, awk.
|
||||
#
|
||||
# PRE-REGISTERED KILL CONDITION:
|
||||
# bucket "only_self_reflection" is near-empty (< 10% of classified failures)
|
||||
# ⇒ do NOT build design §7–§8 (closed loop). Caveat-notes capture only.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="."
|
||||
SINCE="6 months ago"
|
||||
FORMAT="json"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--json) FORMAT="json"; shift ;;
|
||||
--md) FORMAT="md"; shift ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
KILL_CONDITION='bucket only_self_reflection < 10% of classified failures ⇒ do NOT build §7–§8'
|
||||
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||
|
||||
command -v git >/dev/null 2>&1 || { echo "git required" >&2; exit 3; }
|
||||
|
||||
# Collect candidate failure commits: reverts + fix/hotfix subjects.
|
||||
mapfile -t LINES < <(
|
||||
git -C "$REPO" log --since="$SINCE" --pretty='%H%x09%s' 2>/dev/null \
|
||||
| grep -iE 'revert|hotfix|hot-fix|regression|fix(\(|:|!| )' || true
|
||||
)
|
||||
|
||||
total=0; ci=0; human=0; selfonly=0
|
||||
for line in "${LINES[@]}"; do
|
||||
[[ -z "$line" ]] && continue
|
||||
subj="${line#*$'\t'}"
|
||||
total=$((total + 1))
|
||||
# Pre-registered classification heuristic (gate most likely to have caught it):
|
||||
# - build/test/lint/type/ci signals → CI would have caught it
|
||||
# - security/auth/permission/data/migration → human review would flag it
|
||||
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
||||
if grep -qiE 'test|lint|type|build|ci|compile|typo' <<<"$subj"; then
|
||||
ci=$((ci + 1))
|
||||
elif grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection' <<<"$subj"; then
|
||||
human=$((human + 1))
|
||||
else
|
||||
selfonly=$((selfonly + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
pct() { awk "BEGIN{ if ($2==0) print \"0.0\"; else printf \"%.1f\", 100*$1/$2 }"; }
|
||||
self_pct="$(pct "$selfonly" "$total")"
|
||||
verdict="$(awk "BEGIN{print ($self_pct < 10.0) ? \"KILL §7–§8\" : \"signal present — proceed to deeper labelling\"}")"
|
||||
|
||||
if [[ "$FORMAT" == "md" ]]; then
|
||||
cat <<EOF
|
||||
## P2 — git-history failure-gate attribution
|
||||
|
||||
- window: \`${SINCE}\` · repo: \`${REPO}\`
|
||||
- classified failures: **${total}**
|
||||
|
||||
| gate | count | share |
|
||||
|---|---:|---:|
|
||||
| CI would catch | ${ci} | $(pct "$ci" "$total")% |
|
||||
| human review would catch | ${human} | $(pct "$human" "$total")% |
|
||||
| only-self-reflection | ${selfonly} | ${self_pct}% |
|
||||
|
||||
- kill condition: ${KILL_CONDITION}
|
||||
- verdict: **${verdict}**
|
||||
EOF
|
||||
else
|
||||
awk -v t="$total" -v c="$ci" -v h="$human" -v s="$selfonly" -v sp="$self_pct" \
|
||||
-v v="$verdict" -v since="$SINCE" -v repo="$REPO" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||
printf "{\n"
|
||||
printf " \"experiment\": \"P2-git-history\",\n"
|
||||
printf " \"repo\": \"%s\",\n", repo
|
||||
printf " \"since\": \"%s\",\n", since
|
||||
printf " \"classified_failures\": %d,\n", t
|
||||
printf " \"buckets\": { \"ci\": %d, \"human_review\": %d, \"only_self_reflection\": %d },\n", c, h, s
|
||||
printf " \"only_self_reflection_pct\": %s,\n", sp
|
||||
printf " \"kill_condition\": \"%s\",\n", kc
|
||||
printf " \"verdict\": \"%s\"\n", v
|
||||
printf "}\n"
|
||||
}'
|
||||
fi
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
"tools/matrix-presence-harness/run.sh:TSX_CLI=\"$(ls -d \"${REPO}\"/node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs 2>/dev/null | head -1)\"",
|
||||
"tools/e2e-install-test.sh:if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then",
|
||||
"tools/install.sh:EXTRACTED_DIR=\"$(find \"$WORK_DIR\" -maxdepth 1 -mindepth 1 -type d | head -1)\"",
|
||||
"scripts/analysis/reflect-board-history.sh:if git -C \"$DATA_DIR\" log --since=\"${WINDOW_DAYS} days ago\" --pretty='%s' -- \"$file\" 2>/dev/null | grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then",
|
||||
"scripts/analysis/reflect-git-history.sh:if printf '%s' \"$subj\" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then",
|
||||
"scripts/analysis/reflect-git-history.sh:elif printf '%s' \"$subj\" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then",
|
||||
"packages/mosaic/framework/tools/authentik/user-create.sh:group_pk=$(echo \"$group_response\" | jq -r \".results[] | select(.name == \\\"$GROUP\\\") | .pk\" | head -1)",
|
||||
"packages/mosaic/framework/tools/git/mutate-push-guard.sh:PROSE_LO=\"$(grep -n '^usage() {' \"$BAK\" | head -1 | cut -d: -f1)\"",
|
||||
"packages/mosaic/framework/tools/orchestrator/session-resume.sh:echo \"$dirty_files\" | head -20 | while IFS= read -r line; do",
|
||||
"packages/mosaic/framework/tools/prdy/prdy-status.sh:if echo \"$PRD_CONTENT\" | grep -qiE \"$pattern\"; then",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'migration|prisma|schema|\\.sql|entity|repository|seed'; then echo data; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'docker|\\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qiE 'package\\.json|tsconfig|turbo\\.json|pnpm-|\\.config\\.|eslint|vite'; then echo build; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.tsx|\\.css|components/|apps/web/'; then echo ui; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.spec\\.|\\.test\\.|__tests__/'; then echo test; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:if printf '%s' \"$p\" | grep -qE '\\.md$|docs/'; then echo docs; return; fi",
|
||||
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:FILE_PATH=$(echo \"$JSON_INPUT\" | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/' | head -1)",
|
||||
"packages/mosaic/framework/tools/qa/typecheck-hook.sh:RELEVANT=$(echo \"$OUTPUT\" | grep -A2 \"$BASENAME\" 2>/dev/null || echo \"$OUTPUT\" | head -20)",
|
||||
"packages/mosaic/framework/tools/tmux/send-message.sh:if printf '%s' \"$pane\" | grep -qF \"$QUEUED_RE\"; then",
|
||||
"packages/mosaic/framework/tools/tmux/send-message.sh:if [ -n \"$snippet\" ] && printf '%s' \"$promptline\" | grep -qF \"$snippet\"; then",
|
||||
"packages/mosaic/framework/tools/wake/detector.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'",
|
||||
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_sha\" ] && ! printf '%s' \"$snap_sha\" | grep -Eq '^[0-9a-f]{7,64}$'; then",
|
||||
"packages/mosaic/framework/tools/wake/detector.sh:if [ -n \"$snap_ts\" ] && ! printf '%s' \"$snap_ts\" | grep -Eq '^[0-9]{1,12}$'; then",
|
||||
"packages/mosaic/framework/tools/wake/digest.sh:olabel=\"$(_locator_line \"$oloc\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/reconcile.sh:sed -n \"s/^${key}=//p\" \"$MANIFEST\" | head -n1 | tr -d '[:space:]'"
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
"packages/mosaic/framework/systemd/user/test-fleet-units.sh:if tmux -L \"$TEST_SOCKET\" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then",
|
||||
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||
"packages/mosaic/framework/tools/git/test-issue-comment-readback.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||
"packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh:contains() { printf '%s\\n' \"$1\" | grep -qx \"$2\"; }",
|
||||
"packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh:write_response \"$(printf '%s' \"$result\" | head -n1)\" \"$(printf '%s' \"$result\" | tail -n +2)\"",
|
||||
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-r, --repo'",
|
||||
"packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh:echo \"$HELP_TEXT\" | grep -q -- '-H, --host'",
|
||||
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$codex_run_prompt\" | head -n1)\" == \"Now initiating Orchestrator mode...\" ]]; then pass_case \"codex run prompt first line is mode declaration\"; else fail_case \"codex run prompt first line is mode declaration\"; fi",
|
||||
"packages/mosaic/framework/tools/orchestrator/smoke-test.sh:if [[ \"$(printf '%s\\n' \"$claude_run_prompt\" | head -n1)\" == \"## Continuation Mission\" ]]; then pass_case \"claude run prompt remains continuation prompt format\"; else fail_case \"claude run prompt remains continuation prompt format\"; fi",
|
||||
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -qi \"dry run\" || note \"dry-run did not announce itself\"",
|
||||
"packages/mosaic/framework/tools/orchestrator/test-board-roll.sh:echo \"$out\" | grep -q \"would roll\" || note \"dry-run did not report a plan\"",
|
||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh:find \"$1/mosaic/backups\" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null | LC_ALL=C sort -r | head -1",
|
||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:SNAP_E=\"$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTG\" | head -1)\"",
|
||||
"packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh:grep -o '/[^ ]*mosaic-snapshot[^ ]*' \"$OUTH\" 2>/dev/null | head -1 | while read -r s; do rm -rf \"$s\"; done",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"named socket hello\" || fail \"send-message.sh did not deliver to named socket\"",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"named socket hello\"; then",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"[tester:source ->\" || fail \"agent-send.sh did not include preamble\"",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:capture_named | grep -qF \"agent socket hello\" || fail \"agent-send.sh did not deliver to named socket\"",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if capture_default | grep -qF \"agent socket hello\"; then",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${i}-END\" || fail \"concurrent send dropped payload for pane conc-$i\"",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-socket.sh:if printf '%s' \"$pane\" | grep -qF \"CONCPAYLOAD-${j}-END\"; then",
|
||||
"packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh:if [ \"$rc\" -eq 0 ] && printf '%s' \"$out\" | grep -qF \"✓ delivered\"; then"
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
"packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh:fixture_line=\"$(has_match -F \"\\\"id\\\":\\\"$enum_id\\\"\" \"$self\" | has_match -F '\"observed_seq\":5' | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:src_seq=\"$(jq -r 'select(.locators.kind == \"repo\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:pre_seq=\"$(jq -r 'select(.locators.kind == \"preimage\") | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/test-wake-preimage.sh:enum_seq=\"$(jq -r 'select(.locators.reconciled == true) | .observed_seq' \"$sd/pending.jsonl\" | head -n1)\"",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:if ! sed -n \"${ln}p\" \"$f\" | grep -Eq 'has_match|count_lines'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outA\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$outB\" | grep -q 'mini-b: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:check C1 1 \"rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff \"$TMP/expected-c1\" \"$TMP/got-c1\" 2>&1 | head -n 10 | tr '\\n' ' ')\"",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'mini-a: FAILED' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"WAKE-ASSERT ABORT\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"$site\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q \"grep exit 2\" &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'mini-a: OK' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'WAKE-ASSERT ARMED'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'REACHED-PAST-INIT' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:printf '%s' \"$out\" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh:! printf '%s' \"$out\" | grep -q 'all invariants passed' &&",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:diff \"$TMP/expected.txt\" \"$TMP/static.txt\" | head -n 20 | sed 's/^/ /'",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:if printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$s\")\"; then",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ARMED: forcing real grep error at $site\" ||",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -q \"WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit\" ||",
|
||||
"packages/mosaic/framework/tools/wake/validate-973/validate-973.sh:printf '%s\\n' \"$out\" | grep -Eq \"$(sentinel_for \"$f\")\" || rc_sent=$?"
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySnapshot(root) {
|
||||
const snapshot = [];
|
||||
async function walk(current) {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const target = path.join(current, child.name);
|
||||
const relative = path.relative(root, target);
|
||||
const stats = await lstat(target);
|
||||
if (child.isDirectory()) {
|
||||
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
||||
await walk(target);
|
||||
} else {
|
||||
snapshot.push([
|
||||
relative,
|
||||
'file',
|
||||
stats.mode & 0o777,
|
||||
(await readFile(target)).toString('base64'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
async function directoriesMatch(left, right) {
|
||||
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
||||
}
|
||||
|
||||
export async function installHooks({
|
||||
root = process.cwd(),
|
||||
disabled = process.env.HUSKY === '0',
|
||||
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
||||
runHusky = async (_stagingHooks, stagingRepo) => {
|
||||
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
||||
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
||||
cwd: stagingRepo,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
activateHooks = async () => {
|
||||
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
||||
},
|
||||
} = {}) {
|
||||
if (disabled) return;
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn('git not found; skipping hook installation');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const huskyDir = path.join(root, '.husky');
|
||||
const active = path.join(huskyDir, '_');
|
||||
const nonce = `${Date.now()}-${process.pid}`;
|
||||
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
||||
const stagingHooks = path.join(stagingRepo, '.husky');
|
||||
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
||||
await mkdir(huskyDir, { recursive: true });
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
|
||||
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
||||
let previousQuarantined = false;
|
||||
try {
|
||||
if ((await pathExists(active)) && !previousComplete) {
|
||||
await rename(active, quarantined);
|
||||
previousQuarantined = true;
|
||||
}
|
||||
await mkdir(stagingRepo, { recursive: true });
|
||||
await runHusky(stagingHooks, stagingRepo);
|
||||
const staged = path.join(stagingHooks, '_');
|
||||
if (!(await pathExists(path.join(staged, 'h')))) {
|
||||
throw new Error('husky did not produce its required h shim');
|
||||
}
|
||||
if (previousComplete) {
|
||||
if (!(await directoriesMatch(active, staged))) {
|
||||
throw new Error('existing complete hook set differs from the installed Husky version');
|
||||
}
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
} else {
|
||||
await rename(staged, active);
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
}
|
||||
await activateHooks();
|
||||
if (previousQuarantined) {
|
||||
try {
|
||||
await rm(quarantined, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupFailures = [];
|
||||
try {
|
||||
if (await pathExists(stagingRepo)) {
|
||||
await rename(stagingRepo, `${quarantined}-staging`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
||||
}
|
||||
const cleanup =
|
||||
cleanupFailures.length === 0
|
||||
? 'No partial hook set was activated.'
|
||||
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
||||
throw new Error(
|
||||
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await installHooks();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installHooks } from './install-hooks.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`);
|
||||
const quarantineRoot = path.join(fixtureRoot, 'quarantine');
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, '.husky'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an interrupted install quarantines the partial active hook set and fails loudly', async () => {
|
||||
const root = await fixture('interrupted');
|
||||
let restoredHooksPath = 'not-called';
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'partial');
|
||||
throw new Error('simulated interruption');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async (value) => {
|
||||
restoredHooksPath = value;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Hook installation failed/);
|
||||
assert.match(error.message, /pnpm install --frozen-lockfile/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_')), false);
|
||||
assert.equal(restoredHooksPath, 'not-called');
|
||||
const quarantined = await readdir(quarantineRoot);
|
||||
assert.equal(quarantined.length, 1);
|
||||
});
|
||||
|
||||
test('a failed replacement restores a previously complete active hook set', async () => {
|
||||
const root = await fixture('rollback');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'previous-complete');
|
||||
let previousRemainedActiveDuringStaging = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'),
|
||||
runHusky: async () => {
|
||||
previousRemainedActiveDuringStaging =
|
||||
(await readFile(activeShim, 'utf8')) === 'previous-complete';
|
||||
throw new Error('simulated replacement failure');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(previousRemainedActiveDuringStaging, true);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete');
|
||||
});
|
||||
|
||||
test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => {
|
||||
const root = await fixture('mismatch');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'old-complete');
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed.*pnpm install --frozen-lockfile/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'old-complete');
|
||||
});
|
||||
|
||||
test('a competing successful installer is not removed by the losing process', async () => {
|
||||
const root = await fixture('concurrent');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test("a competing installer that replaces this installer's active set is preserved", async () => {
|
||||
const root = await fixture('concurrent-after-rename');
|
||||
const active = path.join(root, '.husky', '_');
|
||||
const activeShim = path.join(active, 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
},
|
||||
activateHooks: async () => {
|
||||
await rm(active, { recursive: true, force: true });
|
||||
await mkdir(active, { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
throw new Error('our activation lost to peer');
|
||||
},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => {
|
||||
const root = await fixture('disabled');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'preserved');
|
||||
let ran = false;
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
disabled: true,
|
||||
runHusky: async () => {
|
||||
ran = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ran, false);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'preserved');
|
||||
});
|
||||
|
||||
test('a successful install leaves a complete active hook set', async () => {
|
||||
const root = await fixture('success');
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const ROOT = new URL('../', import.meta.url);
|
||||
const EXPECTED_BASELINE_SITES = 26;
|
||||
const EXPECTED_TEST_BASELINE_SITES = 22;
|
||||
const EXPECTED_WAKE_BASELINE_SITES = 26;
|
||||
const TARGETS = [
|
||||
'tools/matrix-presence-harness/run.sh',
|
||||
'tools/e2e-install-test.sh',
|
||||
'tools/install.sh',
|
||||
'scripts/agent/session-start.sh',
|
||||
'scripts/analysis/reflect-board-history.sh',
|
||||
'scripts/analysis/reflect-git-history.sh',
|
||||
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
||||
'packages/mosaic/framework/tools/authentik/user-create.sh',
|
||||
'packages/mosaic/framework/tools/git/mutate-push-guard.sh',
|
||||
'packages/mosaic/framework/tools/orchestrator/session-resume.sh',
|
||||
'packages/mosaic/framework/tools/prdy/prdy-status.sh',
|
||||
'packages/mosaic/framework/tools/qa/reflect-stop-hook.sh',
|
||||
'packages/mosaic/framework/tools/qa/typecheck-hook.sh',
|
||||
'packages/mosaic/framework/tools/tmux/send-message.sh',
|
||||
'packages/mosaic/framework/tools/wake/detector.sh',
|
||||
'packages/mosaic/framework/tools/wake/digest.sh',
|
||||
'packages/mosaic/framework/tools/wake/reconcile.sh',
|
||||
'packages/mosaic/framework/systemd/user/test-fleet-units.sh',
|
||||
'packages/mosaic/framework/tools/git/test-issue-comment-readback.sh',
|
||||
'packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh',
|
||||
'packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh',
|
||||
'packages/mosaic/framework/tools/git/test-pr-review-repo-host-override.sh',
|
||||
'packages/mosaic/framework/tools/orchestrator/smoke-test.sh',
|
||||
'packages/mosaic/framework/tools/orchestrator/test-board-roll.sh',
|
||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
|
||||
'packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
||||
'packages/mosaic/framework/tools/tmux/test-send-message-socket.sh',
|
||||
'packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh',
|
||||
'packages/mosaic/framework/tools/wake/test-wake-digest-quarantine.sh',
|
||||
'packages/mosaic/framework/tools/wake/test-wake-preimage.sh',
|
||||
'packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh',
|
||||
'packages/mosaic/framework/tools/wake/validate-973/validate-973.sh',
|
||||
];
|
||||
|
||||
// These statuses are explicitly non-load-bearing or unreachable at designed input.
|
||||
// They remain inventoried until the final #1099 tranche records every verdict.
|
||||
const ACCEPTED = [
|
||||
['tools/install.sh', 'mosaic-bak-', '|| true'],
|
||||
['tools/install.sh', 'mosaicstack-mosaic-*.tgz', 'head -1'],
|
||||
['tools/install.sh', 'mosaicstack-gateway-*.tgz', 'head -1'],
|
||||
['scripts/agent/session-start.sh', 'docs/scratchpads/*.md', '|| true'],
|
||||
[
|
||||
'packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh',
|
||||
'docs/scratchpads/*.md',
|
||||
'|| true',
|
||||
],
|
||||
];
|
||||
|
||||
const earlyExit =
|
||||
/(?<!\|)\|(?!\|)[^;\n]*(?:grep\b[^;\n]*(?:-[A-Za-z]*q|--quiet|-m\s*1)|head\b(?:\s|$))/;
|
||||
|
||||
function scan(sources) {
|
||||
const found = [];
|
||||
for (const [file, rawSource] of sources) {
|
||||
const source = rawSource.replace(/\\\n\s*/g, ' ');
|
||||
for (const rawLine of source.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!earlyExit.test(line)) continue;
|
||||
const accepted = ACCEPTED.some(
|
||||
([acceptedFile, ...fragments]) =>
|
||||
acceptedFile === file && fragments.every((item) => line.includes(item)),
|
||||
);
|
||||
if (!accepted) found.push(`${file}:${line}`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function currentSources() {
|
||||
return Promise.all(
|
||||
TARGETS.map(async (file) => [file, await readFile(new URL(file, ROOT), 'utf8')]),
|
||||
);
|
||||
}
|
||||
|
||||
async function assertBaselineFixture(file, expectedCount, expectedUnique = expectedCount) {
|
||||
const baseline = JSON.parse(await readFile(new URL(file, ROOT), 'utf8'));
|
||||
assert.equal(baseline.length, expectedCount);
|
||||
assert.equal(new Set(baseline).size, expectedUnique);
|
||||
const fixtureSources = baseline.map((site) => {
|
||||
const separator = site.indexOf(':');
|
||||
assert.ok(separator > 0, `invalid baseline site: ${site}`);
|
||||
return [site.slice(0, separator), site.slice(separator + 1)];
|
||||
});
|
||||
assert.deepEqual(scan(fixtureSources), baseline);
|
||||
}
|
||||
|
||||
test('the registered runtime baseline denominator is exactly 26 unsafe sites', async () => {
|
||||
await assertBaselineFixture(
|
||||
'scripts/fixtures/pipefail-early-exit-baseline.json',
|
||||
EXPECTED_BASELINE_SITES,
|
||||
);
|
||||
});
|
||||
|
||||
test('the registered test baseline denominator is exactly 22 unsafe sites', async () => {
|
||||
await assertBaselineFixture(
|
||||
'scripts/fixtures/pipefail-early-exit-test-baseline.json',
|
||||
EXPECTED_TEST_BASELINE_SITES,
|
||||
21,
|
||||
);
|
||||
});
|
||||
|
||||
test('the registered wake baseline denominator is exactly 26 unsafe sites', async () => {
|
||||
await assertBaselineFixture(
|
||||
'scripts/fixtures/pipefail-early-exit-wake-baseline.json',
|
||||
EXPECTED_WAKE_BASELINE_SITES,
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
test('load-bearing pipefail paths do not pipe into early-exiting consumers', async () => {
|
||||
assert.deepEqual(scan(await currentSources()), []);
|
||||
});
|
||||
|
||||
test('gateway verify capability preserves the complete help-probe truth table', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'gateway-help-probe-'));
|
||||
const mosaic = path.join(directory, 'mosaic');
|
||||
const probe = new URL('tools/e2e-gateway-verify-supported.sh', ROOT).pathname;
|
||||
try {
|
||||
await writeFile(
|
||||
mosaic,
|
||||
'#!/usr/bin/env bash\nprintf \'%s\\n\' "${MOCK_HELP_OUTPUT:-}"\nexit "${MOCK_HELP_RC:-0}"\n',
|
||||
);
|
||||
await chmod(mosaic, 0o755);
|
||||
const run = (rc, output) =>
|
||||
spawnSync('bash', [probe], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH}`,
|
||||
MOCK_HELP_RC: String(rc),
|
||||
MOCK_HELP_OUTPUT: output,
|
||||
},
|
||||
}).status;
|
||||
|
||||
assert.equal(run(0, 'commands: verify'), 0);
|
||||
assert.equal(run(0, 'commands: install'), 1);
|
||||
assert.equal(run(1, 'commands: verify'), 1);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('wake JSONL selectors take the first match across the complete input stream', async () => {
|
||||
const source = await readFile(
|
||||
new URL('packages/mosaic/framework/tools/wake/test-wake-preimage.sh', ROOT),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal((source.match(/jq -nr 'first\(inputs \| select\(/g) ?? []).length, 4);
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'wake-jsonl-first-'));
|
||||
const input = path.join(directory, 'pending.jsonl');
|
||||
const filter = 'first(inputs | select(.locators.kind == "preimage") | .observed_seq) // empty';
|
||||
try {
|
||||
await writeFile(
|
||||
input,
|
||||
'{"locators":{"kind":"repo"},"observed_seq":1}\n' +
|
||||
'{"locators":{"kind":"preimage"},"observed_seq":4}\n' +
|
||||
'{"locators":{"kind":"preimage"},"observed_seq":9}\n',
|
||||
);
|
||||
let result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '4\n');
|
||||
|
||||
await writeFile(input, '{"locators":{"kind":"repo"},"observed_seq":1}\n');
|
||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
|
||||
await writeFile(input, '{invalid json}\n');
|
||||
result = spawnSync('jq', ['-nr', filter, input], { encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('board-history preserves non-git data-dir as a non-detectable result', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'reflect-board-non-git-'));
|
||||
try {
|
||||
await writeFile(path.join(directory, 'task.json'), '{}\n');
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[
|
||||
new URL('scripts/analysis/reflect-board-history.sh', ROOT).pathname,
|
||||
'--data-dir',
|
||||
directory,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /"done_tasks": 1/);
|
||||
assert.match(result.stdout, /"detectable_outcomes": 0/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MISSING_DEPS_EXIT = 42;
|
||||
|
||||
// The generated-state certification that used to live here (fingerprinting the
|
||||
// web source tree and certifying apps/web/.next) retired with the Next.js build
|
||||
// in Phase P5 (#1444): the Vite SPA has no generated tree that later gates
|
||||
// consume, so there is no stale-output class left to defend against.
|
||||
|
||||
export async function runPreflight({ root = process.cwd() } = {}) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
const missingBinaries = [];
|
||||
for (const binary of requiredBinaries) {
|
||||
try {
|
||||
await access(path.join(binDir, binary), constants.X_OK);
|
||||
} catch {
|
||||
missingBinaries.push(binary);
|
||||
}
|
||||
}
|
||||
if (missingBinaries.length > 0) {
|
||||
return {
|
||||
code: MISSING_DEPS_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
||||
};
|
||||
}
|
||||
|
||||
return { code: 0, message: 'checkout preflight passed' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runPreflight();
|
||||
const stream = result.code === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${result.message}\n`);
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runPreflight } from './preflight.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
||||
|
||||
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', 'src'), { recursive: true });
|
||||
await writeFile(path.join(root, 'apps', 'web', 'src', 'main.tsx'), 'export default 1;\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
async function installRequiredBins(root) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await Promise.all(
|
||||
requiredBins.map(async (name) => {
|
||||
const target = path.join(binDir, name);
|
||||
await writeFile(target, '');
|
||||
await chmod(target, 0o755);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
||||
const root = await fixture('missing-deps');
|
||||
const result = await runPreflight({ root });
|
||||
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
||||
assert.match(result.message, /run pnpm install/i);
|
||||
});
|
||||
|
||||
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('partial-deps');
|
||||
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('dangling-deps');
|
||||
await installRequiredBins(root);
|
||||
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
||||
await rm(turbo);
|
||||
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('installed dependencies pass', async () => {
|
||||
const root = await fixture('clean');
|
||||
await installRequiredBins(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
// RI-1-002 / RI-N1 publish-gate NEGATIVE CONTROLS (SDLC-D-034).
|
||||
//
|
||||
// scripts/verify-release.test.mjs pins the POSITIVE structure of the publish
|
||||
// gate: every publish effect declares a direct `depends_on: verify` edge and
|
||||
// the verify step asserts commit identity + runs the canonical command. This
|
||||
// suite is the negative-control set: each test feeds a structural gate
|
||||
// checker a pipeline in which the gate is bypassed by ONE specific shape and
|
||||
// asserts the checker goes RED. The controls prove from the pipeline FILE —
|
||||
// never by executing Woodpecker — that a verify step that FAILS (nonzero
|
||||
// exit) blocks every publish effect.
|
||||
//
|
||||
// Woodpecker semantics these controls rely on:
|
||||
// - A step that exits nonzero FAILS, and every step that transitively
|
||||
// depends on a failed step is SKIPPED — never run. That skip is the only
|
||||
// thing standing between a failed mandatory check and a publish effect.
|
||||
// - `detach: true` removes the step from the wait graph: the pipeline does
|
||||
// not wait for detached steps, so their failure can never block anything.
|
||||
// - `failure: ignore` reports a failed step as success to the DAG.
|
||||
// - `success: [codes...]` overrides which exit codes count as success;
|
||||
// admitting any nonzero code launders a failed verification into green.
|
||||
// - `when` on the verify step would skip verification entirely on some
|
||||
// event/path classes while publish effects still run.
|
||||
//
|
||||
// Bypass shapes covered (one negative-control test each):
|
||||
// S1 Missing edge — a publish effect whose dependency closure does not
|
||||
// contain `verify` (a refactor drops the depends_on entry).
|
||||
// S2 Hidden effect — a step whose NAME does not start with `publish` but
|
||||
// whose COMMANDS publish npm packages or push images. Effects are
|
||||
// classified by commands, so renaming a step cannot un-gate it.
|
||||
// S3 Detached verify — `verify: { detach: true }`: publish steps no longer
|
||||
// wait for verify, so the depends_on edge is decorative.
|
||||
// S4 Always-pass verify — `failure: ignore`, or a `success` override
|
||||
// admitting nonzero exit codes: verify fails, the DAG sees success.
|
||||
// S5 Conditional verify — a `when`/path filter on verify itself.
|
||||
// S6 Exact-commit drift — a HEAD-moving step (git checkout/switch/reset/
|
||||
// clean/pull/clone/fetch) ordered between `verify` and a publish
|
||||
// effect: the verified commit would not be the published commit. A
|
||||
// LEGITIMATE re-checkout is allowed only when `verify` itself runs
|
||||
// after it — positive control included.
|
||||
// S7 Gate removal — the verify step deleted or renamed away entirely.
|
||||
|
||||
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
|
||||
// dependency) instead of adding a root dependency or vendoring a parser.
|
||||
const mosaicRequire = createRequire(
|
||||
path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
|
||||
);
|
||||
const { parse: parseYaml } = mosaicRequire('yaml');
|
||||
|
||||
const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
|
||||
|
||||
async function readPublishPipeline() {
|
||||
return parseYaml(await readFile(publishYmlPath, 'utf8'));
|
||||
}
|
||||
|
||||
// A command has a publish EFFECT when it publishes npm packages (`publish`
|
||||
// anywhere after a package-manager token — `pnpm --filter "@x/*" publish`
|
||||
// puts flags and quoted filters between the binary and the subcommand) or
|
||||
// pushes an image (kaniko, docker push, or a registry --destination).
|
||||
// Deliberately over-broad: a false positive forces justification, a false
|
||||
// negative is the actual hazard.
|
||||
function isPublishCommand(command) {
|
||||
return (
|
||||
/(^|\s)\/kaniko\/executor\b/.test(command) ||
|
||||
/(^|\s)docker\s+push\b/.test(command) ||
|
||||
/(^|\s)--destination(\s|=)/.test(command) ||
|
||||
(/\bpublish\b/.test(command) && /(^|\s)(npm|pnpm|yarn)(\s|$)/.test(command))
|
||||
);
|
||||
}
|
||||
|
||||
function hasPublishEffect(step) {
|
||||
return (step.commands ?? []).some(isPublishCommand);
|
||||
}
|
||||
|
||||
// A step is a publish effect when its name says so OR (S2) when any of its
|
||||
// commands does — classification must not depend on the name alone.
|
||||
function publishEffectSteps(pipeline) {
|
||||
return Object.entries(pipeline.steps ?? {})
|
||||
.filter(([name, step]) => name.startsWith('publish') || hasPublishEffect(step))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
// Transitive closure of a step's depends_on graph.
|
||||
function dependencyClosure(pipeline, stepName, seen = new Set()) {
|
||||
const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
|
||||
for (const dependency of dependencies) {
|
||||
if (seen.has(dependency)) continue;
|
||||
seen.add(dependency);
|
||||
dependencyClosure(pipeline, dependency, seen);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
// Deliberately over-broad: `git fetch` alone does not move HEAD, but the
|
||||
// classic re-checkout pair is `git fetch && git reset --hard <remote>`; a
|
||||
// fetch step sitting between verify and a publish effect deserves scrutiny,
|
||||
// so the gate fails closed on it.
|
||||
function movesHead(step) {
|
||||
return (step.commands ?? []).some((command) =>
|
||||
/(^|\s)git\s+(checkout|switch|reset|clean|pull|clone|fetch)\b/.test(command),
|
||||
);
|
||||
}
|
||||
|
||||
// The structural gate checker: green only when a failed (nonzero-exit)
|
||||
// verify provably blocks every publish effect on the same commit.
|
||||
function assertPublishGateBlocksOnVerify(pipeline) {
|
||||
assert.ok(pipeline.steps, 'publish pipeline must define steps');
|
||||
const verify = pipeline.steps.verify;
|
||||
assert.ok(verify, 'publish pipeline must define a `verify` step (S7)');
|
||||
|
||||
// S5: a skipped verification authorizes publishes exactly as much as a
|
||||
// failed one — verify must be unconditional.
|
||||
assert.equal(verify.when, undefined, '`verify` must not carry a when/path filter (S5)');
|
||||
|
||||
// S3/S4: the depends_on edges are only meaningful if verify's own failure
|
||||
// is both awaited and terminal for the DAG.
|
||||
assert.equal(verify.detach, undefined, '`verify` must not be detached (S3)');
|
||||
assert.equal(
|
||||
verify.failure,
|
||||
undefined,
|
||||
'`verify` must not tolerate its own failure (S4: failure: ignore launders a failed gate into success)',
|
||||
);
|
||||
assert.equal(
|
||||
verify.success,
|
||||
undefined,
|
||||
'`verify` must not override success exit codes (S4: nonzero codes would make failed verification pass)',
|
||||
);
|
||||
|
||||
const effects = publishEffectSteps(pipeline);
|
||||
assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');
|
||||
|
||||
const verifyClosure = dependencyClosure(pipeline, 'verify');
|
||||
for (const stepName of effects) {
|
||||
// S1: only the failure-skip semantics of the DAG stand between a failed
|
||||
// verify and this effect — the verify edge in its closure is the proof.
|
||||
const closure = dependencyClosure(pipeline, stepName);
|
||||
assert.ok(
|
||||
closure.has('verify'),
|
||||
`publish effect '${stepName}' must transitively depend on verify (S1) — a failed verify must skip it`,
|
||||
);
|
||||
|
||||
// S6: any step ordered after verify (outside its closure) but inside the
|
||||
// effect's chain must not be able to move HEAD. If the pipeline
|
||||
// legitimately re-checks-out, verify must run after the re-checkout.
|
||||
for (const chainStep of closure) {
|
||||
if (chainStep === 'verify' || verifyClosure.has(chainStep)) continue;
|
||||
assert.ok(
|
||||
!movesHead(pipeline.steps[chainStep]),
|
||||
`step '${chainStep}' sits between verify and publish effect '${stepName}' and can move HEAD (S6)` +
|
||||
' — verify must re-run after any re-checkout',
|
||||
);
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
// A minimal but healthy gate used as the base for every negative-control
|
||||
// mutation: verify (identity + canonical command) → build → publish-npm,
|
||||
// with the publish effect blocked by verify both directly and through build.
|
||||
const HEALTHY_GATE_YAML = `
|
||||
steps:
|
||||
verify:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- |
|
||||
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "identity mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
- pnpm verify:release
|
||||
build:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- verify
|
||||
publish-npm:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- npm publish
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
`;
|
||||
|
||||
// Fresh parse per call so every negative control mutates its own object.
|
||||
function healthyPipeline() {
|
||||
return parseYaml(HEALTHY_GATE_YAML);
|
||||
}
|
||||
|
||||
test('the real publish pipeline: a failed verify provably blocks every publish effect', async () => {
|
||||
const pipeline = await readPublishPipeline();
|
||||
const effects = assertPublishGateBlocksOnVerify(pipeline);
|
||||
assert.deepEqual(effects.sort(), [
|
||||
'build-appservice',
|
||||
'build-gateway',
|
||||
'publish-next-npm',
|
||||
'publish-npm',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fixture sanity: the healthy gate base passes the checker unmutated', () => {
|
||||
assertPublishGateBlocksOnVerify(healthyPipeline());
|
||||
});
|
||||
|
||||
test('S1 negative control: a publish effect with no verify edge fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps['publish-npm'].depends_on = ['build'];
|
||||
pipeline.steps.build.depends_on = [];
|
||||
assert.throws(
|
||||
() => assertPublishGateBlocksOnVerify(pipeline),
|
||||
/publish-npm.*must transitively depend on verify/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('S2 negative control: an npm publish hidden behind a non-publish step name fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
delete pipeline.steps['publish-npm'];
|
||||
pipeline.steps.build.depends_on = [];
|
||||
pipeline.steps.deploy = {
|
||||
image: 'node:24-alpine',
|
||||
commands: ['npm publish'],
|
||||
depends_on: ['build'],
|
||||
};
|
||||
// Detection must be by COMMAND: the name says "deploy", the commands say
|
||||
// publish — an un-gated effect under either reading.
|
||||
assert.throws(
|
||||
() => assertPublishGateBlocksOnVerify(pipeline),
|
||||
/deploy.*must transitively depend on verify/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('S2 negative control: a kaniko image push under a build-* name fails the checker when ungated', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
delete pipeline.steps['publish-npm'];
|
||||
pipeline.steps.build.depends_on = [];
|
||||
pipeline.steps['push-platform-image'] = {
|
||||
image: 'gcr.io/kaniko-project/executor:debug',
|
||||
commands: ['/kaniko/executor --context . --destination reg.example/img:latest'],
|
||||
depends_on: ['build'],
|
||||
};
|
||||
assert.throws(
|
||||
() => assertPublishGateBlocksOnVerify(pipeline),
|
||||
/push-platform-image.*must transitively depend on verify/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('S3 negative control: a detached verify fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.verify.detach = true;
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /detached \(S3\)/);
|
||||
});
|
||||
|
||||
test('S4 negative control: failure: ignore on verify fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.verify.failure = 'ignore';
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /tolerate its own failure/);
|
||||
});
|
||||
|
||||
test('S4 negative control: a success override admitting nonzero exit codes fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.verify.success = [0, 1];
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /success exit codes/);
|
||||
});
|
||||
|
||||
test('S5 negative control: a when filter on verify fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.verify.when = [{ event: 'push' }];
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /when\/path filter \(S5\)/);
|
||||
});
|
||||
|
||||
test('S6 negative control: a HEAD-moving step between verify and publish fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.resync = {
|
||||
image: 'node:24-alpine',
|
||||
commands: ['git fetch origin', 'git reset --hard origin/main'],
|
||||
depends_on: [],
|
||||
};
|
||||
pipeline.steps.build.depends_on = ['verify', 'resync'];
|
||||
// resync sits AFTER verify in the publish chain (verify does not depend on
|
||||
// it), so the verified commit could be replaced before publishing.
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /resync.*can move HEAD/s);
|
||||
});
|
||||
|
||||
test('S6 positive control: a legitimate re-checkout passes when verify re-runs after it', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
pipeline.steps.resync = {
|
||||
image: 'node:24-alpine',
|
||||
commands: ['git fetch origin', 'git reset --hard origin/main'],
|
||||
depends_on: [],
|
||||
};
|
||||
pipeline.steps.verify.depends_on = ['resync'];
|
||||
pipeline.steps.build.depends_on = ['verify'];
|
||||
// resync precedes verify in the chain, so verification covers the
|
||||
// re-checked-out HEAD — the exact-commit contract holds.
|
||||
assertPublishGateBlocksOnVerify(pipeline);
|
||||
});
|
||||
|
||||
test('S7 negative control: deleting the verify step entirely fails the checker', () => {
|
||||
const pipeline = healthyPipeline();
|
||||
delete pipeline.steps.verify;
|
||||
pipeline.steps['publish-npm'].depends_on = ['build'];
|
||||
assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /`verify` step/);
|
||||
});
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish @mosaicstack/* packages to npmjs.org as @mosaicstack/*
|
||||
#
|
||||
# This script patches each package.json to:
|
||||
# 1. Rename @mosaicstack/X → @mosaicstack/X
|
||||
# 2. Replace workspace:^ deps with resolved versions using @mosaicstack/* names
|
||||
# 3. Run npm publish
|
||||
# 4. Restore original package.json
|
||||
#
|
||||
# Usage:
|
||||
# scripts/publish-npmjs.sh [--dry-run] [--filter <package-name>]
|
||||
#
|
||||
# Requirements:
|
||||
# - NPM_TOKEN env var set (npmjs.org auth token)
|
||||
# - jq installed
|
||||
# - Run from monorepo root after `pnpm build`
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
FILTER=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--filter) FILTER="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Collect all publishable package directories (non-private, has publishConfig)
|
||||
PACKAGE_DIRS=()
|
||||
for pkg_json in "$REPO_ROOT"/packages/*/package.json "$REPO_ROOT"/plugins/*/package.json "$REPO_ROOT"/apps/gateway/package.json; do
|
||||
[[ -f "$pkg_json" ]] || continue
|
||||
is_private=$(jq -r '.private // false' "$pkg_json")
|
||||
[[ "$is_private" == "true" ]] && continue
|
||||
PACKAGE_DIRS+=("$(dirname "$pkg_json")")
|
||||
done
|
||||
|
||||
echo "Found ${#PACKAGE_DIRS[@]} publishable packages"
|
||||
|
||||
# Build a version map: @mosaicstack/X → version
|
||||
declare -A VERSION_MAP
|
||||
for dir in "${PACKAGE_DIRS[@]}"; do
|
||||
name=$(jq -r '.name' "$dir/package.json")
|
||||
version=$(jq -r '.version' "$dir/package.json")
|
||||
VERSION_MAP["$name"]="$version"
|
||||
done
|
||||
|
||||
# Configure npmjs auth
|
||||
if [[ -z "${NPM_TOKEN:-}" ]] && [[ "$DRY_RUN" == "false" ]]; then
|
||||
echo "ERROR: NPM_TOKEN is required for publishing. Set it or use --dry-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_package() {
|
||||
local dir="$1"
|
||||
local orig_json="$dir/package.json"
|
||||
local backup="$dir/package.json.bak"
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name' "$orig_json")
|
||||
|
||||
# Apply filter if set
|
||||
if [[ -n "$FILTER" ]] && [[ "$name" != "$FILTER" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local new_name="${name/@mosaic\//@mosaicstack/}"
|
||||
echo ""
|
||||
echo "━━━ Publishing $name → $new_name ━━━"
|
||||
|
||||
# Backup original
|
||||
cp "$orig_json" "$backup"
|
||||
|
||||
# Patch: rename package
|
||||
local patched
|
||||
patched=$(jq --arg new_name "$new_name" '.name = $new_name' "$orig_json")
|
||||
|
||||
# Patch: publishConfig to npmjs
|
||||
patched=$(echo "$patched" | jq '.publishConfig = {"registry": "https://registry.npmjs.org/", "access": "public"}')
|
||||
|
||||
# Patch: replace workspace:^ dependencies with @mosaicstack/* and resolved versions
|
||||
for dep_field in dependencies devDependencies peerDependencies; do
|
||||
if echo "$patched" | jq -e ".$dep_field" > /dev/null 2>&1; then
|
||||
local deps
|
||||
deps=$(echo "$patched" | jq -r ".$dep_field // {} | keys[]")
|
||||
for dep in $deps; do
|
||||
local dep_version
|
||||
dep_version=$(echo "$patched" | jq -r ".$dep_field[\"$dep\"]")
|
||||
|
||||
# Only transform @mosaicstack/* workspace deps
|
||||
if [[ "$dep" == @mosaicstack/* ]] && [[ "$dep_version" == workspace:* ]]; then
|
||||
local new_dep="${dep/@mosaic\//@mosaicstack/}"
|
||||
local resolved="${VERSION_MAP[$dep]:-}"
|
||||
|
||||
if [[ -z "$resolved" ]]; then
|
||||
echo " WARNING: No version found for $dep — using '*'"
|
||||
resolved="*"
|
||||
else
|
||||
# workspace:^ means ^version, workspace:* means *
|
||||
if [[ "$dep_version" == "workspace:^" ]]; then
|
||||
resolved="^$resolved"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rename the dep key and set the resolved version
|
||||
patched=$(echo "$patched" | jq \
|
||||
--arg field "$dep_field" \
|
||||
--arg old_dep "$dep" \
|
||||
--arg new_dep "$new_dep" \
|
||||
--arg version "$resolved" \
|
||||
'.[$field] |= (del(.[$old_dep]) | .[$new_dep] = $version)')
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Write patched package.json
|
||||
echo "$patched" > "$orig_json"
|
||||
|
||||
echo " Patched: $new_name"
|
||||
|
||||
# Publish
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo " [DRY RUN] npm publish --dry-run"
|
||||
(cd "$dir" && npm publish --dry-run 2>&1) || true
|
||||
else
|
||||
echo " Publishing to npmjs..."
|
||||
(cd "$dir" && npm publish 2>&1) || echo " WARNING: Publish failed (may already exist at this version)"
|
||||
fi
|
||||
|
||||
# Restore original
|
||||
mv "$backup" "$orig_json"
|
||||
echo " Restored original package.json"
|
||||
}
|
||||
|
||||
# Set up npmrc for npmjs
|
||||
if [[ -n "${NPM_TOKEN:-}" ]]; then
|
||||
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
fi
|
||||
|
||||
# Publish in dependency order: packages first, then plugins, then apps
|
||||
echo ""
|
||||
echo "=== Publishing packages ==="
|
||||
for dir in "$REPO_ROOT"/packages/*/; do
|
||||
[[ -f "$dir/package.json" ]] || continue
|
||||
publish_package "$dir"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Publishing plugins ==="
|
||||
for dir in "$REPO_ROOT"/plugins/*/; do
|
||||
[[ -f "$dir/package.json" ]] || continue
|
||||
publish_package "$dir"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Publishing apps ==="
|
||||
publish_package "$REPO_ROOT/apps/gateway"
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic structural check for the explicit dogfood Compose overlay.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/worktree" "$tmp/common.git" "$tmp/seat/secrets"
|
||||
|
||||
base_config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
docker compose --profile stack config --format json
|
||||
)
|
||||
|
||||
BASE_CONFIG_JSON="$base_config_json" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["BASE_CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
env = gateway["environment"]
|
||||
for key in (
|
||||
"MOSAIC_AGENT_NAME",
|
||||
"MOSAIC_GIT_IDENTITY",
|
||||
"MOSAIC_BRAIN_HOME",
|
||||
"AGENT_FILE_SANDBOX_DIR",
|
||||
"AGENT_USER_TOOLS",
|
||||
"AGENT_SHELL_ENABLED",
|
||||
"AGENT_DELIVERY_ENABLED",
|
||||
"MOSAIC_GIT_TOOLS_DIR",
|
||||
"MOSAIC_INTEGRATION_TRUNK",
|
||||
):
|
||||
assert key not in env, f"base compose unexpectedly sets dogfood variable {key}"
|
||||
|
||||
targets = {mount["target"] for mount in gateway["volumes"]}
|
||||
assert "/workspace/stack" not in targets
|
||||
assert not any(target.startswith("/opt/mosaic/brain/") for target in targets)
|
||||
PY
|
||||
|
||||
config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.dogfood.yml \
|
||||
--profile stack \
|
||||
config --format json
|
||||
)
|
||||
|
||||
CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_COMMON_GIT="$tmp/common.git" EXPECT_SEAT="$tmp/seat" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
assert gateway.get("init") is True, "gateway must run below an init process for R4 lineage"
|
||||
env = gateway["environment"]
|
||||
|
||||
expected_env = {
|
||||
"MOSAIC_AGENT_NAME": "code-dogfood-01",
|
||||
"MOSAIC_GIT_IDENTITY": "code-dogfood-01",
|
||||
"MOSAIC_BRAIN_HOME": "/opt/mosaic/brain",
|
||||
"AGENT_FILE_SANDBOX_DIR": "/workspace/stack",
|
||||
"AGENT_SHELL_ENABLED": "false",
|
||||
"AGENT_DELIVERY_ENABLED": "true",
|
||||
"MOSAIC_GIT_TOOLS_DIR": "/opt/mosaic/tools/git",
|
||||
"MOSAIC_INTEGRATION_TRUNK": "next",
|
||||
}
|
||||
for key, value in expected_env.items():
|
||||
assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}"
|
||||
|
||||
allowed = set(env["AGENT_USER_TOOLS"].split(","))
|
||||
assert allowed == {
|
||||
"fs_read_file",
|
||||
"fs_write_file",
|
||||
"fs_list_directory",
|
||||
"fs_edit_file",
|
||||
"git_status",
|
||||
"git_log",
|
||||
"git_diff",
|
||||
"git_publish_branch",
|
||||
"git_open_pull_request",
|
||||
}, f"unexpected dogfood tool set: {sorted(allowed)}"
|
||||
assert "shell_exec" not in allowed
|
||||
|
||||
mounts = {mount["target"]: mount for mount in gateway["volumes"]}
|
||||
worktree = mounts["/workspace/stack"]
|
||||
assert worktree["type"] == "bind"
|
||||
assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
|
||||
assert not worktree.get("read_only", False), "dogfood worktree must be writable"
|
||||
|
||||
common_git = mounts[os.environ["EXPECT_COMMON_GIT"]]
|
||||
assert common_git["type"] == "bind"
|
||||
assert common_git["source"] == os.environ["EXPECT_COMMON_GIT"]
|
||||
assert not common_git.get("read_only", False), "common Git directory must accept branch updates"
|
||||
|
||||
seat = mounts["/opt/mosaic/brain/fleet/agents/code-dogfood-01"]
|
||||
assert seat["type"] == "bind"
|
||||
assert seat["source"] == os.environ["EXPECT_SEAT"]
|
||||
assert seat.get("read_only") is True, "seat credential slot must be read-only"
|
||||
|
||||
other_seat_mounts = [
|
||||
target
|
||||
for target in mounts
|
||||
if target.startswith("/opt/mosaic/brain/fleet/agents/")
|
||||
and target != "/opt/mosaic/brain/fleet/agents/code-dogfood-01"
|
||||
]
|
||||
assert other_seat_mounts == [], f"other seat mounts leaked: {other_seat_mounts}"
|
||||
PY
|
||||
|
||||
# Each required path must fail closed rather than falling back to the current checkout.
|
||||
expect_missing_path() {
|
||||
local missing=$1 output rc
|
||||
set +e
|
||||
case "$missing" in
|
||||
MOSAIC_DOGFOOD_WORKTREE)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_WORKTREE \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_COMMON_GIT_DIR \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
MOSAIC_DOGFOOD_SEAT_HOME)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_SEAT_HOME \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
*)
|
||||
echo "FAIL: test requested unknown path variable $missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "FAIL: dogfood compose accepted missing $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *"$missing"* ]]; then
|
||||
echo "FAIL: missing-path failure did not name $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
expect_missing_path MOSAIC_DOGFOOD_WORKTREE
|
||||
expect_missing_path MOSAIC_DOGFOOD_COMMON_GIT_DIR
|
||||
expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME
|
||||
|
||||
printf 'dogfood compose verification passed\n'
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// verify-release.mjs — the ONE canonical terminal verification command
|
||||
// (SDLC-D-034, `pnpm verify:release`).
|
||||
//
|
||||
// Publication (.woodpecker/publish.yml `verify` step) is bound to terminal
|
||||
// verification of the exact commit through this command, which is composed
|
||||
// from the SAME commands the PR CI pipeline (.woodpecker/ci.yml) runs — CI and
|
||||
// publish share one semantic checklist:
|
||||
//
|
||||
// stage | mirrors ci.yml step | commands
|
||||
// --------------|---------------------|------------------------------------------
|
||||
// sanitization | sanitization | verify-sanitized.sh, check-resident-
|
||||
// | | budget.sh (--self-test + run),
|
||||
// | | check-test-enumeration.sh
|
||||
// upgrade-guard | upgrade-guard | test-upgrade-manifest-guard.sh,
|
||||
// | | test-upgrade-rollback.sh,
|
||||
// | | test-upgrade-durable-snapshot.sh,
|
||||
// | | test-install-migration.sh
|
||||
// typecheck | typecheck | pnpm typecheck (runs the checkout
|
||||
// | | preflight, then turbo typecheck)
|
||||
// lint | lint | pnpm lint
|
||||
// format | format | pnpm format:check
|
||||
// test | test | pnpm test
|
||||
// build | build (#1445, P6) | pnpm build (also publish.yml build)
|
||||
// quality-rails | (canonical-only) | the TS quality-rails evaluator
|
||||
// | | (RI-N4, QC-19 monorepo subject).
|
||||
// | | The one stage with no ci.yml
|
||||
// | | mirror; it is implemented by
|
||||
// | | importing the evaluator CLI rather
|
||||
// | | than duplicating its presence logic.
|
||||
//
|
||||
// Caller-provided prerequisites (kept at the pipeline level — see the comments
|
||||
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
|
||||
// the pinned @earendil-works/pi-coding-agent for the test stage, and — on the
|
||||
// postgres path only — the ci-postgres service plus
|
||||
// `pnpm --filter @mosaicstack/db run db:migrate` before the test stage.
|
||||
//
|
||||
// This command works with DATABASE_URL set (CI postgres path) or unset (local
|
||||
// PGlite path); it never sets, exports, or requires a database itself.
|
||||
//
|
||||
// scripts/verify-release.test.mjs enforces that this stage table keeps
|
||||
// matching .woodpecker/ci.yml step-for-step, so the two surfaces cannot drift
|
||||
// apart silently.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const STAGES = [
|
||||
{
|
||||
name: 'sanitization',
|
||||
// Mirror of the .woodpecker/ci.yml `sanitization` step (minus its
|
||||
// `apk add` environment prep). Kept as direct command strings here: the
|
||||
// #1017 test-enumeration guard audits these paths through the ci.yml
|
||||
// surface, so indirection from ci.yml into this file is not possible.
|
||||
commands: [
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
|
||||
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'upgrade-guard',
|
||||
// Mirror of the .woodpecker/ci.yml `upgrade-guard` step (minus its
|
||||
// `apk add` environment prep).
|
||||
commands: [
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
|
||||
'bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh',
|
||||
],
|
||||
},
|
||||
{
|
||||
// `pnpm typecheck` is `pnpm preflight && turbo run typecheck`, so the
|
||||
// checkout preflight (scripts/preflight.mjs) is part of this stage exactly
|
||||
// as it is part of the ci.yml `typecheck` step.
|
||||
name: 'typecheck',
|
||||
commands: ['pnpm typecheck'],
|
||||
},
|
||||
{
|
||||
name: 'lint',
|
||||
commands: ['pnpm lint'],
|
||||
},
|
||||
{
|
||||
name: 'format',
|
||||
commands: ['pnpm format:check'],
|
||||
},
|
||||
{
|
||||
// Requires `openssl` and the pinned `pi` binary on the pipeline path; see
|
||||
// the caller-provided prerequisites above.
|
||||
name: 'test',
|
||||
commands: ['pnpm test'],
|
||||
},
|
||||
{
|
||||
name: 'build',
|
||||
commands: ['pnpm build'],
|
||||
},
|
||||
{
|
||||
// RI-N4 (QC-19, card RI-3-002): the typed quality-rails evaluator, invoked
|
||||
// as the implementation of the check it owns instead of a duplicated
|
||||
// presence loop here. Canonical-only stage (no ci.yml mirror; `build`
|
||||
// gained one in #1445); runs AFTER build so the evaluator's dist/ exists. Subject
|
||||
// is this repository (`.` → monorepo subject kind, per-subject check set).
|
||||
name: 'quality-rails',
|
||||
commands: ['node packages/quality-rails/dist/cli.js quality-rails evaluate --project .'],
|
||||
},
|
||||
];
|
||||
|
||||
export function stageByName(name) {
|
||||
return STAGES.find((stage) => stage.name === name);
|
||||
}
|
||||
|
||||
function missingBinaries(bins) {
|
||||
return bins.filter(
|
||||
(bin) => spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]).status !== 0,
|
||||
);
|
||||
}
|
||||
|
||||
function runCommand(command) {
|
||||
const result = spawnSync(command, { shell: true, stdio: 'inherit' });
|
||||
if (result.error) {
|
||||
console.error(`[verify:release] failed to launch '${command}': ${result.error.message}`);
|
||||
return false;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const reason = result.signal ? `terminated by ${result.signal}` : `exited ${result.status}`;
|
||||
console.error(`[verify:release] command '${command}' ${reason}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runs the complete mandatory verification set (or, with --stage <name>, the
|
||||
// single named stage — used for wiring/smoke-testing, not for gating: only a
|
||||
// run of every stage is a terminal verification). Fails fast: the first
|
||||
// failing command aborts with a non-zero exit code. Returns the exit code.
|
||||
export function verifyRelease({ stages = STAGES } = {}) {
|
||||
const missing = missingBinaries(['bash', 'rsync']);
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`[verify:release] FATAL: required binaries missing from PATH: ${missing.join(', ')}. ` +
|
||||
'The caller provides them (ci-base bakes bash; pipelines apk add rsync).',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
for (const stage of stages) {
|
||||
console.log(`\n[verify:release] === stage: ${stage.name} ===`);
|
||||
for (const command of stage.commands) {
|
||||
console.log(`[verify:release] $ ${command}`);
|
||||
if (!runCommand(command)) {
|
||||
console.error(
|
||||
`[verify:release] FATAL: stage '${stage.name}' failed — verification inconclusive`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\n[verify:release] all ${stages.length} stage(s) passed`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const stageFlagIndex = argv.indexOf('--stage');
|
||||
if (stageFlagIndex !== -1) {
|
||||
const name = argv[stageFlagIndex + 1];
|
||||
const stage = stageByName(name);
|
||||
if (!stage) {
|
||||
console.error(
|
||||
`[verify:release] unknown stage '${name ?? ''}' — expected one of: ${STAGES.map((entry) => entry.name).join(', ')}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
process.exit(verifyRelease({ stages: [stage] }));
|
||||
}
|
||||
process.exit(verifyRelease());
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { STAGES, stageByName } from './verify-release.mjs';
|
||||
|
||||
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
|
||||
// bound to exact-commit terminal verification. This suite parses the real
|
||||
// pipeline files and fails red when the gate is bypassed, weakened, or drifts
|
||||
// out of sync with the canonical `pnpm verify:release` command. The negative
|
||||
// controls for pipeline DAG/bypass shapes live in
|
||||
// scripts/publish-gate-structure.test.mjs (RI-1-002); this file owns the
|
||||
// canonical-command composition controls.
|
||||
|
||||
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
|
||||
// dependency) instead of adding a root dependency or vendoring a parser.
|
||||
const mosaicRequire = createRequire(
|
||||
path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
|
||||
);
|
||||
const { parse: parseYaml } = mosaicRequire('yaml');
|
||||
|
||||
const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
|
||||
const ciYmlPath = path.join(process.cwd(), '.woodpecker', 'ci.yml');
|
||||
|
||||
async function readPublishPipeline() {
|
||||
return parseYaml(await readFile(publishYmlPath, 'utf8'));
|
||||
}
|
||||
|
||||
// A step has an external publication effect when its name starts with
|
||||
// `publish` or when any command pushes an image to a registry.
|
||||
function pushesImage(step) {
|
||||
return (step.commands ?? []).some((command) =>
|
||||
/(^|\s)(\/kaniko\/executor|docker push)\b|--destination/.test(command),
|
||||
);
|
||||
}
|
||||
|
||||
function publishEffectSteps(pipeline) {
|
||||
return Object.entries(pipeline.steps ?? {})
|
||||
.filter(([name, step]) => name.startsWith('publish') || pushesImage(step))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
// Transitive closure of a step's depends_on graph.
|
||||
function dependencyClosure(pipeline, stepName, seen = new Set()) {
|
||||
const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
|
||||
for (const dependency of dependencies) {
|
||||
if (seen.has(dependency)) continue;
|
||||
seen.add(dependency);
|
||||
dependencyClosure(pipeline, dependency, seen);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
function verifyCommands(pipeline) {
|
||||
const verify = pipeline.steps?.verify;
|
||||
assert.ok(verify, 'publish pipeline must define a `verify` step');
|
||||
assert.ok(Array.isArray(verify.commands), '`verify` step must have commands');
|
||||
return verify.commands;
|
||||
}
|
||||
|
||||
function assertCommitIdentityAssertion(commands) {
|
||||
const text = commands.join('\n');
|
||||
assert.match(
|
||||
text,
|
||||
/CI_COMMIT_SHA/,
|
||||
'`verify` must compare the provider commit identity (CI_COMMIT_SHA)',
|
||||
);
|
||||
assert.match(text, /git rev-parse HEAD/, '`verify` must compare against git rev-parse HEAD');
|
||||
assert.match(
|
||||
text,
|
||||
/exit 1/,
|
||||
'`verify` must fail closed (exit 1) on identity mismatch or emptiness',
|
||||
);
|
||||
}
|
||||
|
||||
function assertCanonicalCommand(commands) {
|
||||
assert.ok(
|
||||
commands.some((command) => /^pnpm verify:release\b/.test(command.trim())),
|
||||
'`verify` must run the canonical terminal verification command `pnpm verify:release`',
|
||||
);
|
||||
}
|
||||
|
||||
function assertPublishGate(pipeline) {
|
||||
assert.ok(pipeline.steps, 'publish pipeline must define steps');
|
||||
|
||||
const commands = verifyCommands(pipeline);
|
||||
assertCommitIdentityAssertion(commands);
|
||||
assertCanonicalCommand(commands);
|
||||
|
||||
const effects = publishEffectSteps(pipeline);
|
||||
assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');
|
||||
|
||||
for (const stepName of effects) {
|
||||
const step = pipeline.steps[stepName];
|
||||
assert.ok(
|
||||
Array.isArray(step.depends_on) && step.depends_on.includes('verify'),
|
||||
`publish effect '${stepName}' must depend DIRECTLY on the verify step (SDLC-D-034: transitively through build is not enough)`,
|
||||
);
|
||||
assert.ok(
|
||||
dependencyClosure(pipeline, stepName).has('verify'),
|
||||
`publish effect '${stepName}' must depend on a chain that includes verify`,
|
||||
);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
test('the publish pipeline gates every publish effect behind exact-commit verification', async () => {
|
||||
const pipeline = await readPublishPipeline();
|
||||
const effects = assertPublishGate(pipeline);
|
||||
assert.deepEqual(effects.sort(), [
|
||||
'build-appservice',
|
||||
'build-gateway',
|
||||
'publish-next-npm',
|
||||
'publish-npm',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the verify step carries no path/event short-circuit of its own', async () => {
|
||||
const pipeline = await readPublishPipeline();
|
||||
// A `when` filter on `verify` would let a publish effect fire on an event
|
||||
// class that skipped verification — the gate must be unconditional.
|
||||
assert.equal(pipeline.steps.verify.when, undefined);
|
||||
});
|
||||
|
||||
test('a publish step that bypasses verify fails the gate checker', () => {
|
||||
// Negative fixture: a plausible publish pipeline where `publish-npm` hangs
|
||||
// off `build` only and `build` never chains to `verify` — the exact bypass
|
||||
// class SDLC-D-034 closes. The checker must go red on it.
|
||||
const bypassingPipeline = `
|
||||
steps:
|
||||
install:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm install --frozen-lockfile
|
||||
verify:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- |
|
||||
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "identity mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
- pnpm verify:release
|
||||
depends_on:
|
||||
- install
|
||||
build:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- install
|
||||
publish-npm:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm publish
|
||||
depends_on:
|
||||
- build
|
||||
`;
|
||||
assert.throws(
|
||||
() => assertPublishGate(parseYaml(bypassingPipeline)),
|
||||
/publish-npm.*DIRECTLY.*verify/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('a publish step chained to verify only transitively fails the gate checker', () => {
|
||||
// Negative fixture: `build` depends on verify but `publish-npm` does not
|
||||
// carry the direct edge — weaker than SDLC-D-034 requires of the real DAG.
|
||||
const transitiveOnlyPipeline = `
|
||||
steps:
|
||||
install:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm install --frozen-lockfile
|
||||
verify:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- |
|
||||
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "identity mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
- pnpm verify:release
|
||||
depends_on:
|
||||
- install
|
||||
build:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- install
|
||||
- verify
|
||||
publish-npm:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm publish
|
||||
depends_on:
|
||||
- build
|
||||
`;
|
||||
assert.throws(
|
||||
() => assertPublishGate(parseYaml(transitiveOnlyPipeline)),
|
||||
/publish-npm.*DIRECTLY.*verify/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('a verify step without the commit-identity assertion fails the gate checker', () => {
|
||||
const noIdentityPipeline = `
|
||||
steps:
|
||||
verify:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm verify:release
|
||||
publish-npm:
|
||||
image: node:24-alpine
|
||||
commands:
|
||||
- pnpm publish
|
||||
depends_on:
|
||||
- verify
|
||||
`;
|
||||
assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/);
|
||||
});
|
||||
|
||||
// The composition check: the canonical stage table must mirror the PR CI
|
||||
// pipeline's complete mandatory set. Parameterized by the stage list so the
|
||||
// subset negative control below can prove a dropped stage goes red (RI-1-002:
|
||||
// the canonical command cannot silently lose a check).
|
||||
function assertStagesMirrorCi(stages, ci) {
|
||||
const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));
|
||||
|
||||
// The complete mandatory set, in gate order. `quality-rails` is the one
|
||||
// canonical-only stage (RI-N4, QC-19) with no ci.yml mirror to match — its
|
||||
// contract is asserted separately below. `build` gained a ci.yml mirror in
|
||||
// #1445 (P6) and is enforced with the other pnpm stages.
|
||||
assert.deepEqual(
|
||||
stages.map((stage) => stage.name),
|
||||
[
|
||||
'sanitization',
|
||||
'upgrade-guard',
|
||||
'typecheck',
|
||||
'lint',
|
||||
'format',
|
||||
'test',
|
||||
'build',
|
||||
'quality-rails',
|
||||
],
|
||||
);
|
||||
|
||||
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
|
||||
// exactly the canonical stage commands (order included).
|
||||
for (const stageName of ['sanitization', 'upgrade-guard']) {
|
||||
assert.deepEqual(
|
||||
ci.steps[stageName].commands.filter((command) => !command.startsWith('apk add')),
|
||||
canonical[stageName],
|
||||
`canonical '${stageName}' stage must match the ci.yml step`,
|
||||
);
|
||||
}
|
||||
|
||||
// pnpm stages: ci.yml commands minus `corepack enable` must be exactly the
|
||||
// canonical stage commands.
|
||||
for (const stepName of ['typecheck', 'lint', 'format', 'build']) {
|
||||
assert.deepEqual(
|
||||
ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'),
|
||||
canonical[stepName],
|
||||
`canonical '${stepName}' stage must match the ci.yml step`,
|
||||
);
|
||||
}
|
||||
|
||||
// The test stage is shared, but ci.yml wraps it in pipeline-level
|
||||
// prerequisites the canonical command expects its caller to provide
|
||||
// (SDLC-D-034): the postgres service + readiness wait + db:migrate, openssl,
|
||||
// and the pinned pi runtime. None of those may be dropped silently.
|
||||
for (const command of canonical.test) {
|
||||
assert.ok(
|
||||
ci.steps.test.commands.includes(command),
|
||||
`ci.yml test step must run the canonical test stage command '${command}'`,
|
||||
);
|
||||
}
|
||||
for (const fragment of [
|
||||
'pg_isready -h ci-postgres',
|
||||
'pnpm --filter @mosaicstack/db run db:migrate',
|
||||
'npm install -g @earendil-works/[email protected]',
|
||||
]) {
|
||||
assert.ok(
|
||||
ci.steps.test.commands.some((command) => command.includes(fragment)),
|
||||
`ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
|
||||
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
|
||||
assertStagesMirrorCi(STAGES, ci);
|
||||
});
|
||||
|
||||
test('a subset stage list fails the composition check — a dropped stage cannot pass silently', async () => {
|
||||
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
|
||||
// Drop each stage one at a time: every stage is load-bearing, so every drop
|
||||
// must go red. If any drop went green, a refactor could silently delete a
|
||||
// mandatory check from the canonical command.
|
||||
for (const stage of STAGES) {
|
||||
const subset = STAGES.filter((entry) => entry.name !== stage.name);
|
||||
assert.throws(
|
||||
() => assertStagesMirrorCi(subset, ci),
|
||||
Error,
|
||||
`composition check must fail when the '${stage.name}' stage is dropped from the table`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the root package.json exposes verify:release as the canonical command', async () => {
|
||||
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
|
||||
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
|
||||
});
|
||||
|
||||
// RI-N4 (card RI-3-002): the `quality-rails` stage must route through the TS
|
||||
// evaluator instead of duplicating its presence logic inline. The evaluator
|
||||
// owns QC-19; this file keeps that delegation honest.
|
||||
function assertEvaluatorStage(stage) {
|
||||
assert.ok(stage, 'canonical stages must include a quality-rails stage');
|
||||
assert.ok(Array.isArray(stage.commands) && stage.commands.length > 0);
|
||||
for (const command of stage.commands) {
|
||||
assert.match(
|
||||
command,
|
||||
/packages\/quality-rails\/dist\/cli\.js.*quality-rails evaluate/,
|
||||
`quality-rails stage command must invoke the evaluator CLI, got: '${command}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('the quality-rails stage invokes the evaluator rather than duplicating its logic', () => {
|
||||
assertEvaluatorStage(stageByName('quality-rails'));
|
||||
});
|
||||
|
||||
test('a quality-rails stage that re-implements presence logic inline fails the checker', () => {
|
||||
// Negative control: replacing the evaluator invocation with an inline
|
||||
// `test -f` presence loop is exactly the duplication RI-N4 forbids — the
|
||||
// checker must go red on it.
|
||||
const duplicated = {
|
||||
name: 'quality-rails',
|
||||
commands: ['test -f .husky/pre-commit && test -f .husky/pre-push'],
|
||||
};
|
||||
assert.throws(() => assertEvaluatorStage(duplicated), /must invoke the evaluator CLI/);
|
||||
});
|
||||
|
||||
test('a quality-rails stage that silently drops the evaluator command fails the checker', () => {
|
||||
const empty = { name: 'quality-rails', commands: [] };
|
||||
assert.throws(() => assertEvaluatorStage(empty), /commands/);
|
||||
});
|
||||
Reference in New Issue
Block a user