The file /workspace/extra/second-brain/portfolio/BOARD.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Portfolio Board

One row per active project. Cards with full detail live in `projects/`.
Operating model + decisions: [PLAN.md](PLAN.md).
Stages: `research → discovery → spec → build → test → feedback → maintain`.
Guard: `review-required` (James approves merges) or `autonomous-ok` (agents proceed, report after).

**Seeded 2026-07-06 from git activity — stages and next actions are drafts until James confirms.**

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-12 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | Confirm goal + trial status in brief | confirm card | 2026-07-06 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | James to pick thresholds from sweep results → merge to main | threshold decision | 2026-07-12 |
| [FindRefurbishedAppleProduct](projects/FindRefurbishedAppleProduct.md) | discovery | autonomous-ok | Confirm goal — brand new repo | confirm card | 2026-07-06 |
| [TradingBot25](projects/TradingBot25.md) | build | review-required | Confirm goal + current strategy state | confirm card | 2026-06-26 |
| [AgenticLever](projects/AgenticLever.md) | build | autonomous-ok | Confirm goal | confirm card | 2026-06-29 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; Local Recs next if green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-09 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-08 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | James to run deploy in Studio terminal; confirm metric | none | 2026-07-08 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

Questions for James, appended by Nano; answered items move to the card's Notes and are removed here.

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **congressional-trading dual-poller (2026-07-12):** `com.congresswatch.daily` launchd job confirmed loaded on *both* the Mac Mini and this Studio simultaneously — doubles LLM spend and races notification cooldowns. Project's own `agentic_plan.md` already flags this as unresolved. Which machine is authoritative?
- **qip-ask-qien SEV-1 (2026-07-12):** user-management routes use `requireExpertApiUser` instead of `requireAdminApiUser` — an expert user can self-promote to admin. Fix is small and obvious but touches auth; wants sign-off before merge.
- **nanoclaw provider fallback (2026-07-12):** `push()`/`end()` in `gemini-openai-claude.ts` only route to `activeClaudeQuery` — a follow-up message sent while a REST rung (LM Studio/Codex) is mid-turn is silently dropped. Needs a queuing design, not a one-line fix.

## Recently shipped

- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed

-| [qip-qien](projects/qip-qien.md) | feedback | review-required | Confirm goal + trial status in brief | confirm card | 2026-07-06 |

+| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix committed (ee84254); merge sign-off needed | merge approval | 2026-07-19 |


Edit

- **qip-ask-qien SEV-1 (2026-07-12):** user-management routes use `requireExpertApiUser` instead of `requireAdminApiUser` — an expert user can self-promote to admin. Fix is small and obvious but touches auth; wants sign-off before merge.

- **qip-ask-qien SEV-1 merge (2026-07-19):** fix committed (ee84254) — admin route guards changed from `requireExpertApiUser` → `requireAdminApiUser` on 4 routes. TypeScript + lint clean. Ready to merge; needs James's sign-off to push.


# Portfolio Board

One row per active project. Cards with full detail live in `projects/`.
Operating model + decisions: [PLAN.md](PLAN.md).
Stages: `research → discovery → spec → build → test → feedback → maintain`.
Guard: `review-required` (James approves merges) or `autonomous-ok` (agents proceed, report after).

**Seeded 2026-07-06 from git activity — stages and next actions are drafts until James confirms.**

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-12 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix committed (ee84254); merge sign-off needed | merge approval | 2026-07-19 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | James to pick thresholds from sweep results → merge to main | threshold decision | 2026-07-12 |
| [FindRefurbishedAppleProduct](projects/FindRefurbishedAppleProduct.md) | discovery | autonomous-ok | Confirm goal — brand new repo | confirm card | 2026-07-06 |
| [TradingBot25](projects/TradingBot25.md) | build | review-required | Confirm goal + current strategy state | confirm card | 2026-06-26 |
| [AgenticLever](projects/AgenticLever.md) | build | autonomous-ok | Confirm goal | confirm card | 2026-06-29 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; Local Recs next if green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-09 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-08 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | James to run deploy in Studio terminal; confirm metric | none | 2026-07-08 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

Questions for James, appended by Nano; answered items move to the card's Notes and are removed here.

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **congressional-trading dual-poller (2026-07-12):** `com.congresswatch.daily` launchd job confirmed loaded on *both* the Mac Mini and this Studio simultaneously — doubles LLM spend and races notification cooldowns. Project's own `agentic_plan.md` already flags this as unresolved. Which machine is authoritative?
- **qip-ask-qien SEV-1 (2026-07-12):** user-management routes use `requireExpertApiUser` instead of `requireAdminApiUser` — an expert user can self-promote to admin. Fix is small and obvious but touches auth; wants sign-off before merge.
- **nanoclaw provider fallback (2026-07-12):** `push()`/`end()` in `gemini-openai-claude.ts` only route to `activeClaudeQuery` — a follow-up message sent while a REST rung (LM Studio/Codex) is mid-turn is silently dropped. Needs a queuing design, not a one-line fix.

## Recently shipped

- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed

-- **qip-ask-qien SEV-1 (2026-07-12):** user-management routes use `requireExpertApiUser` instead of `requireAdminApiUser` — an expert user can self-promote to admin. Fix is small and obvious but touches auth; wants sign-off before merge.

+- **qip-ask-qien SEV-1 merge (2026-07-19):** fix committed (ee84254) — admin route guards changed from `requireExpertApiUser` → `requireAdminApiUser` on 4 routes. TypeScript + lint clean. Ready to merge; needs James's sign-off to push.


Ingest Claude blog post on marketing operations automation


Bash

echo '{"ts":"2026-07-19T21:30:00.000Z","project":"qip-qien","outcome":"fix_committed","tier":"high","burnable_pct":81.2,"notes":"Autopilot high-tier cycle. All P1/P2/P3 projects blocked on James except qip-qien SEV-1 auth bug. Applied requireExpertApiUser → requireAdminApiUser to 4 admin routes (users, users/[userId], projects, projects/[id]). TypeScript + lint clean. Committed ee84254. Sent merge approval request to James via slack-infrastructure. Next reset: 2026-07-25T20:59:59.994Z."}' >> /workspace/agent/autopilot-run-log.jsonl

Append autopilot run log entry


(Bash completed with no output)


ToolSearch

Question: select:mcp__nanoclaw__schedule_task,mcp__nanoclaw__send_message


Question: select:mcp__nanoclaw__schedule_task,mcp__nanoclaw__send_message


Now I'll send the merge approval request to James via slack-infrastructure and schedule the next reset-wake for 2026-07-25T20:59:59.994Z — both in parallel.


*qip-qien SEV-1 fix — ready to merge* (Autopilot Jul 19)

**Bug:** 4 `/api/admin/*` routes were guarded by `requireExpertApiUser` (allows role=expert OR admin) instead of `requireAdminApiUser` (admin only). This let an expert user list all users, create new users with any role including admin, and modify other users' roles/status. The routes for config and ai-usage were already correct.

**Fix:** Changed import and all guard calls from `requireExpertApiUser` → `requireAdminApiUser` in:
- `src/app/api/admin/users/route.ts` (GET, POST)
- `src/app/api/admin/users/[userId]/route.ts` (PATCH)
- `src/app/api/admin/projects/route.ts` (GET, POST)
- `src/app/api/admin/projects/[id]/route.ts` (PATCH)

TypeScript type-check clean, ESLint clean (1 pre-existing warning in an unrelated file). Commit: `ee84254`.

**To merge:** `cd ~/projects/qip-ask-qien && git push` — needs your sign-off since guard=review-required on this project.

No other changes — this is the smallest correct fix.


Message sent to slack-infrastructure (id: 2781)


Prompt: Portfolio Autopilot reset-wake. The quota reset at 2026-07-25T20:59:59.994Z. Run the autopilot cycle: check burnable_pct from the portfolio headroom log, determine tier (low/medium/high), pick eligible projects by priority tier, and either execute one unit of autonomous work or prepare a fix for review-required projects. Log the run to /workspace/agent/autopilot-run-log.jsonl.


Task scheduled (id: task-1784496989107-j9jq78, runs at: 2026-07-26T01:59:59.000Z)


All done. Let me summarize what was completed in this autopilot cycle and send a final message to James via Telegram.

Actually, I should send a final message to the user (James via telegram-mg-17796) since the system message said there was a new message from the user that I should address. Let me check - actually looking back at the conversation summary, the user's last message was `2750` asking to install chatterbox. The system message interruption during the work was just the reminder that my output wasn't wrapped. I should send the final summary to telegram-mg-17796.
Though James might also be monitoring the slack-infrastructure channel where the merge approval request is, so I should make sure to send him a completion summary via Telegram as well.


<message to="telegram-mg-17796">Autopilot cycle done. SEV-1 auth fix committed to qip-ask-qien (ee84254) — details + merge request sent to #infrastructure. Next reset-wake scheduled for Jul 25.</message>

