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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' busy --gen 'g1786408690.89755.6034' --source claude-hook --event user-prompt-submit 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"touch '/Users/trilliumsmith/code/firstmate/state/fix-main-ci-fm-send.turn-ended'; '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop 2>/dev/null || true"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop-failure 2>/dev/null || true"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event session-end 2>/dev/null || true"}]}]}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚑ Quick win

Preserve failure when creating the turn-ended marker.

The Stop hook uses touch ...; fm-busy-event.sh ... || true. If touch fails, the command still applies the idle event and returns success. The downstream classifier then receives no .turn-ended marker, so wake handling can be lost.

Make marker creation a prerequisite for the lifecycle update:

Proposed fix
-touch '/Users/trilliumsmith/code/firstmate/state/fix-main-ci-fm-send.turn-ended'; '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply ... || true
+touch '/Users/trilliumsmith/code/firstmate/state/fix-main-ci-fm-send.turn-ended' && '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply ... || true
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' busy --gen 'g1786408690.89755.6034' --source claude-hook --event user-prompt-submit 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"touch '/Users/trilliumsmith/code/firstmate/state/fix-main-ci-fm-send.turn-ended'; '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop 2>/dev/null || true"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop-failure 2>/dev/null || true"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event session-end 2>/dev/null || true"}]}]}}
{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' busy --gen 'g1786408690.89755.6034' --source claude-hook --event user-prompt-submit 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"touch '/Users/trilliumsmith/code/firstmate/state/fix-main-ci-fm-send.turn-ended' && '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop 2>/dev/null || true"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event stop-failure 2>/dev/null || true"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'fix-main-ci-fm-send' idle --gen 'g1786408690.89755.6034' --source claude-hook --event session-end 2>/dev/null || true"}]}]}}
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/settings.local.json at line 1, Update the Stop hook command in the
UserPromptSubmit/Stop configuration so the idle lifecycle update runs only when
creating the .turn-ended marker succeeds. Preserve the existing marker path and
fm-busy-event.sh invocation, but chain the commands with failure propagation
rather than allowing a failed touch to continue or be masked by || true.

🩺 Stability & Availability | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

if git ls-files --error-unmatch .claude/settings.local.json >/dev/null 2>&1; then
  echo "settings.local.json is tracked"
else
  echo "settings.local.json is not tracked"
fi

rg -n --hidden --glob '!.git' \
  '(/Users/trilliumsmith/code/firstmate|fix-main-ci-fm-send|g1786408690\.89755\.6034|fm-busy-event\.sh arm|settings\.local\.json)' .

Repository: trillium/firstmate

Length of output: 5341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- tracked file and status ---'
git ls-files --stage -- .claude/settings.local.json
git status --short -- .claude/settings.local.json
cat -n .claude/settings.local.json

printf '%s\n' '--- generator ---'
sed -n '2465,2510p' bin/fm-spawn.sh

printf '%s\n' '--- hook-related tests ---'
sed -n '1,130p' tests/fm-spawn-reused-worktree-hooks.test.sh
sed -n '1,80p' tests/fm-hook-artifacts-not-tracked.test.sh

printf '%s\n' '--- busy-event generation semantics ---'
sed -n '1,150p' bin/fm-busy-event.sh
rg -n 'fm-busy-event\.sh (arm|apply)|--gen|turn-ended' bin tests | head -160

Repository: trillium/firstmate

Length of output: 33916


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- stale-generation validation ---'
sed -n '140,205p' bin/fm-busy-event.sh

printf '%s\n' '--- ignore rules ---'
sed -n '15,32p' .gitignore
git check-ignore -v .claude/settings.local.json || true

printf '%s\n' '--- tracked hook artifacts ---'
git ls-files -- \
  .claude/settings.local.json \
  .opencode/plugins/fm-busy-state.js \
  .opencode/plugins/fm-turn-end.js \
  .fm-grok-turnend \
  .fm-kimi-turnend

printf '%s\n' '--- read-only contract verifier ---'
python3 - <<'PY'
import json
from pathlib import Path

settings = json.loads(Path(".claude/settings.local.json").read_text())
commands = []
for event, entries in settings["hooks"].items():
    for entry in entries:
        commands.append((event, entry["hooks"][0]["command"]))

expected_literals = {
    "/Users/trilliumsmith/code/firstmate",
    "fix-main-ci-fm-send",
    "g1786408690.89755.6034",
}
found = {literal for literal in expected_literals if any(literal in cmd for _, cmd in commands)}
print("tracked_settings_json:", True)
print("hardcoded_literals_found:", sorted(found))
print("all_hooks_use_literal_root:", all("/Users/trilliumsmith/code/firstmate" in cmd for _, cmd in commands))
print("all_hooks_use_literal_task:", all("fix-main-ci-fm-send" in cmd for _, cmd in commands))
print("all_hooks_use_literal_generation:", all("g1786408690.89755.6034" in cmd for _, cmd in commands))

source = Path("bin/fm-spawn.sh").read_text()
contract = {
    "uses_dynamic_root": 'shell_quote("$FM_ROOT/bin/fm-busy-event.sh")' in source,
    "uses_dynamic_state": 'shell_quote("$STATE_REAL")' in source,
    "uses_dynamic_task": 'shell_quote("$ID")' in source,
    "uses_dynamic_generation": 'shell_quote("$BUSY_GEN")' in source,
    "rewrites_settings": 'cat > "$WT/.claude/settings.local.json"' in source,
    "excludes_settings": "exclude_path '.claude/settings.local.json'" in source,
}
print("generator_contract:", contract)
PY

Repository: trillium/firstmate

Length of output: 3206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

source = Path("bin/fm-spawn.sh").read_text()
patterns = {
    "uses_dynamic_root": r'shell_quote "\$FM_ROOT/bin/fm-busy-event\.sh"',
    "uses_dynamic_state": r'shell_quote "\$STATE_REAL"',
    "uses_dynamic_task": r'shell_quote "\$ID"',
    "uses_dynamic_generation": r'shell_quote "\$BUSY_GEN"',
    "rewrites_settings": r'cat > "\$WT/\.claude/settings\.local\.json"',
    "excludes_settings": r"exclude_path '\.claude/settings\.local\.json'",
}
for name, pattern in patterns.items():
    print(f"{name}:", bool(re.search(pattern, source)))
PY

Repository: trillium/firstmate

Length of output: 307


Stop tracking this generated local hook file.

.claude/settings.local.json is ignored and must not be committed. Its tracked contents bind hooks to one checkout, task, and generation. Remove it from the repository and retain the existing ignore rule.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/settings.local.json at line 1, Remove the tracked generated local
hook file represented by .claude/settings.local.json from version control, while
preserving the existing ignore rule so future local copies remain untracked.

36 changes: 36 additions & 0 deletions bin/fm-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,41 @@ fm_lint_slot_dir() {
printf '%s\n' "${FM_LINT_STATE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/fm-lint}/slots"
}

# fm_lint_changed_base_ref prints the ref to diff the working branch against:
# the local origin/main tracking ref when present, else local main. Returns
# nonzero when neither is resolvable, which the caller treats as "no
# merge-base found" and falls back to a full lint.
fm_lint_changed_base_ref() {
if git rev-parse --verify -q origin/main >/dev/null 2>&1; then
printf 'origin/main\n'
return 0
fi
if git rev-parse --verify -q main >/dev/null 2>&1; then
printf 'main\n'
return 0
fi
return 1
}

# fm_lint_is_canonical_root tests membership in the canonical set (a direct
# *.sh child of bin/, bin/backends/, or tests/) without the shell case
# statement's non-pathname wildcard matching a path separator by accident.
fm_lint_is_canonical_root() {
local path=$1 dir base
case "$path" in
*/*) dir=${path%/*}; base=${path##*/} ;;
*) dir=; base=$path ;;
esac
case "$base" in
*.sh) : ;;
*) return 1 ;;
esac
case "$dir" in
bin|bin/backends|tests) return 0 ;;
*) return 1 ;;
esac
}

