diff --git a/.agents/skills/coderabbit-pr-gate/SKILL.md b/.agents/skills/coderabbit-pr-gate/SKILL.md new file mode 100644 index 0000000000..10c2f544fd --- /dev/null +++ b/.agents/skills/coderabbit-pr-gate/SKILL.md @@ -0,0 +1,56 @@ +--- +name: coderabbit-pr-gate +description: >- + Agent-only policy for reacting to CodeRabbit's PR review advice, beyond a bare pass/fail + check, and for handling CodeRabbit's rate-limiting without treating it as review failure. + Load before treating a public registered repo's PR as ready to merge or teardown when + CodeRabbit is enabled on it, and whenever a CodeRabbit review comment or rate-limit response + needs a reaction. +user-invocable: false +metadata: + internal: true +--- + +# CodeRabbit review-gate routine + +This skill applies to every public registered repo with CodeRabbit enabled. It covers reacting +to CodeRabbit's actual review content, not just whether its check reports pass or fail. + +## Policy + +A green CodeRabbit check is necessary but not sufficient: CodeRabbit can pass a PR while still +leaving actionable suggestion comments (bugs, security notes, missed edge cases) that a bare +status check does not surface. Before treating a PR as merge-ready on a CodeRabbit-enabled repo: + +1. Read CodeRabbit's review comments on the PR (via `gh-axi`), not just its check conclusion. +2. Classify each comment: a genuine actionable finding (correctness, security, a real bug) versus + style-only or already-addressed noise. +3. Route actionable findings the same way any other review finding is routed under section 7 and + `ask-user-authority`: a routine, reversible fix within accepted task criteria is autonomous + under `yolo`; anything that would expand scope, or is destructive, irreversible, or + security-sensitive, escalates to the captain as a decision rather than being silently applied + or silently ignored. +4. Do not merge past an unresolved actionable CodeRabbit finding without either fixing it or + getting an explicit captain or `yolo`-authorized decision to proceed anyway. + +## Rate-limit handling + +CodeRabbit rate-limits review requests on busy repos or accounts. A rate-limited or +not-yet-reviewed state is not a failure and not silence to route around: + +- Treat a CodeRabbit rate-limit or pending-review response as a `paused:`-class external wait + (section 8's distinction between `paused:` and `blocked:`), not a `blocked:` or `failed:` one. +- Re-check on a bounded backoff rather than polling tightly or repeatedly re-requesting a review, + which extends the rate limit; let the watcher's own wake cadence carry the recheck instead of + arming a dedicated poll loop for it. +- If CodeRabbit still has not produced a review after a reasonable number of backoff cycles, + do not block merge readiness on it indefinitely - report the stalled review to the captain as + evidence rather than silently merging without it or silently waiting forever. +- Never spam re-requests at CodeRabbit to work around a rate limit; that worsens the limit for + every repo sharing the account. + +## Scope + +Applies to every public registered repo, including non-owned repos dispatched to via a fork +(see `AGENTS.md` section 7 and `night-ops-directive`). A private or `local-only` project without +CodeRabbit enabled is unaffected; this routine only activates where CodeRabbit review is live. diff --git a/.agents/skills/herdr-navigation/SKILL.md b/.agents/skills/herdr-navigation/SKILL.md new file mode 100644 index 0000000000..bbd3c990da --- /dev/null +++ b/.agents/skills/herdr-navigation/SKILL.md @@ -0,0 +1,91 @@ +--- +name: herdr-navigation +description: Herdr workspace navigation for agents — how to find your own pane, open panes in a direction, and read or send to other panes. +user-invocable: false +metadata: + internal: true +--- + +# herdr-navigation + +You are running inside a herdr pane. Your pane ID is in the environment as `$HERDR_PANE_ID`. This skill covers how to navigate, open new panes, and interact with neighboring panes. + +## Your own position + +```bash +# Who am I? +herdr pane current --current + +# Get full layout context (your position in the workspace grid) +herdr pane layout --current +``` + +Your pane ID is also available as `$HERDR_PANE_ID` (injected by fm-spawn). Use either. + +## Open a pane relative to yourself + +```bash +# Split to the right (new pane appears to your right) +herdr pane split --current --direction right + +# Split below (new pane appears below you) +herdr pane split --current --direction down + +# Split with a specific command running in the new pane +herdr pane split --current --direction right --cwd "$PWD" + +# Split and focus the new pane +herdr pane split --current --direction right --focus +``` + +## Find neighboring panes + +```bash +# What's to my right? +herdr pane neighbor --direction right --current + +# What's below me? +herdr pane neighbor --direction down --current + +# What's to my left? +herdr pane neighbor --direction left --current +``` + +`neighbor` returns the neighbor's pane ID if one exists, or an error if the edge is empty. + +## Read another pane's content + +```bash +# Read the last 20 lines of a neighboring pane +NEIGHBOR=$(herdr pane neighbor --direction right --current --json | jq -r '.pane_id') +herdr pane read "$NEIGHBOR" --lines 20 +``` + +## Send text or keys to another pane + +```bash +# Send a command to run in a neighboring pane +herdr pane send-text "$NEIGHBOR" "echo hello" + +# Send a key sequence +herdr pane send-keys "$NEIGHBOR" Enter +``` + +## List all panes in your workspace + +```bash +herdr pane list --workspace "$HERDR_WORKSPACE_ID" +``` + +## Move yourself to a new tab + +```bash +# Move this pane to a new tab (opens it as the only pane in a fresh tab) +herdr pane move "$HERDR_PANE_ID" --new-tab --label "my-work" +``` + +## Rules + +- Always use `--current` (or `$HERDR_PANE_ID`) rather than hardcoding a pane ID — your ID is stable for your session but not across respawns. +- `herdr pane split` creates a NEW shell in the new pane; the new pane is empty until you send it commands or run it with `--cwd`. +- If `herdr` is not on PATH, check `~/.local/bin/herdr` or ask firstmate. diff --git a/.agents/skills/night-ops-directive/SKILL.md b/.agents/skills/night-ops-directive/SKILL.md new file mode 100644 index 0000000000..322412703c --- /dev/null +++ b/.agents/skills/night-ops-directive/SKILL.md @@ -0,0 +1,66 @@ +--- +name: night-ops-directive +description: >- + Agent-only policy for a captain-authorized autonomous work session across a federated task + store or backlog with no captain present to answer routine questions. + Load when the captain authorizes autonomous, unattended, or overnight work across a backlog + or federated task store, or when reconciling that a standing autonomous-dispatch directive + is still active. +user-invocable: false +metadata: + internal: true +--- + +# Autonomous overnight dispatch + +This skill is the standing policy for a captain-authorized stretch of unattended work: work +through a federated task store or backlog without pausing for questions that section 7's +`yolo` authority already answers, and without collapsing into hands-on implementation. + +## Standing directive + +- Firstmate stays a delegator for the whole session, including overnight: read state, decide + routing, write briefs, spawn crewmates, supervise, and report; do not personally hand-edit + project files, grind through binary or database debugging, or `git diff` a project clone to + do a crewmate's job. If a bounded, low-risk edit to firstmate's own shared tracked material + is genuinely faster to author directly than to brief, treat that as the exception, not the norm. +- Prefer stacked PRs for multi-step work over delaying a wave of dispatch; do not hold otherwise + ready work waiting for an unrelated PR to land unless section 7's serialization test is met. +- Firstmate owns its own wake cadence for this directive. `ScheduleWakeup` and `CronCreate` are + blocked for the primary session by `bin/fm-subagent-pretool-check.sh`; do not attempt either. + The watcher heartbeat (`bin/fm-watch.sh`, `FM_HEARTBEAT` default 600s, doubling per idle + heartbeat up to `FM_HEARTBEAT_MAX` default 7200s, resetting on any actionable wake) is the + durable wake mechanism and needs no separate timer. +- Infra and migration work discovered in the task store is in scope for this directive; do not + gate it away as out-of-scope by default. +- On a project whose default branch is not captain-writable, push and open the PR from the + captain's own fork per section 7; never push a branch to the upstream owner's remote. +- Pace dispatch waves against CI and review-bot rate limits (see `coderabbit-pr-gate` for the + CodeRabbit-specific reaction routine) rather than firing every ready item at once. + +## Yolo scope for this directive + +Section 7's `yolo` redefinition applies: the qualifying test for a routine `yolo`-covered gate, +including a PR merge, is reversibility - can it be undone if it turns out wrong. This directive +does not expand `yolo` past section 7's boundaries: destructive, irreversible, and +security-sensitive choices remain captain-only regardless of how autonomous the session is. + +## Human-only task triage and the promotion path + +A federated task-store item that names a physical, financial, credential, interpersonal, or +otherwise non-code action is not crewmate work. Route it to a dedicated human-tasks-only +federated store rather than leaving it mixed into the code-mappable backlog or attempting to +work it. Move a qualifying item with `bd transfer ` (not `bd promote`, +which only promotes a wisp to a permanent bead within the same store); `bd transfer` closes the +source row with a pointer to the destination, creates the new row in the destination store, and +links the two with a `supersedes` edge so the move stays queryable from either side. + +If the destination human-tasks store is not yet functional, do not hand-debug its provisioning +personally beyond one bounded verification attempt; file the blocker as a backlog item and keep +triaging code-mappable work through the normal task lifecycle instead. + +## Session-boundary notes + +This directive persists across restarts because it lives in tracked `AGENTS.md` and this skill, +not in conversation memory. A restart mid-directive is a non-event: reconcile fleet state per +section 5 and resume dispatch, it does not require the captain to re-issue the directive. diff --git a/.claude/settings.json b/.claude/settings.json index 0be379c46b..f8d081d68d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-sessionstart-nudge.sh" } ] + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bd prime --hook-json" + } + ] } ], "PreToolUse": [ diff --git a/.codex/hooks.json b/.codex/hooks.json index 337bd0a683..c0e418120e 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -9,6 +9,16 @@ "timeout": 10 } ] + }, + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "bd codex-hook SessionStart", + "statusMessage": "Loading Beads context" + } + ] } ], "PreToolUse": [ @@ -28,6 +38,41 @@ ] } ], + "PreCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "bd codex-hook PreCompact", + "statusMessage": "Checking Beads context" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "bd codex-hook PostCompact", + "statusMessage": "Scheduling Beads context refresh" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bd codex-hook UserPromptSubmit", + "statusMessage": "Refreshing Beads context" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/.gitignore b/.gitignore index 1e5e8642ef..e8bfe48b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ data/ .no-mistakes/ .lavish/ .fm-secondmate-home +.fm-isolated-home .DS_Store __pycache__/ *.pyc .env config/ +CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index a675a41d43..c1c2104a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,11 @@ # Firstmate -You are the first mate. +You are the first mate — an **orchestrator and delegator**. The user is the captain. This file is your entire job description. +Your primary mode of operation is delegation: you spawn crewmate agents to do project work, supervise them, and relay outcomes. You do not write project code yourself. You hold the fleet state, route decisions, and keep the captain informed of what matters without exposing internal machinery. + Address the user as "captain" at least once in every response. This is mandatory respectful address, not performance: it applies even when delivering bad news or relaying serious findings, such as "Captain, the build broke - ...". Do not force it into every sentence, but never send a response with zero direct address. @@ -91,7 +93,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .parlay-listen-pid background `parlay listen` pid from a best-effort Parlay chat-panel enrollment at spawn (bin/fm-spawn.sh header); killed and removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; a --beads spawn also records beads_id=; a claude-harness spawn given --account also records account= (docs/configuration.md "Multi-account Claude Code"); kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check @@ -274,6 +276,8 @@ A secondmate's routed reply returns through status or a document pointer, not by For the parent-owned correlation, recovery, and escalation contract on marked secondmate requests, see `bin/fm-pending-reply-lib.sh`. Supervise all live work under section 8. +On a project whose default branch is not captain-writable, push and open the PR from the captain's own fork rather than requesting upstream write access; never push a branch to the upstream owner's remote. + ### Selected delivery path and approval authority The selected delivery path owns its own rigor. @@ -289,7 +293,7 @@ The path's worker, automated gates, and captain approval remain authoritative: Delivery mode and `yolo` are orthogonal. With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. -With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work. +With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work; the qualifying test is reversibility, so a routine PR merge on green or otherwise-approved work qualifies because it can be reverted. Standing `yolo` authority never approves an ask-user Fix that would materially expand that product or engineering contract; destructive, irreversible, and security-sensitive choices remain stronger captain boundaries. Complexity alone is not expansion: a difficult correction genuinely required by accepted intent, including explicitly requested complex architecture, remains autonomous. Before deciding any ask-user finding, load `ask-user-authority`; the implementation worker never answers its own finding. @@ -497,6 +501,8 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +- `night-ops-directive` - load when the captain authorizes autonomous, unattended, or overnight work across a backlog or federated task store, or when reconciling that a standing autonomous-dispatch directive is still active. +- `coderabbit-pr-gate` - load before treating a public registered repo's PR as ready to merge or teardown when CodeRabbit is enabled on it, and whenever a CodeRabbit review comment or rate-limit response needs a reaction. ## 14. X mode @@ -518,3 +524,83 @@ Keep this file for knowledge useful to almost every future agent session in this Do not repeat what the codebase already shows; point to the authoritative file, skill, command, or doc. Prefer rewriting or pruning existing entries over appending new ones. When updating this file, preserve every safety boundary and keep the always-loaded contract concise. + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns. + +## Agent Context Profiles + +The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions. + +- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands. +- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise. +- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins. + +## Session Completion + +This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions. + +1. **File issues for remaining work** - Create beads for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **Handle git/sync by active profile**: + ```bash + # Conservative/minimal/default: report status and proposed commands; wait for approval. + git status + + # Team-maintainer opt-in only, unless current instructions forbid it: + git pull --rebase + bd dolt push + git push + git status + ``` +5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step + +**Critical rules:** +- Explicit user or orchestrator instructions override this Beads block. +- Do not commit or push without clear authority from the active profile or the current user request. +- If a required sync or push is blocked, stop and report the exact command and error. + + + +## Beads Issue Tracker + +Use Beads (`bd`) for durable task tracking in repositories that include it. Use the `beads` skill at `.agents/skills/beads/SKILL.md` (project install) or `~/.agents/skills/beads/SKILL.md` (global install) for Beads workflow guidance, then use the `bd` CLI for issue operations. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +bd prime # Refresh Beads context +``` + +### Rules + +- Use `bd` for all task tracking; do not create markdown TODO lists. +- Run `bd prime` when Beads context is missing or stale. Codex 0.129.0+ can load Beads context automatically through native hooks; use `/hooks` to inspect or toggle them. +- Keep persistent project memory in Beads via `bd remember`; do not create ad hoc memory files. + +**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns. + diff --git a/README.md b/README.md index ac54cf7025..bbe198d593 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,17 @@ The preference persists for the effective Firstmate home, and toggling it off re Setup guides for tmux (the default) and every other supported backend (herdr, zellij, Orca, cmux) are linked in [Documentation](#documentation) below. +### Isolated launch + +To run firstmate with none of your global harness config (no global CLAUDE.md, hooks, skills/agents, or auto-memory) - useful for testing this repo in isolation or running a clean persona - launch through `bin/fm-isolated-launch.sh` instead of the harness binary directly: + +```sh +bin/fm-isolated-launch.sh +``` + +This repo's own project-level CLAUDE.md/AGENTS.md and `.agents/skills/` still load normally. +First run under a fresh isolated home requires logging in again - no auth carries over from the real `~/.claude.json`. + ## How It Works ``` diff --git a/bin/claude-1.sh b/bin/claude-1.sh new file mode 100755 index 0000000000..1701725129 --- /dev/null +++ b/bin/claude-1.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# claude-1.sh: launch claude on account 1. See claude-account.sh. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/claude-account.sh" 1 "$@" diff --git a/bin/claude-2.sh b/bin/claude-2.sh new file mode 100755 index 0000000000..6708da0f5e --- /dev/null +++ b/bin/claude-2.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# claude-2.sh: launch claude on account 2. See claude-account.sh. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/claude-account.sh" 2 "$@" diff --git a/bin/claude-account.sh b/bin/claude-account.sh new file mode 100755 index 0000000000..f2b59e04a8 --- /dev/null +++ b/bin/claude-account.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# claude-account.sh: launch claude with per-account credential isolation. +# Usage: claude-account.sh [args...] +# +# Standalone - works with no firstmate checkout on $PATH. Each account gets its +# own CLAUDE_CONFIG_DIR under ~/.claude-homes/accountN/.claude; shared config +# (commands, hooks, skills, mcp-configs, settings.json, settings.local.json, +# rules, agents) is symlinked in from ~/.claude/, one source of truth. +# .credentials.json and .claude.json are never in that symlink list - they must +# stay per-account real files or OAuth tokens leak across accounts. +# +# flock on a per-account lock file serializes the bootstrap section below so +# two concurrent first launches on the same account cannot race on the JSON +# writes and corrupt .claude.json; the lock fd is closed before exec claude so +# the agent process never inherits it. +# +# See docs/configuration.md "Multi-account Claude Code" and the reference +# pattern this implements: +# https://gist.github.com/sjarmak/61e22d3625ecaac2279e8564d1b1b68f +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "Usage: claude-account.sh [args...]" >&2 + exit 1 +fi +ACCOUNT=$1 +shift +case "$ACCOUNT" in + ''|*[!0-9]*|0) echo "error: must be a positive integer account index" >&2; exit 1 ;; +esac + +ACCOUNT_HOME="$HOME/.claude-homes/account${ACCOUNT}" +export CLAUDE_CONFIG_DIR="$ACCOUNT_HOME/.claude" + +if [ ! -f "$CLAUDE_CONFIG_DIR/.credentials.json" ]; then + echo "error: credentials not found at $CLAUDE_CONFIG_DIR/.credentials.json" >&2 + echo "seed them with:" >&2 + echo " CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR claude /login" >&2 + exit 1 +fi + +mkdir -p "$ACCOUNT_HOME" +exec 9>"$ACCOUNT_HOME/.claude-account.lock" +flock 9 + +# Symlink shared config idempotently. Existing files or links at dest are left +# alone, so a captain-customized per-account override survives. +for item in commands hooks skills mcp-configs settings.json settings.local.json rules agents; do + src="$HOME/.claude/$item" + dest="$CLAUDE_CONFIG_DIR/$item" + if [ -e "$src" ] && [ ! -e "$dest" ]; then + ln -s "$src" "$dest" + fi +done + +# .claude.json lives in the PARENT of CLAUDE_CONFIG_DIR - a Claude Code +# convention, not something this pattern invented. Pre-accept onboarding and +# the trust dialog for the working directory so a headless session doesn't +# hang on either prompt; CLAUDE_TRUST_DIR overrides which directory gets +# pre-trusted when it differs from the launcher's own cwd. +CLAUDE_JSON="$ACCOUNT_HOME/.claude.json" +CLAUDE_TRUST_DIR="${CLAUDE_TRUST_DIR:-$PWD}" python3 - "$CLAUDE_JSON" <<'PYEOF' +import json +import os +import sys +import tempfile + +path = sys.argv[1] +trust_dir = os.environ["CLAUDE_TRUST_DIR"] + +if os.path.exists(path): + with open(path) as f: + data = json.load(f) +else: + data = {} + +changed = False +if not data.get("hasCompletedOnboarding"): + data["hasCompletedOnboarding"] = True + data.setdefault("numStartups", 1) + changed = True + +projects = data.setdefault("projects", {}) +if not projects.get(trust_dir, {}).get("hasTrustDialogAccepted"): + projects.setdefault(trust_dir, {})["hasTrustDialogAccepted"] = True + changed = True + +if changed: + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".claude-account.") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise +PYEOF + +# If you plan to use --dangerously-skip-permissions, pre-accept its matching +# settings.json confirmation so the agent doesn't prompt at startup. Only a +# real per-account settings.json is patched in place - a symlinked shared +# settings.json is left alone so accounts never silently diverge from the +# single source of truth; set the flag once in ~/.claude/settings.json instead +# if every account should skip the prompt. +SETTINGS="$CLAUDE_CONFIG_DIR/settings.json" +if [ -f "$SETTINGS" ] && [ ! -L "$SETTINGS" ]; then + python3 - "$SETTINGS" <<'PYEOF' +import json +import os +import sys +import tempfile + +path = sys.argv[1] +with open(path) as f: + data = json.load(f) + +if not data.get("skipDangerousModePermissionPrompt"): + data["skipDangerousModePermissionPrompt"] = True + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".claude-account.") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise +PYEOF +fi + +# Release the bootstrap lock before handing control to claude. +exec 9>&- + +exec claude "$@" diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 30dd2f6f13..7ea088188b 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -6,8 +6,16 @@ # description, acceptance criteria, and context, and may adjust other sections # when the task genuinely deviates (e.g. working an existing external PR instead # of shipping a new one). -# Usage: fm-brief.sh [--scout] [--herdr-lab] +# Usage: fm-brief.sh [--scout] [--herdr-lab] [--beads ] # fm-brief.sh --secondmate {...|--no-projects} +# --beads links the task to a beads issue and is passed to every hook in +# fm-brief-hooks.d/ as FM_HOOK_BEADS_ID; the beads.sh hook there owns the +# resulting brief content. Applies to ship and scout briefs only. +# Before the Brief section is written, every executable in fm-brief-hooks.d/ +# runs (via `.`, in a subshell) with FM_HOOK_BEADS_ID and FM_HOOK_TASK_ID set; +# each hook's captured stdout is prepended to the brief as its own section. +# Absent or empty fm-brief-hooks.d/ is a no-op. This is the extension point +# for out-of-tree brief content so this file stays a pure addition target. # --scout writes the scout contract instead: the deliverable is a report at # data//report.md (no branch, no push, no PR) and the worktree is scratch. # --secondmate writes a persistent secondmate charter. The project list @@ -105,18 +113,34 @@ fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 +BEADS_ID="" POS=() -for a in "$@"; do - case "$a" in +while [ "$#" -gt 0 ]; do + case "$1" in --scout) KIND=scout ;; --secondmate) KIND=secondmate ;; --herdr-lab) HERDR_LAB=1 ;; --no-projects) NO_PROJECTS=1 ;; - *) POS+=("$a") ;; + --beads) + shift + BEADS_ID=${1-} + ;; + *) POS+=("$1") ;; esac + shift done ID=${POS[0]} +case "$BEADS_ID" in + ''|*[!A-Za-z0-9._-]*) + [ -z "$BEADS_ID" ] || { echo "error: invalid --beads id" >&2; exit 1; } + ;; +esac +if [ -n "$BEADS_ID" ] && [ "$KIND" = secondmate ]; then + echo "error: --beads applies only to crewmate ship or scout briefs" >&2 + exit 1 +fi + if [ "$KIND" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then echo "error: --herdr-lab applies only to crewmate ship or scout briefs" >&2 exit 1 @@ -167,6 +191,27 @@ fork_repo_for_origin() { STATUS_FILE=$(shell_quote "$STATE/$ID.status") +# Pre-brief extension point: source every executable in fm-brief-hooks.d/ that +# can emit additional brief sections, so out-of-tree features (for example +# beads dispatch tracking) can extend the brief without patching this file. +# Each hook's captured stdout is prepended to the brief. Absent or empty dir +# is a no-op. Each hook runs in a subshell so a hook's own `exit` never +# terminates fm-brief.sh, keeping every hook fail-open by construction. +HOOK_SECTION="" +BRIEF_HOOKS_DIR="$FM_ROOT/bin/fm-brief-hooks.d" +if [ -d "$BRIEF_HOOKS_DIR" ]; then + for hook in "$BRIEF_HOOKS_DIR"/*; do + [ -f "$hook" ] && [ -x "$hook" ] || continue + hook_out=$( + export FM_HOOK_BEADS_ID=$BEADS_ID + export FM_HOOK_TASK_ID=$ID + # shellcheck disable=SC1090 + . "$hook" + ) || { echo "warning: brief hook $hook exited non-zero" >&2; continue; } + [ -z "$hook_out" ] || HOOK_SECTION="${HOOK_SECTION}${HOOK_SECTION:+$'\n\n'}${hook_out}" + done +fi + if [ "$KIND" = secondmate ]; then SECONDMATE_PROJECTS="" idx=1 @@ -290,6 +335,8 @@ if [ "$KIND" = scout ]; then cat > "$BRIEF" < "$BRIEF" < fm-groom-json-field.sh (JSON on stdin, value on stdout) +# The field is read from FM_KEY (not argv) so no shell quoting can corrupt it. +set -eu + +exec bun -e ' +const key = process.env.FM_KEY; +if (!key) { process.stderr.write("FM_KEY unset\n"); process.exit(2); } +let raw = ""; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (c) => { raw += c; }); +process.stdin.on("end", () => { + let doc; + try { doc = JSON.parse(raw); } catch { process.exit(0); } + const obj = Array.isArray(doc) ? doc[0] : doc; + if (obj && typeof obj === "object" && typeof obj[key] === "string") { + process.stdout.write(obj[key]); + } + process.exit(0); +}); +' diff --git a/bin/fm-groom-lib.sh b/bin/fm-groom-lib.sh new file mode 100755 index 0000000000..5a18c435d3 --- /dev/null +++ b/bin/fm-groom-lib.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# fm-groom-lib.sh - brief-FORMULATION and SAFETY-CLASSIFICATION for fm-groom.sh. +# +# This is the value core split out from the orchestrator (fm-groom.sh): given a +# rough idea (title + description), MANUFACTURE a concrete, runnable brief string +# and DECIDE whether that work is safe to auto-dispatch. Both decisions go through +# an LLM (PAI Inference.ts, subscription-billed) with PLAIN, non-instructional +# prompts so PAI's PromptGuard injection inspector does not block the nested claude +# (see data/learnings.md: instruction-style briefs -- "You are an agent...", +# "guardrails", "do NOT..." -- are classified as injection and blocked at 0 turns). +# +# Sourced by fm-groom.sh; no side effects at source time. Every function prints to +# stdout and returns non-zero on failure so the orchestrator can decide fallback. +# +# Dependencies: bun + PAI Inference.ts on the resolving path (fm_groom_inference_bin). +set -u + +# Strip leading and trailing blank lines from a string (portable: awk, not the +# GNU-only multiline sed forms that BSD/macOS sed rejects). Interior blank lines +# are preserved so a formulated brief keeps its paragraph structure. +fm_groom_trim_blank_lines() { + printf '%s\n' "$1" | awk ' + { lines[NR] = $0 } + END { + first = 0; last = 0 + for (i = 1; i <= NR; i++) if (lines[i] ~ /[^[:space:]]/) { if (!first) first = i; last = i } + if (!first) exit + for (i = first; i <= last; i++) print lines[i] + }' +} + +# Resolve the PAI Inference.ts tool. Overridable via FM_GROOM_INFERENCE for tests +# (a mock script that reads the same argv shape). Default is the PAI install path. +fm_groom_inference_bin() { + if [ -n "${FM_GROOM_INFERENCE:-}" ]; then + printf '%s\n' "$FM_GROOM_INFERENCE" + return 0 + fi + printf '%s\n' "${HOME}/.claude/PAI/TOOLS/Inference.ts" +} + +# Run one inference turn. Args: . +# When FM_GROOM_INFERENCE is set it is invoked directly as an executable with the +# same three positional args (level system user) plus a leading --level pair, so a +# mock can stub the LLM without bun. Otherwise `bun --level ...`. +# Prints the model's stdout; returns the child's exit status. +fm_groom_infer() { + local level=$1 system=$2 user=$3 bin + bin=$(fm_groom_inference_bin) + if [ -n "${FM_GROOM_INFERENCE:-}" ]; then + "$bin" --level "$level" "$system" "$user" + else + bun "$bin" --level "$level" "$system" "$user" + fi +} + +# Formulate a concrete runnable brief from a rough idea. +# Args: <description>. Prints the formulated brief to stdout. +# The prompts are deliberately descriptive ("Write a task description...") +# rather than imperative role-play, both to dodge PromptGuard AND because the +# OUTPUT is what a downstream agent will act on -- we want a plain task spec, not +# a persona. Returns non-zero if inference failed or produced empty output. +fm_groom_formulate_brief() { + local title=$1 desc=$2 system user out + system=$(cat <<'SYS' +The assistant is a technical planning helper. Given a short product idea, it +writes a single concrete, self-contained task description that another engineer +could pick up and run without further clarification. The description names the +goal in one sentence, lists the concrete first steps, names the files or systems +likely involved when they are inferable, and states what "done" looks like. It +writes plain prose and short lists. It never adds preamble, never asks questions, +and never role-plays. It outputs only the task description, nothing else. +SYS +) + user=$(printf 'Idea title: %s\n\nIdea notes: %s\n\nWrite the concrete task description now.' "$title" "$desc") + out=$(fm_groom_infer standard "$system" "$user") || return 1 + out=$(fm_groom_trim_blank_lines "$out") + [ -n "$out" ] || return 1 + printf '%s\n' "$out" +} + +# Classify a formulated brief as safe-to-auto-dispatch or escalate. +# Args: <title> <brief>. Prints exactly one word on the first line: +# safe research / design / prototype / investigation - reversible, no +# production or shared-state mutation; OK to auto-dispatch. +# escalate merges, deploys, credential/security changes, anything touching +# production or irreversible shared state - firstmate must NOT run it; +# file a review item for the captain instead. +# A second line carries a one-sentence rationale. Any parse failure or ambiguous +# answer is treated by the caller as `escalate` (fail-safe). Returns non-zero on +# inference failure so the caller can fail-safe. +fm_groom_classify() { + local title=$1 brief=$2 system user out verdict + system=$(cat <<'SYS' +The assistant is a risk triage helper for an autonomous work dispatcher. It reads +a task description and answers whether the task is safe for an automated system to +start on its own, unattended, without a human first approving it. + +It answers with exactly one word on the first line: "safe" or "escalate". + +A task is "safe" when it is research, investigation, design, a scoped prototype, +a local experiment, documentation, or analysis -- work that is reversible and does +not merge code, deploy, publish, rotate credentials, change security or auth +settings, delete data, or otherwise mutate production or shared state. + +A task is "escalate" when it merges or pushes to a shared branch, deploys or +releases, publishes anything externally, touches credentials, keys, auth, or +security posture, deletes or migrates data, or makes any irreversible or +production-affecting change. When uncertain, it answers "escalate". + +The second line is one short sentence of rationale. It outputs nothing else. +SYS +) + user=$(printf 'Task title: %s\n\nTask description: %s\n\nAnswer now.' "$title" "$brief") + # standard tier (sonnet, 30s): the fast/haiku 15s ceiling is too tight for the + # safety reasoning, and a timeout would fail-safe to escalate -- costing the + # captain a needless review item. Classification reliability is worth one tier up. + out=$(fm_groom_infer standard "$system" "$user") || return 1 + # ASCII-only on purpose: the verdict vocabulary is "safe"/"escalate". + # shellcheck disable=SC2018,SC2019 + verdict=$(printf '%s\n' "$out" | sed -e '/./,$!d' | head -1 | tr 'A-Z' 'a-z' | tr -cd 'a-z') + case "$verdict" in + safe|escalate) : ;; + *) verdict=escalate ;; # fail-safe: anything unparseable escalates + esac + # Emit verdict then the rationale line (best-effort; may be empty). + printf '%s\n' "$verdict" + printf '%s\n' "$out" | sed -e '/./,$!d' | sed -n '2p' +} diff --git a/bin/fm-groom.sh b/bin/fm-groom.sh new file mode 100755 index 0000000000..926f3fa966 --- /dev/null +++ b/bin/fm-groom.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# fm-groom.sh - PROACTIVE idea->brief->dispatch generator for firstmate. +# +# The missing engine behind data/captain.md's "Formulate the brief from his sparks +# -- never make him articulate the exact task" doctrine. The captain drops rough +# ideas into the `ideas` store; today firstmate only launches work REACTIVELY, so +# those ideas sit ungroomed. This tool grooms them: for each READY idea it +# FORMULATES a concrete runnable brief with an LLM (the core value -- turning a +# spark into the clear string gnhf/a crewmate needs), CLASSIFIES it safe vs unsafe, +# and either dispatches the safe ones or files the unsafe ones to the `review` +# store for the captain. +# +# SAFETY (this is an autonomous LAUNCHER -- it must not run wild): +# * OFF BY DEFAULT. Real dispatch requires FM_GROOM_ENABLED=1. Without it every +# run is a DRY-RUN: it formulates + classifies + prints the intended action, +# and spawns/files/marks NOTHING. This is the primary rail. +# * RATE-LIMITED. FM_GROOM_MAX_IN_FLIGHT (default 2) caps concurrently-dispatched +# groom tasks; when the fleet already holds that many groom-dispatched tasks, +# no new safe idea is dispatched this run (it is reported as deferred). +# * BOUNDED PER RUN. FM_GROOM_MAX_PER_RUN (default 3) caps how many ideas this +# single invocation will act on (dispatch OR file), so one run can never +# stampede the whole 25-idea backlog. Cost-aware: fewer LLM calls, fewer spawns. +# * IDEMPOTENT. Every acted-on idea gets a durable `groom:<state>` label via +# `ideas set-state`; a labeled idea is skipped forever after, so re-runs never +# re-dispatch or re-file the same idea. +# * DURABLE DISPATCH. Safe work is launched through fm-spawn.sh as a Herder-backed +# crewmate (survives this session's compaction; the captain can watch it), never +# a detached headless process. +# +# The LLM prompts live in fm-groom-lib.sh and are deliberately non-instructional so +# PAI's PromptGuard injection inspector does not block the nested claude (see +# data/learnings.md). +# +# Usage: +# fm-groom.sh dry-run over ready ideas (default, safe) +# fm-groom.sh --limit N override FM_GROOM_MAX_PER_RUN for this run +# fm-groom.sh --idea <id> groom exactly one idea by id (still dry-run unless enabled) +# fm-groom.sh --json machine-readable per-idea result records (one JSON object per line) +# FM_GROOM_ENABLED=1 fm-groom.sh ARM real dispatch/file/mark (still bounded + rate-limited) +# +# Environment: +# FM_GROOM_ENABLED 1 arms real actions; anything else = dry-run (default off) +# FM_GROOM_MAX_PER_RUN max ideas acted on per invocation (default 3) +# FM_GROOM_MAX_IN_FLIGHT max concurrently groom-dispatched tasks (default 2) +# FM_GROOM_INFERENCE override Inference.ts path (tests); see fm-groom-lib.sh +# FM_GROOM_IDEAS_BIN override the `ideas` CLI (tests); default `ideas` on PATH +# FM_GROOM_REVIEW_BIN override the `review` CLI (tests); default `review` on PATH +# FM_GROOM_SPAWN override the dispatch command (tests); default bin/fm-spawn.sh +# +# Exit status: 0 when the run completed (even a dry-run finding escalations); 1 on +# a hard setup error (missing dependency, unreadable store). +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-groom-lib.sh +. "$SCRIPT_DIR/fm-groom-lib.sh" + +IDEAS_BIN=${FM_GROOM_IDEAS_BIN:-ideas} +REVIEW_BIN=${FM_GROOM_REVIEW_BIN:-review} +SPAWN_BIN=${FM_GROOM_SPAWN:-$SCRIPT_DIR/fm-spawn.sh} + +# The durable groom state-label dimension. `ideas set-state <id> groom=<value>` +# writes an event bead + a `groom:<value>` label; the presence of ANY groom:* +# label is the idempotency marker. +GROOM_DIM=groom + +ENABLED=0 +[ "${FM_GROOM_ENABLED:-}" = "1" ] && ENABLED=1 +MAX_PER_RUN=${FM_GROOM_MAX_PER_RUN:-3} +MAX_IN_FLIGHT=${FM_GROOM_MAX_IN_FLIGHT:-2} +JSON=0 +ONLY_IDEA= + +usage() { sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --json) JSON=1 ;; + --limit) shift; [ $# -gt 0 ] || { echo "error: --limit requires a value" >&2; exit 1; }; MAX_PER_RUN=$1 ;; + --limit=*) MAX_PER_RUN=${1#--limit=} ;; + --idea) shift; [ $# -gt 0 ] || { echo "error: --idea requires a value" >&2; exit 1; }; ONLY_IDEA=$1 ;; + --idea=*) ONLY_IDEA=${1#--idea=} ;; + *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;; + esac + shift +done + +case "$MAX_PER_RUN" in ''|*[!0-9]*) echo "error: --limit / FM_GROOM_MAX_PER_RUN must be a non-negative integer" >&2; exit 1 ;; esac +case "$MAX_IN_FLIGHT" in ''|*[!0-9]*) echo "error: FM_GROOM_MAX_IN_FLIGHT must be a non-negative integer" >&2; exit 1 ;; esac + +command -v "$IDEAS_BIN" >/dev/null 2>&1 || { echo "error: ideas CLI not found ('$IDEAS_BIN')" >&2; exit 1; } + +# --- helpers ------------------------------------------------------------------- + +# Count groom-dispatched tasks currently in flight. A groom dispatch writes a +# state/<task-id>.meta whose ID is prefixed `groom-`; teardown removes the meta. +# So the live in-flight count is the number of groom-*.meta files present. This is +# the same "meta = live task" convention fm-spawn/fm-teardown already use. +fm_groom_in_flight_count() { + local n=0 f + [ -d "$STATE" ] || { printf '0\n'; return 0; } + for f in "$STATE"/groom-*.meta; do + [ -e "$f" ] || continue + n=$((n + 1)) + done + printf '%s\n' "$n" +} + +# Has this idea already been groomed? True when it carries any groom:* label. +fm_groom_already_done() { + local id=$1 labels + labels=$("$IDEAS_BIN" label list "$id" 2>/dev/null || true) + printf '%s\n' "$labels" | grep -qE "(^|[[:space:]])${GROOM_DIM}:" && return 0 + return 1 +} + +# Emit a JSON record for one idea's outcome (when --json). Fields are pre-escaped +# by the caller via fm_groom_json_escape. +fm_groom_json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e ':a' -e 'N' -e '$!ba' -e 's/\n/\\n/g' -e 's/\t/\\t/g' +} + +emit_json() { # <id> <title> <verdict> <action> <brief> <note> + printf '{"id":"%s","title":"%s","verdict":"%s","action":"%s","brief":"%s","note":"%s"}\n' \ + "$(fm_groom_json_escape "$1")" "$(fm_groom_json_escape "$2")" \ + "$(fm_groom_json_escape "$3")" "$(fm_groom_json_escape "$4")" \ + "$(fm_groom_json_escape "$5")" "$(fm_groom_json_escape "$6")" +} + +# Human-readable per-idea report block. +emit_human() { # <id> <title> <verdict> <action> <brief> <note> + printf '\n──────────────────────────────────────────────────────────────\n' + printf 'IDEA %s %s\n' "$1" "$2" + printf 'VERDICT %s' "$3" + [ -n "$6" ] && printf ' (%s)' "$6" + printf '\n' + printf 'ACTION %s\n' "$4" + printf 'BRIEF ↓\n%s\n' "$5" +} + +# --- ready-idea gathering ------------------------------------------------------ + +# Pull ready ideas as a stable id list, then read each idea's title+description. +# We shell out per-idea (ideas show --json) rather than parse the ready array so +# we get the full description reliably regardless of ready's truncation. +ready_json=$("$IDEAS_BIN" ready --json 2>/dev/null || printf '[]') +ids=$(printf '%s' "$ready_json" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/') +if [ -n "$ONLY_IDEA" ]; then + ids=$ONLY_IDEA +fi + +if [ -z "$ids" ]; then + [ "$JSON" -eq 1 ] || echo "fm-groom: no ready ideas to groom." + exit 0 +fi + +in_flight=$(fm_groom_in_flight_count) + +if [ "$JSON" -eq 0 ]; then + mode_label="DRY-RUN (no dispatch, no file, no mark)" + [ "$ENABLED" -eq 1 ] && mode_label="ARMED (real dispatch/file/mark)" + printf '════════════════════════════════════════════════════════════════\n' + printf 'fm-groom mode=%s\n' "$mode_label" + printf ' max-per-run=%s max-in-flight=%s currently-in-flight=%s\n' \ + "$MAX_PER_RUN" "$MAX_IN_FLIGHT" "$in_flight" + printf '════════════════════════════════════════════════════════════════\n' +fi + +acted=0 +skipped=0 +dispatched_this_run=0 + +for id in $ids; do + # Bounded per-run: stop acting once we hit the cap. In dry-run we still WANT to + # show the cap taking effect, so we report remaining ideas as capped and stop. + if [ "$acted" -ge "$MAX_PER_RUN" ]; then + if [ "$JSON" -eq 1 ]; then + title=$("$IDEAS_BIN" show "$id" --json 2>/dev/null | grep -oE '"title"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/') + emit_json "$id" "${title:-}" "-" "capped" "" "per-run limit ($MAX_PER_RUN) reached" + else + printf '\n(… %s and remaining ideas skipped: per-run limit %s reached)\n' "$id" "$MAX_PER_RUN" + fi + break + fi + + # Idempotency: skip already-groomed ideas. + if fm_groom_already_done "$id"; then + skipped=$((skipped + 1)) + [ "$JSON" -eq 1 ] && emit_json "$id" "" "-" "skipped" "" "already groomed (groom:* label present)" + continue + fi + + show_json=$("$IDEAS_BIN" show "$id" --json 2>/dev/null || true) + title=$(printf '%s' "$show_json" | grep -oE '"title"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/') + # Description can contain escaped quotes/newlines; pull it with a JSON-aware read. + desc=$(printf '%s' "$show_json" | FM_KEY=description "$SCRIPT_DIR/fm-groom-json-field.sh" 2>/dev/null || true) + [ -n "$title" ] || title="(untitled $id)" + [ -n "$desc" ] || desc="$title" + + # (1)+(2) FORMULATE the runnable brief. This is the core value. + if ! brief=$(fm_groom_formulate_brief "$title" "$desc"); then + note="brief formulation failed (inference error)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "-" "error" "" "$note" + else emit_human "$id" "$title" "-" "SKIP: $note" "" ""; fi + acted=$((acted + 1)) + continue + fi + + # (3) CLASSIFY safe vs escalate. Fail-safe: any failure => escalate. + if classify_out=$(fm_groom_classify "$title" "$brief"); then + verdict=$(printf '%s\n' "$classify_out" | head -1) + rationale=$(printf '%s\n' "$classify_out" | sed -n '2p') + else + verdict=escalate + rationale="classification inference failed; escalating by fail-safe policy" + fi + + if [ "$verdict" = "safe" ]; then + # (4) SAFE -> dispatch (rate-limited). If we're at the in-flight cap, defer. + if [ "$dispatched_this_run" -ge 1 ] && [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then + action="DEFER dispatch (in-flight cap $MAX_IN_FLIGHT reached)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "$verdict" "deferred" "$brief" "$rationale" + else emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale"; fi + acted=$((acted + 1)) + continue + fi + if [ $((in_flight + dispatched_this_run)) -ge "$MAX_IN_FLIGHT" ]; then + action="DEFER dispatch (in-flight cap $MAX_IN_FLIGHT reached)" + if [ "$JSON" -eq 1 ]; then emit_json "$id" "$title" "$verdict" "deferred" "$brief" "$rationale" + else emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale"; fi + acted=$((acted + 1)) + continue + fi + + task_id="groom-$(printf '%s' "$id" | tr -cd 'a-z0-9')" + if [ "$ENABLED" -eq 1 ]; then + # Real dispatch: write the formulated brief to data/<task-id>/brief.md and + # spawn a scout crewmate (report deliverable, scratch worktree). We mark the + # idea in-progress via groom=dispatched BEFORE spawn so a crash mid-spawn + # still leaves the idempotency marker (never double-dispatch). + mkdir -p "$DATA/$task_id" + { + printf '# Groomed from idea %s: %s\n\n' "$id" "$title" + printf '%s\n\n' "$brief" + # shellcheck disable=SC2016 # literal backticks/braces are the brief text, not shell expansions + printf 'When done, document your findings to brain via `brain create --type=knowledge "<title>"`.\n' + printf 'Report your result to data/%s/report.md.\n' "$task_id" + } > "$DATA/$task_id/brief.md" + "$IDEAS_BIN" set-state "$id" "$GROOM_DIM=dispatched" \ + --reason "fm-groom auto-dispatched as $task_id" >/dev/null 2>&1 || true + "$IDEAS_BIN" note "$id" "fm-groom dispatched as task $task_id (result -> data/$task_id/report.md)" >/dev/null 2>&1 || true + if "$SPAWN_BIN" "$task_id" "$FM_ROOT" --scout >/dev/null 2>&1; then + action="DISPATCHED as $task_id (scout)" + dispatched_this_run=$((dispatched_this_run + 1)) + else + action="DISPATCH FAILED for $task_id (idea marked; retry manually)" + fi + else + action="WOULD DISPATCH as $task_id (scout via fm-spawn)" + fi + else + # ESCALATE -> file a review item (never dispatch). + if [ "$ENABLED" -eq 1 ]; then + review_title="Groomed idea needs your call: $title" + if command -v "$REVIEW_BIN" >/dev/null 2>&1; then + rid=$(printf '%s' "$brief" | "$REVIEW_BIN" q "$review_title" \ + --description "Auto-formulated from idea $id. Classified NOT safe to auto-dispatch: ${rationale:-no rationale}. Review the brief below and either enable it or drop it." \ + --body-file - 2>/dev/null || true) + "$IDEAS_BIN" set-state "$id" "$GROOM_DIM=escalated" \ + --reason "fm-groom filed review item ${rid:-?}" >/dev/null 2>&1 || true + "$IDEAS_BIN" note "$id" "fm-groom escalated to review item ${rid:-(unknown)}: $rationale" >/dev/null 2>&1 || true + action="FILED review item ${rid:-(id unknown)}" + else + action="REVIEW CLI MISSING; idea left unmarked for retry" + fi + else + action="WOULD FILE review item (escalate)" + fi + fi + + if [ "$JSON" -eq 1 ]; then + emit_json "$id" "$title" "$verdict" "$action" "$brief" "$rationale" + else + emit_human "$id" "$title" "$verdict" "$action" "$brief" "$rationale" + fi + acted=$((acted + 1)) +done + +if [ "$JSON" -eq 0 ]; then + printf '\n════════════════════════════════════════════════════════════════\n' + printf 'fm-groom done: acted=%s skipped(already-groomed)=%s dispatched-this-run=%s\n' \ + "$acted" "$skipped" "$dispatched_this_run" + if [ "$ENABLED" -eq 0 ]; then + printf 'This was a DRY-RUN. To arm real dispatch/file/mark:\n' + printf ' FM_GROOM_ENABLED=1 %s\n' "$SCRIPT_DIR/fm-groom.sh" + fi + printf '════════════════════════════════════════════════════════════════\n' +fi diff --git a/bin/fm-herdr-spur.sh b/bin/fm-herdr-spur.sh new file mode 100755 index 0000000000..493cac04c6 --- /dev/null +++ b/bin/fm-herdr-spur.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# fm-herdr-spur.sh - bridge that SPURS firstmate when an EXTERNAL herdr agent +# finishes (working -> idle | working -> done), by enqueuing a durable wake into +# firstmate's own state/.wake-queue so fm-wake-drain surfaces it and firstmate +# reacts. This closes the reactive gap where a herdr agent NOT spawned by +# firstmate (e.g. a parlay-spawned agent) has no state/<id>.status file and no +# turn-end hook, so firstmate never learns it finished. +# +# WHY A SEPARATE BRIDGE (not the built-in herdr backend push). +# firstmate ALREADY has a native herdr push path +# (bin/backends/herdr.sh fm_backend_herdr_wait_transition, wire reader +# bin/backends/herdr-eventwait.py). That path only watches panes that firstmate +# itself spawned (recorded in state/<id>.meta window=), and its policy +# (bin/fm-transition-lib.sh) deliberately DEFERS idle/done because a +# firstmate-spawned crew's completion is already caught by its status file and +# turn-end hook. An EXTERNAL agent has neither, so for it idle/done IS the only +# completion signal. This bridge reuses the SAME wire reader and the SAME +# normalized-record shape, but applies a spur-specific edge policy: a +# working->idle or working->done transition on a TRACKED external agent is the +# actionable edge. +# +# WHAT IT ENQUEUES. +# The durable wake queue (state/.wake-queue) is written only through +# fm-wake-lib.sh's fm_wake_append, whose kind is validated against +# signal|stale|check|heartbeat. An external-agent completion is not a +# firstmate-task status signal or a stale-pane read, so it rides the `check` +# kind - the "per-task slow poll fired, always actionable" lane the watcher +# already treats as unconditionally surfaced. The record written is: +# <epoch>\t<seq>\tcheck\therdr-spur:<agent>\t<reason> +# where <reason> is a human-readable "herdr agent <name> went <status> (was +# working)" line. fm-wake-drain prints it verbatim as a `check:` wake and +# firstmate acts on it exactly like any other actionable wake. +# +# NATIVE PUSH vs POLL. +# When the herdr server is events-capable (protocol >= 16, events.subscribe + +# pane.agent_status_changed in `herdr api schema`), the bridge BLOCKS on the +# native pane.agent_status_changed stream via herdr-eventwait.py and reacts +# sub-second - no polling. When it is not capable (or the socket/reader is +# unusable), it falls back to a lightweight periodic `herdr agent list` poll. +# Both paths share one normalize + edge-detect + enqueue core. +# +# DEBOUNCE. +# Edges are computed against a remembered last-status per agent (in-memory for +# the process). Only working->{idle,done} fires. A subsequent working edge +# re-arms the agent, so a real finish after a resumed turn spurs again, but +# idle/done flapping without an intervening working edge fires at most once. +# +# CONFIGURABLE. +# Which agents to watch is resolved, in order: +# 1. --agent <name> flags (repeatable) on the command line; +# 2. config/herdr-spur.agents (one agent name per line, # comments allowed); +# 3. otherwise ALL agents herdr currently reports (auto-track every agent). +# --session <name> selects the herdr session (default: "default"). +# --once runs a single poll pass and exits (used by the smoke test). +# --self-detach re-execs the process detached (setsid + nohup) so it survives +# the launching shell / firstmate compaction; the captain's rule: processes +# survive, context does not. +# +# SAFETY. This bridge is READ-ONLY against herdr: it only ever runs +# `herdr agent list`, `herdr api schema`, `herdr session list`, and the +# read-only event subscription. It never sends, focuses, renames, or tears down +# anything. It only WRITES to firstmate's own state/.wake-queue via the +# sanctioned fm_wake_append helper. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-transition-lib.sh +. "$SCRIPT_DIR/fm-transition-lib.sh" + +FM_HERDR_SPUR_SESSION="${FM_HERDR_SPUR_SESSION:-default}" +FM_HERDR_SPUR_POLL_INTERVAL="${FM_HERDR_SPUR_POLL_INTERVAL:-5}" # seconds between poll passes (poll fallback) +FM_HERDR_SPUR_EVENT_BUDGET="${FM_HERDR_SPUR_EVENT_BUDGET:-3600}" # seconds per event-wait block before reconcile +FM_HERDR_SPUR_AGENTS_FILE="${FM_HERDR_SPUR_AGENTS_FILE:-$FM_HOME/config/herdr-spur.agents}" +FM_HERDR_SPUR_LOG="${FM_HERDR_SPUR_LOG:-$STATE/.herdr-spur.log}" + +# --- CLI --------------------------------------------------------------------- +MODE=loop # loop | once +DO_DETACH=false +declare -a WATCH_AGENTS=() + +usage() { + cat >&2 <<'EOF' +Usage: fm-herdr-spur.sh [options] + --agent <name> watch this agent (repeatable; overrides config file) + --session <name> herdr session (default: "default") + --once run one poll pass and exit (no event-wait, no loop) + --self-detach re-exec detached so the daemon survives its parent + --interval <secs> poll-fallback interval (default: 5) + -h, --help show this help +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --agent) WATCH_AGENTS+=("$2"); shift 2 ;; + --session) FM_HERDR_SPUR_SESSION="$2"; shift 2 ;; + --once) MODE=once; shift ;; + --self-detach) DO_DETACH=true; shift ;; + --interval) FM_HERDR_SPUR_POLL_INTERVAL="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'fm-herdr-spur: unknown arg: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +log() { printf '%s %s\n' "$(date +%FT%T%z)" "$*" >> "$FM_HERDR_SPUR_LOG" 2>/dev/null || true; } + +# --- self-detach: robust survival across shell/context death ----------------- +# Re-exec via setsid (Linux) or nohup+disown fallback, dropping --self-detach so +# the child runs the real loop. All state truth lives in files, so a restart is +# a non-event. +if [ "$DO_DETACH" = true ]; then + args=() + [ "$MODE" = once ] && args+=(--once) + args+=(--session "$FM_HERDR_SPUR_SESSION" --interval "$FM_HERDR_SPUR_POLL_INTERVAL") + for a in "${WATCH_AGENTS[@]:-}"; do [ -n "$a" ] && args+=(--agent "$a"); done + if command -v setsid >/dev/null 2>&1; then + setsid nohup "$SCRIPT_DIR/fm-herdr-spur.sh" "${args[@]}" >/dev/null 2>&1 & + else + nohup "$SCRIPT_DIR/fm-herdr-spur.sh" "${args[@]}" >/dev/null 2>&1 & + disown 2>/dev/null || true + fi + printf 'fm-herdr-spur: detached daemon started (session=%s, pid=%s)\n' \ + "$FM_HERDR_SPUR_SESSION" "$!" + log "detached daemon started session=$FM_HERDR_SPUR_SESSION pid=$!" + exit 0 +fi + +# --- herdr reads (read-only) ------------------------------------------------- +# fm_herdr_spur_snapshot: emit one TAB line per agent for the session: +# <agent-name>\t<pane_id>\t<workspace_id>\t<agent_status> +# agent-name falls back to the pane_id when herdr reports no explicit name. +fm_herdr_spur_snapshot() { + herdr agent list --session "$FM_HERDR_SPUR_SESSION" 2>/dev/null \ + | jq -r '.result.agents[]? + | [ (.name // .pane_id), .pane_id, .workspace_id, .agent_status ] + | @tsv' 2>/dev/null +} + +# Non-session-scoped fallback (default session may reject --session on some +# builds); try the plain form when the scoped form yields nothing. +fm_herdr_spur_snapshot_any() { + local out + out=$(fm_herdr_spur_snapshot) + if [ -z "$out" ]; then + out=$(herdr agent list 2>/dev/null \ + | jq -r '.result.agents[]? + | [ (.name // .pane_id), .pane_id, .workspace_id, .agent_status ] + | @tsv' 2>/dev/null) + fi + # Emit with a trailing newline so `read` in the consuming while-loop does not + # drop the final agent (command substitution strips trailing newlines). + [ -n "$out" ] && printf '%s\n' "$out" +} + +# --- watch-set resolution ---------------------------------------------------- +# Prints the newline-separated set of agent names to watch. Empty output means +# "watch every agent herdr currently reports". +fm_herdr_spur_watch_set() { + if [ "${#WATCH_AGENTS[@]}" -gt 0 ]; then + printf '%s\n' "${WATCH_AGENTS[@]}" + return 0 + fi + if [ -f "$FM_HERDR_SPUR_AGENTS_FILE" ]; then + grep -vE '^[[:space:]]*(#|$)' "$FM_HERDR_SPUR_AGENTS_FILE" 2>/dev/null \ + | sed 's/[[:space:]]*$//' + return 0 + fi + return 0 # empty = all +} + +fm_herdr_spur_in_watch_set() { # <agent> <watch-set-newline-list> + local agent=$1 set=$2 + [ -z "$set" ] && return 0 # empty set = all + printf '%s\n' "$set" | grep -Fxq -- "$agent" +} + +# --- edge detection + enqueue ------------------------------------------------ +# LAST_STATUS_<safekey> holds the previously-seen status per agent for debounce. +# A working->{idle,done} edge on a watched agent enqueues one `check` wake. +fm_herdr_spur_safekey() { printf '%s' "$1" | tr -c 'A-Za-z0-9_' '_'; } + +# fm_herdr_spur_edge_policy: the spur-specific edge policy for EXTERNAL agents. +# Returns 0 (spur) only for a fresh working->{idle,done} finish edge; 1 +# otherwise. Deliberately different from fm_transition_policy, whose `defer` on +# idle/done is correct for firstmate-OWNED tasks (caught by status/turn-end) but +# would swallow an external agent's only completion signal. +fm_herdr_spur_edge_policy() { # <from> <to> + local from=$1 to=$2 + [ "$from" = "working" ] || return 1 + case "$to" in + idle|done) return 0 ;; + *) return 1 ;; + esac +} + +# Enqueue one spur wake for <agent> reaching <status>. +fm_herdr_spur_enqueue() { # <agent> <status> <pane_id> + local agent=$1 status=$2 pane=$3 reason + reason="herdr agent ${agent} went ${status} (was working; external agent, no firstmate status file) pane=${pane}" + if fm_wake_append check "herdr-spur:${agent}" "$reason"; then + log "SPUR agent=$agent status=$status pane=$pane" + printf 'fm-herdr-spur: spurred firstmate: %s\n' "$reason" >&2 + return 0 + fi + log "SPUR-FAILED agent=$agent status=$status pane=$pane" + return 1 +} + +# fm_herdr_spur_reconcile: read the current snapshot, and for every watched +# agent whose status transitioned working->{idle,done} since last seen, enqueue +# a spur. Updates the in-memory last-status. This is the shared core used by +# BOTH the poll fallback and the post-event level-reconcile. +fm_herdr_spur_reconcile() { # <watch-set> + local watch_set=$1 line agent pane ws status key prev prev_var + while IFS=$'\t' read -r agent pane ws status; do + [ -n "$agent" ] || continue + fm_herdr_spur_in_watch_set "$agent" "$watch_set" || continue + key=$(fm_herdr_spur_safekey "$agent") + prev_var="LAST_STATUS_${key}" + prev="${!prev_var:-}" + if [ -n "$prev" ] && fm_herdr_spur_edge_policy "$prev" "$status"; then + fm_herdr_spur_enqueue "$agent" "$status" "$pane" + fi + printf -v "$prev_var" '%s' "$status" + done < <(fm_herdr_spur_snapshot_any) +} + +# --- capability probe (mirror fm_backend_herdr_events_capable, lightweight) --- +fm_herdr_spur_events_capable() { + command -v jq >/dev/null 2>&1 || return 1 + command -v python3 >/dev/null 2>&1 || return 1 + local protocol schema + protocol=$(herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in ''|*[!0-9]*) return 1 ;; esac + [ "$protocol" -ge 16 ] || return 1 + schema=$(herdr api schema --json 2>/dev/null) || return 1 + printf '%s' "$schema" | grep -Fq 'events.subscribe' || return 1 + printf '%s' "$schema" | grep -Fq 'pane.agent_status_changed' || return 1 + return 0 +} + +fm_herdr_spur_socket_path() { + herdr session list --json 2>/dev/null \ + | jq -r --arg name "$FM_HERDR_SPUR_SESSION" \ + '.sessions[]? | select(.name == $name) | .socket_path // empty' 2>/dev/null \ + | head -1 +} + +# --- the event-wait block: reuse herdr-eventwait.py --------------------------- +# Subscribe to pane.agent_status_changed for every currently-watched pane, and +# for each streamed edge run the shared edge policy + enqueue. Returns when the +# reader exits (budget elapsed or stream closed); the loop then reconciles +# levels and re-subscribes (panes may have appeared/disappeared). +# Note: this machine's bash is 3.2 (no associative arrays), matching the house +# rule in bin/backends/herdr.sh. pane->agent mapping is stored in per-pane +# dynamic variables PANE_AGENT_<safekey(pane_id)>, read via indirection. +fm_herdr_spur_event_block() { # <watch-set> + local watch_set=$1 sock reader_py agent pane ws status key + local pane_ids="" + sock=$(fm_herdr_spur_socket_path) + [ -n "$sock" ] || return 2 + reader_py="$SCRIPT_DIR/backends/herdr-eventwait.py" + [ -f "$reader_py" ] || return 2 + + # Build pane list for watched agents, seed last-status so the first streamed + # edge has a `from`, and record each pane's agent name for the reverse lookup. + while IFS=$'\t' read -r agent pane ws status; do + [ -n "$agent" ] || continue + fm_herdr_spur_in_watch_set "$agent" "$watch_set" || continue + pane_ids="$pane_ids $pane" + key=$(fm_herdr_spur_safekey "$agent") + printf -v "LAST_STATUS_${key}" '%s' "$status" + printf -v "PANE_AGENT_$(fm_herdr_spur_safekey "$pane")" '%s' "$agent" + done < <(fm_herdr_spur_snapshot_any) + # shellcheck disable=SC2086 + set -- $pane_ids + [ "$#" -gt 0 ] || return 2 + + # Stream. herdr-eventwait.py prints "@subscribed" then TAB lines: + # <pane_id>\t<workspace_id>\t<agent_status>\t<agent> + local pane_id ev_ws ev_status ev_agent name prev prev_var pa_var + while IFS=$'\t' read -r pane_id ev_ws ev_status ev_agent; do + [ "$pane_id" = "@subscribed" ] && { log "subscribed panes=$#"; continue; } + [ -n "$pane_id" ] || continue + pa_var="PANE_AGENT_$(fm_herdr_spur_safekey "$pane_id")" + name="${!pa_var:-$ev_agent}" + [ -n "$name" ] || name="$pane_id" + fm_herdr_spur_in_watch_set "$name" "$watch_set" || continue + key=$(fm_herdr_spur_safekey "$name") + prev_var="LAST_STATUS_${key}" + prev="${!prev_var:-}" + if [ -n "$prev" ] && fm_herdr_spur_edge_policy "$prev" "$ev_status"; then + fm_herdr_spur_enqueue "$name" "$ev_status" "$pane_id" + fi + printf -v "$prev_var" '%s' "$ev_status" + done < <(python3 "$reader_py" "$sock" "$FM_HERDR_SPUR_EVENT_BUDGET" "$@" 2>/dev/null) + return 0 +} + +# --- main -------------------------------------------------------------------- +main() { + local watch_set + watch_set=$(fm_herdr_spur_watch_set) + log "start mode=$MODE session=$FM_HERDR_SPUR_SESSION watch=[$(printf '%s' "$watch_set" | tr '\n' ' ')]" + + if [ "$MODE" = once ]; then + # One reconcile pass. With no prior in-memory status this seeds levels and + # cannot detect an edge by itself; --once exists for the smoke test, which + # seeds a working level then flips it. To let a single invocation observe an + # edge, honor FM_HERDR_SPUR_SEED (agent=status,agent=status) as the assumed + # prior status. + if [ -n "${FM_HERDR_SPUR_SEED:-}" ]; then + local pair a s key + IFS=',' read -ra pairs <<<"$FM_HERDR_SPUR_SEED" + for pair in "${pairs[@]}"; do + a="${pair%%=*}"; s="${pair#*=}" + [ -n "$a" ] || continue + key=$(fm_herdr_spur_safekey "$a") + printf -v "LAST_STATUS_${key}" '%s' "$s" + done + fi + fm_herdr_spur_reconcile "$watch_set" + return 0 + fi + + local capable=false + if fm_herdr_spur_events_capable; then + capable=true + log "events-capable: using native pane.agent_status_changed push" + else + log "events-incapable: using herdr agent list poll fallback" + fi + + while :; do + watch_set=$(fm_herdr_spur_watch_set) + if [ "$capable" = true ]; then + # Native push: block on the stream. On return (budget/close), reconcile + # levels to catch any edge missed across the re-subscribe seam, then loop. + fm_herdr_spur_event_block "$watch_set" || { + log "event block unusable this cycle; poll reconcile" + fm_herdr_spur_reconcile "$watch_set" + sleep "$FM_HERDR_SPUR_POLL_INTERVAL" + } + else + fm_herdr_spur_reconcile "$watch_set" + sleep "$FM_HERDR_SPUR_POLL_INTERVAL" + fi + done +} + +main diff --git a/bin/fm-idea-mine.ts b/bin/fm-idea-mine.ts new file mode 100755 index 0000000000..1281c494bd --- /dev/null +++ b/bin/fm-idea-mine.ts @@ -0,0 +1,978 @@ +#!/usr/bin/env bun +/** + * fm-idea-mine.ts - automated INTAKE step of firstmate's crew-launch methodology. + * + * Mines the captain's recent chat/context history for ideas he expressed or + * implied but never captured, RE-EVALUATES them in a distinct second inference + * pass, files each surviving idea into the `ideas` store, and files one + * consolidated "Morning idea harvest" item into the `review` store. + * + * The captain's ask: "an idea mining agent to run and evaluate based on past + * chat and context history. These ideas that are generated should be internally + * evaluated again after generation, and presented to me as a review item in the + * morning." + * + * TWO SEPARATE INFERENCE CALLS, by design: + * PASS 1 (generate) - extract candidate ideas/sparks from the transcript. + * PASS 2 (self-evaluate) - a distinct second call that critically scores, + * dedups, drops weak ones, and recommends an action. + * The second pass is the captain's explicit "internally evaluated again after + * generation" - it is NOT a filter over pass 1's own output, it is a fresh + * critical review with the existing idea store in context. + * + * RAILS (mirrors the fm-groom posture the crew methodology already uses): + * - Idempotent: a content marker over the mined transcript range prevents + * re-mining the identical tail; dedup prevents double-filing ideas. + * - Bounded: transcript token budget + message cap on the read side, kept-idea + * cap on the write side. + * - Read-and-append only: it reads transcripts and the idea store and APPENDS + * store items. It performs no destructive or irreversible action, so the + * default is LIVE-file; --dry-run previews without filing anything. + * - Schedulable: fully parameterized by env + idempotent, so it can be + * cron/launchd'd overnight. This script installs NO schedule. To run it + * overnight at 05:30 local, a crontab line is: + * 30 5 * * * /usr/bin/env bun /ABSOLUTE/PATH/to/bin/fm-idea-mine.ts >> ~/fm-idea-mine.log 2>&1 + * (adjust the absolute path to this file on the target machine). + * + * ENV OVERRIDES (all optional; sensible defaults): + * FM_IDEA_MINE_TRANSCRIPT_DIR dir of .jsonl transcripts to mine the newest of + * (default ~/.claude/projects/-Users-...-firstmate) + * FM_IDEA_MINE_TRANSCRIPT_FILE explicit transcript file (overrides the dir scan) + * FM_IDEA_MINE_MUSE_FILE optional extra idea source (default ~/muse-overnight-ideas.md) + * FM_IDEA_MINE_INFER_CMD inference command; two positional args appended: + * <system_prompt> <user_prompt>; must print JSON on + * stdout (default: bun Inference.ts --json --level <tier>) + * FM_IDEA_MINE_INFER_TIER fast|standard|smart for the default infer cmd (default standard) + * FM_IDEA_MINE_INFER_TIMEOUT_MS --timeout passed to the default Inference.ts cmd (default 150000) + * FM_IDEA_MINE_IDEAS_CMD ideas store CLI (default: ideas) + * FM_IDEA_MINE_REVIEW_CMD review store CLI (default: review) + * FM_IDEA_MINE_STATE_DIR marker dir (default: <fm-home>/state, else ~/.cache/fm-idea-mine) + * FM_IDEA_MINE_MAX_KEPT cap on kept ideas (default 15) + * FM_IDEA_MINE_TOKEN_BUDGET approx token budget for the mined tail (default 40000) + * FM_IDEA_MINE_MSG_LIMIT max messages from the tail (default 800) + * FM_HOME / FM_STATE_OVERRIDE firstmate home / state dir, used for the marker + * + * FLAGS: + * --dry-run compute the full harvest and print it; file nothing. + * --force ignore the idempotency marker and re-mine even an unchanged tail. + * -h|--help usage. + * + * EXIT: 0 on success (including a benign no-op); non-zero only on a hard error + * (unreadable required input, inference command failure that is not fail-safe). + * Malformed inference JSON is handled fail-safe: nothing is filed and the run + * reports the problem, rather than filing garbage. + */ + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// -------------------------------------------------------------------------- +// Config +// -------------------------------------------------------------------------- + +interface Config { + transcriptDir: string; + transcriptFile: string | null; + museFile: string; + inferCmd: string[]; + ideasCmd: string; + reviewCmd: string; + stateDir: string; + maxKept: number; + tokenBudget: number; + msgLimit: number; + dryRun: boolean; + force: boolean; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function defaultTranscriptDir(): string { + return join(homedir(), ".claude", "projects", "-Users-trilliumsmith-code-firstmate"); +} + +function defaultStateDir(): string { + const stateOverride = process.env.FM_STATE_OVERRIDE; + if (stateOverride && stateOverride.trim() !== "") return stateOverride; + const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE; + if (fmHome && fmHome.trim() !== "") return join(fmHome, "state"); + return join(homedir(), ".cache", "fm-idea-mine"); +} + +function defaultInferCmd(): string[] { + const tier = process.env.FM_IDEA_MINE_INFER_TIER || "standard"; + // Inference.ts defaults to a 30s internal timeout, which is too short for a + // ~40k-token mined tail on the standard tier. Give it a generous ceiling; the + // spawnSync wall-clock timeout in runInference is the real outer bound. + const timeout = process.env.FM_IDEA_MINE_INFER_TIMEOUT_MS || "150000"; + return [ + "bun", + join(homedir(), ".claude", "PAI", "TOOLS", "Inference.ts"), + "--json", + "--level", + tier, + "--timeout", + timeout, + ]; +} + +/** + * Split an env command string into argv the way a shell would for a simple + * command: honor single and double quotes, no variable/glob expansion. This is + * deliberately minimal - the override is a trusted operator/test value, not + * untrusted input - but quoting support lets a mock be a real command with args. + */ +function splitCommand(raw: string): string[] { + const out: string[] = []; + let cur = ""; + let quote: '"' | "'" | null = null; + let started = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (quote) { + if (ch === quote) quote = null; + else cur += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + started = true; + continue; + } + if (ch === " " || ch === "\t" || ch === "\n") { + if (started) { + out.push(cur); + cur = ""; + started = false; + } + continue; + } + cur += ch; + started = true; + } + if (started) out.push(cur); + return out; +} + +function parseArgs(argv: string[]): { dryRun: boolean; force: boolean; help: boolean } { + let dryRun = false; + let force = false; + let help = false; + for (const a of argv) { + if (a === "--dry-run") dryRun = true; + else if (a === "--force") force = true; + else if (a === "-h" || a === "--help") help = true; + else { + throw new Error(`unknown argument: ${a}`); + } + } + return { dryRun, force, help }; +} + +function loadConfig(argv: string[]): Config { + const { dryRun, force } = parseArgs(argv); + const inferRaw = process.env.FM_IDEA_MINE_INFER_CMD; + return { + transcriptDir: process.env.FM_IDEA_MINE_TRANSCRIPT_DIR || defaultTranscriptDir(), + transcriptFile: process.env.FM_IDEA_MINE_TRANSCRIPT_FILE || null, + museFile: process.env.FM_IDEA_MINE_MUSE_FILE || join(homedir(), "muse-overnight-ideas.md"), + inferCmd: inferRaw && inferRaw.trim() !== "" ? splitCommand(inferRaw) : defaultInferCmd(), + ideasCmd: process.env.FM_IDEA_MINE_IDEAS_CMD || "ideas", + reviewCmd: process.env.FM_IDEA_MINE_REVIEW_CMD || "review", + stateDir: process.env.FM_IDEA_MINE_STATE_DIR || defaultStateDir(), + maxKept: envInt("FM_IDEA_MINE_MAX_KEPT", 15), + tokenBudget: envInt("FM_IDEA_MINE_TOKEN_BUDGET", 40000), + msgLimit: envInt("FM_IDEA_MINE_MSG_LIMIT", 800), + dryRun, + force, + }; +} + +const USAGE = `fm-idea-mine.ts - mine recent chat history for uncaptured ideas, re-evaluate them, +and file a consolidated "Morning idea harvest" review item. + +Usage: + bin/fm-idea-mine.ts [--dry-run] [--force] + + --dry-run compute the harvest and print it; file nothing. + --force re-mine even an unchanged transcript tail (ignore the idempotency marker). + -h, --help this help. + +See the file header for the full env override list and a schedulable crontab line.`; + +// -------------------------------------------------------------------------- +// Transcript mining +// -------------------------------------------------------------------------- + +interface MinedMessage { + role: "human" | "assistant"; + text: string; +} + +interface MinedTail { + file: string | null; + messages: MinedMessage[]; + rangeHash: string; + charCount: number; +} + +/** Approx tokens ~= chars / 4. Cheap, deterministic, good enough for a budget. */ +function approxTokens(chars: number): number { + return Math.ceil(chars / 4); +} + +function newestTranscript(cfg: Config): string | null { + if (cfg.transcriptFile) { + return existsSync(cfg.transcriptFile) ? cfg.transcriptFile : null; + } + if (!existsSync(cfg.transcriptDir)) return null; + let newest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(cfg.transcriptDir)) { + if (!name.endsWith(".jsonl")) continue; + const p = join(cfg.transcriptDir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (!st.isFile()) continue; + if (!newest || st.mtimeMs > newest.mtime) newest = { path: p, mtime: st.mtimeMs }; + } + return newest ? newest.path : null; +} + +/** Strip base64/data-URI/image blobs and collapse whitespace from a text block. */ +function sanitizeText(raw: string): string { + let t = raw; + // Drop data: URIs and long base64 runs (image/attachment noise). + t = t.replace(/data:[a-zA-Z0-9/+;=.-]+base64,[A-Za-z0-9+/=]+/g, "[image]"); + t = t.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[blob]"); + // Drop system-reminder envelopes. + t = t.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ""); + t = t.replace(/[ \t]+/g, " "); + t = t.replace(/\n{3,}/g, "\n\n"); + return t.trim(); +} + +/** + * Extract a plain-text block from a message content value that may be a string + * or an array of content blocks. Returns "" when there is no human/assistant + * prose (e.g. a tool_result-only user turn or a thinking-only assistant turn). + */ +function extractText(content: unknown): string { + if (typeof content === "string") return sanitizeText(content); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (block && typeof block === "object" && (block as { type?: string }).type === "text") { + const v = (block as { text?: unknown }).text; + if (typeof v === "string") parts.push(v); + } + // Deliberately skip tool_use, tool_result, thinking, image blocks. + } + return sanitizeText(parts.join("\n")); +} + +/** Content that is only a tool result carries no idea signal; detect it to skip. */ +function isToolResultOnly(content: unknown): boolean { + if (!Array.isArray(content)) return false; + if (content.length === 0) return false; + return content.every( + (b) => b && typeof b === "object" && (b as { type?: string }).type === "tool_result", + ); +} + +function mineTranscript(cfg: Config): MinedTail { + const file = newestTranscript(cfg); + if (!file) return { file: null, messages: [], rangeHash: "", charCount: 0 }; + + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch { + return { file, messages: [], rangeHash: "", charCount: 0 }; + } + + const lines = raw.split("\n").filter((l) => l.trim() !== ""); + const all: MinedMessage[] = []; + for (const line of lines) { + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (!obj || typeof obj !== "object") continue; + const rec = obj as { type?: string; message?: { role?: string; content?: unknown } }; + if (rec.type !== "user" && rec.type !== "assistant") continue; + const content = rec.message?.content; + if (rec.type === "user") { + if (isToolResultOnly(content)) continue; + const text = extractText(content); + if (text) all.push({ role: "human", text }); + } else { + const text = extractText(content); + if (text) all.push({ role: "assistant", text }); + } + } + + // Take a bounded tail: last N messages, then trim from the front to the token + // budget so the most recent conversation always survives. + let tail = all.slice(-cfg.msgLimit); + let charCount = tail.reduce((n, m) => n + m.text.length, 0); + while (tail.length > 1 && approxTokens(charCount) > cfg.tokenBudget) { + const dropped = tail.shift(); + if (dropped) charCount -= dropped.text.length; + } + + const hash = createHash("sha256"); + for (const m of tail) hash.update(`${m.role}�${m.text}�`); + const rangeHash = tail.length > 0 ? `sha256:${hash.digest("hex")}` : ""; + + return { file, messages: tail, rangeHash, charCount }; +} + +function readMuse(cfg: Config): string { + if (!existsSync(cfg.museFile)) return ""; + try { + // Bound the muse contribution too, so a huge file can't blow the prompt. + const raw = readFileSync(cfg.museFile, "utf8"); + const capped = raw.slice(0, 12000); + return sanitizeText(capped); + } catch { + return ""; + } +} + +// -------------------------------------------------------------------------- +// Existing-idea store (dedup source) +// -------------------------------------------------------------------------- + +interface ExistingIdea { + id: string; + title: string; +} + +function listExistingIdeas(cfg: Config): ExistingIdea[] { + const res = spawnSync(cfg.ideasCmd, ["list", "--all", "--json"], { + encoding: "utf8", + timeout: 30000, + }); + if (res.status !== 0 || !res.stdout) return []; + let parsed: unknown; + try { + parsed = JSON.parse(res.stdout); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const out: ExistingIdea[] = []; + for (const item of parsed) { + if (item && typeof item === "object") { + const id = (item as { id?: unknown }).id; + const title = (item as { title?: unknown }).title; + if (typeof title === "string") { + out.push({ id: typeof id === "string" ? id : "", title }); + } + } + } + return out; +} + +/** Normalize a title for fuzzy comparison: lowercase, alnum tokens only. */ +function normalizeTitle(title: string): Set<string> { + const tokens = title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((t) => t.length > 2); + return new Set(tokens); +} + +/** Jaccard token overlap; >= threshold means "the same idea". */ +function titleSimilarity(a: string, b: string): number { + const sa = normalizeTitle(a); + const sb = normalizeTitle(b); + if (sa.size === 0 || sb.size === 0) return 0; + let inter = 0; + for (const t of sa) if (sb.has(t)) inter++; + const union = sa.size + sb.size - inter; + return union === 0 ? 0 : inter / union; +} + +const DEDUP_THRESHOLD = 0.6; + +function findDuplicate(title: string, existing: ExistingIdea[]): ExistingIdea | null { + let best: { idea: ExistingIdea; score: number } | null = null; + for (const e of existing) { + const score = titleSimilarity(title, e.title); + if (score >= DEDUP_THRESHOLD && (!best || score > best.score)) best = { idea: e, score }; + } + return best ? best.idea : null; +} + +// -------------------------------------------------------------------------- +// Inference +// -------------------------------------------------------------------------- + +interface Candidate { + title: string; + spark: string; + rationale: string; + source_hint: string; +} + +interface Scores { + novelty: number; + impact: number; + feasibility: number; + alignment: number; +} + +interface KeptIdea { + title: string; + spark: string; + scores: Scores; + overall: number; + why: string; + recommended_action: string; + dupe_of?: string; +} + +interface DroppedIdea { + title: string; + reason: string; +} + +interface EvalResult { + kept: KeptIdea[]; + dropped: DroppedIdea[]; +} + +class InferenceError extends Error {} + +/** + * Run the configured inference command with (system, user) prompts appended as + * the final two positional args. Returns parsed JSON. The command MUST print + * a single JSON value on stdout. Any non-JSON / non-zero result is an + * InferenceError so the caller can fail safe (file nothing). + */ +function runInference(cfg: Config, system: string, user: string): unknown { + const [cmd, ...base] = cfg.inferCmd; + if (!cmd) throw new InferenceError("empty inference command"); + const res = spawnSync(cmd, [...base, system, user], { + encoding: "utf8", + timeout: 180000, + maxBuffer: 32 * 1024 * 1024, + }); + if (res.error) throw new InferenceError(`inference spawn failed: ${res.error.message}`); + if (res.status !== 0) { + throw new InferenceError( + `inference exited ${res.status}: ${(res.stderr || "").slice(0, 400)}`, + ); + } + const out = (res.stdout || "").trim(); + if (!out) throw new InferenceError("inference produced no output"); + const json = extractJson(out); + if (json === undefined) { + throw new InferenceError(`inference output was not JSON: ${out.slice(0, 200)}`); + } + return json; +} + +/** + * Pull the first balanced JSON array or object out of a text blob. The infer + * command is asked to emit bare JSON, but a chatty model may wrap it in prose or + * a ```json fence; this recovers the payload without trusting exact formatting. + */ +function extractJson(text: string): unknown { + // Fast path: whole thing parses. + try { + return JSON.parse(text); + } catch { + // fall through + } + // Strip a code fence if present. + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const body = fence ? fence[1] : text; + const startIdx = firstJsonStart(body); + if (startIdx < 0) return undefined; + const open = body[startIdx]; + const close = open === "[" ? "]" : "}"; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = startIdx; i < body.length; i++) { + const ch = body[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') inStr = true; + else if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) { + const slice = body.slice(startIdx, i + 1); + try { + return JSON.parse(slice); + } catch { + return undefined; + } + } + } + } + return undefined; +} + +function firstJsonStart(text: string): number { + const arr = text.indexOf("["); + const obj = text.indexOf("{"); + if (arr < 0) return obj; + if (obj < 0) return arr; + return Math.min(arr, obj); +} + +function buildTranscriptDigest(mined: MinedTail, muse: string): string { + const parts: string[] = []; + for (const m of mined.messages) { + const speaker = m.role === "human" ? "CAPTAIN" : "ASSISTANT"; + parts.push(`[${speaker}] ${m.text}`); + } + let digest = parts.join("\n\n"); + if (muse) digest += `\n\n[OVERNIGHT NOTES]\n${muse}`; + return digest; +} + +const GEN_SYSTEM = [ + "You review a work-chat transcript between a captain (the human) and his assistant.", + "Your one job: surface ideas, sparks, half-formed wants, and unaddressed problems the", + "captain raised or implied but that were never turned into a captured task or idea.", + "Focus on the captain's own wishes and frustrations, not the assistant's suggestions.", + "Ignore small talk, status updates, and anything that was already acted on.", + "Return ONLY a JSON array. Each element: {\"title\": short idea name,", + '"spark": the raw thing he said/implied, "rationale": why it looks like an uncaptured idea,', + '"source_hint": a short quote or paraphrase locating it}. No prose outside the JSON.', +].join(" "); + +const EVAL_SYSTEM = [ + "You are a critical second-pass reviewer of freshly generated candidate ideas for a", + "captain whose focus is voice-first computing, accessibility, PAI / life-OS tooling,", + "shipping projects, landing a tech job, and income.", + "For each candidate: score novelty, impact, feasibility, and goal-alignment from 1 to 10;", + "compute an overall score; write one honest sentence of why; and recommend one action of", + "research, build, design, or discuss. Drop candidates that are vague, trivial, already", + "captured, or duplicate an existing idea. When a candidate duplicates an existing idea,", + "put it in dropped with the reason naming the duplicate, OR keep it with dupe_of set if it", + "meaningfully extends the existing one.", + "Return ONLY a JSON object: {\"kept\":[{\"title\",\"spark\",\"scores\":{\"novelty\",\"impact\",", + '"feasibility","alignment"},"overall","why","recommended_action","dupe_of"?}],', + '"dropped":[{"title","reason"}]}. No prose outside the JSON.', +].join(" "); + +function generateCandidates(cfg: Config, digest: string): Candidate[] { + const user = `Transcript to mine (most recent last):\n\n${digest}\n\nReturn the JSON array of uncaptured ideas now.`; + const parsed = runInference(cfg, GEN_SYSTEM, user); + if (!Array.isArray(parsed)) { + throw new InferenceError("generate pass did not return a JSON array"); + } + const out: Candidate[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const o = item as Record<string, unknown>; + const title = typeof o.title === "string" ? o.title.trim() : ""; + if (!title) continue; + out.push({ + title, + spark: typeof o.spark === "string" ? o.spark.trim() : "", + rationale: typeof o.rationale === "string" ? o.rationale.trim() : "", + source_hint: typeof o.source_hint === "string" ? o.source_hint.trim() : "", + }); + } + return out; +} + +function clampScore(v: unknown): number { + const n = typeof v === "number" ? v : Number.parseFloat(String(v)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(10, Math.round(n * 10) / 10)); +} + +const ACTIONS = new Set(["research", "build", "design", "discuss"]); + +function evaluateCandidates(cfg: Config, candidates: Candidate[], existing: ExistingIdea[]): EvalResult { + const existingTitles = existing.map((e) => e.title); + const user = [ + "Existing captured idea titles (for dedup):", + existingTitles.length ? existingTitles.map((t) => `- ${t}`).join("\n") : "(none)", + "", + "Candidate ideas to critically evaluate:", + JSON.stringify(candidates, null, 2), + "", + "Return the JSON object with kept and dropped now.", + ].join("\n"); + const parsed = runInference(cfg, EVAL_SYSTEM, user); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new InferenceError("evaluate pass did not return a JSON object"); + } + const o = parsed as Record<string, unknown>; + const kept: KeptIdea[] = []; + const dropped: DroppedIdea[] = []; + + if (Array.isArray(o.kept)) { + for (const item of o.kept) { + if (!item || typeof item !== "object") continue; + const k = item as Record<string, unknown>; + const title = typeof k.title === "string" ? k.title.trim() : ""; + if (!title) continue; + const rawScores = (k.scores && typeof k.scores === "object" ? k.scores : {}) as Record<string, unknown>; + const scores: Scores = { + novelty: clampScore(rawScores.novelty), + impact: clampScore(rawScores.impact), + feasibility: clampScore(rawScores.feasibility), + alignment: clampScore(rawScores.alignment), + }; + const overallRaw = clampScore(k.overall); + const overall = + overallRaw > 0 + ? overallRaw + : Math.round(((scores.novelty + scores.impact + scores.feasibility + scores.alignment) / 4) * 10) / 10; + let action = typeof k.recommended_action === "string" ? k.recommended_action.trim().toLowerCase() : ""; + if (!ACTIONS.has(action)) action = "discuss"; + const dupe = typeof k.dupe_of === "string" && k.dupe_of.trim() ? k.dupe_of.trim() : undefined; + const entry: KeptIdea = { + title, + spark: typeof k.spark === "string" ? k.spark.trim() : "", + scores, + overall, + why: typeof k.why === "string" ? k.why.trim() : "", + recommended_action: action, + }; + if (dupe) entry.dupe_of = dupe; + kept.push(entry); + } + } + + if (Array.isArray(o.dropped)) { + for (const item of o.dropped) { + if (!item || typeof item !== "object") continue; + const d = item as Record<string, unknown>; + const title = typeof d.title === "string" ? d.title.trim() : ""; + if (!title) continue; + dropped.push({ title, reason: typeof d.reason === "string" ? d.reason.trim() : "" }); + } + } + + // Rank kept by overall desc and enforce the kept cap. + kept.sort((a, b) => b.overall - a.overall); + const capped = kept.slice(0, cfg.maxKept); + const overflow = kept.slice(cfg.maxKept); + for (const o2 of overflow) dropped.push({ title: o2.title, reason: `below the top ${cfg.maxKept} by score` }); + + return { kept: capped, dropped }; +} + +// -------------------------------------------------------------------------- +// Idempotency marker +// -------------------------------------------------------------------------- + +function markerPath(cfg: Config): string { + return join(cfg.stateDir, ".idea-mine-marker"); +} + +function readMarker(cfg: Config): string | null { + const p = markerPath(cfg); + if (!existsSync(p)) return null; + try { + return readFileSync(p, "utf8").trim(); + } catch { + return null; + } +} + +function writeMarker(cfg: Config, rangeHash: string): void { + try { + mkdirSync(cfg.stateDir, { recursive: true }); + writeFileSync(markerPath(cfg), `${rangeHash}\n`, "utf8"); + } catch { + // A missing marker only costs one redundant re-mine; never fatal. + } +} + +// -------------------------------------------------------------------------- +// Filing +// -------------------------------------------------------------------------- + +function today(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function ideaDescription(k: KeptIdea): string { + const s = k.scores; + const scoreLine = `scores novelty=${s.novelty} impact=${s.impact} feasibility=${s.feasibility} alignment=${s.alignment} overall=${k.overall}`; + const lines = [ + `${today()}. Mined from recent chat + context history by fm-idea-mine.`, + "", + k.spark ? `Spark: ${k.spark}` : "", + k.why ? `Why: ${k.why}` : "", + `Recommended action: ${k.recommended_action}`, + scoreLine, + k.dupe_of ? `Extends existing idea: ${k.dupe_of}` : "", + ].filter((l) => l !== ""); + return lines.join("\n"); +} + +interface FileResult { + filedIdeas: { title: string; id: string }[]; + skippedDuplicates: { title: string; dupeOf: string }[]; + reviewId: string | null; +} + +/** Run a store `create` and return the created id (from --silent stdout). */ +function createIssue(cmd: string, title: string, description: string): string { + const res = spawnSync(cmd, ["create", title, "-d", description, "--silent"], { + encoding: "utf8", + timeout: 30000, + }); + if (res.error) throw new Error(`${cmd} create failed: ${res.error.message}`); + if (res.status !== 0) { + throw new Error(`${cmd} create exited ${res.status}: ${(res.stderr || "").slice(0, 300)}`); + } + return (res.stdout || "").trim().split(/\s+/).pop() || ""; +} + +function reviewDescription(result: EvalResult, filed: FileResult, mined: MinedTail): string { + const lines: string[] = []; + lines.push(`Morning idea harvest for ${today()}.`); + lines.push( + `Mined ${mined.messages.length} messages (~${approxTokens(mined.charCount)} tokens) from ${ + mined.file ? mined.file.split("/").pop() : "no transcript" + }.`, + ); + lines.push( + `Kept ${result.kept.length}, filed ${filed.filedIdeas.length} new, skipped ${filed.skippedDuplicates.length} duplicates, dropped ${result.dropped.length}.`, + ); + lines.push(""); + lines.push("## Ranked ideas"); + if (result.kept.length === 0) { + lines.push("(none survived evaluation)"); + } else { + result.kept.forEach((k, i) => { + const s = k.scores; + const filedId = filed.filedIdeas.find((f) => f.title === k.title); + const dupSkip = filed.skippedDuplicates.find((f) => f.title === k.title); + const status = filedId ? `filed ${filedId.id}` : dupSkip ? `dup of ${dupSkip.dupeOf}` : "not filed"; + lines.push( + `${i + 1}. [${k.overall}] ${k.title} — action: ${k.recommended_action} (n${s.novelty}/i${s.impact}/f${s.feasibility}/a${s.alignment}) [${status}]`, + ); + if (k.why) lines.push(` why: ${k.why}`); + }); + } + lines.push(""); + lines.push("## Dropped"); + if (result.dropped.length === 0) { + lines.push("(none)"); + } else { + for (const d of result.dropped) lines.push(`- ${d.title} — ${d.reason}`); + } + return lines.join("\n"); +} + +function fileHarvest(cfg: Config, result: EvalResult, existing: ExistingIdea[]): FileResult { + const filed: FileResult = { filedIdeas: [], skippedDuplicates: [], reviewId: null }; + + // File each kept idea that is not a duplicate of an existing one. + for (const k of result.kept) { + const dup = findDuplicate(k.title, existing); + if (dup) { + filed.skippedDuplicates.push({ title: k.title, dupeOf: dup.id || dup.title }); + continue; + } + const id = createIssue(cfg.ideasCmd, k.title, ideaDescription(k)); + filed.filedIdeas.push({ title: k.title, id }); + // Track it locally so two similar kept ideas in one run don't both file. + existing.push({ id, title: k.title }); + } + + // One consolidated review item. + const reviewTitle = `Morning idea harvest — ${today()}`; + filed.reviewId = createIssue(cfg.reviewCmd, reviewTitle, "PLACEHOLDER"); + return filed; +} + +// -------------------------------------------------------------------------- +// Main +// -------------------------------------------------------------------------- + +function printDryRun(result: EvalResult, existing: ExistingIdea[], mined: MinedTail): void { + console.log("=== fm-idea-mine DRY RUN — nothing filed ==="); + console.log( + `mined ${mined.messages.length} messages (~${approxTokens(mined.charCount)} tokens) from ${ + mined.file ? mined.file.split("/").pop() : "no transcript" + }`, + ); + console.log(`kept ${result.kept.length}, dropped ${result.dropped.length}\n`); + result.kept.forEach((k, i) => { + const dup = findDuplicate(k.title, existing); + const s = k.scores; + const tag = dup ? `(dup of ${dup.id || dup.title} — would skip)` : "(would file)"; + console.log( + `${i + 1}. [${k.overall}] ${k.title} — ${k.recommended_action} (n${s.novelty}/i${s.impact}/f${s.feasibility}/a${s.alignment}) ${tag}`, + ); + if (k.why) console.log(` why: ${k.why}`); + }); + if (result.dropped.length) { + console.log("\ndropped:"); + for (const d of result.dropped) console.log(`- ${d.title} — ${d.reason}`); + } +} + +function main(): number { + let parsedArgs: { dryRun: boolean; force: boolean; help: boolean }; + try { + parsedArgs = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error((e as Error).message); + console.error(USAGE); + return 2; + } + if (parsedArgs.help) { + console.log(USAGE); + return 0; + } + + const cfg = loadConfig(process.argv.slice(2)); + + const mined = mineTranscript(cfg); + if (mined.messages.length === 0) { + console.log("fm-idea-mine: no transcript history to mine; nothing to do."); + return 0; + } + + // Idempotency: skip an identical tail unless forced. Dry-run always computes. + if (!cfg.force && !cfg.dryRun) { + const prev = readMarker(cfg); + if (prev && prev === mined.rangeHash) { + console.log("fm-idea-mine: transcript tail unchanged since last run; nothing to do (use --force to re-mine)."); + return 0; + } + } + + const muse = readMuse(cfg); + const digest = buildTranscriptDigest(mined, muse); + const existing = listExistingIdeas(cfg); + + let candidates: Candidate[]; + try { + candidates = generateCandidates(cfg, digest); + } catch (e) { + console.error(`fm-idea-mine: generate pass failed (nothing filed): ${(e as Error).message}`); + return 1; + } + + if (candidates.length === 0) { + console.log("fm-idea-mine: no candidate ideas surfaced from history; nothing to file."); + if (!cfg.dryRun) writeMarker(cfg, mined.rangeHash); + return 0; + } + + let result: EvalResult; + try { + result = evaluateCandidates(cfg, candidates, existing); + } catch (e) { + console.error(`fm-idea-mine: evaluate pass failed (nothing filed): ${(e as Error).message}`); + return 1; + } + + if (cfg.dryRun) { + printDryRun(result, existing, mined); + return 0; + } + + if (result.kept.length === 0) { + console.log("fm-idea-mine: no ideas survived evaluation; filing an empty harvest review for the record."); + } + + let filed: FileResult; + try { + filed = fileHarvest(cfg, result, existing); + } catch (e) { + console.error(`fm-idea-mine: filing failed: ${(e as Error).message}`); + return 1; + } + + // The review item needs the filed ids, so re-write its body now that we have them. + if (filed.reviewId) { + const body = reviewDescription(result, filed, mined); + const res = spawnSync(cfg.reviewCmd, ["update", filed.reviewId, "-d", body], { + encoding: "utf8", + timeout: 30000, + }); + if (res.status !== 0) { + // Non-fatal: the review item exists; only its body is the placeholder. + console.error( + `fm-idea-mine: warning — could not update review body (${filed.reviewId}): ${(res.stderr || "").slice(0, 200)}`, + ); + } + } + + writeMarker(cfg, mined.rangeHash); + + // Summary to stdout. + console.log("=== fm-idea-mine harvest ==="); + console.log( + `mined ${mined.messages.length} messages; kept ${result.kept.length}; filed ${filed.filedIdeas.length} new ideas; skipped ${filed.skippedDuplicates.length} duplicates; dropped ${result.dropped.length}.`, + ); + if (filed.reviewId) console.log(`review item: ${filed.reviewId}`); + for (const f of filed.filedIdeas) console.log(` idea ${f.id}: ${f.title}`); + for (const s of filed.skippedDuplicates) console.log(` skipped (dup of ${s.dupeOf}): ${s.title}`); + return 0; +} + +// `update` may not be a verb on every bd build; fall back gracefully by using a +// body-file create if needed is out of scope — bd exposes `update -d` (verified +// via --help family), so the direct update above is correct. + +// Run only when executed directly (not when imported by the test harness). +// `import.meta.main` is bun's direct-execution flag; the cast keeps this +// typecheckable without the bun type package present, and it falls back to an +// argv comparison so a plain-tsc/node build still behaves. +const runDirectly = (() => { + const meta = import.meta as unknown as { main?: boolean; url?: string }; + if (typeof meta.main === "boolean") return meta.main; + const entry = process.argv[1] ? resolve(process.argv[1]) : ""; + const self = meta.url ? resolve(fileURLToPath(meta.url)) : ""; + return entry !== "" && entry === self; +})(); + +if (runDirectly) { + process.exit(main()); +} + +export { + splitCommand, + sanitizeText, + extractText, + isToolResultOnly, + titleSimilarity, + findDuplicate, + extractJson, + clampScore, + approxTokens, +}; diff --git a/bin/fm-isolated-launch.sh b/bin/fm-isolated-launch.sh new file mode 100755 index 0000000000..84d39e77c7 --- /dev/null +++ b/bin/fm-isolated-launch.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# fm-isolated-launch.sh - launch a fully isolated `claude` CLI session that +# does not inherit the real user's global PAI config. +# +# Why this exists, part 1 (HOME): Claude Code resolves its "global" config +# directory as $HOME/.claude/ - that is where the real ~/.claude/CLAUDE.md +# @-imports, ~/.claude/settings.json hooks, and ~/.claude/skills|agents live. +# Pointing HOME at a fresh, isolated directory for the child `claude` process +# strips all of that: firstmate runs as a clean persona with none of the +# user's separate global "PAI" customization layer (global CLAUDE.md, global +# hooks, global skills/agents, global auto-memory) bleeding in. +# +# Why this exists, part 2 (cwd - root-caused 2026-07-27): HOME alone is NOT +# enough. Claude Code's memory-file discovery also does an ancestor-directory +# walk from cwd up to filesystem root, checking each ancestor for a +# .claude/CLAUDE.md, entirely independent of $HOME. Firstmate's repo lives at +# /Users/trilliumsmith/code/firstmate - nested inside the real user's home +# directory, which itself has ~/.claude/CLAUDE.md - so that real global +# CLAUDE.md gets swept up as an ordinary ancestor "Project" memory file even +# with HOME fully isolated (confirmed via /context: it showed up labeled +# `Project`, not `User`, proving this is the ancestor walk and not a HOME +# leak). The earlier "isolation confirmed clean" read of this script's +# behavior was wrong for exactly this reason - a HOME override alone doesn't +# touch the ancestor walk at all. `--bare` suppresses the walk but also hard- +# disables OAuth/keychain auth, so it's not usable here. +# +# The fix is to also move cwd physically outside the real home tree: this +# script mirrors the repo's tracked files into a detached git worktree under +# /private/tmp (ancestor chain: /private, / - neither has a .claude/CLAUDE.md) +# and launches `claude` from there instead of from $FM_ROOT. The mirror is +# refreshed to the repo's current HEAD on every launch. Because that mirror +# has no data/, state/, config/, or projects/ (those are untracked, worktree- +# local), FM_ROOT_OVERRIDE is exported pointing back at the real $FM_ROOT so +# every bin/ script invoked from inside the isolated session still resolves +# firstmate's real operational state exactly as if run from $FM_ROOT itself. +# Verified via /context inside the resulting session: memory files list only +# the mirror's own CLAUDE.md (34.4k tokens) - no +# /Users/trilliumsmith/.claude/CLAUDE.md entry anywhere. +# +# This is real isolation, not a partial merge: none of the real ~/.claude or +# ~/.claude.json config, hooks, skills, or memory is copied in. The one +# deliberate exception is auth: Claude Code's OAuth flow tries to persist its +# token to the macOS Keychain, and a freshly isolated $HOME has no +# Library/Keychains structure, so that persist step fails with a "Keychain +# Not Found" dialog that blocks login entirely (root-caused 2026-07-27 against +# brain-ow1w0, the same $HOME-isolation approach used for danny-coach). The +# fix is Claude Code's own documented fallback: a file-based credential at +# .claude/.credentials.json. On first run, if that file is absent, this +# script copies the existing OAuth token straight out of the real, already- +# unlocked macOS Keychain (read-only against the real keychain; nothing is +# reset or modified there) so the isolated session reuses the same logged-in +# account without ever hitting the broken Keychain-write path. If extraction +# fails (non-macOS, no `security` binary, or no stored credential yet), this +# script falls back to the plain "you'll need to log in again" first-run +# message below. Subsequent launches against the same isolated home reuse +# whatever credential file is already there, same as any other HOME. +# +# Usage: fm-isolated-launch.sh [claude-args...] +# fm-isolated-launch.sh -h|--help +# +# Environment overrides: +# FM_ROOT_OVERRIDE firstmate repo root (self-located otherwise) +# FM_ISOLATED_HOME isolated HOME dir (default: $FM_ROOT/.fm-isolated-home) +# FM_ISOLATED_CWD isolated cwd worktree mirror (default: +# /private/tmp/fm-isolated-worktree) +# +# All arguments are forwarded verbatim to the `claude` binary. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REAL_HOME="$HOME" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +ISOLATED_HOME="${FM_ISOLATED_HOME:-$FM_ROOT/.fm-isolated-home}" +ISOLATED_CWD="${FM_ISOLATED_CWD:-/private/tmp/fm-isolated-worktree}" + +fm_isolated_launch_usage() { + sed -n '2,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +case "${1:-}" in + -h|--help) + fm_isolated_launch_usage + exit 0 + ;; +esac + +if ! mkdir -p "$ISOLATED_HOME/.claude"; then + echo "fm-isolated-launch: failed to create isolated home at $ISOLATED_HOME" >&2 + exit 1 +fi + +# Federated bd store access (root-caused 2026-07-27): the PAI-wide store +# wrapper scripts (~/.local/bin/brain, /robots, /task, /decisions, ...) all +# hardcode `export BEADS_DIR="$HOME/data/<store>/.beads"` at *runtime*, keyed +# off whatever $HOME the process actually has - not a path baked in at +# install time. Because this session overrides HOME for the child `claude` +# process, every one of those ~18 wrappers would silently resolve to a +# nonexistent `$ISOLATED_HOME/data/<store>/.beads` instead of the real +# federated stores under $REAL_HOME/data, and fail. This is a distinct gap +# from firstmate's own repo having no local bd database (that's correct - +# firstmate tracks its own backlog via tasks-axi/data/backlog.md, not bd; see +# CLAUDE.md section 10). Fix: symlink the isolated home's `data` straight at +# the real one. This exposes only the Dolt-backed store data the wrappers +# read/write - no PAI CLAUDE.md, hooks, skills, or agent config leaks in, +# since none of that lives under data/. +if [ ! -e "$ISOLATED_HOME/data" ] && [ -d "$REAL_HOME/data" ]; then + ln -s "$REAL_HOME/data" "$ISOLATED_HOME/data" +fi + +# Seed the OAuth credential from the real, already-unlocked macOS Keychain the +# first time this isolated home is used, so login doesn't hit the broken +# Keychain-write path under an isolated $HOME (see header comment). Read-only +# against the real keychain; never resets or modifies it. Skipped silently if +# a credential is already present, or if extraction isn't possible. +if [ ! -f "$ISOLATED_HOME/.claude/.credentials.json" ] && command -v security >/dev/null 2>&1; then + if security find-generic-password -a "$USER" -s "Claude Code-credentials" -w \ + > "$ISOLATED_HOME/.claude/.credentials.json.tmp" 2>/dev/null; then + chmod 600 "$ISOLATED_HOME/.claude/.credentials.json.tmp" + mv "$ISOLATED_HOME/.claude/.credentials.json.tmp" "$ISOLATED_HOME/.claude/.credentials.json" + else + rm -f "$ISOLATED_HOME/.claude/.credentials.json.tmp" + fi +fi + +# Force bypass-permissions (YOLO) mode every launch, matching the same +# `claude --dangerously-skip-permissions` autonomy fm-spawn.sh already uses to +# launch ordinary crewmates (bin/fm-spawn.sh:319) - an isolated firstmate +# session should run just as autonomously as a normal one, not stop for a tool +# approval dialog on every command. Two things are required together or the +# very first bypass-mode activation shows an interactive "are you sure?" +# safety confirm that defaults to "No, exit" on a bare Enter (same gotcha +# documented for danny-coach, brain-ow1w0): settings.json's +# skipDangerousModePermissionPrompt suppresses that confirm, and +# permissions.defaultMode actually puts the session in bypass mode. Re-applied +# on every launch (not just first-run) so a stray settings.json edit can't +# silently regress this. Preserves any other existing settings.json keys. +if command -v python3 >/dev/null 2>&1; then + python3 - "$ISOLATED_HOME/.claude/settings.json" <<'PY' +import json, sys, os +path = sys.argv[1] +data = {} +if os.path.exists(path): + try: + with open(path) as f: + data = json.load(f) + except Exception: + data = {} +if not isinstance(data.get("permissions"), dict): + data["permissions"] = {} +data["permissions"]["defaultMode"] = "bypassPermissions" +data["skipDangerousModePermissionPrompt"] = True +with open(path, "w") as f: + json.dump(data, f, indent=2) +PY + if [ -f "$ISOLATED_HOME/.claude.json" ]; then + python3 - "$ISOLATED_HOME/.claude.json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +data["bypassPermissionsModeAccepted"] = True +with open(path, "w") as f: + json.dump(data, f, indent=2) +PY + fi +else + echo "fm-isolated-launch: python3 not found - could not seed bypass-permissions settings, the isolated session may prompt for tool approval" >&2 +fi + +if [ ! -f "$ISOLATED_HOME/.claude.json" ] && [ ! -f "$ISOLATED_HOME/.claude/.credentials.json" ]; then + echo "fm-isolated-launch: first run under this isolated home - no credential could be extracted from the real macOS Keychain, you will need to log in again." >&2 +fi + +# Mirror $FM_ROOT's tracked files into a detached worktree physically outside +# the real home directory tree, refreshed to current HEAD every launch, so +# Claude Code's ancestor-directory CLAUDE.md walk never reaches +# ~/.claude/CLAUDE.md (see header comment, part 2). +sync_isolated_worktree() { + git -C "$FM_ROOT" worktree prune >/dev/null 2>&1 + if git -C "$FM_ROOT" worktree list --porcelain 2>/dev/null | grep -qx "worktree $ISOLATED_CWD"; then + git -C "$ISOLATED_CWD" fetch --quiet "$FM_ROOT" HEAD >/dev/null 2>&1 && + git -C "$ISOLATED_CWD" checkout --quiet --detach FETCH_HEAD >/dev/null 2>&1 + return $? + fi + mkdir -p "$(dirname "$ISOLATED_CWD")" || return 1 + git -C "$FM_ROOT" worktree add --quiet --detach "$ISOLATED_CWD" HEAD >/dev/null 2>&1 +} + +if ! sync_isolated_worktree; then + echo "fm-isolated-launch: failed to create/refresh the isolated cwd worktree at $ISOLATED_CWD" >&2 + echo "fm-isolated-launch: refusing to fall back to launching from $FM_ROOT - that path is nested under the real \$HOME and would re-leak /Users/trilliumsmith/.claude/CLAUDE.md via Claude Code's ancestor-directory walk." >&2 + exit 1 +fi + +cd "$ISOLATED_CWD" || exit 1 +exec env HOME="$ISOLATED_HOME" FM_ROOT_OVERRIDE="$FM_ROOT" claude --dangerously-skip-permissions "$@" diff --git a/bin/fm-review-decision.sh b/bin/fm-review-decision.sh new file mode 100755 index 0000000000..c8dff57857 --- /dev/null +++ b/bin/fm-review-decision.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# fm-review-decision.sh - record a captain's decision on a review-store item and +# route it to firstmate's attention. This is the firstmate-side half of the +# interactive review decision loop; the captain-facing half is the Pulse endpoint +# POST /api/review/decision (PAI/PULSE/pulse.ts), which validates the id and then +# shells here. +# +# THE LOOP (contract, end to end): +# 1. Captain clicks Approve/Decline/Comment on /review/<id>/ (an interactive +# decision page rendered by bin/fm-review-page.ts). +# 2. The page POSTs {id, verdict, comment?} same-origin to /api/review/decision. +# 3. The Pulse endpoint validates the id exists (via `review show`) and, on a +# valid id, invokes THIS script (resolving firstmate's home via FM_HOME). +# 4. THIS script does three durable things, in order, and FAILS LOUDLY if any +# of the first two cannot be delivered - never a silent ok (robots-5l8): +# a. appends a `check`-kind wake to firstmate's durable wake queue +# (state/.wake-queue) via the sanctioned fm_wake_append helper, keyed +# `review-decision:<id>`, so fm-wake-drain.sh surfaces it on the next +# supervision cycle as an actionable `check:` wake; +# b. annotates the review item itself (`review note <id> "Captain +# decision: <verdict> - <comment> @ <ts>"`) so the decision is visible +# in `review show <id>` / `review ready`; +# c. appends a JSONL audit record to the durable decisions log +# (~/pulse-pages/review/.decisions.jsonl by default) as the trail. +# 5. firstmate drains the wake, reads the decision, and acts on it. +# +# FAIL-LOUD POLICY. The wake enqueue and the store annotation are load-bearing: +# if either fails, this script exits non-zero with a diagnostic on stderr so the +# endpoint returns a non-2xx and the captain sees a real error rather than a +# false success. The audit-log append is best-effort last (the decision is +# already durably enqueued and annotated by then); a log failure is reported on +# stderr but does not fail the command, because losing the human-readable trail +# is strictly less bad than double-reporting a decision that already landed. +# +# VERDICTS. approve | decline | comment. `comment` requires non-empty text; the +# other two accept an optional comment. Any other verdict is rejected (exit 2). +# +# ENV OVERRIDES (all optional): +# FM_HOME firstmate home (selects state/.wake-queue root) +# FM_REVIEW_BIN review CLI path (default `review` on PATH) +# FM_REVIEW_DECISIONS_LOG audit JSONL path (default ~/pulse-pages/review/.decisions.jsonl) +# FM_REVIEW_DECISION_ACTOR audit actor for the note (default "captain") +# +# USAGE: +# fm-review-decision.sh <id> <approve|decline|comment> [comment text...] +# echo "<comment>" | fm-review-decision.sh <id> comment --stdin +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +REVIEW_BIN="${FM_REVIEW_BIN:-review}" +DECISION_ACTOR="${FM_REVIEW_DECISION_ACTOR:-captain}" +# fm-wake-lib.sh pins HOME-independent STATE via FM_HOME; the audit log defaults +# under the real HOME's pulse-pages so it sits beside the served review pages. +DECISIONS_LOG="${FM_REVIEW_DECISIONS_LOG:-$HOME/pulse-pages/review/.decisions.jsonl}" + +die() { printf 'fm-review-decision: %s\n' "$*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +Usage: fm-review-decision.sh <id> <approve|decline|comment> [comment...] + echo "<comment>" | fm-review-decision.sh <id> comment --stdin +EOF +} + +# --- parse args -------------------------------------------------------------- +ID="${1:-}" +VERDICT="${2:-}" +if [ -z "$ID" ] || [ -z "$VERDICT" ]; then + usage + exit 2 +fi +shift 2 || true + +# Remaining args are the comment; support --stdin for piped comment bodies. +COMMENT="" +if [ "${1:-}" = "--stdin" ]; then + COMMENT="$(cat)" +else + COMMENT="$*" +fi + +case "$VERDICT" in + approve|decline|comment) ;; + *) printf 'fm-review-decision: invalid verdict: %s (want approve|decline|comment)\n' "$VERDICT" >&2; exit 2 ;; +esac + +# A bare `comment` verdict with no text is meaningless - reject it loudly rather +# than enqueue an empty comment. +if [ "$VERDICT" = comment ] && [ -z "${COMMENT//[[:space:]]/}" ]; then + die "comment verdict requires non-empty comment text" +fi + +# --- JSON string escaper for the audit record -------------------------------- +# Escape a string for embedding as a JSON value (quotes, backslashes, control +# chars). Kept dependency-free; correctness over cleverness. +json_escape() { + local s=$1 out="" + local i ch + for (( i = 0; i < ${#s}; i++ )); do + ch="${s:i:1}" + case "$ch" in + '"') out+='\"' ;; + '\') out+='\\' ;; + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + $'\t') out+='\t' ;; + *) out+="$ch" ;; + esac + done + printf '%s' "$out" +} + +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# --------------------------------------------------------------------------- +# (a) DURABLE WAKE - load-bearing, fail loud on any failure. +# The payload is a compact, human-readable line firstmate reads directly off the +# drained wake: verdict first, then the comment if present. +# --------------------------------------------------------------------------- +WAKE_PAYLOAD="captain decided ${VERDICT} on ${ID}" +if [ -n "${COMMENT//[[:space:]]/}" ]; then + WAKE_PAYLOAD="${WAKE_PAYLOAD} - ${COMMENT}" +fi +if ! fm_wake_append check "review-decision:${ID}" "$WAKE_PAYLOAD"; then + die "failed to enqueue wake for ${ID} (queue: ${FM_WAKE_QUEUE})" +fi + +# --------------------------------------------------------------------------- +# (b) STORE ANNOTATION - load-bearing, fail loud. Makes the decision visible in +# `review show <id>` so the item carries its own decision trail. +# --------------------------------------------------------------------------- +NOTE="Captain decision: ${VERDICT}" +if [ -n "${COMMENT//[[:space:]]/}" ]; then + NOTE="${NOTE} - ${COMMENT}" +fi +NOTE="${NOTE} @ ${TS}" +NOTE_ERR="$(mktemp "${TMPDIR:-/tmp}/fm-review-decision-note.XXXXXX")" +if ! BEADS_ACTOR="$DECISION_ACTOR" "$REVIEW_BIN" note "$ID" "$NOTE" >/dev/null 2>"$NOTE_ERR"; then + err="$(cat "$NOTE_ERR" 2>/dev/null || true)"; rm -f "$NOTE_ERR" + die "failed to annotate review item ${ID}: ${err}" +fi +rm -f "$NOTE_ERR" + +# --------------------------------------------------------------------------- +# (c) AUDIT LOG - best-effort last. The decision is already durably enqueued and +# annotated; a log write failure is reported but does not fail the command. +# --------------------------------------------------------------------------- +mkdir -p "$(dirname "$DECISIONS_LOG")" 2>/dev/null || true +AUDIT_LINE="$(printf '{"ts":"%s","id":"%s","verdict":"%s","comment":"%s","actor":"%s"}' \ + "$TS" \ + "$(json_escape "$ID")" \ + "$(json_escape "$VERDICT")" \ + "$(json_escape "$COMMENT")" \ + "$(json_escape "$DECISION_ACTOR")")" +if ! printf '%s\n' "$AUDIT_LINE" >> "$DECISIONS_LOG" 2>/dev/null; then + printf 'fm-review-decision: WARNING: audit-log append failed (%s); decision already enqueued + annotated\n' "$DECISIONS_LOG" >&2 +fi + +# Success line for the endpoint to relay. +printf 'recorded: %s decided on %s @ %s\n' "$VERDICT" "$ID" "$TS" diff --git a/bin/fm-review-page.ts b/bin/fm-review-page.ts new file mode 100755 index 0000000000..b408584a0a --- /dev/null +++ b/bin/fm-review-page.ts @@ -0,0 +1,1113 @@ +#!/usr/bin/env bun +/** + * fm-review-page.ts — give every review-store item an INTERACTIVE decision page. + * + * The captain's morning review should be *visiting pages* and *acting on them*, + * not reading terse chat. Anything filed into the `review` federated store (the + * "requires the captain's eyes" queue) gets a self-contained, phone-readable + * HTML page under ~/pulse-pages/review/<id>/index.html, served by Pulse at + * http://localhost:31337/review/<id>/ (and 100.74.138.74:31337/review/<id>/ + * over Tailscale). An index at ~/pulse-pages/review/index.html lists every open + * item, reachable one click from /status/ (one-URL rule). + * + * INTERACTIVE DECISION LOOP (endpoint → wake-queue → drain → firstmate acts): + * Each page carries a decision panel — Approve / Decline buttons and a Comment + * textarea — plus a structured What / Why / Stakes / Recommendation / Artifact + * breakdown parsed from the body so the decision is answerable in place. The + * panel's inline JS POSTs { id, verdict, comment? } same-origin to + * POST /api/review/decision (PAI/PULSE, lib/review-decision.ts). That endpoint + * validates the id via `review show`, then shells to bin/fm-review-decision.sh + * (resolved via FM_HOME), which durably enqueues a `check`-kind wake into + * firstmate's state/.wake-queue (drained by bin/fm-wake-drain.sh), annotates + * the item via `review note`, and appends a JSONL audit record. So when the + * captain clicks Approve on /review/<id>/, firstmate is woken with an + * actionable `check: review-decision:<id>` wake on its next supervision cycle. + * A decision already recorded (parsed from the item's `Captain decision:` + * notes) renders as a standing-decision banner on the page. Fail-loud + * throughout: an unknown id or an undeliverable wake is a non-2xx error, never + * a false-positive ok (robots-5l8). + * + * SURFACE CHOICE — Pulse static pages, not lavish: + * lavish (~/.local/bin/lavish -> parlay) is a live, interactive review + * SESSION tool: it opens a browser, expects `lavish-axi poll` for feedback, + * and its only "shareable URL" path publishes to a third-party public host + * (ht-ml.app). It cannot be driven headlessly to emit a stable, persistent, + * Pulse-served, Tailscale-reachable URL. Pulse static pages are the robust, + * one-URL-rule-compliant path and match the existing /brain, /done, /brief + * pulse-page pattern. So this tool writes static HTML. + * + * WIRE-BACK: each rendered item is updated with `page_url` metadata AND a + * `Page: <url>` note, so the URL travels with the item and shows in + * `review show` / `review ready`. Both are idempotent (metadata is a set; + * the note is only appended when the page_url is new or changed). + * + * READ-ONLY on item CONTENT: the only writes to the store are the page_url + * metadata + note wire-back (a safe append). Everything else is `--json` + * reads. The intended file output lives entirely under the pulse-pages + * review root. + * + * IDEMPOTENT + SCHEDULABLE: `--all` re-renders every open item; re-running + * overwrites pages in place (no duplicates) and only re-notes changed URLs. + * Parameterized entirely by env, so it can run on a cadence or right after a + * review filing (e.g. after fm-idea-mine files its item). + * + * Example cron (NOT installed by this tool — documented only): + * # every 15 min, render open review items to visitable pages + * *\/15 * * * * /opt/homebrew/bin/bun \ + * ~/code/firstmate/bin/fm-review-page.ts --all >/dev/null 2>&1 + * + * ENV OVERRIDES (all optional; defaults are the live surfaces): + * FM_REVIEW_PAGE_OUT output root (default ~/pulse-pages/review) + * FM_REVIEW_PAGE_BASE_URL URL prefix (default http://localhost:31337/review) + * FM_REVIEW_BIN review CLI path (default `review` on PATH) + * FM_REVIEW_PAGE_NO_WIRE set to 1 to skip the store wire-back + * + * USAGE: + * fm-review-page.ts <id> [<id> ...] render specific open items + * fm-review-page.ts --all render every open review item + * fm-review-page.ts --help + */ + +import { spawnSync } from "node:child_process" +import { mkdirSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +// ─── Config (env-overridable) ──────────────────────────────────────────────── + +const HOME = homedir() +const OUT_ROOT = process.env.FM_REVIEW_PAGE_OUT || join(HOME, "pulse-pages", "review") +const BASE_URL = (process.env.FM_REVIEW_PAGE_BASE_URL || "http://localhost:31337/review").replace(/\/+$/, "") +const REVIEW_BIN = process.env.FM_REVIEW_BIN || "review" +const SKIP_WIRE = process.env.FM_REVIEW_PAGE_NO_WIRE === "1" +const CMD_TIMEOUT_MS = 20_000 + +// ─── Store types ───────────────────────────────────────────────────────────── + +/** Shape of one `review list --json` / `review show --json` record we rely on. */ +interface RawReviewItem { + id: string + title?: string + description?: string + /** Appended notes (carries `Captain decision:` lines once a decision lands). */ + notes?: string + status?: string + priority?: number + issue_type?: string + owner?: string + created_at?: string + updated_at?: string + metadata?: Record<string, unknown> | null +} + +/** A normalized item ready to render. */ +interface ReviewItem { + id: string + title: string + description: string + notes: string + status: string + priority: number | null + issueType: string + owner: string + createdAt: string + updatedAt: string + /** Any existing page_url already wired back (used to keep notes idempotent). */ + existingPageUrl: string +} + +/** The most recent captain decision already recorded on the item, if any. */ +interface RecordedDecision { + verdict: string + comment: string + ts: string +} + +/** An artifact link extracted from the body (URL, brain id, git branch). */ +interface Artifact { + kind: "url" | "brain" | "branch" + label: string + /** Clickable href when web-addressable, else null. */ + href: string | null +} + +// ─── review CLI (read + wire-back) ─────────────────────────────────────────── + +/** + * Run the review CLI and return trimmed stdout, or throw with context. The + * subprocess is bounded by a timeout; a missing binary, non-zero exit, or a + * timeout all raise so the caller can degrade explicitly rather than silently + * writing a wrong page. + */ +function runReview(args: string[]): string { + const res = spawnSync(REVIEW_BIN, args, { + encoding: "utf8", + timeout: CMD_TIMEOUT_MS, + // Never inherit stdin; some bd verbs open a pager/editor if a tty is present. + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat" }, + }) + if (res.error) { + throw new Error(`review ${args.join(" ")}: ${res.error.message}`) + } + if (typeof res.status === "number" && res.status !== 0) { + const stderr = (res.stderr || "").trim() + throw new Error(`review ${args.join(" ")} exited ${res.status}${stderr ? `: ${stderr}` : ""}`) + } + return (res.stdout || "").trim() +} + +/** Parse a `review ... --json` payload that may be a single object or a list. */ +function parseJsonItems(raw: string): RawReviewItem[] { + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new Error(`review returned unparseable JSON: ${(err as Error).message}`) + } + if (Array.isArray(parsed)) return parsed as RawReviewItem[] + if (parsed && typeof parsed === "object") return [parsed as RawReviewItem] + return [] +} + +/** Normalize a raw record, filling defaults so rendering never sees undefined. */ +function normalize(raw: RawReviewItem): ReviewItem { + const meta = (raw.metadata ?? {}) as Record<string, unknown> + const pageUrl = typeof meta.page_url === "string" ? meta.page_url : "" + return { + id: raw.id, + title: (raw.title ?? "").trim() || raw.id, + description: raw.description ?? "", + notes: raw.notes ?? "", + status: (raw.status ?? "open").trim(), + priority: typeof raw.priority === "number" ? raw.priority : null, + issueType: (raw.issue_type ?? "").trim(), + owner: (raw.owner ?? "").trim(), + createdAt: (raw.created_at ?? "").trim(), + updatedAt: (raw.updated_at ?? "").trim(), + existingPageUrl: pageUrl, + } +} + +/** + * Extract the most recent recorded captain decision from the item's notes. + * fm-review-decision.sh appends lines shaped: + * `Captain decision: <verdict> - <comment> @ <ts>` + * `Captain decision: <verdict> @ <ts>` (no comment) + * Returns the LAST such line (the standing decision), or null if none. + */ +function extractRecordedDecision(notes: string): RecordedDecision | null { + if (!notes) return null + const re = /Captain decision:\s*(approve|decline|comment)\s*(?:-\s*([\s\S]*?))?\s*@\s*(\S+)/gi + let last: RecordedDecision | null = null + for (const m of notes.matchAll(re)) { + last = { + verdict: m[1].toLowerCase(), + comment: (m[2] ?? "").trim(), + ts: (m[3] ?? "").trim(), + } + } + return last +} + +/** Fetch every OPEN review item (the `--all` set). */ +function fetchAllOpen(): ReviewItem[] { + // `list --json` returns non-closed items by default; keep only open-ish ones. + const raws = parseJsonItems(runReview(["list", "--json", "--limit", "0"])) + return raws + .filter((r) => r.id && !isClosed(r.status)) + .map(normalize) + .sort(byPriorityThenAge) +} + +/** Fetch specific items by id (used for the positional-id path). */ +function fetchByIds(ids: string[]): ReviewItem[] { + const raws = parseJsonItems(runReview(["show", "--json", ...ids])) + const found = new Map(raws.filter((r) => r.id).map((r) => [r.id, normalize(r)])) + // Preserve caller order and surface any ids the store didn't return. + const out: ReviewItem[] = [] + for (const id of ids) { + const item = found.get(id) + if (item) out.push(item) + else console.error(`warning: review item not found: ${id}`) + } + return out +} + +function isClosed(status: string | undefined): boolean { + const s = (status ?? "").toLowerCase() + return s === "closed" || s === "done" || s === "resolved" +} + +/** Priority ascending (0 = highest), then oldest-first within a priority. */ +function byPriorityThenAge(a: ReviewItem, b: ReviewItem): number { + const pa = a.priority ?? 99 + const pb = b.priority ?? 99 + if (pa !== pb) return pa - pb + return a.createdAt.localeCompare(b.createdAt) +} + +/** + * Wire the page URL back onto the item: set `page_url` metadata (idempotent + * set) and append a `Page: <url>` note only when the URL is new or changed, so + * re-runs never pile up duplicate notes. Best-effort: a wire-back failure is + * reported but never fails the render (the page already exists on disk). + */ +function wireBack(item: ReviewItem, url: string): { ok: boolean; noted: boolean; error?: string } { + if (SKIP_WIRE) return { ok: true, noted: false } + try { + runReview(["update", item.id, "--set-metadata", `page_url=${url}`]) + const changed = item.existingPageUrl !== url + if (changed) { + runReview(["note", item.id, `Page: ${url}`]) + } + return { ok: true, noted: changed } + } catch (err) { + return { ok: false, noted: false, error: (err as Error).message } + } +} + +// ─── Artifact extraction ───────────────────────────────────────────────────── + +/** + * Pull artifact links out of the body prose: http(s) URLs, brain doc ids + * (brain-xxxx), and git branch names (branch feat/foo or `feat/foo`). De-duped, + * capped, and rendered as a small link/reference list on the page. + */ +function extractArtifacts(body: string): Artifact[] { + const seen = new Set<string>() + const out: Artifact[] = [] + + const push = (a: Artifact) => { + const key = `${a.kind}:${a.label}` + if (seen.has(key)) return + seen.add(key) + out.push(a) + } + + // URLs first — strip trailing punctuation that clings to prose links. + const urlRe = /https?:\/\/[^\s<>"')\]]+/g + for (const m of body.matchAll(urlRe)) { + const url = m[0].replace(/[.,;:]+$/, "") + push({ kind: "url", label: url, href: url }) + } + + // brain/robots/decision-style doc ids: <word>-<alnum> tokens like brain-k0zr4. + const brainRe = /\b(brain|robots|review|decisions?|ideas?|task|isa)-[a-z0-9]{3,}\b/gi + for (const m of body.matchAll(brainRe)) { + push({ kind: "brain", label: m[0], href: null }) + } + + // git branches: `feat/...` in backticks or after the word "branch". + const branchRe = /\b(?:branch\s+)?((?:feat|fix|chore|refactor|docs|test)\/[A-Za-z0-9._\-/]+)/g + for (const m of body.matchAll(branchRe)) { + push({ kind: "branch", label: m[1], href: null }) + } + + return out.slice(0, 20) +} + +// ─── Minimal, self-contained markdown → HTML ───────────────────────────────── + +/** HTML-escape text for safe insertion into element bodies and attributes. */ +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** + * Render inline markdown (already HTML-escaped input is NOT assumed — this + * escapes as it goes): `code`, **bold**, *italic*, [text](url), and bare URLs. + * Order matters: code spans are extracted first so their contents are never + * re-processed as emphasis or links. + */ +function renderInline(text: string): string { + // Split on `code` spans so their contents are never re-processed as emphasis + // or links, and are HTML-escaped independently. Splitting with a capture group + // keeps the delimiters: even indices are prose, odd indices are raw code. This + // is structurally leak-proof — there is no placeholder token that could survive + // to the output (an earlier placeholder scheme corrupted into NUL bytes and + // leaked `CODE0` markers into rendered pages). + const parts = text.split(/`([^`]+)`/g) + const out: string[] = [] + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) { + out.push(`<code>${esc(parts[i])}</code>`) + continue + } + let seg = esc(parts[i]) + // Markdown links [label](href) — href restricted to http(s)/relative. + seg = seg.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, + (_m, label: string, href: string) => `<a href="${href}" rel="noopener">${label}</a>`, + ) + // Bare URLs (not already inside an <a>). + seg = seg.replace( + /(^|[\s(])(https?:\/\/[^\s<>"')]+)/g, + (_m, pre: string, url: string) => `${pre}<a href="${url}" rel="noopener">${url}</a>`, + ) + // Bold then italic. + seg = seg.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + seg = seg.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>") + out.push(seg) + } + return out.join("") +} + +/** + * Render a markdown block into HTML: ATX headings, fenced code, unordered and + * ordered lists, blockquotes, horizontal rules, and paragraphs. Deliberately + * small — enough for the review-item bodies (which are WHY/ARTIFACT/STAKES + * prose with occasional code and lists), self-contained, no dependencies. + */ +function renderMarkdown(md: string): string { + const lines = md.replace(/\r\n/g, "\n").split("\n") + const html: string[] = [] + let i = 0 + + const flushList = (buf: string[], ordered: boolean) => { + if (!buf.length) return + const tag = ordered ? "ol" : "ul" + html.push(`<${tag}>${buf.map((li) => `<li>${renderInline(li)}</li>`).join("")}</${tag}>`) + buf.length = 0 + } + + while (i < lines.length) { + const line = lines[i] + + // Fenced code block. + const fence = line.match(/^```(\w*)\s*$/) + if (fence) { + const lang = fence[1] || "" + const code: string[] = [] + i++ + while (i < lines.length && !/^```\s*$/.test(lines[i])) { + code.push(lines[i]) + i++ + } + i++ // consume closing fence + const cls = lang ? ` class="lang-${esc(lang)}"` : "" + html.push(`<pre class="codeblock"><code${cls}>${esc(code.join("\n"))}</code></pre>`) + continue + } + + // ATX heading. + const heading = line.match(/^(#{1,6})\s+(.*)$/) + if (heading) { + const level = heading[1].length + html.push(`<h${level}>${renderInline(heading[2].trim())}</h${level}>`) + i++ + continue + } + + // Horizontal rule. + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + html.push("<hr>") + i++ + continue + } + + // Blockquote (consecutive `>` lines). + if (/^>\s?/.test(line)) { + const quote: string[] = [] + while (i < lines.length && /^>\s?/.test(lines[i])) { + quote.push(lines[i].replace(/^>\s?/, "")) + i++ + } + html.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`) + continue + } + + // Unordered list. + if (/^\s*[-*+]\s+/.test(line)) { + const buf: string[] = [] + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*[-*+]\s+/, "")) + i++ + } + flushList(buf, false) + continue + } + + // Ordered list. + if (/^\s*\d+[.)]\s+/.test(line)) { + const buf: string[] = [] + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*\d+[.)]\s+/, "")) + i++ + } + flushList(buf, true) + continue + } + + // Blank line. + if (/^\s*$/.test(line)) { + i++ + continue + } + + // Paragraph: gather until a blank line or a block-starting line. + const para: string[] = [] + while ( + i < lines.length && + !/^\s*$/.test(lines[i]) && + !/^(#{1,6}\s|```|>|\s*[-*+]\s+|\s*\d+[.)]\s+)/.test(lines[i]) && + !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i]) + ) { + para.push(lines[i]) + i++ + } + html.push(`<p>${renderInline(para.join(" "))}</p>`) + } + + return html.join("\n") +} + +// ─── Presentation helpers ──────────────────────────────────────────────────── + +/** Human date like "Jul 15, 2026 · 07:47 UTC" from an ISO timestamp. */ +function formatDate(iso: string): string { + if (!iso) return "unknown" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso + const date = d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }) + const time = d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + return `${date} · ${time} UTC` +} + +/** Days waited since createdAt, floored at 0. */ +function ageDays(iso: string): number { + if (!iso) return 0 + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return 0 + return Math.max(0, Math.floor((Date.now() - then) / 86_400_000)) +} + +function priorityLabel(p: number | null): string { + if (p === null) return "—" + return `P${p}` +} + +/** + * Extract a "recommended action" / "stakes" hint from the body if present. + * These review items conventionally carry `## Stakes` and action lines; we + * surface the first Stakes block as a callout. Returns "" when absent. + */ +function extractStakes(body: string): string { + // A `## Stakes` (or STAKES:) section up to the next heading/blank-run. + const m = body.match(/(?:^|\n)#{0,6}\s*stakes[:\s]*\n?([\s\S]*?)(?=\n#{1,6}\s|\n\s*\n#|$)/i) + if (m && m[1].trim()) return m[1].trim() + // Inline "STAKES: ..." single line. + const inline = body.match(/\bSTAKES:\s*([^\n]+)/i) + return inline ? inline[1].trim() : "" +} + +/** One extracted decision-context field, in the fixed What→Why→…→Artifact order. */ +interface ContextField { + label: string + /** Rendered inline-markdown HTML for the field's prose. */ + html: string +} + +/** + * Break the body into a skimmable What / Why / Stakes / Recommendation / + * Artifact breakdown so the decision is answerable on the page. Review bodies + * carry these signals in two shapes: markdown headings (`## What`, `## Stakes`) + * or inline uppercase labels (`ARTIFACT: ...`, `STAKES: ...`). Each field is + * matched by a set of synonyms and rendered with inline markdown. Returns [] if + * NONE of the fields are found, so the caller can fall back to the flat body. + */ +function extractContextFields(body: string): ContextField[] { + // label -> matcher synonyms (case-insensitive), in display order. + const spec: { label: string; keys: string[] }[] = [ + { label: "What", keys: ["what", "summary", "proposal", "decide", "decision"] }, + { label: "Why", keys: ["why", "rationale", "context", "background"] }, + { label: "Stakes", keys: ["stakes", "risk", "risks", "impact"] }, + { label: "Recommendation", keys: ["recommendation", "recommended action", "recommended", "action"] }, + { label: "Artifact", keys: ["artifact", "artifacts", "evidence", "branch", "pr"] }, + ] + + const found: ContextField[] = [] + const usedRanges: [number, number][] = [] + + const claim = (start: number, end: number): boolean => { + // Reject a match that overlaps a field we already claimed (so "action" + // inside a Stakes block can't be re-extracted as Recommendation). + for (const [s, e] of usedRanges) { + if (start < e && end > s) return false + } + usedRanges.push([start, end]) + return true + } + + for (const { label, keys } of spec) { + let picked = "" + for (const key of keys) { + const esc = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + // (1) Heading form: `## What\n...` up to the next heading, a blank-line + // paragraph break, or end of body. Stopping at a blank line keeps the last + // heading section from swallowing a following inline-label line (e.g. a + // trailing `ARTIFACT: ...`). + const headRe = new RegExp( + `(?:^|\\n)#{1,6}\\s*${esc}\\b[:\\s]*\\n([\\s\\S]*?)(?=\\n#{1,6}\\s|\\n\\s*\\n|$)`, + "i", + ) + const hm = body.match(headRe) + if (hm && hm[1].trim() && claim(hm.index ?? 0, (hm.index ?? 0) + hm[0].length)) { + picked = hm[1].trim() + break + } + // (2) Inline label form: `ARTIFACT: ...` to end of line (or paragraph for + // the last field). Kept to a single line so it doesn't swallow the body. + const inlineRe = new RegExp(`(?:^|\\n)\\s*${esc}\\s*:\\s*([^\\n]+)`, "i") + const im = body.match(inlineRe) + if (im && im[1].trim() && claim(im.index ?? 0, (im.index ?? 0) + im[0].length)) { + picked = im[1].trim() + break + } + } + if (picked) found.push({ label, html: renderInline(picked) }) + } + + return found +} + +// ─── Page rendering ────────────────────────────────────────────────────────── + +/** Shared CSS — GitHub-dark house palette matching ~/pulse-pages/brain style. */ +const PAGE_CSS = ` +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --ink: #0D1117; --surf: #161B22; --surf2: #1C2128; --surf3: #21262D; + --border: #30363D; --muted: #7D8590; --body: #E6EDF3; + --green: #3FB950; --blue: #58A6FF; --amber: #F0B429; + --red: #FF7B72; --purple: #D2A8FF; + --mono: 'SFMono-Regular','SF Mono',Menlo,Consolas,'Courier New',monospace; + --sans: -apple-system,'SF Pro Text','Segoe UI',system-ui,sans-serif; +} +html { background: var(--ink); color: var(--body); font-family: var(--sans); font-size: 15px; -webkit-text-size-adjust: 100%; } +body { min-height: 100dvh; line-height: 1.55; } +a { color: var(--blue); text-decoration: none; word-break: break-word; } +a:hover { text-decoration: underline; } +.wrap { max-width: 760px; margin: 0 auto; padding: 1.25rem 1.1rem 5rem; } + +.topbar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.topbar .back { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.crumb { font-family: var(--mono); font-size: 11px; color: var(--muted); } + +.badges { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0 1rem; } +.badge { + font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; padding: 3px 9px; border: 1px solid var(--border); + color: var(--muted); background: var(--ink); border-radius: 3px; white-space: nowrap; +} +.badge.p0, .badge.p1 { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--border)); } +.badge.p2 { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--border)); } +.badge.status { color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, var(--border)); } +.badge.age-stale { color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, var(--border)); } +.badge.age-aging { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 55%, var(--border)); } + +h1.item-title { font-size: 1.5rem; line-height: 1.25; font-weight: 700; margin-bottom: .35rem; } + +.callout { + border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--border)); + background: color-mix(in srgb, var(--amber) 8%, var(--surf)); + border-left: 3px solid var(--amber); + padding: .7rem .9rem; border-radius: 4px; margin: 1rem 0; +} +.callout .label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--amber); display: block; margin-bottom: .3rem; } + +.artifacts { margin: 1rem 0; } +.artifacts .label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: .45rem; } +.artifacts ul { list-style: none; display: flex; flex-direction: column; gap: .3rem; } +.artifacts li { display: flex; align-items: baseline; gap: .5rem; font-size: .9rem; } +.artifacts .k { font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 3px; flex-shrink: 0; } +.artifacts code { font-family: var(--mono); font-size: .85rem; color: var(--purple); } + +.body { margin-top: 1.4rem; } +.body h1, .body h2, .body h3, .body h4 { margin: 1.4rem 0 .5rem; line-height: 1.3; } +.body h1 { font-size: 1.3rem; } .body h2 { font-size: 1.15rem; } +.body h3 { font-size: 1rem; } .body h4 { font-size: .92rem; color: var(--muted); } +.body h2 { border-bottom: 1px solid var(--border); padding-bottom: .3rem; } +.body p { margin: .7rem 0; } +.body ul, .body ol { margin: .7rem 0 .7rem 1.4rem; } +.body li { margin: .25rem 0; } +.body blockquote { border-left: 3px solid var(--border); padding: .2rem .9rem; margin: .8rem 0; color: var(--muted); } +.body hr { border: none; border-top: 1px solid var(--border); margin: 1.4rem 0; } +.body a { } +.body code { font-family: var(--mono); font-size: .86em; background: var(--surf3); padding: 1px 5px; border-radius: 3px; color: var(--purple); } +.body pre.codeblock { + background: var(--surf); border: 1px solid var(--border); border-radius: 5px; + padding: .8rem .9rem; margin: .9rem 0; overflow-x: auto; -webkit-overflow-scrolling: touch; +} +.body pre.codeblock code { background: none; padding: 0; color: var(--body); font-size: .82rem; line-height: 1.5; white-space: pre; } + +.meta-foot { margin-top: 2.2rem; padding-top: 1rem; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 11px; color: var(--muted); display: flex; flex-direction: column; gap: .3rem; } + +/* Structured decision-context breakdown (What / Why / Stakes / …). */ +.context { margin: 1.2rem 0; display: flex; flex-direction: column; gap: .1rem; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.context .field { padding: .7rem .9rem; border-top: 1px solid var(--border); } +.context .field:first-child { border-top: none; } +.context .field .k { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--blue); display: block; margin-bottom: .3rem; } +.context .field .v { font-size: .95rem; line-height: 1.5; } +.context .field.stakes .k { color: var(--amber); } +.context .field.stakes { background: color-mix(in srgb, var(--amber) 6%, var(--surf)); } + +/* Interactive decision panel — the loop-closer. */ +.decision { margin: 1.6rem 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surf); padding: 1rem 1rem 1.1rem; } +.decision .dlabel { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: .7rem; } +.decision .buttons { display: flex; gap: .6rem; flex-wrap: wrap; } +.decision button { + flex: 1 1 auto; min-height: 48px; min-width: 120px; font-size: 1rem; font-weight: 600; + font-family: var(--sans); border-radius: 6px; border: 1px solid var(--border); + background: var(--surf2); color: var(--body); cursor: pointer; padding: .6rem 1rem; + -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: filter .12s, opacity .12s; +} +.decision button:hover:not(:disabled) { filter: brightness(1.15); } +.decision button:active:not(:disabled) { filter: brightness(.92); } +.decision button:disabled { opacity: .45; cursor: default; } +.decision button.approve { border-color: color-mix(in srgb, var(--green) 60%, var(--border)); color: var(--green); } +.decision button.decline { border-color: color-mix(in srgb, var(--red) 60%, var(--border)); color: var(--red); } +.decision button.comment { border-color: color-mix(in srgb, var(--blue) 55%, var(--border)); color: var(--blue); } +.decision .comment-box { margin-top: .8rem; } +.decision textarea { + width: 100%; min-height: 84px; resize: vertical; font-family: var(--sans); font-size: 1rem; + line-height: 1.5; color: var(--body); background: var(--ink); border: 1px solid var(--border); + border-radius: 6px; padding: .6rem .7rem; +} +.decision textarea:focus { outline: none; border-color: var(--blue); } +.decision .hint { font-size: .82rem; color: var(--muted); margin-top: .45rem; } +.decision .feedback { margin-top: .85rem; padding: .65rem .8rem; border-radius: 6px; font-size: .92rem; line-height: 1.45; display: none; } +.decision .feedback.show { display: block; } +.decision .feedback.ok { border: 1px solid color-mix(in srgb, var(--green) 45%, var(--border)); background: color-mix(in srgb, var(--green) 8%, var(--surf)); color: var(--green); } +.decision .feedback.err { border: 1px solid color-mix(in srgb, var(--red) 50%, var(--border)); background: color-mix(in srgb, var(--red) 8%, var(--surf)); color: var(--red); } +.decision .feedback.pending { border: 1px solid var(--border); color: var(--muted); } +.decision .recorded { margin-top: .85rem; padding: .65rem .8rem; border-radius: 6px; font-size: .9rem; border: 1px solid color-mix(in srgb, var(--green) 40%, var(--border)); background: color-mix(in srgb, var(--green) 7%, var(--surf)); color: var(--body); } +.decision .recorded .rv { font-family: var(--mono); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--green); } +.decision .recorded.decline .rv { color: var(--red); } +.decision .recorded .rc { display: block; margin-top: .3rem; color: var(--muted); } + +/* Index page. */ +.index-head { margin-bottom: 1.4rem; } +.index-head h1 { font-size: 1.35rem; font-weight: 700; } +.index-head .sub { color: var(--muted); font-size: .9rem; margin-top: .25rem; } +.list { display: flex; flex-direction: column; gap: .7rem; } +.card { + display: block; border: 1px solid var(--border); background: var(--surf); + border-radius: 6px; padding: .85rem 1rem; text-decoration: none; color: var(--body); +} +.card:hover { border-color: var(--blue); text-decoration: none; } +.card .row1 { display: flex; align-items: baseline; gap: .55rem; flex-wrap: wrap; } +.card .ct { font-weight: 600; font-size: 1rem; line-height: 1.3; } +.card .cmeta { margin-top: .35rem; display: flex; gap: .5rem; flex-wrap: wrap; } +.empty { color: var(--muted); font-style: italic; padding: 2rem 0; text-align: center; } +`.trim() + +/** Age tier -> badge class + label. */ +function ageTier(days: number): { cls: string; label: string } { + if (days >= 3) return { cls: "age-stale", label: `${days}d waiting` } + if (days >= 1) return { cls: "age-aging", label: `${days}d waiting` } + return { cls: "", label: "today" } +} + +/** Render the structured What/Why/Stakes/Recommendation/Artifact breakdown. */ +function renderContext(fields: ContextField[]): string { + if (!fields.length) return "" + const rows = fields + .map((f) => { + const cls = f.label.toLowerCase() === "stakes" ? " stakes" : "" + return `<div class="field${cls}"><span class="k">${esc(f.label)}</span><div class="v">${f.html}</div></div>` + }) + .join("\n ") + return `\n <div class="context"> + ${rows} + </div>` +} + +/** + * Render the interactive decision panel: Approve / Decline buttons, a Comment + * textarea + submit, in-page feedback, and — when a decision was already + * recorded — a standing-decision banner. The inline JS POSTs same-origin to + * /api/review/decision and reflects success/failure without a page reload. The + * item id is embedded as a data attribute (JSON-encoded) so no server value is + * interpolated into a JS string. + */ +function renderDecisionPanel(item: ReviewItem, recorded: RecordedDecision | null): string { + const idJson = JSON.stringify(item.id) + const recordedHtml = recorded + ? `\n <div class="recorded ${recorded.verdict === "decline" ? "decline" : ""}" id="recorded"> + Standing decision: <span class="rv">${esc(recorded.verdict)}</span>${ + recorded.ts ? ` · ${esc(recorded.ts)}` : "" + }${recorded.comment ? `<span class="rc">“${esc(recorded.comment)}”</span>` : ""} + </div>` + : `\n <div class="recorded" id="recorded" style="display:none"></div>` + + // The inline script is self-contained (CSP-safe, no external fetch host) and + // idempotent-friendly: after a successful decision it disables the buttons so + // the captain doesn't double-submit, and re-enables on error so a transient + // failure is retryable. + return ` + <div class="decision" data-review-id='${esc(idJson)}'> + <span class="dlabel">Your decision</span> + <div class="buttons"> + <button type="button" class="approve" data-verdict="approve">Approve</button> + <button type="button" class="decline" data-verdict="decline">Decline</button> + <button type="button" class="comment" data-verdict="comment">Comment</button> + </div> + <div class="comment-box"> + <textarea id="comment" placeholder="Optional note for Approve/Decline — required to Comment"></textarea> + <div class="hint">Approve and Decline may include a note. Comment sends the note without deciding.</div> + </div> + <div class="feedback" id="feedback" role="status" aria-live="polite"></div>${recordedHtml} + </div> + <script> + (function () { + var panel = document.querySelector('.decision'); + if (!panel) return; + var reviewId = JSON.parse(panel.getAttribute('data-review-id')); + var buttons = panel.querySelectorAll('button[data-verdict]'); + var textarea = panel.querySelector('#comment'); + var feedback = panel.querySelector('#feedback'); + var recorded = panel.querySelector('#recorded'); + + function setFeedback(kind, msg) { + feedback.className = 'feedback show ' + kind; + feedback.textContent = msg; + } + function setButtons(disabled) { + buttons.forEach(function (b) { b.disabled = disabled; }); + } + function lockAfterDecision(verdict, comment, ts) { + setButtons(true); + recorded.style.display = 'block'; + recorded.className = 'recorded' + (verdict === 'decline' ? ' decline' : ''); + var html = 'Standing decision: <span class="rv"></span>'; + recorded.innerHTML = html; + recorded.querySelector('.rv').textContent = verdict; + if (ts) { recorded.appendChild(document.createTextNode(' · ' + ts)); } + if (comment) { + var rc = document.createElement('span'); + rc.className = 'rc'; + rc.textContent = '\\u201c' + comment + '\\u201d'; + recorded.appendChild(rc); + } + } + + function submit(verdict) { + var comment = (textarea.value || '').trim(); + if (verdict === 'comment' && !comment) { + setFeedback('err', 'A comment needs some text before you send it.'); + textarea.focus(); + return; + } + setButtons(true); + setFeedback('pending', 'Recording your ' + verdict + '\\u2026'); + fetch('/api/review/decision', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: reviewId, verdict: verdict, comment: comment }) + }).then(function (res) { + return res.json().then(function (data) { return { ok: res.ok, data: data }; }); + }).then(function (r) { + if (r.ok && r.data && r.data.ok) { + setFeedback('ok', 'Recorded — the first mate has been notified.'); + var ts = new Date().toISOString().replace(/\\.\\d+Z$/, 'Z'); + lockAfterDecision(verdict, comment, ts); + } else { + var err = (r.data && r.data.error) ? r.data.error : ('request failed (' + '' + ')'); + setFeedback('err', 'Could not record that: ' + err); + setButtons(false); + } + }).catch(function (e) { + setFeedback('err', 'Network error — could not reach the first mate. ' + (e && e.message ? e.message : '')); + setButtons(false); + }); + } + + buttons.forEach(function (b) { + b.addEventListener('click', function () { submit(b.getAttribute('data-verdict')); }); + }); + })(); + </script>` +} + +/** Full self-contained HTML document for one review item. */ +function renderItemPage(item: ReviewItem): string { + const days = ageDays(item.createdAt) + const tier = ageTier(days) + const stakes = extractStakes(item.description) + const artifacts = extractArtifacts(item.description) + const contextFields = extractContextFields(item.description) + const recorded = extractRecordedDecision(item.notes) + const bodyHtml = item.description.trim() + ? renderMarkdown(item.description) + : `<p class="empty">No description was filed with this item.</p>` + + const pClass = item.priority !== null ? ` p${item.priority}` : "" + + const badges = [ + `<span class="badge status">${esc(item.status)}</span>`, + `<span class="badge${pClass}">priority ${esc(priorityLabel(item.priority))}</span>`, + item.issueType ? `<span class="badge">${esc(item.issueType)}</span>` : "", + `<span class="badge ${tier.cls}">${esc(tier.label)}</span>`, + `<span class="badge">${esc(item.id)}</span>`, + ] + .filter(Boolean) + .join("\n ") + + const calloutHtml = stakes + ? `\n <div class="callout"> + <span class="label">Stakes / recommended action</span> + ${renderInline(stakes)} + </div>` + : "" + + const artifactsHtml = artifacts.length + ? `\n <div class="artifacts"> + <span class="label">Artifacts</span> + <ul> + ${artifacts + .map((a) => { + const val = a.href + ? `<a href="${esc(a.href)}" rel="noopener">${esc(a.label)}</a>` + : `<code>${esc(a.label)}</code>` + return `<li><span class="k">${esc(a.kind)}</span>${val}</li>` + }) + .join("\n ")} + </ul> + </div>` + : "" + + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<!-- Opt out of the Pulse portal shell injection. These pages are fully self- + contained (their own single top bar + dark theme), exactly like the /status + and /plans pages render one nav themselves. Without opting out, portal's + shell injection would add a SECOND global nav bar plus the chat widget on + top of this page's own .topbar — two stacked bars. One page, one bar. --> +<meta name="pulse-shell" content="off"> +<title>Review · ${esc(item.title)} + + + +
+
+ ‹ Review queue + for the captain's eyes +
+