<message to="slack-infrastructure">*(Above is the SEV-1 merge request from this cycle's autopilot run — no other action needed from Nano until you reply with merge approval or questions.)*</message>


<context timezone="America/Chicago" />
<task from="telegram-mg-17796" time="Jul 19, 2026, 9:35 PM">Script output:
{
  "burnable_pct": 75,
  "tier": "high",
  "implementationModel": "claude-sonnet",
  "eligibleProjects": [
    {
      "project": "StageHand",
      "priority": "P1"
    },
    {
      "project": "nanoclaw",
      "priority": "P1"
    },
    {
      "project": "AgenticLever",
      "priority": "P2"
    },
    {
      "project": "RoadSense",
      "priority": "P2"
    },
    {
      "project": "qip-qien",
      "priority": "P2"
    },
    {
      "project": "congressional-trading",
      "priority": "P3"
    },
    {
      "project": "TradingBot25",
      "priority": "P3"
    }
  ],
  "next_reset_at": "2026-07-20T03:00:01.102Z",
  "providerSnapshot": [
    {
      "name": "Claude",
      "tracked": true,
      "5h_remaining_pct": 100,
      "7d_remaining_pct": 92
    },
    {
      "name": "Codex",
      "tracked": true,
      "5h_remaining_pct": null,
      "7d_remaining_pct": 88
    },
    {
      "name": "Gemini",
      "tracked": false,
      "note": "$20/mo subscription — no CodexBar tracking, manually updated from gemini.google.com/usage"
    },
    {
      "name": "LM Studio",
      "tracked": false,
      "note": "local model server — always-available when running at lm_studio_url"
    }
  ],
  "claude_windows": {
    "5h": {
      "remaining_pct": 100,
      "resets_in_sec": -1
    },
    "7d": {
      "remaining_pct": 92,
      "resets_in_sec": 514798
    }
  }
}

Instructions:
Portfolio Autopilot run triggered. Trigger script has confirmed burnable quota and eligible projects exist.

Script `data` contains:
- `burnable_pct`: effective burnable quota (%)
- `tier`: quota tier (minimal/low/medium/high)
- `implementationModel`: model for implementation tasks
- `eligibleProjects`: [{project, priority}] sorted by priority — P1 first
- `next_reset_at`: ISO timestamp when the constraining window resets
- `providerSnapshot`: remaining % for all providers (Claude + Codex) for context
- `claude_windows`: { "5h": { remaining_pct, resets_in_sec }, "7d": { ... } }

## Your job this cycle

1. Read `/workspace/extra/second-brain/portfolio/BOARD.md` and the relevant `projects/*.md` cards for eligible projects.

2. Pick ONE unit of work — the highest-priority eligible project with a clear, actionable `Next action`. Skip anything where `Next action` is "confirm with James" or blocked on external input.

3. Determine task type and proceed accordingly:
   - **Greenfield** (new plan, new feature direction, new project direction): Plan only → auto red-team → send package (plan + critique + numbered open questions) to James via Slack (`slack-infrastructure`). Do NOT start implementation.
   - **Continuation** (`autonomous-ok` guard, clear next action): Implement directly. Report outcome to James after.
   - **review-required** (TradingBot25, qip-qien): Produce change + summary, send to James via Slack (`slack-infrastructure`) for merge approval.

4. Planning always uses Claude Sonnet regardless of `implementationModel`. Implementation uses whatever `implementationModel` says.

5. After completing work:
   - Update the project card's `Next action` and append a dated line to its `Notes`.
   - Update the `Last activity` date in `BOARD.md`.
   - Send James a brief 2–3 line outcome note to `slack-infrastructure`. Use Central Time (America/Chicago) for any timestamps — never UTC.
   - Log the run to `/workspace/agent/autopilot-run-log.jsonl` — one JSON line: `{ timestamp, tier, burnable_pct, project, task_type, model_used, outcome, notes }`.

6. **Schedule the next reset-wake**: After completing work, call `mcp__nanoclaw__schedule_task` with:
   - `processAfter`: the `next_reset_at` value from the trigger data (the exact window reset time)
   - `recurrence`: omit (one-shot)
   - `prompt`: "Portfolio Autopilot reset-wake: check quota and run one autopilot cycle. Read /workspace/agent/autopilot-config.json for context. Run node /workspace/agent/autopilot.mjs to see current state, then proceed as the autopilot prompt instructs."
   - `script`: `node /workspace/agent/autopilot-trigger.mjs 2>/dev/null`
   This ensures the next cycle starts exactly when the window resets, not up to 30 min later.

If no eligible project has a clear actionable next step, log it and send a brief note to `slack-infrastructure`: what's stalled and why. Use Central Time for any times mentioned.

Config at `/workspace/agent/autopilot-config.json` — thresholds and priorities live there.</task>


The autopilot cycle is firing again (75% burnable, high tier, next reset at 2026-07-20T03:00:01.102Z). The previous cycle I just completed handled the qip-qien SEV-1 fix. Now I need to run another cycle.

Let me read the BOARD.md and determine what the next actionable item is. I already did qip-qien this cycle, so I need to find another actionable item.

Let me read BOARD.md to get the current state.

Actually, I just updated BOARD.md so I know its state. The eligible projects are:
1.

Looking at the project list, most items are blocked waiting on James to take action. StageHand needs toolchain answers, nanoclaw needs a deploy confirmation, and several others are waiting on decisions or external input. The qip-qien SEV-1 fix just got approved, so that's complete. I need to find something I can actually move forward on without external dependencies. Most of these projects are blocked waiting on James to make decisions or confirmations. But looking at congressional-trading more closely, there's an autonomous-ok guard in place and a specific decision queued up about thresholds from the sweep results that could potentially be merged to main.

For nanoclaw, the mDNS documentation gap got fixed today, though there are still a few minor open items around the pace-formula and lock-ordering that need attention. I should also figure out whether the dual-poller for congressional-trading should run on the Mac Mini or Studio. The voice question might actually be partially resolved now after the Chatterbox demo today. According to the autopilot instructions, I should focus on the highest-priority eligible project with a clear, actionable next step—skipping anything blocked on James or external input. Most projects seem stuck, but let me check the nanoclaw and congressional-trading cards since those might have something I can actually move forward on.


New autopilot cycle. Let me check the board and project cards to find what's actionable.


1	# Portfolio Board
2
3	One row per active project. Cards with full detail live in `projects/`.
4	Operating model + decisions: [PLAN.md](PLAN.md).
5	Stages: `research → discovery → spec → build → test → feedback → maintain`.
6	Guard: `review-required` (James approves merges) or `autonomous-ok` (agents proceed, report after).
7
8	**Seeded 2026-07-06 from git activity — stages and next actions are drafts until James confirms.**
9
10	| Project | Stage | Guard | Next action | Blocked on James | Last activity |
11	|---|---|---|---|---|---|
12	| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-12 |
13	| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix committed (ee84254); merge sign-off needed | merge approval | 2026-07-19 |
14	| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | James to pick thresholds from sweep results → merge to main | threshold decision | 2026-07-12 |
15	| [FindRefurbishedAppleProduct](projects/FindRefurbishedAppleProduct.md) | discovery | autonomous-ok | Confirm goal — brand new repo | confirm card | 2026-07-06 |
16	| [TradingBot25](projects/TradingBot25.md) | build | review-required | Confirm goal + current strategy state | confirm card | 2026-06-26 |
17	| [AgenticLever](projects/AgenticLever.md) | build | autonomous-ok | Confirm goal | confirm card | 2026-06-29 |
18	| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
19	| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; Local Recs next if green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-09 |
20	| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
21	| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-08 |
22	| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | James to run deploy in Studio terminal; confirm metric | none | 2026-07-08 |
23	| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
24	| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
25	| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
26	| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
27	| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
28	| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |
29
30	## Decision queue
31
32	Questions for James, appended by Nano; answered items move to the card's Notes and are removed here.
33
34	- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
35	- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
36	- **congressional-trading dual-poller (2026-07-12):** `com.congresswatch.daily` launchd job confirmed loaded on *both* the Mac Mini and this Studio simultaneously — doubles LLM spend and races notification cooldowns. Project's own `agentic_plan.md` already flags this as unresolved. Which machine is authoritative?
37	- **qip-ask-qien SEV-1 merge (2026-07-19):** fix committed (ee84254) — admin route guards changed from `requireExpertApiUser` → `requireAdminApiUser` on 4 routes. TypeScript + lint clean. Ready to merge; needs James's sign-off to push.
38	- **nanoclaw provider fallback (2026-07-12):** `push()`/`end()` in `gemini-openai-claude.ts` only route to `activeClaudeQuery` — a follow-up message sent while a REST rung (LM Studio/Codex) is mid-turn is silently dropped. Needs a queuing design, not a one-line fix.
39
40	## Recently shipped
41
42	- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
43	- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed
44

# Portfolio Board

One row per active project. Cards with full detail live in `projects/`.
Operating model + decisions: [PLAN.md](PLAN.md).
Stages: `research → discovery → spec → build → test → feedback → maintain`.
Guard: `review-required` (James approves merges) or `autonomous-ok` (agents proceed, report after).

**Seeded 2026-07-06 from git activity — stages and next actions are drafts until James confirms.**

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-12 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix committed (ee84254); merge sign-off needed | merge approval | 2026-07-19 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | James to pick thresholds from sweep results → merge to main | threshold decision | 2026-07-12 |
| [FindRefurbishedAppleProduct](projects/FindRefurbishedAppleProduct.md) | discovery | autonomous-ok | Confirm goal — brand new repo | confirm card | 2026-07-06 |
| [TradingBot25](projects/TradingBot25.md) | build | review-required | Confirm goal + current strategy state | confirm card | 2026-06-26 |
| [AgenticLever](projects/AgenticLever.md) | build | autonomous-ok | Confirm goal | confirm card | 2026-06-29 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; Local Recs next if green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-09 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-08 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | James to run deploy in Studio terminal; confirm metric | none | 2026-07-08 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

Questions for James, appended by Nano; answered items move to the card's Notes and are removed here.

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **congressional-trading dual-poller (2026-07-12):** `com.congresswatch.daily` launchd job confirmed loaded on *both* the Mac Mini and this Studio simultaneously — doubles LLM spend and races notification cooldowns. Project's own `agentic_plan.md` already flags this as unresolved. Which machine is authoritative?
- **qip-ask-qien SEV-1 merge (2026-07-19):** fix committed (ee84254) — admin route guards changed from `requireExpertApiUser` → `requireAdminApiUser` on 4 routes. TypeScript + lint clean. Ready to merge; needs James's sign-off to push.
- **nanoclaw provider fallback (2026-07-12):** `push()`/`end()` in `gemini-openai-claude.ts` only route to `activeClaudeQuery` — a follow-up message sent while a REST rung (LM Studio/Codex) is mid-turn is silently dropped. Needs a queuing design, not a one-line fix.

## Recently shipped

- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed


1	# nanoclaw
2
3	- Repo: `~/projects/nanoclaw` · https://github.com/jamesjlopez/nanoclaw
4	- Goal: the agent platform itself — powers this whole operating model
5	- Stage: build
6	- Guard: autonomous-ok
7	- Metric: _undefined — James to name (candidate: decision questions James actually answers/week — proxy for brief usefulness)_
8	- Blocked on James: none
9	- Next action: James to run deploy one-liner in Studio terminal; confirm nanoclaw metric
10
11	## Notes
12	- 2026-07-06 — Migrated Mini → Studio (see nanoclaw memory: machine-migration-gotchas). Daily brief + portfolio board went live today.
13	- 2026-07-06 — Reddit ingestion merged 2026-06-13 but non-functional: unauthenticated `.json` API is 403-blocked. **Dropped** 2026-07-07 — Reddit now requires manual approval for new OAuth apps (scraping crackdown). James doesn't find Reddit content high-signal enough to pursue. Dead feature, no further action.
14	- 2026-07-08 — Autopilot cycle (medium tier): built `/workspace/agent/autopilot-summary.mjs` — outcome tracker for autopilot run log. Reads autopilot-run-log.jsonl, prints stats by outcome/project/tier/model + recent run history. Supports `--json` and `--last N` flags.
15	- 2026-07-08 — Autopilot cycle (minimal tier): built `/workspace/agent/autopilot-calibrate.mjs` — weekly calibration script. Computes 7-day stats (runs, success rate, active projects), flags stalled autopilot or low success rate. Scheduled weekly Mondays 08:00 CT (task-1783485079941-19i8vt).
16	- 2026-07-08 — Autopilot cycle (high tier): added skip-lock mechanism to `autopilot-trigger.mjs` — checks `autopilot-skip-lock.json` before waking; agent writes lock after all-blocked cycles to suppress repeated 30-min trigger fires. Eliminated ~6 wasted cycles per blocked period. Also queued nanoclaw metric proposal in Decision queue.
17	- 2026-07-08 — Autopilot cycle (medium tier): committed fix(router) — mention-sticky session scoping now respects wiring's `session_mode`. Previously Slack shared-mode wirings were coerced to per-thread, causing bot to go silent after initial @mention. RED→GREEN regression test added.
18	- 2026-07-08 — Autopilot cycle (medium tier): committed fix(provider) — hallucinated tool-call tag detection on REST rungs. Adds retry + honestNoToolsReply fallback. Both commits pushed to GitHub (846ce6b..b724c2a). Awaiting Mini deploy (SSH unavailable from container).
19

# nanoclaw

- Repo: `~/projects/nanoclaw` · https://github.com/jamesjlopez/nanoclaw
- Goal: the agent platform itself — powers this whole operating model
- Stage: build
- Guard: autonomous-ok
- Metric: _undefined — James to name (candidate: decision questions James actually answers/week — proxy for brief usefulness)_
- Blocked on James: none
- Next action: James to run deploy one-liner in Studio terminal; confirm nanoclaw metric

## Notes
- 2026-07-06 — Migrated Mini → Studio (see nanoclaw memory: machine-migration-gotchas). Daily brief + portfolio board went live today.
- 2026-07-06 — Reddit ingestion merged 2026-06-13 but non-functional: unauthenticated `.json` API is 403-blocked. **Dropped** 2026-07-07 — Reddit now requires manual approval for new OAuth apps (scraping crackdown). James doesn't find Reddit content high-signal enough to pursue. Dead feature, no further action.
- 2026-07-08 — Autopilot cycle (medium tier): built `/workspace/agent/autopilot-summary.mjs` — outcome tracker for autopilot run log. Reads autopilot-run-log.jsonl, prints stats by outcome/project/tier/model + recent run history. Supports `--json` and `--last N` flags.
- 2026-07-08 — Autopilot cycle (minimal tier): built `/workspace/agent/autopilot-calibrate.mjs` — weekly calibration script. Computes 7-day stats (runs, success rate, active projects), flags stalled autopilot or low success rate. Scheduled weekly Mondays 08:00 CT (task-1783485079941-19i8vt).
- 2026-07-08 — Autopilot cycle (high tier): added skip-lock mechanism to `autopilot-trigger.mjs` — checks `autopilot-skip-lock.json` before waking; agent writes lock after all-blocked cycles to suppress repeated 30-min trigger fires. Eliminated ~6 wasted cycles per blocked period. Also queued nanoclaw metric proposal in Decision queue.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(router) — mention-sticky session scoping now respects wiring's `session_mode`. Previously Slack shared-mode wirings were coerced to per-thread, causing bot to go silent after initial @mention. RED→GREEN regression test added.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(provider) — hallucinated tool-call tag detection on REST rungs. Adds retry + honestNoToolsReply fallback. Both commits pushed to GitHub (846ce6b..b724c2a). Awaiting Mini deploy (SSH unavailable from container).


1	# congressional-trading
2
3	- Repo: `~/projects/congressional-trading` · https://github.com/jamesjlopez/congressional-trading
4	- Goal: Score congressional trade disclosures (0–13 scale) and push high-conviction buy signals via ntfy when score ≥ 9 + quality gates pass. In shadow/measurement mode — scoring and logging signals but not pushing live notifications yet.
5	- Stage: test
6	- Guard: autonomous-ok
7	- Metric: Signal precision (% of signals that are wins at 30/60d) over shadow period — threshold TBD by James
8	- Blocked on James: define exit criteria — what win-rate and sample size graduates it to live?
9	- Next action: James to review sweep findings and decide: (a) drop conviction gate LOW/MEDIUM? (b) raise threshold to 11.5? Then merge feat/agentic-alert-loop → main
10
11	## Notes
12	- 2026-07-06 — Seeded from git activity: "Keep shadow measurement active during..." (same-day commit). Runs on always-on machine.
13	- 2026-07-06 — Explored codebase: scoring algorithm is 0–13 scale with 8 components (committee, cluster, size, small-cap bonus, options bonus, sell pressure, strategic LLM score, disruption LLM score). Notification threshold = 9.0 after timeliness multiplier. Max 1 notification/run. 14k+ historical trades stored. `signal_outcomes` table tracks 30/60d returns. Key open issue: win-rate uses current price snapshot, not fixed-horizon (backlog item). Forward-looking predictor module also exists.
14	- 2026-07-06 — James approved agentic calibration approach: backtest first → data-derived thresholds → ongoing automated monitoring. Scoped as 3 phases below.
15
16	### Calibration task — 3 phases
17
18	**Phase 1: Fix win-rate calculation** ✅ DONE 2026-07-12
19	- Added `compute_fixed_horizon()` to `tools/win_rate.py`: uses `price_history` table (price at tx_date+30d vs tx_date) — stable classification, not today's rolling price
20	- `compute()` dispatches to fixed-horizon when `price_history` is populated; rolling fallback for fresh installs
21	- 8 TDD tests added to `tests/test_win_rate.py`, all GREEN
22	- Committed: `feat/agentic-alert-loop` — "fix(win_rate): use fixed-horizon returns instead of rolling price snapshot"
23
24	**Phase 2: Backtest sweep script** ✅ DONE 2026-07-12
25	- `scripts/backtest_sweep.py` added. Sweeps score threshold (7–13), conviction (LOW/MEDIUM/HIGH), edge-factor min (2/3/4).
26	- Quick mode: reads signal_outcomes (n=36, no price_history needed). Full mode: replays 7k+ trades from price_history.
27	- Quick mode ran: signals scored 10.8–14.5. Threshold 12.5+ improves precision 58%→67% and cuts max drawdown from -40.7% to -7.7%.
28	- **Price_history backfill needed for full sweep** (python scripts/backtest_sweep.py --backfill --csv results.csv) — yfinance not installed in container.
29	- Committed: `feat/agentic-alert-loop` — "feat(backtest): add threshold/conviction/edge sweep script"
30
31	**Phase 3: Backfill + full sweep** ✅ DONE 2026-07-12
32	- Batched yfinance backfill: 960k price rows across 1,378 tickers in 39s
33	- Ran full replay sweep: 6,493 trades, 15 months, 117 config combinations
34	- CSV saved to `data/backtest_sweep_2026-07-12.csv`
35	- **Key finding**: Current config (HIGH conviction, thresh=9.0, edge≥3) = 44% precision, -0.96% median excess vs SPY
36	- **Best config**: threshold=11.5, LOW conviction, edge≥2 → 54% precision, +0.67% median excess, 398 signals/year
37	- **Surprising**: HIGH conviction = WORSE performance. Unresearched signals at high base scores outperform LLM-tagged HIGH conviction ones — suggesting base score is more predictive than LLM overlay.
38	- Results sent to James via Slack 2026-07-12. Waiting for threshold decision to implement config changes.
39
40	**Phase 4: Ongoing automated outcome monitoring** (closes the loop)
41	- Schedule a weekly Nano task: read `signal_outcomes`, compute rolling 30/60d precision for last 10/20/all-time signals
42	- If precision drops below threshold: surface tuning suggestion to James via Telegram
43	- If precision is holding: brief weekly note in daily brief or silence
44	- Estimated effort: ~2 hours to set up scheduled task
45

# congressional-trading

- Repo: `~/projects/congressional-trading` · https://github.com/jamesjlopez/congressional-trading
- Goal: Score congressional trade disclosures (0–13 scale) and push high-conviction buy signals via ntfy when score ≥ 9 + quality gates pass. In shadow/measurement mode — scoring and logging signals but not pushing live notifications yet.
- Stage: test
- Guard: autonomous-ok
- Metric: Signal precision (% of signals that are wins at 30/60d) over shadow period — threshold TBD by James
- Blocked on James: define exit criteria — what win-rate and sample size graduates it to live?
- Next action: James to review sweep findings and decide: (a) drop conviction gate LOW/MEDIUM? (b) raise threshold to 11.5? Then merge feat/agentic-alert-loop → main

## Notes
- 2026-07-06 — Seeded from git activity: "Keep shadow measurement active during..." (same-day commit). Runs on always-on machine.
- 2026-07-06 — Explored codebase: scoring algorithm is 0–13 scale with 8 components (committee, cluster, size, small-cap bonus, options bonus, sell pressure, strategic LLM score, disruption LLM score). Notification threshold = 9.0 after timeliness multiplier. Max 1 notification/run. 14k+ historical trades stored. `signal_outcomes` table tracks 30/60d returns. Key open issue: win-rate uses current price snapshot, not fixed-horizon (backlog item). Forward-looking predictor module also exists.
- 2026-07-06 — James approved agentic calibration approach: backtest first → data-derived thresholds → ongoing automated monitoring. Scoped as 3 phases below.

### Calibration task — 3 phases

**Phase 1: Fix win-rate calculation** ✅ DONE 2026-07-12
- Added `compute_fixed_horizon()` to `tools/win_rate.py`: uses `price_history` table (price at tx_date+30d vs tx_date) — stable classification, not today's rolling price
- `compute()` dispatches to fixed-horizon when `price_history` is populated; rolling fallback for fresh installs
- 8 TDD tests added to `tests/test_win_rate.py`, all GREEN
- Committed: `feat/agentic-alert-loop` — "fix(win_rate): use fixed-horizon returns instead of rolling price snapshot"

**Phase 2: Backtest sweep script** ✅ DONE 2026-07-12
- `scripts/backtest_sweep.py` added. Sweeps score threshold (7–13), conviction (LOW/MEDIUM/HIGH), edge-factor min (2/3/4).
- Quick mode: reads signal_outcomes (n=36, no price_history needed). Full mode: replays 7k+ trades from price_history.
- Quick mode ran: signals scored 10.8–14.5. Threshold 12.5+ improves precision 58%→67% and cuts max drawdown from -40.7% to -7.7%.
- **Price_history backfill needed for full sweep** (python scripts/backtest_sweep.py --backfill --csv results.csv) — yfinance not installed in container.
- Committed: `feat/agentic-alert-loop` — "feat(backtest): add threshold/conviction/edge sweep script"

**Phase 3: Backfill + full sweep** ✅ DONE 2026-07-12
- Batched yfinance backfill: 960k price rows across 1,378 tickers in 39s
- Ran full replay sweep: 6,493 trades, 15 months, 117 config combinations
- CSV saved to `data/backtest_sweep_2026-07-12.csv`
- **Key finding**: Current config (HIGH conviction, thresh=9.0, edge≥3) = 44% precision, -0.96% median excess vs SPY
- **Best config**: threshold=11.5, LOW conviction, edge≥2 → 54% precision, +0.67% median excess, 398 signals/year
- **Surprising**: HIGH conviction = WORSE performance. Unresearched signals at high base scores outperform LLM-tagged HIGH conviction ones — suggesting base score is more predictive than LLM overlay.
- Results sent to James via Slack 2026-07-12. Waiting for threshold decision to implement config changes.

**Phase 4: Ongoing automated outcome monitoring** (closes the loop)
- Schedule a weekly Nano task: read `signal_outcomes`, compute rolling 30/60d precision for last 10/20/all-time signals
- If precision drops below threshold: surface tuning suggestion to James via Telegram
- If precision is holding: brief weekly note in daily brief or silence
- Estimated effort: ~2 hours to set up scheduled task


1	# StageHand (CreatorFlow)
2
3	- Repo: `~/projects/stagehand`
4	- Goal: Real estate agent video suite — automate production of listing walkthroughs, local recommendations, and home improvement content for real estate agents.
5	- Stage: discovery
6	- Guard: autonomous-ok
7	- Metric: _undefined_ (candidate: 3 reels reviewed + posted by one agent within 30 days)
8	- Blocked on James: 5 questions sent — voice strategy, agent lined up?, posting model, competitor pricing, Local Recs green-light
9	- Next action: Await James's answers; if Local Recs green-lit, spec Places API + script-gen implementation
10
11	## Video types planned
12
13	1. **Listing walkthroughs** — Ken Burns effects on listing photos; possibly AI narration or agent voiceover
14	2. **Local recommendations** — restaurants, cultural events near a listing
15	3. **Home improvement ideas** — ROI-focused, utility-focused content
16
17	## Avatar / voice clone notes (from HyperFrames experiment, 2026-07-06)
18
19	- Tested HyperFrames using a RoadSense product launch video as the test case
20	- Avatar video quality: impressive
21	- Voice clone: not a close match to James's voice — may be acceptable for agent persona, or may need better tooling
22	- Talking avatar not required for all video types; listing walkthroughs and local recs may not need it
23
24	## Competitive landscape
25
26	- James shared Instagram links to real estate agent video tools (ads he encountered) — research complete per James 2026-07-08.
27	- Goal: inform toolchain decisions and find pricing anchors for potential productization.
28
29	## Notes
30
31	- 2026-07-06 — James described the project and HyperFrames experiment. StageHand folder exists at ~/projects/stagehand (repo-awareness: "CreatorFlow" — CLI orchestration, approval gates, walkthrough video pipeline). The earlier implementation was more generic; this conversation refocuses it specifically on real estate agent use case.
32	- Real estate agent as the persona: needs automated content creation across multiple formats, not just listing walkthroughs.
33	- 2026-07-09 — Autopilot produced MVP toolchain spec. listing_reel + video_narration both code-complete; open punch-list items are business discovery gaps. Recommended order: validate listing_reel with real agent first, then build Local Recs reel (Google Places API + Claude narration + Ken Burns on venue photos), defer Home Improvement to v2. TTS recommendation: ElevenLabs as default paid provider. 5 questions sent to James via Slack (agent lined up?, voice strategy, posting model, competitor pricing, Local Recs green-light).
34

# StageHand (CreatorFlow)

- Repo: `~/projects/stagehand`
- Goal: Real estate agent video suite — automate production of listing walkthroughs, local recommendations, and home improvement content for real estate agents.
- Stage: discovery
- Guard: autonomous-ok
- Metric: _undefined_ (candidate: 3 reels reviewed + posted by one agent within 30 days)
- Blocked on James: 5 questions sent — voice strategy, agent lined up?, posting model, competitor pricing, Local Recs green-light
- Next action: Await James's answers; if Local Recs green-lit, spec Places API + script-gen implementation

## Video types planned

1. **Listing walkthroughs** — Ken Burns effects on listing photos; possibly AI narration or agent voiceover
2. **Local recommendations** — restaurants, cultural events near a listing
3. **Home improvement ideas** — ROI-focused, utility-focused content

## Avatar / voice clone notes (from HyperFrames experiment, 2026-07-06)

- Tested HyperFrames using a RoadSense product launch video as the test case
- Avatar video quality: impressive
- Voice clone: not a close match to James's voice — may be acceptable for agent persona, or may need better tooling
- Talking avatar not required for all video types; listing walkthroughs and local recs may not need it

## Competitive landscape

- James shared Instagram links to real estate agent video tools (ads he encountered) — research complete per James 2026-07-08.
- Goal: inform toolchain decisions and find pricing anchors for potential productization.

## Notes

- 2026-07-06 — James described the project and HyperFrames experiment. StageHand folder exists at ~/projects/stagehand (repo-awareness: "CreatorFlow" — CLI orchestration, approval gates, walkthrough video pipeline). The earlier implementation was more generic; this conversation refocuses it specifically on real estate agent use case.
- Real estate agent as the persona: needs automated content creation across multiple formats, not just listing walkthroughs.
- 2026-07-09 — Autopilot produced MVP toolchain spec. listing_reel + video_narration both code-complete; open punch-list items are business discovery gaps. Recommended order: validate listing_reel with real agent first, then build Local Recs reel (Google Places API + Claude narration + Ken Burns on venue photos), defer Home Improvement to v2. TTS recommendation: ElevenLabs as default paid provider. 5 questions sent to James via Slack (agent lined up?, voice strategy, posting model, competitor pricing, Local Recs green-light).


Let me assess what's actionable:

1. **StageHand (P1)**: Blocked on 5 toolchain questions from James. BUT — we did the Chatterbox demo today, which partly answers the voice strategy question. The voice question was one of the 5 questions. However, the other 4 (agent lined up?, posting model, competitor pricing, Local Recs green-light) are still outstanding. Still blocked.

2. **nanoclaw (P1)**: "James to run deploy in Studio terminal; confirm metric" — the BOARD.md "Blocked on James" column says "none" but the Next action requires James to run a deploy.

There's a pending fix waiting for deployment on the Mac Mini, but it's stuck because SSH isn't available from the container. The decision queue also flags a provider fallback issue where messages sent during REST operations get dropped — that's a design problem needing a queuing solution, not a quick fix.

3. **congressional-trading (P3)**: Phase 4 is about setting up automated outcome monitoring, which is a clear next action that doesn't depend on James's threshold decision. The monitoring task itself is autonomous-ok and can be implemented now, even though the threshold/dual-poller decision is still pending.

4. **AgenticLever (P2)**: Blocked waiting on James to confirm the goal.

5. **RoadSense (P2)**: Blocked waiting on James to answer five launch questions.

6. **qip-qien (P2)**: Already done.

7. **TradingBot25 (P3)**: Also blocked on James's confirmation.

So the most actionable item right now is congressional-trading Phase 4 — it's autonomous-ok, has a clear defined next step, doesn't require James's input, and is estimated at about 2 hours. Though I'm also wondering if the nanoclaw provider fallback could be tackled as a planning task with a queuing design, but nanoclaw isn't in the eligible projects list, so that's probably not the right move here.

Actually, wait — nanoclaw is in the eligible list. So I could work on the provider fallback queuing design as a greenfield planning task, producing a plan and red-team for James. But looking at the autopilot rules, congressional-trading Phase 4 is a continuation task with a clear next step, so that's probably the better choice to execute directly. Since nanoclaw is P1, I should prioritize it if there's actionable work. The provider fallback design is a valid Greenfield task I could tackle—I'd need to examine the relevant code, design a queuing mechanism, red-team it, and present it to James. Let me check if the nanoclaw repo is accessible at the expected path. are still unresolved. Looking at what I can actually accomplish right now, I'm weighing two paths: implementing the congressional-trading monitoring task, which is a complete, autonomous unit of work, versus designing the nanoclaw provider fallback queuing system, which is higher priority but blocked on James for the next deployment step. The autopilot principle is to pick the highest-priority work with a clear next action, and the provider fallback design seems like the more valuable contribution even though it's planning work rather than implementation.

I'm going to focus on the nanoclaw provider fallback design since it's actionable from my end and doesn't require waiting on James. Let me start by reviewing the relevant code to understand the current architecture.


Nanoclaw is P1 with an open design problem in the decision queue (provider fallback queuing). That's the highest-priority actionable item — design work I can do without waiting on James. Let me read the code.


/workspace/extra/projects/nanoclaw/src/providers/gemini-openai-claude.ts
/workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/gemini-openai-claude.ts


1	import { readEnvFile } from '../env.js';
2	import { registerProviderContainerConfig } from './provider-container-registry.js';
3
4	// Env passthrough for the fallback-chain provider. Rungs after Phase 0 of
5	// docs/provider-routing-plan.md: LM Studio (local REST), Claude Code
6	// (subscription OAuth), Codex bridge (host shim over `codex exec`).
7	// Gemini/OpenAI/Antigravity REST vars were removed with their rungs.
8	registerProviderContainerConfig('gemini-openai-claude', ({ hostEnv }) => {
9	  const keys = [
10	    'ANTHROPIC_MODEL',
11	    'CLAUDE_MODEL',
12	    'CLAUDE_CODE_OAUTH_TOKEN',
13	    'LM_STUDIO_BASE_URL',
14	    'LM_STUDIO_API_KEY',
15	    'LM_STUDIO_MODEL',
16	    'CODEX_BRIDGE_BASE_URL',
17	    'CODEX_BRIDGE_API_KEY',
18	    'CODEX_MODEL',
19	  ];
20	  const dotenv = readEnvFile([...keys, 'ANTHROPIC_BASE_URL']);
21
22	  const env: Record<string, string> = {};
23	  for (const key of keys) {
24	    const value = hostEnv[key] || dotenv[key];
25	    if (value) env[key] = value;
26	  }
27
28	  const anthropicBaseUrl = hostEnv.ANTHROPIC_BASE_URL || dotenv.ANTHROPIC_BASE_URL;
29	  if (anthropicBaseUrl) {
30	    env.ANTHROPIC_BASE_URL = anthropicBaseUrl;
31	    env.ANTHROPIC_AUTH_TOKEN = 'placeholder';
32	  }
33
34	  return Object.keys(env).length > 0 ? { env } : {};
35	});
36

import { readEnvFile } from '../env.js';
import { registerProviderContainerConfig } from './provider-container-registry.js';

// Env passthrough for the fallback-chain provider. Rungs after Phase 0 of
// docs/provider-routing-plan.md: LM Studio (local REST), Claude Code
// (subscription OAuth), Codex bridge (host shim over `codex exec`).
// Gemini/OpenAI/Antigravity REST vars were removed with their rungs.
registerProviderContainerConfig('gemini-openai-claude', ({ hostEnv }) => {
  const keys = [
    'ANTHROPIC_MODEL',
    'CLAUDE_MODEL',
    'CLAUDE_CODE_OAUTH_TOKEN',
    'LM_STUDIO_BASE_URL',
    'LM_STUDIO_API_KEY',
    'LM_STUDIO_MODEL',
    'CODEX_BRIDGE_BASE_URL',
    'CODEX_BRIDGE_API_KEY',
    'CODEX_MODEL',
  ];
  const dotenv = readEnvFile([...keys, 'ANTHROPIC_BASE_URL']);

  const env: Record<string, string> = {};
  for (const key of keys) {
    const value = hostEnv[key] || dotenv[key];
    if (value) env[key] = value;
  }

  const anthropicBaseUrl = hostEnv.ANTHROPIC_BASE_URL || dotenv.ANTHROPIC_BASE_URL;
  if (anthropicBaseUrl) {
    env.ANTHROPIC_BASE_URL = anthropicBaseUrl;
    env.ANTHROPIC_AUTH_TOKEN = 'placeholder';
  }

  return Object.keys(env).length > 0 ? { env } : {};
});


That's the host-side provider config registration, not the agent-runner side. Let me read the agent-runner version which is where the actual push()/end() routing happens.


1	import fs from 'fs';
2	import path from 'path';
3	import { randomUUID } from 'crypto';
4
5	import { ClaudeProvider } from './claude.js';
6	import { registerProvider } from './provider-registry.js';
7	import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
8
9	const HISTORY_LIMIT = 24;
10	const ANTHROPIC_MODEL = 'sonnet';
11	const MAX_CONTEXT_FILE_CHARS = 12000;
12	const MAX_SYSTEM_CONTEXT_CHARS = 60000;
13
14	interface ChatTurn {
15	  role: 'user' | 'assistant';
16	  content: string;
17	}
18
19	interface ChatResult {
20	  continuation: string;
21	  text: string;
22	}
23
24	interface RestProvider {
25	  readonly name: string;
26	  readonly noticeName: string;
27	  complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult>;
28	}
29
30	function log(msg: string): void {
31	  console.error(`[fallback-provider] ${msg}`);
32	}
33
34	function historyPath(providerName: string, continuation: string): string {
35	  const historyDir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
36	  return path.join(historyDir, providerName, `${continuation}.json`);
37	}
38
39	function providerContinuation(providerName: string, continuation?: string): string | undefined {
40	  const prefix = `${providerName}:`;
41	  return continuation?.startsWith(prefix) ? continuation.slice(prefix.length) : undefined;
42	}
43
44	function prefixedContinuation(providerName: string, continuation: string): string {
45	  return `${providerName}:${continuation}`;
46	}
47
48	function loadHistory(providerName: string, continuation?: string): { id: string; turns: ChatTurn[] } {
49	  const providerId = providerContinuation(providerName, continuation);
50	  const id = providerId || randomUUID();
51	  if (!providerId) return { id, turns: [] };
52
53	  try {
54	    const raw = JSON.parse(fs.readFileSync(historyPath(providerName, providerId), 'utf8')) as { turns?: ChatTurn[] };
55	    const turns = Array.isArray(raw.turns) ? raw.turns.filter(isChatTurn) : [];
56	    return { id, turns };
57	  } catch {
58	    return { id, turns: [] };
59	  }
60	}
61
62	function saveHistory(providerName: string, continuation: string, turns: ChatTurn[]): void {
63	  const file = historyPath(providerName, continuation);
64	  fs.mkdirSync(path.dirname(file), { recursive: true });
65	  fs.writeFileSync(file, JSON.stringify({ turns: turns.slice(-HISTORY_LIMIT) }, null, 2) + '\n');
66	}
67
68	function isChatTurn(value: unknown): value is ChatTurn {
69	  if (!value || typeof value !== 'object') return false;
70	  const turn = value as { role?: unknown; content?: unknown };
71	  return (turn.role === 'user' || turn.role === 'assistant') && typeof turn.content === 'string';
72	}
73
74	function systemPrompt(input: QueryInput): string {
75	  const localContext = loadLocalInstructionContext(input.cwd);
76	  const parts = [
77	    input.systemContext?.instructions || '',
78	    localContext,
79	    [
80	      'You are running as a NanoClaw chat agent.',
81	      'Reply only with final text wrapped in <message to="name">...</message> blocks unless you need private scratchpad text inside <internal>...</internal>.',
82	      'Every reply MUST contain at least one <message to="name">...</message> block — a reply with only <internal> content is discarded and the user sees nothing.',
83	      'Use the destination names listed above. If the incoming message has a from="name" attribute, reply to that destination by default.',
84	      'You have been given the local agent instructions above as context. You have NO tool access in this provider mode: you cannot read files, browse, or defer work — answer now with what you know, and do not claim to have read or modified files unless the user provided that content in chat.',
85	    ].join('\n'),
86	  ].filter(Boolean);
87	  return parts.join('\n\n').slice(0, MAX_SYSTEM_CONTEXT_CHARS);
88	}
89
90	function loadLocalInstructionContext(cwd: string): string {
91	  const files = [
92	    path.join(cwd, 'CLAUDE.md'),
93	    path.join(cwd, 'CLAUDE.local.md'),
94	    path.join(cwd, 'repo-awareness.md'),
95	    '/workspace/extra/second-brain/SCHEMA.md',
96	    '/workspace/extra/second-brain/wiki/Home.md',
97	    '/workspace/extra/second-brain/wiki/Wiki Ingest Log.md',
98	  ];
99	  const seen = new Set<string>();
100	  const sections: string[] = [];
101	  for (const file of files) {
102	    const content = readMarkdownWithImports(file, seen);
103	    if (content) sections.push(content);
104	  }
105	  const secondBrainTree = listSecondBrainWiki();
106	  if (secondBrainTree) {
107	    sections.push(['# Mounted Wiki Snapshot', '', secondBrainTree].join('\n'));
108	  }
109	  return sections.length > 0 ? ['# Local Agent Instructions', '', ...sections].join('\n\n') : '';
110	}
111
112	function readMarkdownWithImports(filePath: string, seen: Set<string>): string {
113	  let realPath: string;
114	  try {
115	    realPath = fs.realpathSync(filePath);
116	  } catch {
117	    return '';
118	  }
119	  if (seen.has(realPath)) return '';
120	  seen.add(realPath);
121
122	  let content: string;
123	  try {
124	    content = fs.readFileSync(realPath, 'utf8').slice(0, MAX_CONTEXT_FILE_CHARS);
125	  } catch {
126	    return '';
127	  }
128
129	  const dir = path.dirname(filePath);
130	  const expanded = content
131	    .split('\n')
132	    .map((line) => {
133	      const match = line.match(/^@(.+\.md)\s*$/);
134	      if (!match) return line;
135	      const importPath = match[1].startsWith('/') ? match[1] : path.resolve(dir, match[1]);
136	      return readMarkdownWithImports(importPath, seen);
137	    })
138	    .filter(Boolean)
139	    .join('\n');
140
141	  return [`## ${path.basename(filePath)}`, '', expanded].join('\n');
142	}
143
144	function listSecondBrainWiki(): string {
145	  const wikiDir = '/workspace/extra/second-brain/wiki';
146	  let entries: string[];
147	  try {
148	    entries = fs.readdirSync(wikiDir);
149	  } catch {
150	    return '';
151	  }
152	  const md = entries.filter((name) => name.endsWith('.md')).sort().slice(0, 80);
153	  return md.length > 0 ? `Available wiki pages in ${wikiDir}:\n${md.map((name) => `- ${name}`).join('\n')}` : '';
154	}
155
156	const MESSAGE_BLOCK_RE = /<message\s+to="[^"]+"\s*>/;
157
158	const WRAP_RETRY_PROMPT =
159	  '<system>Your previous reply was NOT delivered: it contained no <message to="name">...</message> block. ' +
160	  'You have no tools — you cannot read files, browse, or perform actions, so do not say you will. ' +
161	  'Send your final answer NOW, wrapped in a <message to="name">...</message> block. ' +
162	  'Use the destination name from the incoming message\'s from="..." attribute.</system>';
163
164	function hasMessageBlock(text: string): boolean {
165	  return MESSAGE_BLOCK_RE.test(text);
166	}
167
168	/**
169	 * Tags this protocol actually understands. Anything else is a hallucinated
170	 * tool call — the REST rungs (LM Studio, Codex bridge) have no real tool
171	 * access, but a model sometimes fabricates plausible-looking tool syntax
172	 * (e.g. `<read_file path="..." />`) anyway, ignoring the system prompt's
173	 * explicit "you have no tools" instruction. hasMessageBlock only validates
174	 * the outer wrapper, so this garbage previously sailed straight through to
175	 * the user looking like gibberish/non-English output.
176	 */
177	const KNOWN_TAGS = new Set(['message', 'internal', 'system']);
178	const ANY_TAG_RE = /<\/?([a-zA-Z][\w-]*)\b[^>]*>/g;
179
180	function hasHallucinatedToolTag(text: string): boolean {
181	  ANY_TAG_RE.lastIndex = 0;
182	  let match: RegExpExecArray | null;
183	  while ((match = ANY_TAG_RE.exec(text)) !== null) {
184	    if (!KNOWN_TAGS.has(match[1].toLowerCase())) return true;
185	  }
186	  return false;
187	}
188
189	const TOOL_HALLUCINATION_RETRY_PROMPT =
190	  '<system>Your previous reply contained tool-call-like syntax (e.g. <read_file>, <search_conversations>). ' +
191	  'You have NO tool access in this provider mode — you cannot read files, browse, or search anything, so do not ' +
192	  'emit any tags other than <message to="name">...</message> and <internal>...</internal>. ' +
193	  'Answer in plain natural language only, using what you already know from this conversation. ' +
194	  'Use the destination name from the incoming message\'s from="..." attribute.</system>';
195
196	/** Honest last resort when the final REST rung can't stop fabricating tool calls after a corrective retry. */
197	function honestNoToolsReply(prompt: string): string {
198	  const text =
199	    "I don't have tool access on this fallback model right now, so I can't read files or search anything for you. Please try again once the primary provider is available.";
200	  const dest = lastFromDestination(prompt);
201	  return dest ? `<message to="${dest}">${text}</message>` : text;
202	}
203
204	/** Destination of the most recent inbound message in the formatted prompt. */
205	function lastFromDestination(prompt: string): string | undefined {
206	  let dest: string | undefined;
207	  const re = /<message\s[^>]*\bfrom="([^"]+)"/g;
208	  let match: RegExpExecArray | null;
209	  while ((match = re.exec(prompt)) !== null) dest = match[1];
210	  return dest;
211	}
212
213	function stripInternalBlocks(text: string): string {
214	  return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
215	}
216
217	function unwrapInternalBlocks(text: string): string {
218	  const parts: string[] = [];
219	  const re = /<internal>([\s\S]*?)<\/internal>/g;
220	  let match: RegExpExecArray | null;
221	  while ((match = re.exec(text)) !== null) {
222	    const body = match[1].trim();
223	    if (body) parts.push(body);
224	  }
225	  return parts.join('\n\n');
226	}
227
228	/**
229	 * Last-resort wrap for a reply that still has no <message> block after the
230	 * corrective retry. Local models (LM Studio) routinely answer with only
231	 * <internal> scratchpad; dropping that as undeliverable means the user gets
232	 * total silence from the final fallback rung. Imperfect delivery beats none.
233	 */
234	function forceWrapReply(primary: string, fallback: string, prompt: string): string {
235	  const dest = lastFromDestination(prompt);
236	  if (!dest) return primary;
237	  const visible =
238	    stripInternalBlocks(primary) || unwrapInternalBlocks(primary) || stripInternalBlocks(fallback) || unwrapInternalBlocks(fallback);
239	  if (!visible) return primary;
240	  return `<message to="${dest}">${visible}</message>`;
241	}
242
243	function needsClaudeCode(input: QueryInput): boolean {
244	  const text = input.prompt.toLowerCase();
245	  const sourceLink = /https?:\/\/\S+/.test(text);
246	  return (
247	    sourceLink ||
248	    /\b(ingest|youtube|podcast|instagram|repo|repository|transcript|wiki|second-brain|write|update|append|create file|edit file|read file|git|commit|push|stage changes|merge|rebase|checkout|tool-recommendations|\/workspace|\/workspace\/extra)\b/.test(
249	      text,
250	    )
251	  );
252	}
253
254	function failMessage(provider: string, response: Response, body: string): string {
255	  const detail = body.trim().slice(0, 500);
256	  return `${provider} request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}`;
257	}
258
259	async function fetchWithFallbackUrls(urls: string[], init: RequestInit): Promise<Response> {
260	  let lastError: unknown;
261	  for (const url of urls) {
262	    try {
263	      const res = await fetch(url, init);
264	      return res;
265	    } catch (err) {
266	      lastError = err;
267	      log(`Failed fetch to ${url}: ${err instanceof Error ? err.message : String(err)}`);
268	    }
269	  }
270	  throw lastError || new Error('All fallback URLs failed to respond');
271	}
272
273	/**
274	 * Shared OpenAI-compatible chat-completions rung. LM Studio and the Codex
275	 * bridge both speak this protocol on a host-local port; the Antigravity
276	 * bridge (planned, docs/provider-routing-plan.md) will too. Deprecated
277	 * direct-API rungs (Gemini REST, OpenAI REST, Antigravity :8045 stub) were
278	 * removed 2026-07-07 — see git history and the plan doc.
279	 */
280	class OpenAiCompatRestProvider implements RestProvider {
281	  constructor(
282	    readonly name: string,
283	    readonly noticeName: string,
284	    private readonly baseUrl: string,
285	    private readonly apiKey: string,
286	    private readonly model: string,
287	  ) {}
288
289	  async complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult> {
290	    const history = loadHistory(this.name, input.continuation);
291	    const turns = [...history.turns, { role: 'user' as const, content: input.prompt }];
292	    const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`;
293	    const urls = [
294	      url,
295	      url.replace('host.docker.internal', 'localhost'),
296	      url.replace('host.docker.internal', '127.0.0.1'),
297	    ];
298
299	    const res = await fetchWithFallbackUrls(urls, {
300	      method: 'POST',
301	      signal,
302	      headers: {
303	        authorization: `Bearer ${this.apiKey}`,
304	        'content-type': 'application/json',
305	      },
306	      body: JSON.stringify({
307	        model: this.model,
308	        messages: [{ role: 'system', content: systemPrompt(input) }, ...turns],
309	      }),
310	    });
311
312	    const body = await res.text();
313	    if (!res.ok) throw new Error(failMessage(this.noticeName, res, body));
314
315	    const parsed = JSON.parse(body) as { choices?: Array<{ message?: { content?: string } }> };
316	    const text = parsed.choices?.[0]?.message?.content?.trim();
317	    if (!text) throw new Error(`${this.noticeName} returned no message content`);
318
319	    saveHistory(this.name, history.id, [...turns, { role: 'assistant', content: text }]);
320	    return { continuation: prefixedContinuation(this.name, history.id), text };
321	  }
322	}
323
324	export class GeminiOpenAiClaudeProvider implements AgentProvider {
325	  readonly supportsNativeSlashCommands = false;
326
327	  private readonly claude: ClaudeProvider;
328	  private readonly lmstudio: OpenAiCompatRestProvider;
329	  private readonly codex: OpenAiCompatRestProvider;
330
331	  constructor(options: ProviderOptions = {}) {
332	    const env = options.env ?? {};
333	    this.claude = new ClaudeProvider({
334	      ...options,
335	      model: env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || options.model || ANTHROPIC_MODEL,
336	    });
337	    this.lmstudio = new OpenAiCompatRestProvider(
338	      'lmstudio',
339	      'LM Studio',
340	      env.LM_STUDIO_BASE_URL || 'http://host.docker.internal:1234/v1',
341	      env.LM_STUDIO_API_KEY || 'lm-studio',
342	      env.LM_STUDIO_MODEL || 'local-model',
343	    );
344	    // Codex bridge: host-side shim that fronts `codex exec` (ChatGPT
345	    // subscription) with an OpenAI-compatible endpoint. scripts/codex-bridge.mjs
346	    this.codex = new OpenAiCompatRestProvider(
347	      'codex',
348	      'Codex',
349	      env.CODEX_BRIDGE_BASE_URL || 'http://host.docker.internal:8046/v1',
350	      env.CODEX_BRIDGE_API_KEY || 'codex-bridge',
351	      env.CODEX_MODEL || 'default',
352	    );
353	  }
354
355	  isSessionInvalid(err: unknown): boolean {
356	    return this.claude.isSessionInvalid(err);
357	  }
358
359	  maybeRotateContinuation(continuation: string, cwd: string): string | null {
360	    const claudeContinuation = providerContinuation('claude', continuation);
361	    void cwd;
362	    return claudeContinuation ? this.claude.maybeRotateContinuation?.(claudeContinuation) ?? null : null;
363	  }
364
365	  query(input: QueryInput): AgentQuery {
366	    const abortController = new AbortController();
367	    let activeClaudeQuery: AgentQuery | null = null;
368	    let aborted = false;
369
370	    async function* eventsFor(
371	      provider: RestProvider,
372	      options: { allowHonestNoToolsReply: boolean },
373	    ): AsyncGenerator<ProviderEvent> {
374	      let result = await provider.complete(input, abortController.signal);
375	      if (!hasMessageBlock(result.text)) {
376	        log(`${provider.noticeName} reply had no <message> block; retrying with corrective instruction`);
377	        const firstText = result.text;
378	        try {
379	          const retry = await provider.complete(
380	            { ...input, prompt: WRAP_RETRY_PROMPT, continuation: result.continuation },
381	            abortController.signal,
382	          );
383	          result = hasMessageBlock(retry.text)
384	            ? retry
385	            : { ...retry, text: forceWrapReply(retry.text, firstText, input.prompt) };
386	        } catch (err) {
387	          log(`${provider.noticeName} corrective retry failed: ${err instanceof Error ? err.message : String(err)}`);
388	          result = { ...result, text: forceWrapReply(firstText, '', input.prompt) };
389	        }
390	      }
391	      if (hasHallucinatedToolTag(result.text)) {
392	        log(`${provider.noticeName} reply contained hallucinated tool-call syntax; retrying with corrective instruction`);
393	        let retry: ChatResult | null = null;
394	        try {
395	          retry = await provider.complete(
396	            { ...input, prompt: TOOL_HALLUCINATION_RETRY_PROMPT, continuation: result.continuation },
397	            abortController.signal,
398	          );
399	        } catch (err) {
400	          log(
401	            `${provider.noticeName} tool-hallucination retry failed: ${err instanceof Error ? err.message : String(err)}`,
402	          );
403	          if (!options.allowHonestNoToolsReply) throw err;
404	          result = { ...result, text: honestNoToolsReply(input.prompt) };
405	        }
406	        if (retry) {
407	          if (hasMessageBlock(retry.text) && !hasHallucinatedToolTag(retry.text)) {
408	            result = retry;
409	          } else if (options.allowHonestNoToolsReply) {
410	            result = { ...retry, text: honestNoToolsReply(input.prompt) };
411	          } else {
412	            throw new Error(`${provider.noticeName} repeatedly emitted tool-call-like syntax`);
413	          }
414	        }
415	      }
416	      yield { type: 'init', continuation: result.continuation };
417	      yield { type: 'result', text: result.text };
418	    }
419
420	    // Set when the Claude rung has already delivered a result — a later
421	    // stream error must then propagate instead of triggering a fallback
422	    // (the reply is already on its way; a second rung would double-send).
423	    let claudeDelivered = false;
424	    const self = this;
425
426	    async function* claudeEvents(): AsyncGenerator<ProviderEvent> {
427	      activeClaudeQuery = self.claude.query({
428	        ...input,
429	        continuation: providerContinuation('claude', input.continuation),
430	      });
431	      for await (const event of activeClaudeQuery.events) {
432	        if (event.type === 'init') {
433	          yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
434	        } else {
435	          yield event;
436	        }
437	        if (event.type === 'error' && !event.retryable) {
438	          throw new Error(event.message);
439	        }
440	        // Only a delivered result counts as success — init/activity events
441	        // arrive before billing/quota failures and must not block fallback.
442	        if (event.type === 'result') claudeDelivered = true;
443	      }
444	      if (!claudeDelivered) {
445	        throw new Error('Anthropic query ended without a result');
446	      }
447	    }
448
449	    // Rung order (docs/provider-routing-plan.md, Phase 0 decisions):
450	    //   tool work  → Claude (only rung with tools) → Codex → LM Studio
451	    //   plain chat → LM Studio (free, local)       → Claude → Codex
452	    const events = (async function* (): AsyncGenerator<ProviderEvent> {
453	      const toolsFirst = needsClaudeCode(input);
454	      if (toolsFirst) log('Prompt requires local workspace/wiki access; using Claude Code directly');
455
456	      const rungs: Array<{ name: string; run: (isFinal: boolean) => AsyncGenerator<ProviderEvent>; isClaude?: boolean }> = toolsFirst
457	        ? [
458	            { name: 'Anthropic', run: claudeEvents, isClaude: true },
459	            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
460	            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
461	          ]
462	        : [
463	            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
464	            { name: 'Anthropic', run: claudeEvents, isClaude: true },
465	            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
466	          ];
467
468	      for (let i = 0; i < rungs.length; i++) {
469	        const rung = rungs[i];
470	        const next = rungs[i + 1];
471	        try {
472	          yield* rung.run(!next);
473	          return;
474	        } catch (err) {
475	          if (aborted) return;
476	          if (rung.isClaude && claudeDelivered) throw err;
477	          log(`${rung.name} failed: ${err instanceof Error ? err.message : String(err)}`);
478	          if (!next) throw err;
479	          yield {
480	            type: 'provider_notice',
481	            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
482	          };
483	        }
484	      }
485	    })();
486
487	    return {
488	      push: (message) => activeClaudeQuery?.push(message),
489	      end: () => activeClaudeQuery?.end(),
490	      events,
491	      abort: () => {
492	        aborted = true;
493	        abortController.abort();
494	        activeClaudeQuery?.abort();
495	      },
496	    };
497	  }
498	}
499
500	// Registered name kept for compatibility with existing container.json configs
501	// even though the Gemini/OpenAI REST rungs were removed (see
502	// docs/provider-routing-plan.md). Rename to a routing-table provider in Phase 1.
503	registerProvider('gemini-openai-claude', (opts) => new GeminiOpenAiClaudeProvider(opts));
504

import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';

import { ClaudeProvider } from './claude.js';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';

const HISTORY_LIMIT = 24;
const ANTHROPIC_MODEL = 'sonnet';
const MAX_CONTEXT_FILE_CHARS = 12000;
const MAX_SYSTEM_CONTEXT_CHARS = 60000;

interface ChatTurn {
  role: 'user' | 'assistant';
  content: string;
}

interface ChatResult {
  continuation: string;
  text: string;
}

interface RestProvider {
  readonly name: string;
  readonly noticeName: string;
  complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult>;
}

function log(msg: string): void {
  console.error(`[fallback-provider] ${msg}`);
}

function historyPath(providerName: string, continuation: string): string {
  const historyDir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
  return path.join(historyDir, providerName, `${continuation}.json`);
}

function providerContinuation(providerName: string, continuation?: string): string | undefined {
  const prefix = `${providerName}:`;
  return continuation?.startsWith(prefix) ? continuation.slice(prefix.length) : undefined;
}

function prefixedContinuation(providerName: string, continuation: string): string {
  return `${providerName}:${continuation}`;
}

function loadHistory(providerName: string, continuation?: string): { id: string; turns: ChatTurn[] } {
  const providerId = providerContinuation(providerName, continuation);
  const id = providerId || randomUUID();
  if (!providerId) return { id, turns: [] };

  try {
    const raw = JSON.parse(fs.readFileSync(historyPath(providerName, providerId), 'utf8')) as { turns?: ChatTurn[] };
    const turns = Array.isArray(raw.turns) ? raw.turns.filter(isChatTurn) : [];
    return { id, turns };
  } catch {
    return { id, turns: [] };
  }
}

function saveHistory(providerName: string, continuation: string, turns: ChatTurn[]): void {
  const file = historyPath(providerName, continuation);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify({ turns: turns.slice(-HISTORY_LIMIT) }, null, 2) + '\n');
}

function isChatTurn(value: unknown): value is ChatTurn {
  if (!value || typeof value !== 'object') return false;
  const turn = value as { role?: unknown; content?: unknown };
  return (turn.role === 'user' || turn.role === 'assistant') && typeof turn.content === 'string';
}

function systemPrompt(input: QueryInput): string {
  const localContext = loadLocalInstructionContext(input.cwd);
  const parts = [
    input.systemContext?.instructions || '',
    localContext,
    [
      'You are running as a NanoClaw chat agent.',
      'Reply only with final text wrapped in <message to="name">...</message> blocks unless you need private scratchpad text inside <internal>...</internal>.',
      'Every reply MUST contain at least one <message to="name">...</message> block — a reply with only <internal> content is discarded and the user sees nothing.',
      'Use the destination names listed above. If the incoming message has a from="name" attribute, reply to that destination by default.',
      'You have been given the local agent instructions above as context. You have NO tool access in this provider mode: you cannot read files, browse, or defer work — answer now with what you know, and do not claim to have read or modified files unless the user provided that content in chat.',
    ].join('\n'),
  ].filter(Boolean);
  return parts.join('\n\n').slice(0, MAX_SYSTEM_CONTEXT_CHARS);
}

function loadLocalInstructionContext(cwd: string): string {
  const files = [
    path.join(cwd, 'CLAUDE.md'),
    path.join(cwd, 'CLAUDE.local.md'),
    path.join(cwd, 'repo-awareness.md'),
    '/workspace/extra/second-brain/SCHEMA.md',
    '/workspace/extra/second-brain/wiki/Home.md',
    '/workspace/extra/second-brain/wiki/Wiki Ingest Log.md',
  ];
  const seen = new Set<string>();
  const sections: string[] = [];
  for (const file of files) {
    const content = readMarkdownWithImports(file, seen);
    if (content) sections.push(content);
  }
  const secondBrainTree = listSecondBrainWiki();
  if (secondBrainTree) {
    sections.push(['# Mounted Wiki Snapshot', '', secondBrainTree].join('\n'));
  }
  return sections.length > 0 ? ['# Local Agent Instructions', '', ...sections].join('\n\n') : '';
}

function readMarkdownWithImports(filePath: string, seen: Set<string>): string {
  let realPath: string;
  try {
    realPath = fs.realpathSync(filePath);
  } catch {
    return '';
  }
  if (seen.has(realPath)) return '';
  seen.add(realPath);

  let content: string;
  try {
    content = fs.readFileSync(realPath, 'utf8').slice(0, MAX_CONTEXT_FILE_CHARS);
  } catch {
    return '';
  }

  const dir = path.dirname(filePath);
  const expanded = content
    .split('\n')
    .map((line) => {
      const match = line.match(/^@(.+\.md)\s*$/);
      if (!match) return line;
      const importPath = match[1].startsWith('/') ? match[1] : path.resolve(dir, match[1]);
      return readMarkdownWithImports(importPath, seen);
    })
    .filter(Boolean)
    .join('\n');

  return [`## ${path.basename(filePath)}`, '', expanded].join('\n');
}

function listSecondBrainWiki(): string {
  const wikiDir = '/workspace/extra/second-brain/wiki';
  let entries: string[];
  try {
    entries = fs.readdirSync(wikiDir);
  } catch {
    return '';
  }
  const md = entries.filter((name) => name.endsWith('.md')).sort().slice(0, 80);
  return md.length > 0 ? `Available wiki pages in ${wikiDir}:\n${md.map((name) => `- ${name}`).join('\n')}` : '';
}

const MESSAGE_BLOCK_RE = /<message\s+to="[^"]+"\s*>/;