FM_LINT_WORKER_SLOT=
FM_LINT_WORKER_SLOT_WAIT=0

Expand Down Expand Up @@ -288,6 +323,7 @@ fm_lint_usage() {
JOBS=${FM_LINT_JOBS:-2}
TELEMETRY=${FM_LINT_TELEMETRY:-}
LIST_FILES=0
CHANGED_MODE=0
while [ "$#" -gt 0 ]; do
case "$1" in
--jobs)
Expand Down
38 changes: 37 additions & 1 deletion bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,39 @@ fm_send_resolve_target "$RAW_TARGET" || exit 1
T=$RESOLVED_TARGET
shift

# Collect --resolve-key flags (answerer-closes; see the header contract). They
# must precede --key or the message text; everything after the last flag is the
# message exactly as before, so ordinary sends are byte-identical.
RESOLVE_KEYS=
fm_send_add_resolve_key() { # <key>
local k=$1
case "$k" in
''|*[!A-Za-z0-9._-]*)
echo "error: --resolve-key '$k' is not a valid decision key (allowed: A-Z a-z 0-9 . _ -)" >&2
return 1
;;
esac
case " $RESOLVE_KEYS " in
*" $k "*)
echo "error: duplicate --resolve-key '$k'" >&2
return 1
;;
esac
RESOLVE_KEYS="${RESOLVE_KEYS}${RESOLVE_KEYS:+ }$k"
}
while :; do
case "${1:-}" in
--resolve-key)
[ $# -ge 2 ] || { echo "error: --resolve-key requires a key" >&2; exit 1; }
fm_send_add_resolve_key "$2" || exit 1
shift 2
Comment on lines +343 to +346

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'Option definitions:'
rg -n -C 4 -- '--resolve-key|--key' bin/fm-send.sh || true

printf '%s\n' 'Existing coverage:'
rg -n -C 2 --glob '*test*' --glob '*.sh' \
  -- '--resolve-key|duplicate|requires a key' . || true

Repository: trillium/firstmate

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'fm-send.sh header and parser:'
sed -n '1,110p' bin/fm-send.sh
sed -n '300,475p' bin/fm-send.sh

printf '%s\n' 'Resolve-key and close-flow definitions/usages:'
rg -n -C 6 'fm_send_close_resolved_keys|RESOLVE_KEYS|decision key|valid decision key' \
  bin tests --glob '*.sh' --glob '*test*' | head -n 240

printf '%s\n' 'fm-send-specific tests:'
fd -i 'fm-send' tests bin

Repository: trillium/firstmate

Length of output: 32867


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'Resolve-key tests:'
sed -n '1,430p' tests/fm-send-resolve-key.test.sh

printf '%s\n' 'Decision-key parsing and validation:'
rg -n -C 8 'status_open_decisions|key=.*A-Za-z|validate.*key|decision.*key|pending-reply-' \
  bin/fm-classify-lib.sh bin/fm-pending-reply-lib.sh tests/fm-watch-triage.test.sh tests/fm-remote-reply.test.sh | head -n 320

Repository: trillium/firstmate

Length of output: 42345


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re

allowed = re.compile(r'^[A-Za-z0-9._-]+$')

def parse(args):
    keys = []
    i = 0
    while i < len(args) and args[i] == '--resolve-key':
        if i + 1 >= len(args):
            raise ValueError('requires a key')
        key = args[i + 1]
        if not allowed.fullmatch(key):
            raise ValueError('invalid key')
        keys.append(key)
        i += 2
    return keys, args[i:]

def close_lines(keys, answer):
    return [f'resolved [key={key}]: answered: {answer}' for key in keys]

for case in (
    ['--resolve-key', '--key', 'Enter'],
    ['--resolve-key', '--resolve-key', 'answer'],
):
    keys, rest = parse(case)
    print(f'input={case!r}')
    print(f'accepted_keys={keys!r}')
    print(f'remaining_args={rest!r}')
    print(f'close_lines={close_lines(keys, "answer")!r}')
    print(f'reserved_token_accepted={any(k.startswith("--") for k in keys)}')
PY

Repository: trillium/firstmate

Length of output: 532


Reject reserved option tokens as --resolve-key values.

--key and --resolve-key pass the current key validation and can be persisted as resolved decision keys. Reject them before fm_send_add_resolve_key, and add tests for both cases.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/fm-send.sh` around lines 343 - 346, Update the --resolve-key option
handling to reject reserved option tokens --key and --resolve-key before calling
fm_send_add_resolve_key, while retaining existing argument validation and exit
behavior. Add coverage for both reserved-token cases.

;;
*)
break
;;
esac
done

# Raw/unmanaged pane escape hatch: best-effort literal send + Enter with no
# submit verification and no from-firstmate marking (see header). Parsed here so
# it precedes both the --key branch and the verified text path.
Expand Down Expand Up @@ -577,7 +610,10 @@ else
# Delivery is fully confirmed: close each answered decision in this home's
# ledger (answerer-closes; see the header contract).
if [ -n "$RESOLVE_KEYS" ]; then
fm_send_close_resolved_keys "$RESOLVE_ANSWER_TEXT" || exit 1
# Extract the plain answer text (strip marker and corr token if present).
answer_text=
fm_pending_reply_carrier_body "$MESSAGE" answer_text
fm_send_close_resolved_keys "$answer_text" || exit 1
fi
# Submit landed with exact empty. Confirmation only proves the text was
# accepted; the harness still needs a beat to spin up the
Expand Down
1 change: 0 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2196,7 +2196,6 @@ EOF
T="$ORCA_TERMINAL"
;;
esac
fi
if [ "$KIND" = secondmate ]; then
FM_INHERITABLE_CONFIG=trace-context \
propagate_inheritable_config "$CONFIG" "$PROJ_ABS/config" \
Expand Down
Loading