Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e858f1bc62 | ||
|
|
9c8b6ebfe1 | ||
|
|
7d86d55577 | ||
|
|
18f3dd49ec | ||
|
|
41f1bc0d37 | ||
|
|
a27b7dc9be |
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
||||
#
|
||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||
@@ -32,45 +33,61 @@ ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker
|
||||
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c|--comment)
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||
# backward-compatible alias.
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -b, --body Comment text (required; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
usage_error "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
fi
|
||||
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment is required (-c)"
|
||||
exit 1
|
||||
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -340,7 +357,15 @@ PY
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
||||
# usage errors exit 2, which would collide with this wrapper's reserved
|
||||
# usage-error status if propagated raw (codex review of 08a00149).
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# A --login override selects a NAMED tea credential and is the only way to
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
|
||||
# (R1, 2026-08-28): a full verified write via -b alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -409,11 +411,28 @@ run_comment() {
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
|
||||
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
|
||||
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
|
||||
# mosaic.gitIdentity pin, and resolves the brain home (whose
|
||||
# fleet/agents presence arms the no-identity fail-loud branch) from
|
||||
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
|
||||
# wrapper either resolves the REAL seat-slot token (stub curl rejects
|
||||
# it: the documented HTTP 401) or fails loud before any request.
|
||||
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
|
||||
# NOTE: keep this comment block ABOVE the assignment chain — a comment
|
||||
# inside a backslash-continued prefix chain terminates the command and
|
||||
# silently demotes every earlier assignment to an unexported subshell
|
||||
# assignment (measured 2026-08-28: the wrapper then ran without
|
||||
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
|
||||
# with zero diagnostic output).
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GIT_IDENTITY="" \
|
||||
MOSAIC_BRAIN_HOME="" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
@@ -430,7 +449,7 @@ run_comment() {
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
@@ -614,4 +633,21 @@ done
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
|
||||
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
|
||||
# swaps only the flag spelling; every assertion below is case 1's contract.
|
||||
BODY_FLAG="-b"
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||
echo "FAIL: --body write went through tea instead of REST" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
assert_no_temp_leak "fresh-success-body-flag"
|
||||
assert_token_not_in_argv "fresh-success-body-flag"
|
||||
unset BODY_FLAG
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1), so a caller (or a stop gate)
|
||||
# can tell an invocation defect from a delivery blocker. Before this contract
|
||||
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
|
||||
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
|
||||
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
|
||||
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
|
||||
# blocked".
|
||||
#
|
||||
# R1: -b/--body is the canonical comment flag (matching issue-create,
|
||||
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
|
||||
# alias.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. Missing required comment exits 2 (stderr).
|
||||
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
|
||||
# "requires a value" message on stderr (the former silent-death class).
|
||||
# 6. -b and -c both pass parsing (the run then fails at platform detection
|
||||
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
|
||||
# without any provider fixture.
|
||||
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
|
||||
# record every invocation and the probe log must stay empty.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Provider shims: any invocation is recorded and fails the run at the end.
|
||||
# Usage-error arms must exit during argument parsing, before detect_platform,
|
||||
# so these prove "no provider request on parser failure".
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
|
||||
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
|
||||
# normalizes provider failures to exit 1 instead of propagating 2.
|
||||
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
|
||||
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
|
||||
# so the arm fails at credential resolution in ANY cwd repo, never reading a
|
||||
# real token or contacting a provider. Measured 2026-08-28: without this, the
|
||||
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
|
||||
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
|
||||
# seat token, and invoked the curl stub — the suite then failed its own
|
||||
# no-provider-contact check, correctly).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage on stdout.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, message on stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Missing required comment: rc 2, stderr.
|
||||
expect_rc 2 "missing comment exits 2" -i 5
|
||||
expect_stderr "comment is required" "missing comment message on stderr"
|
||||
|
||||
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
|
||||
# died here silently (set -e on the failed shift 2).
|
||||
for flag in -i -b -c -l --issue --body --comment --login; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
||||
# parsing; the wrapper then fails at platform detection (not a git repo)
|
||||
# nonzero but NOT as a usage error (rc must not be 2).
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
|
||||
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
|
||||
# for the wrapper's usage-error contract). With a github remote and a gh stub
|
||||
# whose comment write exits 2, the wrapper must exit 1 with the normalized
|
||||
# error on stderr.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
git -C "$GH_REPO" config mosaic.gitIdentity ""
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||
|
||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||
# invocation is the only permitted entry in the probe log).
|
||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
grep -v '^gh issue comment' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -14,7 +14,6 @@
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_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-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.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-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_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-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.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-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -11,6 +11,9 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerCommsCommand } from './commands/comms.js';
|
||||
import { registerQCommand } from './commands/q.js';
|
||||
import { registerWatchCommand } from './commands/watch.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
@@ -428,6 +431,9 @@ registerSkillCommand(program);
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
registerWatchCommand(program);
|
||||
registerQCommand(program);
|
||||
registerCommsCommand(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveBrainHome } from '../fleet/brain-home.js';
|
||||
|
||||
/**
|
||||
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
|
||||
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
|
||||
* implementations stay in the brain (tools/). Commands resolve the brain
|
||||
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
|
||||
* there. Nothing operator-specific ships inside the package.
|
||||
*
|
||||
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
|
||||
* to the tool. The CLI adds nothing on success; absent tools fail loudly
|
||||
* with the resolved path (127) instead of guessing.
|
||||
*/
|
||||
|
||||
/** Absolute path of a brain-relative tool. */
|
||||
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
|
||||
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
|
||||
}
|
||||
|
||||
/** Map a spawnSync result + tool existence to the CLI exit status. */
|
||||
export function exitStatusFor(
|
||||
result: { status: number | null; error?: NodeJS.ErrnoException },
|
||||
toolExists: boolean,
|
||||
): number {
|
||||
if (!toolExists) return 127;
|
||||
if (result.status !== null) return result.status;
|
||||
return 125; // killed by signal / could not run
|
||||
}
|
||||
|
||||
export function brainToolExists(tool: string): boolean {
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exec a brain tool with full pass-through. `interpreter` runs the tool
|
||||
* through e.g. python3 (renderers); omit it for executable scripts.
|
||||
* Returns the process exit status; callers assign it to process.exitCode.
|
||||
*/
|
||||
export function execBrainTool(
|
||||
mosaicHome: string,
|
||||
relPath: string,
|
||||
args: string[],
|
||||
interpreter?: string,
|
||||
): number {
|
||||
const tool = resolveBrainTool(mosaicHome, relPath);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic: brain tool not found (expected ${tool}). ` +
|
||||
'Tool suites live in the brain tree under tools/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
return 127;
|
||||
}
|
||||
const result = interpreter
|
||||
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
|
||||
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
|
||||
return exitStatusFor(result, true);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||
|
||||
describe('arg translation', () => {
|
||||
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(
|
||||
tmuxSendArgs('orch-01', 'unused', {
|
||||
class: 'actionable',
|
||||
socket: 'mosaic-fleet',
|
||||
file: '/tmp/body.txt',
|
||||
}),
|
||||
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
|
||||
});
|
||||
|
||||
it('fleet-comms path: -t site/agent and -c class', () => {
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
|
||||
'-t',
|
||||
'usc/fred',
|
||||
'-c',
|
||||
'human',
|
||||
'-m',
|
||||
'hi',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCommsCommand routing', () => {
|
||||
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
|
||||
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
|
||||
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
|
||||
afterEach(() => {
|
||||
for (const [k, v] of [
|
||||
['MOSAIC_BRAIN_HOME', savedBrain],
|
||||
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
|
||||
['MOSAIC_AGENT_NAME', savedAgent],
|
||||
] as const) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
|
||||
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
|
||||
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
|
||||
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
|
||||
mkdirSync(join(repo, 'tools'), { recursive: true });
|
||||
const tmuxLog = join(brain, 'tmux.log');
|
||||
const commsLog = join(repo, 'comms.log');
|
||||
writeFileSync(
|
||||
join(brain, 'tools', 'tmux', 'agent-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'tools', 'comms-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
|
||||
);
|
||||
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
|
||||
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'tester';
|
||||
return { brain, repo, tmuxLog, commsLog };
|
||||
}
|
||||
|
||||
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
[
|
||||
'comms',
|
||||
'send',
|
||||
'orch-01',
|
||||
'--class',
|
||||
'actionable',
|
||||
'--socket',
|
||||
'mosaic-fleet',
|
||||
'verdict',
|
||||
'landed',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(7);
|
||||
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
|
||||
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
|
||||
);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(5);
|
||||
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
|
||||
'-t usc/fred -c human -m hello there',
|
||||
);
|
||||
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
|
||||
expect(readFileSync(f.commsLog, 'utf8')).toContain(
|
||||
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
|
||||
);
|
||||
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
|
||||
});
|
||||
|
||||
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
|
||||
const f = fixture();
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
|
||||
fixture();
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Command } from 'commander';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
|
||||
*
|
||||
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
|
||||
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
|
||||
* local traffic (a git round trip per message; Jason 2026-08-28).
|
||||
*
|
||||
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
|
||||
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
|
||||
*/
|
||||
export interface CommsSendOptions {
|
||||
readonly class?: string;
|
||||
readonly file?: string;
|
||||
readonly socket?: string;
|
||||
readonly site?: string;
|
||||
readonly commsRepo?: string;
|
||||
}
|
||||
|
||||
export function defaultCommsRepo(): string {
|
||||
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
|
||||
}
|
||||
|
||||
/** Build the agent-send.sh argv for the same-host path. */
|
||||
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
|
||||
const args = ['-s', target];
|
||||
if (opts.class) args.push('-C', opts.class);
|
||||
if (opts.socket) args.push('-L', opts.socket);
|
||||
if (opts.file) args.push('-f', opts.file);
|
||||
else args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Build the comms-send.sh argv for the inter-site path. */
|
||||
export function fleetCommsSendArgs(
|
||||
site: string,
|
||||
target: string,
|
||||
message: string,
|
||||
opts: CommsSendOptions,
|
||||
): string[] {
|
||||
const args = ['-t', `${site}/${target}`];
|
||||
if (opts.class) args.push('-c', opts.class);
|
||||
args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function registerCommsCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('comms')
|
||||
.description(
|
||||
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
|
||||
)
|
||||
.command('send')
|
||||
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||
.option('--file <path>', 'message body from file (same-host path only)')
|
||||
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
||||
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||
.argument('<target>', 'destination seat (session name)')
|
||||
.argument('[message...]', 'message text (joined; or use --file)')
|
||||
.action(
|
||||
async (
|
||||
target: string,
|
||||
messageWords: string[],
|
||||
opts: CommsSendOptions & Record<string, unknown>,
|
||||
command: Command,
|
||||
) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
const message = messageWords.join(' ');
|
||||
|
||||
if (opts.site) {
|
||||
const repo = opts.commsRepo ?? defaultCommsRepo();
|
||||
const tool = join(repo, 'tools', 'comms-send.sh');
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
} catch {
|
||||
console.error(
|
||||
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
|
||||
'Clone the fleet-comms repo or point --comms-repo at it.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
if (!process.env['MOSAIC_AGENT_NAME']) {
|
||||
console.error(
|
||||
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
|
||||
);
|
||||
process.exitCode = 2; // invocation defect: fixable by the caller
|
||||
return;
|
||||
}
|
||||
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
|
||||
// (default $HOME/src/fleet-comms); without this, --comms-repo
|
||||
// would select the executable but not the repository it operates
|
||||
// on (codex review of 9c8b6ebf).
|
||||
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
|
||||
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exitCode = result.status ?? 125;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-host: the brain tool owns validation (bad class -> its rc 3)
|
||||
// and absence (execBrainTool -> 127 with the resolved path).
|
||||
process.exitCode = execBrainTool(
|
||||
home,
|
||||
'tools/tmux/agent-send.sh',
|
||||
tmuxSendArgs(target, message, opts),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerQCommand, resolveQuestionTool } from './q.js';
|
||||
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
describe('resolveBrainTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('joins brain home with the relative tool path', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
|
||||
join(tmp, 'tools', 'questions', 'q-new.sh'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQuestionTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('maps new/render subcommands to their brain tools', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
|
||||
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
|
||||
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerQCommand usage + dispatch', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
await program.parseAsync(['q', 'bogus'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
// Reserved property names must not leak through the record lookup.
|
||||
await program.parseAsync(['q', 'toString'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it('execs the brain tool with pass-through args and exit code', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
|
||||
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
|
||||
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
|
||||
chmodSync(stub, 0o755);
|
||||
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor (shared dispatch contract)', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
it('passes tool status through', () => {
|
||||
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
|
||||
});
|
||||
it('maps signal death to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic q` — tracked decision questions (brain tools/questions).
|
||||
* `new` files a question file (one FILE per question, merge-conflict
|
||||
* impossible by construction); `render` regenerates the
|
||||
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
|
||||
*/
|
||||
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
|
||||
new: {
|
||||
path: 'tools/questions/q-new.sh',
|
||||
help: 'file a question (--question, --slug, --owed-by, ...)',
|
||||
},
|
||||
render: {
|
||||
path: 'tools/questions/render.py',
|
||||
interpreter: 'python3',
|
||||
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
|
||||
const entry = SUBCOMMANDS[sub];
|
||||
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
|
||||
}
|
||||
|
||||
export function registerQCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('q')
|
||||
.description('Tracked decision questions: file and render (brain tools/questions)')
|
||||
.allowUnknownOption()
|
||||
.argument('[args...]', 'subcommand + args passed through to the question tools')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const sub = args[0];
|
||||
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
|
||||
console.error('mosaic q: expected a subcommand:');
|
||||
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
|
||||
console.error(` mosaic q ${name} ${entry.help}`);
|
||||
}
|
||||
process.exitCode = 2; // usage error contract: invocation defect
|
||||
return;
|
||||
}
|
||||
const entry = SUBCOMMANDS[sub]!;
|
||||
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
|
||||
|
||||
// The dispatch command execs a real process with inherited stdio; the spec
|
||||
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
|
||||
// path (which exits without spawning). Live pass-through is exercised by the
|
||||
// fleet smoke test against the real brain tool.
|
||||
|
||||
describe('resolveAgentWatchTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
|
||||
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves inside the brain tools tree', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
const tool = resolveAgentWatchTool(tmp);
|
||||
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
|
||||
it('passes the tool exit status through', () => {
|
||||
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
|
||||
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
|
||||
});
|
||||
|
||||
it('maps signal death / null status to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWatchCommand absent-tool path', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
|
||||
// The suite directory exists but the tool file does not.
|
||||
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
|
||||
const program = new Command();
|
||||
registerWatchCommand(program);
|
||||
await program.parseAsync(['watch', 'list'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import {
|
||||
brainToolExists,
|
||||
execBrainTool,
|
||||
exitStatusFor,
|
||||
resolveBrainTool,
|
||||
} from './brain-dispatch.js';
|
||||
|
||||
export { exitStatusFor };
|
||||
|
||||
/**
|
||||
* `mosaic watch` — dispatch to the brain's agent-watch suite.
|
||||
* See brain-dispatch.ts for the architecture and pass-through contract.
|
||||
*/
|
||||
export function resolveAgentWatchTool(mosaicHome: string): string {
|
||||
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
|
||||
}
|
||||
|
||||
export function registerWatchCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('watch')
|
||||
.description('Wake-me-when watchers (agent-watch): start, list, stop')
|
||||
// allowUnknownOption + variadic = full ordered pass-through: unknown
|
||||
// options (--name, --when, ...) and their values land in args verbatim
|
||||
// (commander 13 measured behavior), so the tool owns its own flag
|
||||
// surface without the CLI needing passThroughOptions (which would
|
||||
// force enablePositionalOptions fleet-wide on the root program).
|
||||
.allowUnknownOption()
|
||||
// The tool owns help too: without this, commander would intercept
|
||||
// --help and answer with wrapper help instead of agent-watch's own
|
||||
// (codex review of 18f3dd49).
|
||||
.helpOption(false)
|
||||
.argument('[args...]', 'args passed through to agent-watch.sh')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
// --mosaic-home is not global in this CLI; walk parents for it and
|
||||
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
|
||||
// it) wins inside resolveBrainHome regardless.
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const tool = resolveAgentWatchTool(home);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
||||
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user