const WRAP_RETRY_PROMPT =
  '<system>Your previous reply was NOT delivered: it contained no <message to="name">...</message> block. ' +
  'You have no tools — you cannot read files, browse, or perform actions, so do not say you will. ' +
  'Send your final answer NOW, wrapped in a <message to="name">...</message> block. ' +
  'Use the destination name from the incoming message\'s from="..." attribute.</system>';

function hasMessageBlock(text: string): boolean {
  return MESSAGE_BLOCK_RE.test(text);
}

/**
 * Tags this protocol actually understands. Anything else is a hallucinated
 * tool call — the REST rungs (LM Studio, Codex bridge) have no real tool
 * access, but a model sometimes fabricates plausible-looking tool syntax
 * (e.g. `<read_file path="..." />`) anyway, ignoring the system prompt's
 * explicit "you have no tools" instruction. hasMessageBlock only validates
 * the outer wrapper, so this garbage previously sailed straight through to
 * the user looking like gibberish/non-English output.
 */
const KNOWN_TAGS = new Set(['message', 'internal', 'system']);
const ANY_TAG_RE = /<\/?([a-zA-Z][\w-]*)\b[^>]*>/g;

function hasHallucinatedToolTag(text: string): boolean {
  ANY_TAG_RE.lastIndex = 0;
  let match: RegExpExecArray | null;
  while ((match = ANY_TAG_RE.exec(text)) !== null) {
    if (!KNOWN_TAGS.has(match[1].toLowerCase())) return true;
  }
  return false;
}