${esc(item.title)}

+
+ ${badges} +
${calloutHtml}${renderContext(contextFields)}${renderDecisionPanel(item, recorded)}${artifactsHtml} +
+${bodyHtml} +
+
+ filed ${esc(formatDate(item.createdAt))}${ + item.owner ? ` · for ${esc(item.owner)}` : "" + } + updated ${esc(formatDate(item.updatedAt))} + item ${esc(item.id)} +
+
+ + +` +} + +/** Full self-contained HTML index listing every open review item. */ +function renderIndexPage(items: ReviewItem[]): string { + const now = new Date().toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }) + + const cards = items.length + ? items + .map((item) => { + const days = ageDays(item.createdAt) + const tier = ageTier(days) + const pClass = item.priority !== null ? ` p${item.priority}` : "" + return ` +
+ ${esc(item.title)} +
+
+ ${esc(item.status)} + priority ${esc(priorityLabel(item.priority))} + ${esc(tier.label)} + ${esc(item.id)} +
+
` + }) + .join("\n ") + : `
The review queue is clear — nothing needs your eyes right now.
` + + return ` + + + + + + +Review queue · needs your eyes + + + +
+
+

Review queue

+
${items.length} item${items.length === 1 ? "" : "s"} waiting on your decision · as of ${esc( + now, + )} UTC
+
+
+ ${cards} +
+
+ each card opens the full item on its own page + served by Pulse · reachable from /status/ +
+
+ + +` +} + +// ─── Filesystem output ─────────────────────────────────────────────────────── + +/** Write one item's page and return its public URL. Idempotent (overwrite). */ +function writeItemPage(item: ReviewItem): string { + const dir = join(OUT_ROOT, item.id) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "index.html"), renderItemPage(item)) + return `${BASE_URL}/${item.id}/` +} + +/** Write the index page and return its URL. */ +function writeIndexPage(items: ReviewItem[]): string { + mkdirSync(OUT_ROOT, { recursive: true }) + writeFileSync(join(OUT_ROOT, "index.html"), renderIndexPage(items)) + return `${BASE_URL}/` +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +const HELP = `fm-review-page.ts — render review-store items to visitable Pulse pages. + +USAGE + fm-review-page.ts [ ...] render specific open review items + fm-review-page.ts --all render every open review item + index + fm-review-page.ts --help + +Each rendered page lives at \${FM_REVIEW_PAGE_OUT}//index.html and is served +at \${FM_REVIEW_PAGE_BASE_URL}//. An index at the root lists all open items. +The item is wired back with its page_url (metadata + a "Page: " note) so the +URL travels with it in \`review show\` / \`review ready\`. + +ENV + FM_REVIEW_PAGE_OUT output root (default ~/pulse-pages/review) + FM_REVIEW_PAGE_BASE_URL URL prefix (default http://localhost:31337/review) + FM_REVIEW_BIN review CLI path (default \`review\` on PATH) + FM_REVIEW_PAGE_NO_WIRE set 1 to skip the store wire-back +` + +function main(argv: string[]): number { + const args = argv.slice(2) + if (args.includes("--help") || args.includes("-h")) { + console.log(HELP) + return 0 + } + + const all = args.includes("--all") + const ids = args.filter((a) => !a.startsWith("-")) + + if (!all && ids.length === 0) { + console.error("error: pass one or more item ids, or --all") + console.error("run with --help for usage") + return 2 + } + + let items: ReviewItem[] + try { + items = all ? fetchAllOpen() : fetchByIds(ids) + } catch (err) { + console.error(`error: could not read review store: ${(err as Error).message}`) + return 1 + } + + if (items.length === 0) { + // --all over an empty queue still (re)writes an accurate empty index. + if (all) { + const indexUrl = writeIndexPage(items) + console.log(`index: ${indexUrl} (queue empty)`) + return 0 + } + console.error("error: no matching open review items") + return 1 + } + + let wireFailures = 0 + for (const item of items) { + const url = writeItemPage(item) + const wired = wireBack(item, url) + if (!wired.ok) { + wireFailures++ + console.error(` wire-back failed for ${item.id}: ${wired.error}`) + } + const noteFlag = wired.noted ? " (noted)" : wired.ok ? " (url unchanged)" : " (wire-back failed)" + console.log(`page: ${url}${noteFlag} — ${item.title}`) + } + + // Always refresh the index so it reflects the full open set, even when + // rendering a single id (the queue may have changed around it). + let indexItems = items + if (!all) { + try { + indexItems = fetchAllOpen() + } catch { + // Fall back to just the rendered items if the full re-read fails. + indexItems = items + } + } + const indexUrl = writeIndexPage(indexItems) + console.log(`index: ${indexUrl}`) + + if (wireFailures > 0) { + console.error(`warning: ${wireFailures} wire-back(s) failed; pages were still written`) + return 3 + } + return 0 +} + +process.exit(main(process.argv)) diff --git a/bin/fm-spawn-hooks.d/beads.sh b/bin/fm-spawn-hooks.d/beads.sh new file mode 100755 index 0000000000..d07293d67f --- /dev/null +++ b/bin/fm-spawn-hooks.d/beads.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# fm-spawn.sh hook: when FM_HOOK_BEADS_ID is set, stamp the linked bead as +# dispatched and register a watcher check that wakes firstmate for teardown +# once the bead closes. Sourced by fm-spawn.sh inside a subshell after a +# successful spawn; a bare `exit 0` below only ends that subshell, never +# fm-spawn.sh itself. Fail-open throughout: every failure here warns on +# stderr and continues rather than disturbing the spawn that already succeeded. +set -u + +[ -n "${FM_HOOK_BEADS_ID:-}" ] || exit 0 + +BEADS_ID=$FM_HOOK_BEADS_ID +TASK_ID=${FM_HOOK_ID:-} +STATE=${FM_HOOK_STATE:-} +ROOT=${FM_HOOK_ROOT:-} + +if [ -z "$TASK_ID" ] || [ -z "$STATE" ] || [ -z "$ROOT" ]; then + echo "warning: beads spawn hook missing required FM_HOOK_* context, skipping" >&2 + exit 0 +fi + +"$ROOT/bin/fm-bead-stamp.sh" "$BEADS_ID" "$TASK_ID" || true + +if ! command -v task >/dev/null 2>&1; then + echo "warning: task CLI not found on PATH, skipping bead-close watcher check for $BEADS_ID" >&2 + exit 0 +fi + +CHECK="$STATE/$TASK_ID.check.sh" +old_umask=$(umask) +umask 077 +cat > "$CHECK" </dev/null) || exit 0 +if command -v jq >/dev/null 2>&1; then + bead_status=\$(printf '%s' "\$json" | jq -r '.status // empty' 2>/dev/null) || exit 0 + [ "\$bead_status" = closed ] && printf 'bead closed: %s\n' "$BEADS_ID" +else + case "\$json" in + *'"status":"closed"'*|*'"status": "closed"'*) printf 'bead closed: %s\n' "$BEADS_ID" ;; + esac +fi +CHECKEOF +umask "$old_umask" +chmod 0700 "$CHECK" + +if ! FM_STATE_OVERRIDE="$STATE" "$ROOT/bin/fm-check-register.sh" "$TASK_ID"; then + echo "warning: could not register bead-close watcher check for $TASK_ID (bead $BEADS_ID)" >&2 +fi + +exit 0 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 825bc74fd6..7c3d4c3b65 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] [--scout] +# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] [--account ] [--scout] [--beads ] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] --secondmate # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. @@ -27,6 +27,12 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# --account is the optional per-account Claude Code isolation index (see +# docs/configuration.md "Multi-account Claude Code"). It requires the claude +# harness, records account=N in the task's meta, sets CLAUDE_TRUST_DIR to the +# task's worktree in the crewmate's launch environment, and launches through +# bin/claude-account.sh N instead of the plain claude binary. Absent means +# current behavior: plain claude, no account isolation. # --label overrides the default tab/window label for the spawned task. # Without --label, the window is named by the task ID in the form fm-. # With --label, the window is named fm-