diff --git a/packages/mosaic/framework/defaults/wake-watch-list.schema.json b/packages/mosaic/framework/defaults/wake-watch-list.schema.json new file mode 100644 index 00000000..959c0b60 --- /dev/null +++ b/packages/mosaic/framework/defaults/wake-watch-list.schema.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaicstack.dev/schemas/wake-watch-list.schema.json", + "title": "Mosaic Wake Watch-List", + "description": "Declarative watch-list for the wake/heartbeat detector (EPIC #892). The SCHEMA is framework-owned; the VALUES are operator-supplied (repos, board files, lane anchors, per-class SLOs). This is the W2 schema contract only — the detector (W4) and digest renderer (W3) consume it. Per CONVERGED-DESIGN §1.4: 'operator repo; schema is framework, values are operator.'", + "type": "object", + "required": ["schema_version", "watches"], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Watch-list schema version. The wake component's manifest.txt declares the supported range (schema_min/schema_max, Gate B); a watch-list outside that range is rejected by the component, not silently coerced." + }, + "host": { + "type": "string", + "description": "Optional operator label for the host this watch-list serves. Per-host single-instance detector (§1.1). Operator-supplied; no semantic meaning to the schema." + }, + "repos": { + "type": "array", + "description": "Git repositories to watch. Source SHAs are descriptors, not the cursor (§2.4).", + "items": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Operator-chosen stable identifier for this repo watch." + }, + "remote": { + "type": "string", + "description": "Remote/clone locator (operator-supplied). No credentials inline; secrets are by-name via load_credentials." + }, + "branches": { + "type": "array", + "items": { "type": "string" }, + "description": "Branch refs to track. Empty => default branch." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" }, + "aba_sensitive": { + "type": "boolean", + "default": false, + "description": "If true, this source needs an event-stream/webhook rather than poll-only (intra-poll ABA mitigation, §2.4 / gate G5). Poll-only remains a mitigation, not elimination." + } + } + } + }, + "board_files": { + "type": "array", + "description": "Board / decision files whose edits must be caught (repo-section/anchor-scoped hashing, §1.1). Human-decision file edits, not just API-visible state.", + "items": { + "type": "object", + "required": ["id", "path"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "repo": { + "type": "string", + "description": "Optional reference to a repos[].id this file lives in." + }, + "path": { + "type": "string", + "description": "File path (operator-supplied). Locators are hard: repo/issue#/SHA/file:anchor (§2.1)." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" } + } + } + }, + "lane_anchors": { + "type": "array", + "description": "In-file anchors (headings/markers) scoping a lane's obligations, so a file edit outside the lane's anchor does not wake it.", + "items": { + "type": "object", + "required": ["id", "anchor"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "board_file": { + "type": "string", + "description": "Optional reference to a board_files[].id this anchor lives in." + }, + "anchor": { + "type": "string", + "description": "Anchor text/marker delimiting the lane's section within the file." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" } + } + } + }, + "slos": { + "type": "object", + "description": "Named per-class urgency SLO tiers. SYMBOLIC — the operator sets concrete durations; the schema only fixes the shape and the class ordering intent (§4: security/lease/CI = tight; board = tens of minutes; routine = hours). No numeric parameters are baked into the framework.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "class": { "$ref": "#/$defs/class" }, + "fallback_bound": { + "type": "string", + "description": "Operator-supplied duration (e.g. '5m', '30m', '4h'). Symbolic tier is set by the operator, not the framework." + }, + "quiet_hours_may_suppress": { + "type": "boolean", + "default": false, + "description": "If true, quiet-hours may suppress the cold fallback for this tier. MUST remain false for actionable/critical classes (§3: quiet-hours never gate an actionable/critical class)." + }, + "measure_to": { + "type": "string", + "enum": ["consumed", "qualified-action-or-handoff"], + "description": "Terminal the SLO is measured to (§4/G8): CONSUMED measures reading; qualified-action-or-handoff measures doing. Actionable/critical classes measure to the action terminal." + } + } + } + }, + "watches": { + "type": "array", + "description": "The declared source-coverage inventory: a lane-by-lane list of every operational source the lane depends on, so an omitted source cannot make the retirement vector pass vacuously (§4/G3 parity inventory). Each entry references a source declared above by kind+id.", + "items": { + "type": "object", + "required": ["lane", "sources"], + "additionalProperties": false, + "properties": { + "lane": { + "type": "string", + "description": "Operator lane identifier this watch serves." + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["kind", "id"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["repo", "board_file", "lane_anchor"], + "description": "Which top-level collection the id refers to." + }, + "id": { + "type": "string", + "description": "Reference to repos[].id / board_files[].id / lane_anchors[].id." + } + } + } + } + } + } + } + }, + "$defs": { + "class": { + "type": "string", + "enum": ["digest", "actionable", "human", "terminal-log", "reaction"], + "description": "Wake class (§2.3). Only `digest` coalesces (cumulative-state replace); actionable/human APPEND. ALL classes are durable. Absent class => the consumer treats it as `actionable` (fail-safe)." + }, + "slo_ref": { + "type": "string", + "description": "Name of an entry in the top-level `slos` map to apply to this source." + } + } +} diff --git a/packages/mosaic/framework/tools/wake/_wake-common.sh b/packages/mosaic/framework/tools/wake/_wake-common.sh new file mode 100755 index 00000000..68e701ad --- /dev/null +++ b/packages/mosaic/framework/tools/wake/_wake-common.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# _wake-common.sh — shared state resolution + atomic-write primitive for the +# wake/heartbeat durable queue (W2 of the wake canon, EPIC #892). +# +# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): +# §1.2 three-cursor durable queue; ALL state XDG, atomic write-tmp+rename. +# §2.3 durability is NEVER bypassed. +# +# This file is sourced by store.sh and ack.sh. It defines NO top-level actions; +# sourcing it is side-effect-free except for setting readonly path vars. +# +# Operator-agnostic (framework firewall): state location is derived purely from +# XDG / env. No operator paths, names, or secrets appear here. + +# --------------------------------------------------------------------------- +# State layout (XDG). §1.2: "all state XDG". +# base = ${WAKE_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake} +# agent = ${WAKE_AGENT:-default} (per-agent queue namespace) +# STATE_DIR = / +# --------------------------------------------------------------------------- +wake_state_dir() { + local base agent + base="${WAKE_STATE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake}" + agent="${WAKE_AGENT:-default}" + printf '%s/%s' "$base" "$agent" +} + +# File names within STATE_DIR. +# observed_seq — highest observed_seq ever recorded (max monotonic int). +# consumed_seq — consumer cursor: top of the contiguous consumed prefix. +# observed.set — observed seqs in the live window (> consumed_seq), one int +# per line; the gap-detector's source of truth. Immune to +# coalescing (coalescing removes a pending ENTRY, never the +# fact that its seq was observed). +# pending.jsonl — the durable pending-inbox: one entry JSON object per line, +# {observed_seq, locators, class, emit_ts, hmac}. +# ack-ledger.jsonl — local-write-only ack ledger (RECEIVED / CONSUMED). +# ack-sync.state — background-sync bookkeeping (last shipped / outage flag). + +# _wake_tmp_glob DIR — the glob used for atomic-write temp files, so readers can +# ignore in-flight/crashed writes. A crash leaves one of these; it is NEVER the +# live file (only rename promotes content), so it can never corrupt a read. +_wake_tmp_prefix='.wake.tmp.' + +# _atomic_write TARGET (content on stdin) +# §1.2 / task: EVERY state mutation is atomic write-tmp+rename. Write a temp +# file in the SAME directory (so mv is a same-filesystem atomic rename), fsync +# is best-effort, then rename over the target. A crash before the rename leaves +# the old target fully intact and a stale .wake.tmp.* that readers ignore. +_atomic_write() { + local target="$1" dir tmp + dir="$(dirname "$target")" + [ -d "$dir" ] || mkdir -p "$dir" + tmp="$(mktemp "$dir/${_wake_tmp_prefix}XXXXXX")" || return 1 + if ! cat >"$tmp"; then + rm -f "$tmp" + return 1 + fi + # Best-effort durability of the temp file before the rename. Not fatal if the + # platform lacks it — the rename atomicity is the load-bearing guarantee. + sync "$tmp" 2>/dev/null || true + if ! mv -f "$tmp" "$target"; then + rm -f "$tmp" + return 1 + fi +} + +# _wake_clean_stale_tmp DIR — remove leftover atomic-write temp files (e.g. from +# a crash mid-write). Safe: these are never the live store. +_wake_clean_stale_tmp() { + local dir="$1" + [ -d "$dir" ] || return 0 + find "$dir" -maxdepth 1 -name "${_wake_tmp_prefix}*" -type f -delete 2>/dev/null || true +} + +# _wake_read_int FILE DEFAULT — read a single integer from FILE, or DEFAULT. +_wake_read_int() { + local file="$1" def="$2" val + if [ -f "$file" ]; then + val="$(tr -d '[:space:]' <"$file")" + case "$val" in + '' | *[!0-9]*) printf '%s' "$def" ;; + *) printf '%s' "$val" ;; + esac + else + printf '%s' "$def" + fi +} + +# _wake_init_dir STATE_DIR — ensure the state layout exists; idempotent. +_wake_init_dir() { + local dir="$1" + mkdir -p "$dir" || return 1 + _wake_clean_stale_tmp "$dir" + [ -f "$dir/observed_seq" ] || printf '0' | _atomic_write "$dir/observed_seq" + [ -f "$dir/consumed_seq" ] || printf '0' | _atomic_write "$dir/consumed_seq" + [ -f "$dir/observed.set" ] || printf '' | _atomic_write "$dir/observed.set" + [ -f "$dir/pending.jsonl" ] || printf '' | _atomic_write "$dir/pending.jsonl" + [ -f "$dir/ack-ledger.jsonl" ] || printf '' | _atomic_write "$dir/ack-ledger.jsonl" +} diff --git a/packages/mosaic/framework/tools/wake/ack.sh b/packages/mosaic/framework/tools/wake/ack.sh new file mode 100755 index 00000000..413b0208 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/ack.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# ack.sh — A4 of the wake canon (EPIC #892, W2): the RECEIVED / CONSUMED +# ack-wrapper. +# +# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): +# §2.2 ack protocol: RECEIVED vs CONSUMED; local-write + async ship. +# §1.2 three-cursor: consumed_seq advances ONLY on a consumer CONSUMED ack. +# +# RECEIVED — delivery happened (paste landed). `wake_id` dedups DELIVERY ONLY: +# a duplicate delivery is a re-RECEIVE, NEVER a re-action. +# CONSUMED N — emitted ONLY after durable capture OR no-op disposition of a +# CONTIGUOUS prefix <=N. first-action-before-capture is FORBIDDEN +# (caller discipline: capture BEFORE invoking this). Cannot ack N +# while N-1 is unconsumed (the store enforces the gapless prefix). +# Acks are CUMULATIVE: CONSUMED N implies all <=N. +# +# LOCAL-WRITE-ONLY: the ack path writes the XDG ledger and advances the cursor +# with NO network call. A BACKGROUND sync ships the ack (WAKE_ACK_SYNC_CMD). +# Ack-path outage => a single re-wake after timeout then fall back to cadence +# (no retry spam) — the background sync runs ONCE and records an outage marker; +# it never loops. +# +# The `embed` subcommand prints the one copy-run line meant to be EMBEDDED in +# each digest (W3 renders it into the digest body). +# +# Operator-agnostic: ledger via XDG/env only; no operator paths/names/secrets. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./_wake-common.sh disable=SC1091 +. "$SCRIPT_DIR/_wake-common.sh" + +STATE_DIR="$(wake_state_dir)" +STORE_SH="$SCRIPT_DIR/store.sh" + +_need_jq() { + command -v jq >/dev/null 2>&1 || { + echo "ack.sh: jq is required" >&2 + exit 3 + } +} + +usage() { + cat >&2 <<'EOF' +Usage: ack.sh [options] + +Commands: + received --wake-id ID Record a RECEIVED ack (delivery). The + wake_id DEDUPS DELIVERY only: a repeat + is a re-RECEIVE (prints DUP), never a + re-action. Local-write only. + consumed --upto N [--wake-id ID] Record a CONSUMED ack for the contiguous + prefix <=N and advance consumed_seq. + Local-write only; a background sync + ships it (never blocks on network). + --no-sync suppresses the background ship. + embed --upto N [--wake-id ID] Print the copy-run ack line to EMBED in + a digest (does not perform the ack). + status Print ledger tail + cursor state. + +Environment: + WAKE_STATE_HOME override base state dir (XDG by default). + WAKE_AGENT per-agent queue namespace (default: default). + WAKE_ACK_SYNC_CMD command run in the BACKGROUND to ship an ack. Receives the + ack JSON on stdin. Never invoked on the synchronous path. + WAKE_ACK_CLI override the embedded copy-run invocation prefix (default: + the resolved path to this script). +EOF +} + +# _ledger_append JSON — append one ack record to the local ledger, atomically. +# §2.2: LOCAL-WRITE-ONLY. No network here. +_ledger_append() { + local record="$1" + _wake_init_dir "$STATE_DIR" + { + cat "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null + printf '%s\n' "$record" + } | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/ack-ledger.jsonl" +} + +# _wake_id_seen ID — true if a RECEIVED record for this wake_id already exists. +# Count-based (jq -s slurp): an EMPTY ledger slurps to [] => length 0. (jq -e +# over a zero-input file returns exit 0, which would false-positive here — so +# never rely on -e exit semantics for presence.) +_wake_id_seen() { + local id="$1" n + [ -f "$STATE_DIR/ack-ledger.jsonl" ] || return 1 + n="$(jq -s --arg id "$id" \ + '[.[] | select(.type=="RECEIVED" and .wake_id==$id)] | length' \ + "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null || echo 0)" + [ "${n:-0}" -gt 0 ] +} + +# _ship_async JSON — fire the background sync ONCE, fully detached, so the ack +# path returns immediately and NEVER blocks on the network (§2.2). No retry +# loop: a failure records an outage marker and stops (single re-wake + cadence +# fallback is the escalation policy, handled off this path). +_ship_async() { + local record="$1" + [ -n "${WAKE_ACK_SYNC_CMD:-}" ] || return 0 + local marker="$STATE_DIR/ack-sync.state" + ( + if printf '%s\n' "$record" | sh -c "$WAKE_ACK_SYNC_CMD" >/dev/null 2>&1; then + printf 'shipped %s\n' "$(date +%s)" >"$marker" 2>/dev/null || true + else + printf 'outage %s\n' "$(date +%s)" >"$marker" 2>/dev/null || true + fi + ) /dev/null 2>&1 & + # Detach so no shell job-control state ties the ack turn to the ship. + disown 2>/dev/null || true +} + +cmd_received() { + _need_jq + local wake_id='' + while [ $# -gt 0 ]; do + case "$1" in + --wake-id) + wake_id="${2:-}" + shift 2 + ;; + *) + echo "ack.sh received: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + [ -n "$wake_id" ] || { + echo "ack.sh received: --wake-id is required" >&2 + exit 2 + } + + _wake_init_dir "$STATE_DIR" + + # wake_id dedups DELIVERY only. A duplicate delivery is a re-RECEIVE — logged, + # but flagged dup so no consumer re-action is triggered. It NEVER advances + # consumed_seq (delivery != consumption). + local dup="false" verb="RECEIVED" + if _wake_id_seen "$wake_id"; then + dup="true" + verb="DUP" + fi + + local record + record="$(jq -cn \ + --arg id "$wake_id" \ + --argjson dup "$dup" \ + --argjson ts "$(date +%s)" \ + '{type:"RECEIVED", wake_id:$id, dup:$dup, ts:$ts}')" + _ledger_append "$record" + echo "$verb" +} + +cmd_consumed() { + _need_jq + local upto='' wake_id='' do_sync="1" + while [ $# -gt 0 ]; do + case "$1" in + --upto) + upto="${2:-}" + shift 2 + ;; + --wake-id) + wake_id="${2:-}" + shift 2 + ;; + --no-sync) + do_sync="0" + shift + ;; + *) + echo "ack.sh consumed: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + case "$upto" in + '' | *[!0-9]*) + echo "ack.sh consumed: --upto must be a non-negative integer" >&2 + exit 2 + ;; + esac + + # Advance consumed_seq via the store. The store enforces the CONTIGUOUS + # gapless-prefix rule and rejects a gap (cannot ack N while N-1 unconsumed). + # This is a LOCAL-WRITE cursor advance — no network. + local new_cursor + if ! new_cursor="$("$STORE_SH" consume --upto "$upto" 2>&1)"; then + echo "ack.sh consumed: refused — $new_cursor" >&2 + exit 1 + fi + + # Record the CONSUMED ack in the local ledger (still no network). + local record + record="$(jq -cn \ + --argjson upto "$upto" \ + --arg id "$wake_id" \ + --argjson ts "$(date +%s)" \ + '{type:"CONSUMED", upto:$upto, wake_id:$id, ts:$ts}')" + _ledger_append "$record" + + # Only now, OFF the ack path, ship it in the background. The synchronous + # portion is already complete and durable. + if [ "$do_sync" = "1" ]; then + _ship_async "$record" + fi + + echo "CONSUMED $new_cursor" +} + +cmd_embed() { + local upto='' wake_id='' + while [ $# -gt 0 ]; do + case "$1" in + --upto) + upto="${2:-}" + shift 2 + ;; + --wake-id) + wake_id="${2:-}" + shift 2 + ;; + *) + echo "ack.sh embed: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + case "$upto" in + '' | *[!0-9]*) + echo "ack.sh embed: --upto must be a non-negative integer" >&2 + exit 2 + ;; + esac + # The one copy-run line a digest embeds. WAKE_ACK_CLI lets an installer point + # it at the deployed path; default is this script's resolved path. + local cli="${WAKE_ACK_CLI:-$SCRIPT_DIR/ack.sh}" + if [ -n "$wake_id" ]; then + printf '%s consumed --upto %s --wake-id %s\n' "$cli" "$upto" "$wake_id" + else + printf '%s consumed --upto %s\n' "$cli" "$upto" + fi +} + +cmd_status() { + _wake_init_dir "$STATE_DIR" + echo "# cursors" + "$STORE_SH" cursors + echo "# ack-ledger (tail)" + tail -n 10 "$STATE_DIR/ack-ledger.jsonl" 2>/dev/null || true + if [ -f "$STATE_DIR/ack-sync.state" ]; then + echo "# sync" + cat "$STATE_DIR/ack-sync.state" + fi +} + +main() { + [ $# -ge 1 ] || { + usage + exit 2 + } + local cmd="$1" + shift + case "$cmd" in + received) cmd_received "$@" ;; + consumed) cmd_consumed "$@" ;; + embed) cmd_embed "$@" ;; + status) cmd_status "$@" ;; + -h | --help | help) usage ;; + *) + echo "ack.sh: unknown command '$cmd'" >&2 + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt new file mode 100644 index 00000000..ed4a734f --- /dev/null +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -0,0 +1,29 @@ +# Mosaic wake component — VERSION metadata manifest (Gate B). +# +# EPIC #892, W2 of the wake/heartbeat canon. +# +# SCOPE — THIS FILE IS VERSION METADATA ONLY. It declares the wake component's +# semantic version and the RANGE of watch-list schema versions it supports. It +# does NOT authorize file/path ownership: path-ownership remains the sole domain +# of packages/mosaic/framework/framework-manifest.txt (Gate A). Do not read any +# ownership meaning into this file. +# +# Format: KEY=VALUE, one per line. '#' and blank lines ignored. + +# Component identity + semantic version (the store+drain lib + ack-wrapper). +component=wake +version=0.1.0 + +# Watch-list schema this component consumes, and the INCLUSIVE range of +# schema_version values it supports. A wake-watch-list.json whose schema_version +# falls outside [schema_min, schema_max] is rejected by the component (fail-loud), +# never silently coerced. +schema=wake-watch-list +schema_min=1 +schema_max=1 + +# W2 pieces shipped by this component version (informational): +# store.sh A2 — three-cursor durable store + drain lib. +# ack.sh A4 — RECEIVED/CONSUMED ack-wrapper (local-write + async ship). +# Out of W2 scope (later waves): detector (W4), digest renderer/HMAC (W3), +# FN-oracle/reconciler (W5), beacon (W6), installer (W7). diff --git a/packages/mosaic/framework/tools/wake/store.sh b/packages/mosaic/framework/tools/wake/store.sh new file mode 100755 index 00000000..f4577152 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/store.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# store.sh — A2 of the wake canon (EPIC #892, W2): the THREE-CURSOR durable +# queue (store + drain lib). +# +# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md): +# §1.2 three-cursor data-flow: observe -> durable store -> coalesce -> drain. +# §2.3 classes & durability: ALL classes durable; coalescing is optional. +# §2.4 cursor semantics: observed_seq is authoritative; SHAs are descriptors. +# +# Three cursors: +# observed_seq (detector-owned; this lib RECORDS/stores it) — monotonic int +# assigned at observation. Authoritative. Source SHAs are +# descriptors, NOT the cursor (§2.4). +# pending-inbox (durable) — append store; retains until CONSUMED; survives +# pane death / park / restart. +# consumed_seq (consumer-owned) — advances ONLY on a CONSUMED ack, over a +# CONTIGUOUS gapless prefix <=N. NEVER on sender exit-0 / paste. +# +# Coalesce (§1.2/§2.3): class=digest REPLACES the pending cumulative-state entry +# (newest subsumes prior). Non-coalescible classes (actionable, human, ...) +# APPEND. ALL classes are stored durably — durability is NEVER bypassed. +# Absent class => treated as `actionable` (fail-safe). +# +# HMAC: entries carry an `hmac` field left as an UNSIGNED PLACEHOLDER (""). +# Signing is W3/A5, explicitly NOT W2. +# +# Every state mutation is atomic write-tmp+rename (see _wake-common.sh). +# +# Operator-agnostic: state via XDG/env only; no operator paths/names/secrets. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./_wake-common.sh disable=SC1091 +. "$SCRIPT_DIR/_wake-common.sh" + +STATE_DIR="$(wake_state_dir)" + +_need_jq() { + command -v jq >/dev/null 2>&1 || { + echo "store.sh: jq is required" >&2 + exit 3 + } +} + +usage() { + cat >&2 <<'EOF' +Usage: store.sh [options] + +Commands: + init Create/repair the XDG state layout. + enqueue --seq N --class C [opts] Record an observation into the durable + pending-inbox (coalesce or append). + --seq N observed_seq (detector-assigned monotonic int). Required. + --class C digest|actionable|human|terminal-log|reaction. + Absent/empty => actionable (fail-safe). + --locators JSON JSON value for the entry's locators (default {}). + --emit-ts TS epoch seconds (default: now). + drain [--require-idle-cmd CMD] Emit the deliverable set (pending-inbox, + coalesced) as JSONL. WHAT to deliver; + the actual paste is out of scope. + If --require-idle-cmd is given and it + exits non-zero, emit nothing (not idle). + consume --upto N Advance consumed_seq over the contiguous + gapless prefix <=N; drop consumed + entries. Rejects a gap (cannot ack N + while N-1 is unconsumed). Cumulative & + idempotent. + cursors Print observed_seq / consumed_seq / depth. + +Environment: + WAKE_STATE_HOME override base (default ${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake) + WAKE_AGENT per-agent queue namespace (default: default) +EOF +} + +cmd_init() { + _wake_init_dir "$STATE_DIR" || { + echo "store.sh: failed to init $STATE_DIR" >&2 + exit 1 + } + echo "$STATE_DIR" +} + +cmd_enqueue() { + _need_jq + local seq='' class='' locators='{}' emit_ts='' + while [ $# -gt 0 ]; do + case "$1" in + --seq) + seq="${2:-}" + shift 2 + ;; + --class) + class="${2:-}" + shift 2 + ;; + --locators) + locators="${2:-}" + shift 2 + ;; + --emit-ts) + emit_ts="${2:-}" + shift 2 + ;; + *) + echo "store.sh enqueue: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + + case "$seq" in + '' | *[!0-9]*) + echo "store.sh enqueue: --seq must be a non-negative integer" >&2 + exit 2 + ;; + esac + + # Absent class => actionable (fail-safe, §2.3). + [ -n "$class" ] || class="actionable" + case "$class" in + digest | actionable | human | terminal-log | reaction) : ;; + *) + echo "store.sh enqueue: unknown class '$class'" >&2 + exit 2 + ;; + esac + + [ -n "$emit_ts" ] || emit_ts="$(date +%s)" + case "$emit_ts" in + *[!0-9]*) + echo "store.sh enqueue: --emit-ts must be epoch seconds" >&2 + exit 2 + ;; + esac + + # Validate locators is well-formed JSON. + if ! printf '%s' "$locators" | jq -e . >/dev/null 2>&1; then + echo "store.sh enqueue: --locators must be valid JSON" >&2 + exit 2 + fi + + _wake_init_dir "$STATE_DIR" + + local consumed + consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" + + # An observation already inside the consumed prefix is idempotently ignored + # (§1.2: cursor never regresses; a re-observed already-consumed seq is a no-op). + if [ "$seq" -le "$consumed" ]; then + return 0 + fi + + # Build the durable entry. `hmac` is an UNSIGNED PLACEHOLDER (W3/A5 signs it). + local entry + entry="$(jq -cn \ + --argjson seq "$seq" \ + --arg class "$class" \ + --argjson locators "$locators" \ + --argjson emit_ts "$emit_ts" \ + '{observed_seq:$seq, locators:$locators, class:$class, emit_ts:$emit_ts, hmac:""}')" || { + echo "store.sh enqueue: failed to build entry" >&2 + exit 1 + } + + # --- durable store write (coalesce vs append) ----------------------------- + # digest: REPLACE the single pending cumulative-state entry (drop any existing + # digest lines, then append the newest). Newest subsumes prior. + # others: APPEND (never replaced). + # In BOTH cases the entry lands in the durable store. + local new_pending + if [ "$class" = "digest" ]; then + new_pending="$(jq -c 'select(.class != "digest")' "$STATE_DIR/pending.jsonl" 2>/dev/null || true)" + new_pending="$(printf '%s\n%s\n' "$new_pending" "$entry" | grep -v '^[[:space:]]*$' || true)" + else + new_pending="$(cat "$STATE_DIR/pending.jsonl" 2>/dev/null; printf '%s\n' "$entry")" + new_pending="$(printf '%s' "$new_pending" | grep -v '^[[:space:]]*$' || true)" + fi + printf '%s\n' "$new_pending" | grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl" + + # --- observed.set: record the seq in the live window (gap-detector SoT) ---- + { + cat "$STATE_DIR/observed.set" 2>/dev/null + printf '%s\n' "$seq" + } | grep -v '^[[:space:]]*$' | sort -n | uniq | _atomic_write "$STATE_DIR/observed.set" + + # --- observed_seq cursor: max monotonic int ------------------------------- + local observed + observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" + if [ "$seq" -gt "$observed" ]; then + printf '%s' "$seq" | _atomic_write "$STATE_DIR/observed_seq" + fi +} + +cmd_drain() { + local idle_cmd='' + while [ $# -gt 0 ]; do + case "$1" in + --require-idle-cmd) + idle_cmd="${2:-}" + shift 2 + ;; + *) + echo "store.sh drain: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + + # Drain returns the deliverable set ONLY when the pane is idle-at-prompt. The + # actual idle detection + paste is out of W2 scope (send-message.sh); the + # caller supplies an idle predicate. No predicate => emit unconditionally. + if [ -n "$idle_cmd" ]; then + if ! sh -c "$idle_cmd" >/dev/null 2>&1; then + return 0 # not idle-at-prompt: deliver nothing this cycle. + fi + fi + + [ -f "$STATE_DIR/pending.jsonl" ] || return 0 + grep -v '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || true +} + +cmd_consume() { + local upto='' + while [ $# -gt 0 ]; do + case "$1" in + --upto) + upto="${2:-}" + shift 2 + ;; + *) + echo "store.sh consume: unknown option '$1'" >&2 + exit 2 + ;; + esac + done + case "$upto" in + '' | *[!0-9]*) + echo "store.sh consume: --upto must be a non-negative integer" >&2 + exit 2 + ;; + esac + + _need_jq + _wake_init_dir "$STATE_DIR" + + local consumed observed + consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" + observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" + + # Cumulative & idempotent: CONSUMED N implies all <=N. N already covered is a + # no-op success (the sender checks cursor-advanced-past-X, not per-wake). + if [ "$upto" -le "$consumed" ]; then + echo "$consumed" + return 0 + fi + + # Never consume beyond what was observed. + if [ "$upto" -gt "$observed" ]; then + echo "store.sh consume: cannot CONSUMED $upto beyond observed_seq $observed" >&2 + exit 1 + fi + + # CONTIGUOUS gapless prefix: every seq in (consumed, upto] must have been + # observed. A missing interior seq is a GAP => reject (cannot ack N while N-1 + # is unconsumed). observed.set is coalescing-immune, so a subsumed digest seq + # still counts as observed and does not read as a gap. + local k + k=$((consumed + 1)) + while [ "$k" -le "$upto" ]; do + if ! grep -qxF "$k" "$STATE_DIR/observed.set" 2>/dev/null; then + echo "store.sh consume: gap at seq $k — cannot CONSUMED $upto while $k is unobserved/unconsumed" >&2 + exit 1 + fi + k=$((k + 1)) + done + + # Advance the consumer cursor and drop the now-consumed prefix from the + # durable store (retain-until-CONSUMED is satisfied). + jq -c "select(.observed_seq > $upto)" "$STATE_DIR/pending.jsonl" 2>/dev/null | + grep -v '^[[:space:]]*$' | _atomic_write "$STATE_DIR/pending.jsonl" || true + awk -v c="$upto" 'NF && $1+0 > c' "$STATE_DIR/observed.set" 2>/dev/null | + _atomic_write "$STATE_DIR/observed.set" || true + printf '%s' "$upto" | _atomic_write "$STATE_DIR/consumed_seq" + echo "$upto" +} + +cmd_cursors() { + _wake_init_dir "$STATE_DIR" + local observed consumed depth + observed="$(_wake_read_int "$STATE_DIR/observed_seq" 0)" + consumed="$(_wake_read_int "$STATE_DIR/consumed_seq" 0)" + depth="$(grep -cv '^[[:space:]]*$' "$STATE_DIR/pending.jsonl" 2>/dev/null || echo 0)" + printf 'observed_seq=%s\nconsumed_seq=%s\npending_depth=%s\n' "$observed" "$consumed" "$depth" +} + +main() { + [ $# -ge 1 ] || { + usage + exit 2 + } + local cmd="$1" + shift + case "$cmd" in + init) cmd_init "$@" ;; + enqueue) cmd_enqueue "$@" ;; + drain) cmd_drain "$@" ;; + consume) cmd_consume "$@" ;; + cursors) cmd_cursors "$@" ;; + -h | --help | help) usage ;; + *) + echo "store.sh: unknown command '$cmd'" >&2 + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/packages/mosaic/framework/tools/wake/test-wake-store-ack.sh b/packages/mosaic/framework/tools/wake/test-wake-store-ack.sh new file mode 100755 index 00000000..ab095ab2 --- /dev/null +++ b/packages/mosaic/framework/tools/wake/test-wake-store-ack.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# test-wake-store-ack.sh — RED-FIRST invariant harness for W2 (EPIC #892): +# three-cursor durable store (store.sh, A2) + RECEIVED/CONSUMED ack-wrapper +# (ack.sh, A4). +# +# Each test asserts ONE CONVERGED-DESIGN invariant and is designed to go RED if +# that invariant regresses: +# T1 three-cursor advancement (§1.2/§2.4) +# T2 digest-coalesce-REPLACE vs actionable-APPEND (§1.2/§2.3) +# T3 contiguous-prefix CONSUMED / gap rejection (§1.2/§2.2) +# T4 wake_id DELIVERY-dedup (never re-action) (§2.2) +# T5 atomic write-tmp+rename survives crash mid-write (§1.2) +# T6 durability survives restart (retain until CONSUMED) (§2.3) +# T7 ack local-write NEVER blocks on network (§2.2) +# +# Isolated: every test runs against a fresh WAKE_STATE_HOME temp dir. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STORE="$SCRIPT_DIR/store.sh" +ACK="$SCRIPT_DIR/ack.sh" + +command -v jq >/dev/null 2>&1 || { + echo "SKIP: jq not available" >&2 + exit 0 +} + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +# Failures are recorded to a marker FILE, not a shell var: each test runs in a +# subshell (for env isolation) and a subshell cannot mutate a parent variable, +# so a var-based counter would silently swallow failures (the exact bug this +# harness must never have). +FAILFILE="$TMP_ROOT/failures" +: >"$FAILFILE" +pass=0 +fail_msg() { + echo " FAIL: $*" >&2 + echo "x" >>"$FAILFILE" +} +ok() { pass=$((pass + 1)); } + +# jq_any FILE FILTER — true iff any JSONL record in FILE matches FILTER. Uses +# slurp (-s), NOT `jq -e select` (which reflects only the LAST input's value +# over a multi-line JSONL and would misreport presence). +jq_any() { + local file="$1" filter="$2" + [ -f "$file" ] || return 1 + [ "$(jq -s "[.[] | select($filter)] | length" "$file" 2>/dev/null || echo 0)" -gt 0 ] +} +# stream_any — same, but reads JSONL from stdin. +stream_any() { + local filter="$1" + [ "$(jq -s "[.[] | select($filter)] | length" 2>/dev/null || echo 0)" -gt 0 ] +} + +# fresh_state NAME — echoes a fresh isolated state home for export. +fresh_state() { + local d="$TMP_ROOT/$1" + rm -rf "$d" + mkdir -p "$d" + printf '%s' "$d" +} + +echo "== T1: three-cursor advancement ==" +( + WAKE_STATE_HOME="$(fresh_state t1)" + export WAKE_STATE_HOME + unset WAKE_AGENT + "$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":1}' >/dev/null + "$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":2}' >/dev/null + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'observed_seq=2' || fail_msg "T1: observed_seq should be 2 after enqueue 1,2 [$cur]" + echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T1: consumed_seq must NOT advance on enqueue (only on CONSUMED ack) [$cur]" + echo "$cur" | grep -q 'pending_depth=2' || fail_msg "T1: pending depth should be 2 [$cur]" + # consumed_seq advances only on CONSUMED. + "$STORE" consume --upto 2 >/dev/null + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'consumed_seq=2' || fail_msg "T1: consumed_seq should be 2 after CONSUMED 2 [$cur]" + echo "$cur" | grep -q 'pending_depth=0' || fail_msg "T1: pending drained after CONSUMED [$cur]" +) && ok + +echo "== T2: digest coalesce-REPLACE vs actionable APPEND ==" +( + WAKE_STATE_HOME="$(fresh_state t2)" + export WAKE_STATE_HOME + unset WAKE_AGENT + "$STORE" enqueue --seq 1 --class digest --locators '{"head":"aaa"}' >/dev/null + "$STORE" enqueue --seq 2 --class actionable --locators '{"issue":7}' >/dev/null + "$STORE" enqueue --seq 3 --class digest --locators '{"head":"bbb"}' >/dev/null + "$STORE" enqueue --seq 4 --class human --locators '{"from":"peer"}' >/dev/null + out="$("$STORE" drain)" + ndigest="$(printf '%s\n' "$out" | jq -c 'select(.class=="digest")' | grep -c . || true)" + [ "$ndigest" = "1" ] || fail_msg "T2: digest must COALESCE to a single pending entry, got $ndigest" + head="$(printf '%s\n' "$out" | jq -r 'select(.class=="digest") | .locators.head')" + [ "$head" = "bbb" ] || fail_msg "T2: newest digest must REPLACE prior (expected bbb, got $head)" + nactionable="$(printf '%s\n' "$out" | jq -c 'select(.class=="actionable")' | grep -c . || true)" + nhuman="$(printf '%s\n' "$out" | jq -c 'select(.class=="human")' | grep -c . || true)" + [ "$nactionable" = "1" ] || fail_msg "T2: actionable must APPEND (never replaced), got $nactionable" + [ "$nhuman" = "1" ] || fail_msg "T2: human must APPEND / stay durable, got $nhuman" + # Durability never bypassed: all classes present in the durable store. + total="$(printf '%s\n' "$out" | grep -c . || true)" + [ "$total" = "3" ] || fail_msg "T2: durable store should hold digest+actionable+human = 3, got $total" +) && ok + +echo "== T3: contiguous-prefix CONSUMED (reject ack N while N-1 unconsumed) ==" +( + WAKE_STATE_HOME="$(fresh_state t3)" + export WAKE_STATE_HOME + unset WAKE_AGENT + # Gap: observe seq 1 and 3, but NOT 2. + "$STORE" enqueue --seq 1 --class actionable --locators '{}' >/dev/null + "$STORE" enqueue --seq 3 --class actionable --locators '{}' >/dev/null + if "$STORE" consume --upto 3 >/dev/null 2>&1; then + fail_msg "T3: CONSUMED 3 must be REJECTED while seq 2 is a gap (unconsumed)" + fi + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T3: rejected CONSUMED must NOT advance the cursor [$cur]" + # Also reject via the ack wrapper. + if "$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1; then + fail_msg "T3: ack.sh CONSUMED 3 must be REJECTED over a gap" + fi + # Fill the gap; now the contiguous prefix is ackable. + "$STORE" enqueue --seq 2 --class actionable --locators '{}' >/dev/null + "$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1 || fail_msg "T3: CONSUMED 3 should succeed once gap at 2 is filled" + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'consumed_seq=3' || fail_msg "T3: consumed_seq should be 3 after contiguous prefix filled [$cur]" + # Cumulative + can't regress: CONSUMED 2 after 3 is an idempotent no-op. + "$ACK" consumed --upto 2 --no-sync >/dev/null 2>&1 || fail_msg "T3: cumulative CONSUMED 2 (<=3) should be an idempotent success" + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'consumed_seq=3' || fail_msg "T3: cursor must not regress below 3 [$cur]" +) && ok + +echo "== T4: wake_id DELIVERY-dedup (dup delivery = re-RECEIVE, never re-action) ==" +( + WAKE_STATE_HOME="$(fresh_state t4)" + export WAKE_STATE_HOME + unset WAKE_AGENT + "$STORE" enqueue --seq 1 --class digest --locators '{}' >/dev/null + r1="$("$ACK" received --wake-id WID-abc)" + [ "$r1" = "RECEIVED" ] || fail_msg "T4: first delivery should be RECEIVED, got '$r1'" + r2="$("$ACK" received --wake-id WID-abc)" + [ "$r2" = "DUP" ] || fail_msg "T4: duplicate delivery of same wake_id should be DUP (re-RECEIVE), got '$r2'" + # RECEIVED (delivery) must NEVER advance consumed_seq (delivery != consumption). + cur="$("$STORE" cursors)" + echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T4: RECEIVED must not advance consumed_seq [$cur]" +) && ok + +echo "== T5: atomic write-tmp+rename survives crash mid-write ==" +( + WAKE_STATE_HOME="$(fresh_state t5)" + export WAKE_STATE_HOME + unset WAKE_AGENT + "$STORE" enqueue --seq 1 --class actionable --locators '{"issue":1}' >/dev/null + STATE_DIR="$WAKE_STATE_HOME/default" + before="$(cat "$STATE_DIR/pending.jsonl")" + # Simulate a crash mid-write: a leftover atomic-write temp file with GARBAGE + # that was never renamed over the live file. + printf '{"observed_seq": 999, TRUNCATED-GARBAGE' >"$STATE_DIR/.wake.tmp.crash12" + # The live store must be untouched and still valid JSON. + after="$(cat "$STATE_DIR/pending.jsonl")" + [ "$before" = "$after" ] || fail_msg "T5: live pending.jsonl changed by a crash temp file" + drained="$("$STORE" drain)" + printf '%s\n' "$drained" | jq -e . >/dev/null 2>&1 || fail_msg "T5: drain returned non-JSON — garbage temp corrupted the store" + printf '%s\n' "$drained" | stream_any '.observed_seq==1' || fail_msg "T5: committed entry (seq 1) lost after simulated crash" + printf '%s\n' "$drained" | grep -q 999 && fail_msg "T5: uncommitted garbage (seq 999) leaked into the live store" + # A subsequent real mutation must succeed and clean the stale temp. + "$STORE" enqueue --seq 2 --class actionable --locators '{"issue":2}' >/dev/null || fail_msg "T5: enqueue after crash temp failed" + [ -e "$STATE_DIR/.wake.tmp.crash12" ] && fail_msg "T5: stale crash temp not cleaned by next mutation" + d2="$("$STORE" drain)" + [ "$(printf '%s\n' "$d2" | grep -c .)" = "2" ] || fail_msg "T5: store not healthy after crash+recovery (expected 2 entries)" +) && ok + +echo "== T5b: _atomic_write is tmp+rename (killing the writer mid-write leaves the target intact) ==" +( + WAKE_STATE_HOME="$(fresh_state t5b)" + export WAKE_STATE_HOME + unset WAKE_AGENT + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/_wake-common.sh" + d="$WAKE_STATE_HOME/atomic" + mkdir -p "$d" + target="$d/file" + printf 'GOOD\n' | _atomic_write "$target" + # Feed a writer via a FIFO but NEVER send EOF, then kill it mid-write. With + # true tmp+rename the partial bytes land in a temp file and the rename never + # runs, so `target` keeps its committed content. A direct (non-atomic) write + # would have streamed the garbage straight into `target`. + fifo="$d/fifo" + mkfifo "$fifo" + ( _atomic_write "$target" <"$fifo" ) & + wpid=$! + exec 9>"$fifo" + printf 'PARTIAL-GARBAGE-NO-EOF' >&9 + sleep 0.3 + pkill -9 -P "$wpid" 2>/dev/null || true + kill -9 "$wpid" 2>/dev/null || true + exec 9>&- + wait "$wpid" 2>/dev/null || true + got="$(cat "$target" 2>/dev/null)" + [ "$got" = "GOOD" ] || fail_msg "T5b: target corrupted by a killed mid-write (got '$got') — write is not tmp+rename atomic" +) && ok + +echo "== T6: durability survives restart (retain until CONSUMED) ==" +( + WAKE_STATE_HOME="$(fresh_state t6)" + export WAKE_STATE_HOME + unset WAKE_AGENT + # "Session 1": enqueue then vanish (no in-memory state carried). + "$STORE" enqueue --seq 1 --class human --locators '{"from":"peer"}' >/dev/null + "$STORE" enqueue --seq 2 --class actionable --locators '{"issue":9}' >/dev/null + # "Session 2": a brand-new process (this subshell invocation of store.sh) must + # see the persisted entries — nothing was held in memory. + d="$("$STORE" drain)" + [ "$(printf '%s\n' "$d" | grep -c .)" = "2" ] || fail_msg "T6: entries not retained across restart (expected 2)" + printf '%s\n' "$d" | stream_any '.class=="human"' || fail_msg "T6: a human message was lost across restart (durability bypassed)" + # Retained UNTIL consumed: after CONSUMED they are released. + "$STORE" consume --upto 2 >/dev/null + d2="$("$STORE" drain)" + n2="$(printf '%s\n' "$d2" | grep -c . || true)" + [ "$n2" = "0" ] || fail_msg "T6: entries should be released only AFTER CONSUMED (still $n2 pending)" +) && ok + +echo "== T7: ack local-write NEVER blocks on network ==" +( + WAKE_STATE_HOME="$(fresh_state t7)" + export WAKE_STATE_HOME + unset WAKE_AGENT + "$STORE" enqueue --seq 1 --class digest --locators '{}' >/dev/null + # A network-shaped shim on PATH: if the SYNCHRONOUS ack path ever invoked the + # network directly, this marker would appear before ack returns. + bin="$TMP_ROOT/t7bin" + mkdir -p "$bin" + for netcmd in curl wget nc ssh; do + cat >"$bin/$netcmd" <&2 + exit 1 +fi +echo "wake store/ack harness: all invariants passed ($pass groups)" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index b49eb0f1..4cf6d4fd 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",