const TOOL_HALLUCINATION_RETRY_PROMPT =
  '<system>Your previous reply contained tool-call-like syntax (e.g. <read_file>, <search_conversations>). ' +
  'You have NO tool access in this provider mode — you cannot read files, browse, or search anything, so do not ' +
  'emit any tags other than <message to="name">...</message> and <internal>...</internal>. ' +
  'Answer in plain natural language only, using what you already know from this conversation. ' +
  'Use the destination name from the incoming message\'s from="..." attribute.</system>';

/** Honest last resort when the final REST rung can't stop fabricating tool calls after a corrective retry. */
function honestNoToolsReply(prompt: string): string {
  const text =
    "I don't have tool access on this fallback model right now, so I can't read files or search anything for you. Please try again once the primary provider is available.";
  const dest = lastFromDestination(prompt);
  return dest ? `<message to="${dest}">${text}</message>` : text;
}

/** Destination of the most recent inbound message in the formatted prompt. */
function lastFromDestination(prompt: string): string | undefined {
  let dest: string | undefined;
  const re = /<message\s[^>]*\bfrom="([^"]+)"/g;
  let match: RegExpExecArray | null;
  while ((match = re.exec(prompt)) !== null) dest = match[1];
  return dest;
}

function stripInternalBlocks(text: string): string {
  return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}

function unwrapInternalBlocks(text: string): string {
  const parts: string[] = [];
  const re = /<internal>([\s\S]*?)<\/internal>/g;
  let match: RegExpExecArray | null;
  while ((match = re.exec(text)) !== null) {
    const body = match[1].trim();
    if (body) parts.push(body);
  }
  return parts.join('\n\n');
}

/**
 * Last-resort wrap for a reply that still has no <message> block after the
 * corrective retry. Local models (LM Studio) routinely answer with only
 * <internal> scratchpad; dropping that as undeliverable means the user gets
 * total silence from the final fallback rung. Imperfect delivery beats none.
 */
function forceWrapReply(primary: string, fallback: string, prompt: string): string {
  const dest = lastFromDestination(prompt);
  if (!dest) return primary;
  const visible =
    stripInternalBlocks(primary) || unwrapInternalBlocks(primary) || stripInternalBlocks(fallback) || unwrapInternalBlocks(fallback);
  if (!visible) return primary;
  return `<message to="${dest}">${visible}</message>`;
}

function needsClaudeCode(input: QueryInput): boolean {
  const text = input.prompt.toLowerCase();
  const sourceLink = /https?:\/\/\S+/.test(text);
  return (
    sourceLink ||
    /\b(ingest|youtube|podcast|instagram|repo|repository|transcript|wiki|second-brain|write|update|append|create file|edit file|read file|git|commit|push|stage changes|merge|rebase|checkout|tool-recommendations|\/workspace|\/workspace\/extra)\b/.test(
      text,
    )
  );
}

function failMessage(provider: string, response: Response, body: string): string {
  const detail = body.trim().slice(0, 500);
  return `${provider} request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}`;
}

async function fetchWithFallbackUrls(urls: string[], init: RequestInit): Promise<Response> {
  let lastError: unknown;
  for (const url of urls) {
    try {
      const res = await fetch(url, init);
      return res;
    } catch (err) {
      lastError = err;
      log(`Failed fetch to ${url}: ${err instanceof Error ? err.message : String(err)}`);
    }
  }
  throw lastError || new Error('All fallback URLs failed to respond');
}

/**
 * Shared OpenAI-compatible chat-completions rung. LM Studio and the Codex
 * bridge both speak this protocol on a host-local port; the Antigravity
 * bridge (planned, docs/provider-routing-plan.md) will too. Deprecated
 * direct-API rungs (Gemini REST, OpenAI REST, Antigravity :8045 stub) were
 * removed 2026-07-07 — see git history and the plan doc.
 */
class OpenAiCompatRestProvider implements RestProvider {
  constructor(
    readonly name: string,
    readonly noticeName: string,
    private readonly baseUrl: string,
    private readonly apiKey: string,
    private readonly model: string,
  ) {}

  async complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult> {
    const history = loadHistory(this.name, input.continuation);
    const turns = [...history.turns, { role: 'user' as const, content: input.prompt }];
    const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`;
    const urls = [
      url,
      url.replace('host.docker.internal', 'localhost'),
      url.replace('host.docker.internal', '127.0.0.1'),
    ];

    const res = await fetchWithFallbackUrls(urls, {
      method: 'POST',
      signal,
      headers: {
        authorization: `Bearer ${this.apiKey}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        model: this.model,
        messages: [{ role: 'system', content: systemPrompt(input) }, ...turns],
      }),
    });

    const body = await res.text();
    if (!res.ok) throw new Error(failMessage(this.noticeName, res, body));

    const parsed = JSON.parse(body) as { choices?: Array<{ message?: { content?: string } }> };
    const text = parsed.choices?.[0]?.message?.content?.trim();
    if (!text) throw new Error(`${this.noticeName} returned no message content`);

    saveHistory(this.name, history.id, [...turns, { role: 'assistant', content: text }]);
    return { continuation: prefixedContinuation(this.name, history.id), text };
  }
}

export class GeminiOpenAiClaudeProvider implements AgentProvider {
  readonly supportsNativeSlashCommands = false;

  private readonly claude: ClaudeProvider;
  private readonly lmstudio: OpenAiCompatRestProvider;
  private readonly codex: OpenAiCompatRestProvider;

  constructor(options: ProviderOptions = {}) {
    const env = options.env ?? {};
    this.claude = new ClaudeProvider({
      ...options,
      model: env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || options.model || ANTHROPIC_MODEL,
    });
    this.lmstudio = new OpenAiCompatRestProvider(
      'lmstudio',
      'LM Studio',
      env.LM_STUDIO_BASE_URL || 'http://host.docker.internal:1234/v1',
      env.LM_STUDIO_API_KEY || 'lm-studio',
      env.LM_STUDIO_MODEL || 'local-model',
    );
    // Codex bridge: host-side shim that fronts `codex exec` (ChatGPT
    // subscription) with an OpenAI-compatible endpoint. scripts/codex-bridge.mjs
    this.codex = new OpenAiCompatRestProvider(
      'codex',
      'Codex',
      env.CODEX_BRIDGE_BASE_URL || 'http://host.docker.internal:8046/v1',
      env.CODEX_BRIDGE_API_KEY || 'codex-bridge',
      env.CODEX_MODEL || 'default',
    );
  }

  isSessionInvalid(err: unknown): boolean {
    return this.claude.isSessionInvalid(err);
  }

  maybeRotateContinuation(continuation: string, cwd: string): string | null {
    const claudeContinuation = providerContinuation('claude', continuation);
    void cwd;
    return claudeContinuation ? this.claude.maybeRotateContinuation?.(claudeContinuation) ?? null : null;
  }

  query(input: QueryInput): AgentQuery {
    const abortController = new AbortController();
    let activeClaudeQuery: AgentQuery | null = null;
    let aborted = false;

    async function* eventsFor(
      provider: RestProvider,
      options: { allowHonestNoToolsReply: boolean },
    ): AsyncGenerator<ProviderEvent> {
      let result = await provider.complete(input, abortController.signal);
      if (!hasMessageBlock(result.text)) {
        log(`${provider.noticeName} reply had no <message> block; retrying with corrective instruction`);
        const firstText = result.text;
        try {
          const retry = await provider.complete(
            { ...input, prompt: WRAP_RETRY_PROMPT, continuation: result.continuation },
            abortController.signal,
          );
          result = hasMessageBlock(retry.text)
            ? retry
            : { ...retry, text: forceWrapReply(retry.text, firstText, input.prompt) };
        } catch (err) {
          log(`${provider.noticeName} corrective retry failed: ${err instanceof Error ? err.message : String(err)}`);
          result = { ...result, text: forceWrapReply(firstText, '', input.prompt) };
        }
      }
      if (hasHallucinatedToolTag(result.text)) {
        log(`${provider.noticeName} reply contained hallucinated tool-call syntax; retrying with corrective instruction`);
        let retry: ChatResult | null = null;
        try {
          retry = await provider.complete(
            { ...input, prompt: TOOL_HALLUCINATION_RETRY_PROMPT, continuation: result.continuation },
            abortController.signal,
          );
        } catch (err) {
          log(
            `${provider.noticeName} tool-hallucination retry failed: ${err instanceof Error ? err.message : String(err)}`,
          );
          if (!options.allowHonestNoToolsReply) throw err;
          result = { ...result, text: honestNoToolsReply(input.prompt) };
        }
        if (retry) {
          if (hasMessageBlock(retry.text) && !hasHallucinatedToolTag(retry.text)) {
            result = retry;
          } else if (options.allowHonestNoToolsReply) {
            result = { ...retry, text: honestNoToolsReply(input.prompt) };
          } else {
            throw new Error(`${provider.noticeName} repeatedly emitted tool-call-like syntax`);
          }
        }
      }
      yield { type: 'init', continuation: result.continuation };
      yield { type: 'result', text: result.text };
    }

    // Set when the Claude rung has already delivered a result — a later
    // stream error must then propagate instead of triggering a fallback
    // (the reply is already on its way; a second rung would double-send).
    let claudeDelivered = false;
    const self = this;

    async function* claudeEvents(): AsyncGenerator<ProviderEvent> {
      activeClaudeQuery = self.claude.query({
        ...input,
        continuation: providerContinuation('claude', input.continuation),
      });
      for await (const event of activeClaudeQuery.events) {
        if (event.type === 'init') {
          yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
        } else {
          yield event;
        }
        if (event.type === 'error' && !event.retryable) {
          throw new Error(event.message);
        }
        // Only a delivered result counts as success — init/activity events
        // arrive before billing/quota failures and must not block fallback.
        if (event.type === 'result') claudeDelivered = true;
      }
      if (!claudeDelivered) {
        throw new Error('Anthropic query ended without a result');
      }
    }

    // Rung order (docs/provider-routing-plan.md, Phase 0 decisions):
    //   tool work  → Claude (only rung with tools) → Codex → LM Studio
    //   plain chat → LM Studio (free, local)       → Claude → Codex
    const events = (async function* (): AsyncGenerator<ProviderEvent> {
      const toolsFirst = needsClaudeCode(input);
      if (toolsFirst) log('Prompt requires local workspace/wiki access; using Claude Code directly');

      const rungs: Array<{ name: string; run: (isFinal: boolean) => AsyncGenerator<ProviderEvent>; isClaude?: boolean }> = toolsFirst
        ? [
            { name: 'Anthropic', run: claudeEvents, isClaude: true },
            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
          ]
        : [
            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
            { name: 'Anthropic', run: claudeEvents, isClaude: true },
            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
          ];

      for (let i = 0; i < rungs.length; i++) {
        const rung = rungs[i];
        const next = rungs[i + 1];
        try {
          yield* rung.run(!next);
          return;
        } catch (err) {
          if (aborted) return;
          if (rung.isClaude && claudeDelivered) throw err;
          log(`${rung.name} failed: ${err instanceof Error ? err.message : String(err)}`);
          if (!next) throw err;
          yield {
            type: 'provider_notice',
            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
          };
        }
      }
    })();

    return {
      push: (message) => activeClaudeQuery?.push(message),
      end: () => activeClaudeQuery?.end(),
      events,
      abort: () => {
        aborted = true;
        abortController.abort();
        activeClaudeQuery?.abort();
      },
    };
  }
}

// Registered name kept for compatibility with existing container.json configs
// even though the Gemini/OpenAI REST rungs were removed (see
// docs/provider-routing-plan.md). Rename to a routing-table provider in Phase 1.
registerProvider('gemini-openai-claude', (opts) => new GeminiOpenAiClaudeProvider(opts));