File created successfully at: /workspace/agent/autopilot-trigger.mjs (file state is current in your context — no need to Read it back)

/**
 * autopilot-trigger.mjs — scheduled trigger script for portfolio-autopilot
 *
 * Runs every 30 min via nanoclaw schedule_task. Returns:
 *   { wakeAgent: true, data: {...} }  — if burnable > 0 and eligible projects exist
 *   { wakeAgent: false }              — if quota is zero or no eligible projects
 */

process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
  .filter(Boolean)
  .join(",");

import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(
  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
);

async function fetchUsage() {
  const res = await fetch(config.codexbar_url);
  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
  return res.json();
}

function extractWindows(data) {
  const providers = data.providers ?? data;
  const claudeProvider = Array.isArray(providers)
    ? providers.find((p) => /claude/i.test(p.name ?? p.label ?? ""))
    : null;
  const windows = claudeProvider?.windows ?? data.windows ?? [];
  const sorted = [...windows].sort((a, b) => a.duration_sec - b.duration_sec);
  const w5h = sorted.find(
    (w) => Math.abs(w.duration_sec - 18000) < 300
  ) ?? sorted[0];
  const w7d = sorted.find(
    (w) => Math.abs(w.duration_sec - 604800) < 600
  ) ?? sorted[sorted.length - 1];
  return { w5h, w7d };
}

function computeBurnable(window, basePct) {
  if (!window) return 0;
  const remaining = window.pct ?? 100;
  if (remaining <= 0) return 0;
  const resets = Math.max(0, window.resets_in_sec);
  const timeRatio = resets / window.duration_sec;
  return Math.max(0, remaining - basePct * timeRatio);
}

function quotaTier(burnablePct) {
  if (burnablePct <= 0) return { tier: "zero", allowedPriorities: [], implementationModel: null };
  if (burnablePct < 5) return { tier: "minimal", allowedPriorities: ["P1"], implementationModel: "lm-studio" };
  if (burnablePct < 20) return { tier: "low", allowedPriorities: ["P1"], implementationModel: "gemini-or-haiku" };
  if (burnablePct < 50) return { tier: "medium", allowedPriorities: ["P1", "P2"], implementationModel: "claude-sonnet" };
  return { tier: "high", allowedPriorities: ["P1", "P2", "P3"], implementationModel: "claude-sonnet" };
}

function sustainableGatePasses(window) {
  if (!window) return false;
  const windowHours = window.duration_sec / 3600;
  const elapsedHours = (window.duration_sec - Math.max(0, window.resets_in_sec)) / 3600;
  const expectedConsumed = (100 / windowHours) * elapsedHours;
  const actualConsumed = 100 - (window.pct ?? 100);
  return actualConsumed < expectedConsumed;
}

async function main() {
  let data;
  try {
    data = await fetchUsage();
  } catch (err) {
    // Can't reach CodexBar — don't wake agent, try next cycle
    console.log(JSON.stringify({ wakeAgent: false, reason: `codexbar_unreachable: ${err.message}` }));
    return;
  }

  const { w5h, w7d } = extractWindows(data);
  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
  const burnable = Math.min(burnable5h, burnable7d);

  const tier = quotaTier(burnable);
  if (tier.tier === "zero") {
    console.log(JSON.stringify({ wakeAgent: false, reason: "no_burnable_quota" }));
    return;
  }

  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;

  const eligibleProjects = Object.entries(config.project_priorities)
    .filter(([_, priority]) => tier.allowedPriorities.includes(priority))
    .filter(([_, priority]) => priority === "P1" || sustainableGate)
    .map(([project, priority]) => ({ project, priority }));

  if (eligibleProjects.length === 0) {
    console.log(JSON.stringify({ wakeAgent: false, reason: "no_eligible_projects" }));
    return;
  }

  console.log(
    JSON.stringify({
      wakeAgent: true,
      data: {
        burnable_pct: +burnable.toFixed(1),
        tier: tier.tier,
        implementationModel: tier.implementationModel,
        eligibleProjects,
        windows: {
          "5h": w5h ? { remaining_pct: w5h.pct, resets_in_sec: w5h.resets_in_sec } : null,
          "7d": w7d ? { remaining_pct: w7d.pct, resets_in_sec: w7d.resets_in_sec } : null,
        },
      },
    })
  );
}

main();


Expand Roadsense to new areas and plan public launch


Correct — 5h window is at 0%, so the trigger correctly stays quiet. Now let me wire up the scheduled task:


Now let me schedule the autopilot trigger task. It should run every 30 minutes, check quota via the script, and only wake the agent when there's burnable quota and eligible projects.

The prompt should instruct the agent on what to do when woken up.


Prompt:
Portfolio Autopilot run triggered. The trigger script has already checked quota and confirmed burnable capacity exists.

Script data is in `data` — it contains:
- `burnable_pct`: effective burnable quota percentage
- `tier`: quota tier (minimal/low/medium/high)
- `implementationModel`: which model tier to use for implementation tasks
- `eligibleProjects`: list of projects with their priorities that are eligible to run this cycle

Your job:
1. Read `/workspace/extra/second-brain/portfolio/BOARD.md` and the relevant project cards (`projects/*.md`) for each eligible project
2. For each eligible project (in priority order — P1 first), identify the current `Next action` from its card
3. Pick ONE unit of work to do this cycle (the highest-priority project with a clear, actionable next step)
4. Determine the task type:
   - **Greenfield** (new plan, new feature direction, new project): Plan → Auto red-team → package and send to James for approval via Telegram (telegram-mg-17796). Do NOT start implementation.
   - **Continuation** (clear next action on existing spec, `autonomous-ok` guard): Implement directly, then report outcome to James.
   - **review-required projects** (TradingBot25, qip-qien): Produce a PR-ready change, send summary to James for merge approval.
5. Model guidance from the trigger:
   - Planning always uses Claude Sonnet (non-negotiable)
   - Implementation uses whatever `implementationModel` says (lm-studio / gemini-or-haiku / claude-sonnet)
6. After completing work: update the project card's `Next action` and `Last activity`, update `BOARD.md`, and send James a brief outcome message on Telegram.
7. Log the run to `/workspace/agent/autopilot-run-log.jsonl` — one JSON line with: timestamp, tier, burnable_pct, project, task_type, model_used, outcome (completed/blocked/sent_for_review), tokens_estimate.

If no eligible project has a clear, actionable next step (e.g., all are blocked on James), log it and notify James with a one-liner about what's stalled.

Read `/workspace/agent/autopilot-config.json` for all thresholds and priorities.


Task scheduled (id: task-1783433888329-w9msxw, runs at: 2026-07-07T14:30:00.000Z, recurrence: */30 * * * *)


The scheduled task is now set up. Let me now update the portfolio-autopilot.md and BOARD.md to reflect the current state (spec → build, task scheduled).

Actually, looking at the portfolio-autopilot.md, it was in "spec" stage. Now that we've built the config file, routing script, trigger script, and scheduled the task, it's moved to "build" stage with some items complete. Let me update both files.


Scheduled. Now let me update the board — autopilot has moved from spec to build with core pieces done:


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


Edit

| [portfolio-autopilot](projects/portfolio-autopilot.md) | spec | autonomous-ok | Build config + quota reader + routing logic | confirm P1/P2/P3 tiers | 2026-07-07 |

| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Outcome tracker + weekly calibration loop | none | 2026-07-07 |


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) | spec | autonomous-ok | Build config + quota reader + routing logic | confirm P1/P2/P3 tiers | 2026-07-07 |
| [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 | Define precision target; run backtest | exit criteria for shadow mode | 2026-07-06 |
| [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 | Competitive analysis (James sharing links) | share Instagram competitor links | 2026-07-06 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Scope public-launch work + GTM; fix display bug | none | 2026-06-06 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | portfolio-autopilot routing logic (P1 infra) | none | 2026-07-07 |

## Decision queue

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

- (empty — first daily brief will ask to confirm the seeded cards above)

## Recently shipped

- (empty)

-| [portfolio-autopilot](projects/portfolio-autopilot.md) | spec | autonomous-ok | Build config + quota reader + routing logic | confirm P1/P2/P3 tiers | 2026-07-07 |

+| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Outcome tracker + weekly calibration loop | none | 2026-07-07 |


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

# Portfolio Autopilot

- Repo: n/a — lives in Nano's scheduled tasks + config
- Goal: Quota-aware autonomous work loop that advances portfolio projects using excess LLM capacity, while preserving a hard reserve for James's interactive use. Self-adjusting over time.
- Stage: spec
- Guard: autonomous-ok
- Metric: portfolio stages advanced per week at ≤ 15% of James's interactive quota consumed
- Blocked on James: confirm priority tiers (P1/P2/P3) for each project
- Next action: build routing logic + config file; wire to CodexBar endpoint at host.docker.internal:7842

## Design

### Three levers (all configurable)

| Lever | Default | Purpose |
|---|---|---|
| Base reserve (5h window) | 25% | Interactive buffer — scales down as reset approaches |
| Base reserve (7d window) | 15% | Interactive buffer — scales down as reset approaches |

**No per-session cap.** If there's burnable quota and work to do, use it.

### Adaptive reserve formula

```
adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
burnable_pct         = remaining_pct − adaptive_reserve_pct
```

This means:
- Start of window: full reserve held
- Mid-window: reserve shrinks proportionally
- Near reset: reserve → 0 (almost all remaining quota is burnable)

Applied to both windows independently; take the more conservative (lower burnable) as the constraint:
```
burnable = min(burnable_5h, burnable_7d)
```

Work runs only when `burnable > 0`.

### Task types + model assignment

Task type determines model and effort *regardless of quota tier* — planning always uses the best available model because the cost of a bad plan exceeds the cost of the tokens.

| Task type | Model | Effort | Notes |
|---|---|---|---|
| Plan (greenfield) | Claude Sonnet | high | Always capable model — non-negotiable |
| Red-team review | Claude Sonnet | medium | Auto-runs after every plan, before human sees it |
| Plan (continuation) | Claude Sonnet | medium | Existing direction, lower risk |
| Implementation | Claude Sonnet or Haiku | medium | Quota-tier dependent |
| Research / analysis | Gemini or Haiku | low | Bulk work, quota-saving |
| Maintenance | LM Studio or Haiku | low | Formatting, summaries, simple edits |

### Human-in-the-loop checkpoints

**Greenfield work** (new plan, new feature, new project direction):
```
Plan → Auto red-team → Package (plan + critique + open questions) → Send to James → James approves → Implementation
```
Implementation does not start until James explicitly approves. The package sent to James includes: the plan, the red-team's blind spots and risk flags, and a numbered list of open questions.

**Continuing work** (clear next action on an existing spec):
```
Implement → Report outcome → James can redirect if needed
```
No checkpoint required for `autonomous-ok` projects with a well-defined next action.

**review-required projects** (TradingBot25, qip-qien):
All changes go as PRs; James approves merges. No auto-merge ever.

### Quota tiers → implementation model routing

Planning always uses capable model (see above). Implementation routing adapts to quota:

| Burnable quota | Implementation model | Projects allowed |
|---|---|---|
| High (>50% burnable) | Claude Sonnet | P1, P2, P3 |
| Medium (20–50%) | Claude Sonnet | P1, P2 |
| Low (5–20%) | Gemini or Haiku | P1 only |
| Minimal (<5%) | LM Studio | maintenance only |
| Zero | No autonomous work | — |

### Burn-rate gate (for P2/P3)

`sustainable_pct_per_hour = 100 / window_hours`

P2/P3 work only runs when actual burn rate < sustainable pace — i.e., quota is ahead of the smooth curve. P1 work bypasses this gate (runs whenever above the reserve floor).

### Priority tiers (proposed — James to confirm)

| Project | Priority | Rationale |
|---|---|---|
| StageHand | P1 | James's priority — real estate video suite, active discovery |
| nanoclaw | P1 | Maintenance + ongoing infra work |
| AgenticLever | P2 | Business-facing, active build |
| RoadSense | P2 | Going public, active momentum |
| qip-qien | P2 | Live trial — needs progress but review-required gate limits autonomous scope |
| congressional-trading | P3 | Runs autonomously; calibration is in-flight, low need for new LLM spend |
| TradingBot25 | P3 | Deep investment already; review-required, low autonomous-work surface |

### Closed-loop auto-calibration

Track per run:
- Quota spent (from CodexBar delta before/after)
- Output produced (git commits, wiki pages, board moves)
- Did James use Claude within 2h after an autonomous run? (proxy for "left enough headroom")

Weekly review: if James-usage-after-run rate is high → reserve floor was too low → bump it up. If output/quota ratio is consistently low → narrow per-session cap. If P1 projects are stalling → loosen moderate-tier threshold.

The loop surfaces a weekly "autopilot health" note (not a full brief — one line per signal that changed).

### Config file

`/workspace/agent/autopilot-config.json` — all thresholds live here so any run can tweak them without code changes.

```json
{
  "reserve_5h_pct": 25,
  "reserve_7d_pct": 15,

  "project_priorities": {
    "StageHand": "P1",
    "nanoclaw": "P1",
    "AgenticLever": "P2",
    "RoadSense": "P2",
    "qip-qien": "P2",
    "congressional-trading": "P3",
    "TradingBot25": "P3"
  },
  "flush_threshold": 60,
  "comfortable_threshold": 40,
  "moderate_threshold": 20
}
```

## Implementation plan

1. **Config + quota reader** — `autopilot-config.json` + script that reads CodexBar endpoint and computes current tier and burn rate
2. **Routing logic** — given tier + priority + project next-action, decide: proceed / skip / use alternate model
3. **Task executor** — runs one unit of work per session, commits result, updates board
4. **Scheduled trigger** — every 30 min, runs quota check; fires task if conditions met
5. **Outcome tracker** — logs quota delta + output produced per run
6. **Weekly calibration** — reviews outcome log, suggests threshold changes

## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.


- Blocked on James: confirm priority tiers (P1/P2/P3) for each project
- Next action: build routing logic + config file; wire to CodexBar endpoint at host.docker.internal:7842

- Blocked on James: none
- Next action: outcome tracker (log quota delta + output per run) + weekly calibration loop


- Blocked on James: none
- Next action: outcome tracker (log quota delta + output per run) + weekly calibration loop

# Portfolio Autopilot

- Repo: n/a — lives in Nano's scheduled tasks + config
- Goal: Quota-aware autonomous work loop that advances portfolio projects using excess LLM capacity, while preserving a hard reserve for James's interactive use. Self-adjusting over time.
- Stage: build
- Guard: autonomous-ok
- Metric: portfolio stages advanced per week at ≤ 15% of James's interactive quota consumed
- Blocked on James: confirm priority tiers (P1/P2/P3) for each project
- Next action: build routing logic + config file; wire to CodexBar endpoint at host.docker.internal:7842

## Design

### Three levers (all configurable)

| Lever | Default | Purpose |
|---|---|---|
| Base reserve (5h window) | 25% | Interactive buffer — scales down as reset approaches |
| Base reserve (7d window) | 15% | Interactive buffer — scales down as reset approaches |

**No per-session cap.** If there's burnable quota and work to do, use it.

### Adaptive reserve formula

```
adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
burnable_pct         = remaining_pct − adaptive_reserve_pct
```

This means:
- Start of window: full reserve held
- Mid-window: reserve shrinks proportionally
- Near reset: reserve → 0 (almost all remaining quota is burnable)

Applied to both windows independently; take the more conservative (lower burnable) as the constraint:
```
burnable = min(burnable_5h, burnable_7d)
```

Work runs only when `burnable > 0`.

### Task types + model assignment

Task type determines model and effort *regardless of quota tier* — planning always uses the best available model because the cost of a bad plan exceeds the cost of the tokens.

| Task type | Model | Effort | Notes |
|---|---|---|---|
| Plan (greenfield) | Claude Sonnet | high | Always capable model — non-negotiable |
| Red-team review | Claude Sonnet | medium | Auto-runs after every plan, before human sees it |
| Plan (continuation) | Claude Sonnet | medium | Existing direction, lower risk |
| Implementation | Claude Sonnet or Haiku | medium | Quota-tier dependent |
| Research / analysis | Gemini or Haiku | low | Bulk work, quota-saving |
| Maintenance | LM Studio or Haiku | low | Formatting, summaries, simple edits |

### Human-in-the-loop checkpoints

**Greenfield work** (new plan, new feature, new project direction):
```
Plan → Auto red-team → Package (plan + critique + open questions) → Send to James → James approves → Implementation
```
Implementation does not start until James explicitly approves. The package sent to James includes: the plan, the red-team's blind spots and risk flags, and a numbered list of open questions.

**Continuing work** (clear next action on an existing spec):
```
Implement → Report outcome → James can redirect if needed
```
No checkpoint required for `autonomous-ok` projects with a well-defined next action.

**review-required projects** (TradingBot25, qip-qien):
All changes go as PRs; James approves merges. No auto-merge ever.

### Quota tiers → implementation model routing

Planning always uses capable model (see above). Implementation routing adapts to quota:

| Burnable quota | Implementation model | Projects allowed |
|---|---|---|
| High (>50% burnable) | Claude Sonnet | P1, P2, P3 |
| Medium (20–50%) | Claude Sonnet | P1, P2 |
| Low (5–20%) | Gemini or Haiku | P1 only |
| Minimal (<5%) | LM Studio | maintenance only |
| Zero | No autonomous work | — |

### Burn-rate gate (for P2/P3)

`sustainable_pct_per_hour = 100 / window_hours`

P2/P3 work only runs when actual burn rate < sustainable pace — i.e., quota is ahead of the smooth curve. P1 work bypasses this gate (runs whenever above the reserve floor).

### Priority tiers (proposed — James to confirm)

| Project | Priority | Rationale |
|---|---|---|
| StageHand | P1 | James's priority — real estate video suite, active discovery |
| nanoclaw | P1 | Maintenance + ongoing infra work |
| AgenticLever | P2 | Business-facing, active build |
| RoadSense | P2 | Going public, active momentum |
| qip-qien | P2 | Live trial — needs progress but review-required gate limits autonomous scope |
| congressional-trading | P3 | Runs autonomously; calibration is in-flight, low need for new LLM spend |
| TradingBot25 | P3 | Deep investment already; review-required, low autonomous-work surface |

### Closed-loop auto-calibration

Track per run:
- Quota spent (from CodexBar delta before/after)
- Output produced (git commits, wiki pages, board moves)
- Did James use Claude within 2h after an autonomous run? (proxy for "left enough headroom")

Weekly review: if James-usage-after-run rate is high → reserve floor was too low → bump it up. If output/quota ratio is consistently low → narrow per-session cap. If P1 projects are stalling → loosen moderate-tier threshold.

The loop surfaces a weekly "autopilot health" note (not a full brief — one line per signal that changed).

### Config file

`/workspace/agent/autopilot-config.json` — all thresholds live here so any run can tweak them without code changes.

```json
{
  "reserve_5h_pct": 25,
  "reserve_7d_pct": 15,

  "project_priorities": {
    "StageHand": "P1",
    "nanoclaw": "P1",
    "AgenticLever": "P2",
    "RoadSense": "P2",
    "qip-qien": "P2",
    "congressional-trading": "P3",
    "TradingBot25": "P3"
  },
  "flush_threshold": 60,
  "comfortable_threshold": 40,
  "moderate_threshold": 20
}
```

## Implementation plan

1. **Config + quota reader** — `autopilot-config.json` + script that reads CodexBar endpoint and computes current tier and burn rate
2. **Routing logic** — given tier + priority + project next-action, decide: proceed / skip / use alternate model
3. **Task executor** — runs one unit of work per session, commits result, updates board
4. **Scheduled trigger** — every 30 min, runs quota check; fires task if conditions met
5. **Outcome tracker** — logs quota delta + output produced per run
6. **Weekly calibration** — reviews outcome log, suggests threshold changes

## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.

-- Blocked on James: confirm priority tiers (P1/P2/P3) for each project

-- Next action: build routing logic + config file; wire to CodexBar endpoint at host.docker.internal:7842

+- Next action: outcome tracker (log quota delta + output per run) + weekly calibration loop


## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.

## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- 2026-07-07 — Core build complete: `autopilot-config.json` (thresholds + priority map), `autopilot.mjs` (quota reader + routing logic, human-readable + JSON mode), `autopilot-trigger.mjs` (pre-script for scheduler — returns wakeAgent true/false). Scheduled task `task-1783433888329-w9msxw` runs every 30 min.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.


## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- 2026-07-07 — Core build complete: `autopilot-config.json` (thresholds + priority map), `autopilot.mjs` (quota reader + routing logic, human-readable + JSON mode), `autopilot-trigger.mjs` (pre-script for scheduler — returns wakeAgent true/false). Scheduled task `task-1783433888329-w9msxw` runs every 30 min.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.

# Portfolio Autopilot

- Repo: n/a — lives in Nano's scheduled tasks + config
- Goal: Quota-aware autonomous work loop that advances portfolio projects using excess LLM capacity, while preserving a hard reserve for James's interactive use. Self-adjusting over time.
- Stage: build
- Guard: autonomous-ok
- Metric: portfolio stages advanced per week at ≤ 15% of James's interactive quota consumed
- Blocked on James: none
- Next action: outcome tracker (log quota delta + output per run) + weekly calibration loop

## Design

### Three levers (all configurable)

| Lever | Default | Purpose |
|---|---|---|
| Base reserve (5h window) | 25% | Interactive buffer — scales down as reset approaches |
| Base reserve (7d window) | 15% | Interactive buffer — scales down as reset approaches |

**No per-session cap.** If there's burnable quota and work to do, use it.

### Adaptive reserve formula

```
adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
burnable_pct         = remaining_pct − adaptive_reserve_pct
```

This means:
- Start of window: full reserve held
- Mid-window: reserve shrinks proportionally
- Near reset: reserve → 0 (almost all remaining quota is burnable)

Applied to both windows independently; take the more conservative (lower burnable) as the constraint:
```
burnable = min(burnable_5h, burnable_7d)
```

Work runs only when `burnable > 0`.

### Task types + model assignment

Task type determines model and effort *regardless of quota tier* — planning always uses the best available model because the cost of a bad plan exceeds the cost of the tokens.

| Task type | Model | Effort | Notes |
|---|---|---|---|
| Plan (greenfield) | Claude Sonnet | high | Always capable model — non-negotiable |
| Red-team review | Claude Sonnet | medium | Auto-runs after every plan, before human sees it |
| Plan (continuation) | Claude Sonnet | medium | Existing direction, lower risk |
| Implementation | Claude Sonnet or Haiku | medium | Quota-tier dependent |
| Research / analysis | Gemini or Haiku | low | Bulk work, quota-saving |
| Maintenance | LM Studio or Haiku | low | Formatting, summaries, simple edits |

### Human-in-the-loop checkpoints

**Greenfield work** (new plan, new feature, new project direction):
```
Plan → Auto red-team → Package (plan + critique + open questions) → Send to James → James approves → Implementation
```
Implementation does not start until James explicitly approves. The package sent to James includes: the plan, the red-team's blind spots and risk flags, and a numbered list of open questions.

**Continuing work** (clear next action on an existing spec):
```
Implement → Report outcome → James can redirect if needed
```
No checkpoint required for `autonomous-ok` projects with a well-defined next action.

**review-required projects** (TradingBot25, qip-qien):
All changes go as PRs; James approves merges. No auto-merge ever.

### Quota tiers → implementation model routing

Planning always uses capable model (see above). Implementation routing adapts to quota:

| Burnable quota | Implementation model | Projects allowed |
|---|---|---|
| High (>50% burnable) | Claude Sonnet | P1, P2, P3 |
| Medium (20–50%) | Claude Sonnet | P1, P2 |
| Low (5–20%) | Gemini or Haiku | P1 only |
| Minimal (<5%) | LM Studio | maintenance only |
| Zero | No autonomous work | — |

### Burn-rate gate (for P2/P3)

`sustainable_pct_per_hour = 100 / window_hours`

P2/P3 work only runs when actual burn rate < sustainable pace — i.e., quota is ahead of the smooth curve. P1 work bypasses this gate (runs whenever above the reserve floor).

### Priority tiers (proposed — James to confirm)

| Project | Priority | Rationale |
|---|---|---|
| StageHand | P1 | James's priority — real estate video suite, active discovery |
| nanoclaw | P1 | Maintenance + ongoing infra work |
| AgenticLever | P2 | Business-facing, active build |
| RoadSense | P2 | Going public, active momentum |
| qip-qien | P2 | Live trial — needs progress but review-required gate limits autonomous scope |
| congressional-trading | P3 | Runs autonomously; calibration is in-flight, low need for new LLM spend |
| TradingBot25 | P3 | Deep investment already; review-required, low autonomous-work surface |

### Closed-loop auto-calibration

Track per run:
- Quota spent (from CodexBar delta before/after)
- Output produced (git commits, wiki pages, board moves)
- Did James use Claude within 2h after an autonomous run? (proxy for "left enough headroom")

Weekly review: if James-usage-after-run rate is high → reserve floor was too low → bump it up. If output/quota ratio is consistently low → narrow per-session cap. If P1 projects are stalling → loosen moderate-tier threshold.

The loop surfaces a weekly "autopilot health" note (not a full brief — one line per signal that changed).

### Config file

`/workspace/agent/autopilot-config.json` — all thresholds live here so any run can tweak them without code changes.

```json
{
  "reserve_5h_pct": 25,
  "reserve_7d_pct": 15,

  "project_priorities": {
    "StageHand": "P1",
    "nanoclaw": "P1",
    "AgenticLever": "P2",
    "RoadSense": "P2",
    "qip-qien": "P2",
    "congressional-trading": "P3",
    "TradingBot25": "P3"
  },
  "flush_threshold": 60,
  "comfortable_threshold": 40,
  "moderate_threshold": 20
}
```

## Implementation plan

1. **Config + quota reader** — `autopilot-config.json` + script that reads CodexBar endpoint and computes current tier and burn rate
2. **Routing logic** — given tier + priority + project next-action, decide: proceed / skip / use alternate model
3. **Task executor** — runs one unit of work per session, commits result, updates board
4. **Scheduled trigger** — every 30 min, runs quota check; fires task if conditions met
5. **Outcome tracker** — logs quota delta + output produced per run
6. **Weekly calibration** — reviews outcome log, suggests threshold changes

## Notes

- 2026-07-07 — James approved design. Start with intelligent defaults, evolve to closed-loop. Config file approach so thresholds are tweakable without code changes.
- 2026-07-07 — James set priorities (inverted from initial proposal): P1 = StageHand, nanoclaw; P2 = AgenticLever, RoadSense, qip-qien; P3 = congressional-trading, TradingBot25.
- 2026-07-07 — Reserve revised to adaptive formula (scales linearly to 0 as window resets). Per-session cap removed — no artificial throttle on session size.
- CodexBar endpoint: `http://host.docker.internal:7842/usage` — confirmed live and accessible from container.
- LM Studio confirmed live at `http://host.docker.internal:1234` with Gemma 4 12B + 26B models loaded.

+- 2026-07-07 — Core build complete: `autopilot-config.json` (thresholds + priority map), `autopilot.mjs` (quota reader + routing logic, human-readable + JSON mode), `autopilot-trigger.mjs` (pre-script for scheduler — returns wakeAgent true/false). Scheduled task `task-1783433888329-w9msxw` runs every 30 min.


Also update the nanoclaw card since the routing logic work is done:


- Next action: portfolio-autopilot routing logic (P1 infra work); commit Slack channel files when convenient

- Next action: commit Slack channel files when convenient; outcome tracker for autopilot run log


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

# 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: portfolio-autopilot routing logic (P1 infra work); commit Slack channel files when convenient

## 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.

-- Next action: portfolio-autopilot routing logic (P1 infra work); commit Slack channel files when convenient

+- Next action: commit Slack channel files when convenient; outcome tracker for autopilot run log


| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | portfolio-autopilot routing logic (P1 infra) | none | 2026-07-07 |

| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Commit Slack channel files; autopilot outcome tracker | none | 2026-07-07 |


# 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 | Outcome tracker + weekly calibration loop | none | 2026-07-07 |
| [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 | Define precision target; run backtest | exit criteria for shadow mode | 2026-07-06 |
| [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 | Competitive analysis (James sharing links) | share Instagram competitor links | 2026-07-06 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Scope public-launch work + GTM; fix display bug | none | 2026-06-06 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | portfolio-autopilot routing logic (P1 infra) | none | 2026-07-07 |

## Decision queue

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

- (empty — first daily brief will ask to confirm the seeded cards above)

## Recently shipped

- (empty)

-| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | portfolio-autopilot routing logic (P1 infra) | none | 2026-07-07 |

+| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Commit Slack channel files; autopilot outcome tracker | none | 2026-07-07 |


<message to="telegram-mg-17796">
Portfolio Autopilot is live. Here's what got built:

**Files**
- `/workspace/agent/autopilot-config.json` — all thresholds, priorities, endpoint URLs (edit this to tune without touching code)
- `/workspace/agent/autopilot.mjs` — quota reader + routing logic; run it anytime to see current state
- `/workspace/agent/autopilot-trigger.mjs` — pre-script for the scheduler (returns wakeAgent true/false)

**Scheduled task** `task-1783433888329-w9msxw` — runs every 30 min, checks quota via the trigger script, only wakes an agent session when there's burnable capacity and eligible projects

**Current state** (right now): 5h window at 0%, 7d window at 13% → burnable 0% → ZERO tier, nothing runs. Will stay quiet until the 5h window resets.

**Routing behavior** once the window resets:
- P1 (StageHand, nanoclaw) — runs whenever burnable > 0%
- P2 (AgenticLever, RoadSense, qip-qien) — runs when burnable ≥ 20% and burn rate is ahead of sustainable pace
- P3 (congressional-trading, TradingBot25) — runs only when burnable ≥ 50%
- Greenfield work (new plan) → auto red-team → packaged for your approval before any implementation starts
- Continuation work on `autonomous-ok` projects → proceeds directly, reports after

**Still to build**: outcome tracker (log quota delta + output per run) and weekly calibration loop — that's the closed-loop part. I'll do those in the next available cycle.
</message>


<context timezone="America/Chicago" />
<message id="1084" from="telegram-mg-17796" sender="James Lopez" time="Jul 7, 2026, 9:28 AM">When listing state of quotas please include all 3 (claude, ChatGPT and Gemini) also if the 5h data you are referring to is from Claude, that one is at 0% used, per Claude mobile app. So there could be a bug there in how you are interpreting what the end point is returning. Also wondering, knowing that we know when the usage windows end and begin, work could actually begin right when a new window opens rather than waiting for the next 30 minute poll.</message>


James has two issues and one enhancement request:

1. **Show all 3 providers** (Claude, ChatGPT, Gemini) in quota output, not just Claude
2. **Bug: 5h window interpretation** — James says the 5h window is at 0% *used* (meaning 100% remaining), but CodexBar is returning `pct: 0` which I'm interpreting as 0% remaining. So `pct` might mean "percent used" not "percent remaining", OR there's something else going on. Let me look at the raw response again:
Looking at the 5h window data, `pct: 0` with `resets_in_sec: -1` suggests that if `pct` represents percent used, then 0% used would mean 100% remaining — which matches what James is saying. But the 7d window shows `pct: 13`, implying 13% used and 87% remaining. So I think I've been misinterpreting `pct` as percent remaining when it's actually percent used. The `resets_in_sec: -1` for the 5h window probably means it just reset and the timer hasn't been properly initialized yet. So the calculation becomes `remaining_pct = 100 - window.pct`, and with the adaptive reserve formula factoring in time until reset, the 5h window would show very high burnable capacity since it's at 100% remaining. I should also schedule a one-shot task at the exact reset time instead of waiting for the next 30-minute poll, so when a window resets I can immediately recalculate based on the new `resets_in_sec` value.

The CodexBar endpoint returns multiple providers in the response, so I need to handle Claude, ChatGPT, and Gemini separately. Let me fetch the full response to see all the providers and their window data, then fix the `pct` interpretation bug, add multi-provider support, and implement the reset-time scheduling with `processAfter` tasks.


Bash

curl -s http://host.docker.internal:7842/usage 2>/dev/null | python3 -m json.tool 2>/dev/null | head -80


Interesting! The response only shows Claude and Codex (not ChatGPT or Gemini). The `pct` values are:
- Claude 5h: pct=0, resets_in_sec=-1
- Claude 7d: pct=13, resets_in_sec=369096 (~4.3 days)
- Codex 5h: pct=0, resets_in_sec=17999 (5h window fresh)
- Codex 7d: pct=28, resets_in_sec=523609

Now, is `pct` = percent used or percent remaining?
</current_rewritten_thinking>

James clarifies that `pct: 0` means 0% used, so `pct` represents percent consumed rather than remaining. The negative reset timer for Claude's 5h window is puzzling—it might indicate the window just reset or hasn't started tracking yet, whereas Codex's 5h window shows a normal countdown with 0% usage. The CodexBar app was built specifically for Claude and OpenAI Codex, so Gemini probably isn't tracked through it at all—that might explain why only two providers show up in the API response even though James has three subscriptions. I need to fix how I'm interpreting the `pct` field (it's percent used, so remaining is 100 minus that), display both Claude and Codex data, and set up the reset-time scheduling to wake the app immediately when a rate limit window resets.

For the reset scheduling, when `resets_in_sec` is positive I can calculate the exact reset timestamp and schedule a one-shot task to fire at that moment instead of polling every 30 minutes. The `-1` value is trickier—it might mean the window recently reset and CodexBar hasn't computed the next reset time yet, or it could indicate no active session for that window. Either way, I should treat it as a fresh window (100% remaining) but skip scheduling since I don't have a reliable reset time.

My implementation needs to fix the remaining percentage calculation, handle the `-1` case by falling back to the window duration, display all providers in the output, and set up that one-shot wake task for the main constraint window when I have a valid reset time.


Two things clear up from the raw data: `pct` = **percent used** (not remaining), and ChatGPT/Gemini aren't in this endpoint — CodexBar only tracks Claude and Codex. Let me fix the `pct` bug first, then add the reset-wake scheduling, and address the missing providers:


1	/**
2	 * Portfolio Autopilot — quota reader + routing logic
3	 *
4	 * Reads CodexBar usage, computes burnable quota, and outputs a routing decision:
5	 * which projects are eligible to run and at what model tier.
6	 *
7	 * Usage:
8	 *   node autopilot.mjs              → prints routing summary to stdout
9	 *   node autopilot.mjs --json       → prints routing decision as JSON
10	 *   node autopilot.mjs --check      → prints only burnable pct (for scripts)
11	 */
12
13	// Bypass proxy for local Docker host
14	process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
15	  .filter(Boolean)
16	  .join(",");
17
18	import { readFileSync } from "fs";
19	import { fileURLToPath } from "url";
20	import { dirname, join } from "path";
21
22	const __dirname = dirname(fileURLToPath(import.meta.url));
23	const config = JSON.parse(
24	  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
25	);
26
27	const JSON_MODE = process.argv.includes("--json");
28	const CHECK_MODE = process.argv.includes("--check");
29
30	// ─── Quota Reader ────────────────────────────────────────────────────────────
31
32	async function fetchUsage() {
33	  const res = await fetch(config.codexbar_url);
34	  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
35	  return res.json();
36	}
37
38	/**
39	 * Find the 5h and 7d windows from CodexBar response.
40	 * Returns { pct, resets_in_sec, duration_sec } for each.
41	 */
42	function extractWindows(data) {
43	  // CodexBar returns providers array; find Claude provider, then its windows
44	  const providers = data.providers ?? data;
45	  const claudeProvider = Array.isArray(providers)
46	    ? providers.find((p) => /claude/i.test(p.name ?? p.label ?? ""))
47	    : null;
48
49	  const windows = claudeProvider?.windows ?? data.windows ?? [];
50
51	  const w5h = windows.find(
52	    (w) => w.duration_sec <= 5 * 3600 + 60 && w.duration_sec >= 5 * 3600 - 60
53	  );
54	  const w7d = windows.find(
55	    (w) =>
56	      w.duration_sec <= 7 * 86400 + 300 && w.duration_sec >= 7 * 86400 - 300
57	  );
58
59	  if (!w5h || !w7d) {
60	    // Fallback: pick shortest and longest available windows
61	    const sorted = [...windows].sort((a, b) => a.duration_sec - b.duration_sec);
62	    return {
63	      w5h: sorted[0] ?? null,
64	      w7d: sorted[sorted.length - 1] ?? null,
65	      raw: windows,
66	    };
67	  }
68
69	  return { w5h, w7d, raw: windows };
70	}
71
72	// ─── Adaptive Reserve Formula ────────────────────────────────────────────────
73
74	/**
75	 * adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
76	 * burnable_pct = remaining_pct − adaptive_reserve_pct
77	 */
78	function computeBurnable(window, basePct) {
79	  if (!window) return 0;
80	  const remaining = window.pct ?? 100;
81	  if (remaining <= 0) return 0;
82	  // resets_in_sec <= 0 means window expired/resetting — treat as full timeRatio approaching 0
83	  const resets = Math.max(0, window.resets_in_sec);
84	  const timeRatio = resets / window.duration_sec;
85	  const adaptiveReserve = basePct * timeRatio;
86	  return Math.max(0, remaining - adaptiveReserve);
87	}
88
89	// ─── Tier Routing ────────────────────────────────────────────────────────────
90
91	/**
92	 * Given burnable %, return the quota tier label and which project priorities run.
93	 */
94	function quotaTier(burnablePct) {
95	  if (burnablePct <= 0) {
96	    return { tier: "zero", label: "No autonomous work", allowedPriorities: [] };
97	  }
98	  if (burnablePct < 5) {
99	    return {
100	      tier: "minimal",
101	      label: "Maintenance only (LM Studio)",
102	      allowedPriorities: ["P1"],
103	      implementationModel: "lm-studio",
104	    };
105	  }
106	  if (burnablePct < 20) {
107	    return {
108	      tier: "low",
109	      label: "P1 only — Gemini/Haiku",
110	      allowedPriorities: ["P1"],
111	      implementationModel: "gemini-or-haiku",
112	    };
113	  }
114	  if (burnablePct < 50) {
115	    return {
116	      tier: "medium",
117	      label: "P1+P2 — Claude Sonnet",
118	      allowedPriorities: ["P1", "P2"],
119	      implementationModel: "claude-sonnet",
120	    };
121	  }
122	  return {
123	    tier: "high",
124	    label: "P1+P2+P3 — Claude Sonnet",
125	    allowedPriorities: ["P1", "P2", "P3"],
126	    implementationModel: "claude-sonnet",
127	  };
128	}
129
130	/**
131	 * Sustainable burn rate gate for P2/P3.
132	 * Returns true if current burn rate is ahead of the smooth curve.
133	 */
134	function sustainableGatePasses(window) {
135	  if (!window) return false;
136	  const windowHours = window.duration_sec / 3600;
137	  const sustainablePctPerHour = 100 / windowHours;
138	  const elapsedHours = (window.duration_sec - window.resets_in_sec) / 3600;
139	  const expectedConsumed = sustainablePctPerHour * elapsedHours;
140	  const actualConsumed = 100 - (window.pct ?? 100);
141	  // Passes if actual consumption is BELOW expected (ahead of smooth curve)
142	  return actualConsumed < expectedConsumed;
143	}
144
145	// ─── Project Routing ─────────────────────────────────────────────────────────
146
147	function routeProjects(tier, burnablePct, w7d) {
148	  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;
149	  const projects = [];
150
151	  for (const [project, priority] of Object.entries(config.project_priorities)) {
152	    const allowed = tier.allowedPriorities.includes(priority);
153	    if (!allowed) {
154	      projects.push({ project, priority, decision: "skip", reason: "quota tier" });
155	      continue;
156	    }
157
158	    // P2/P3 require sustainable gate (P1 bypasses)
159	    if (priority !== "P1" && !sustainableGate) {
160	      projects.push({
161	        project,
162	        priority,
163	        decision: "skip",
164	        reason: "burn rate above sustainable pace",
165	      });
166	      continue;
167	    }
168
169	    projects.push({
170	      project,
171	      priority,
172	      decision: "proceed",
173	      implementationModel: tier.implementationModel,
174	    });
175	  }
176
177	  return projects;
178	}
179
180	// ─── Main ────────────────────────────────────────────────────────────────────
181
182	async function main() {
183	  let data;
184	  try {
185	    data = await fetchUsage();
186	  } catch (err) {
187	    const out = { error: `Failed to reach CodexBar: ${err.message}`, burnable: 0 };
188	    if (JSON_MODE) console.log(JSON.stringify(out));
189	    else console.error(out.error);
190	    process.exit(1);
191	  }
192
193	  const { w5h, w7d, raw } = extractWindows(data);
194
195	  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
196	  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
197	  const burnable = Math.min(burnable5h, burnable7d);
198
199	  if (CHECK_MODE) {
200	    console.log(burnable.toFixed(1));
201	    return;
202	  }
203
204	  const tier = quotaTier(burnable);
205	  const projects = routeProjects(tier, burnable, w7d);
206
207	  const result = {
208	    timestamp: new Date().toISOString(),
209	    windows: {
210	      "5h": w5h
211	        ? {
212	            remaining_pct: w5h.pct,
213	            resets_in_sec: w5h.resets_in_sec,
214	            burnable_pct: +burnable5h.toFixed(1),
215	          }
216	        : null,
217	      "7d": w7d
218	        ? {
219	            remaining_pct: w7d.pct,
220	            resets_in_sec: w7d.resets_in_sec,
221	            burnable_pct: +burnable7d.toFixed(1),
222	          }
223	        : null,
224	    },
225	    burnable_pct: +burnable.toFixed(1),
226	    tier: tier.tier,
227	    tier_label: tier.label,
228	    projects,
229	  };
230
231	  if (JSON_MODE) {
232	    console.log(JSON.stringify(result, null, 2));
233	    return;
234	  }
235
236	  // Human-readable summary
237	  console.log(`\n=== Portfolio Autopilot — ${result.timestamp} ===\n`);
238
239	  if (w5h) {
240	    const hrs5 = Math.round(w5h.resets_in_sec / 60);
241	    console.log(
242	      `5h window:  ${w5h.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${hrs5}m)  →  burnable: ${burnable5h.toFixed(1)}%`
243	    );
244	  }
245	  if (w7d) {
246	    const days7 = Math.round(w7d.resets_in_sec / 3600);
247	    console.log(
248	      `7d window:  ${w7d.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${days7}h)  →  burnable: ${burnable7d.toFixed(1)}%`
249	    );
250	  }
251
252	  console.log(`\nEffective burnable: ${burnable.toFixed(1)}%`);
253	  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}\n`);
254
255	  const proceeding = projects.filter((p) => p.decision === "proceed");
256	  const skipped = projects.filter((p) => p.decision === "skip");
257
258	  if (proceeding.length === 0) {
259	    console.log("No projects eligible to run.");
260	  } else {
261	    console.log("Eligible projects:");
262	    for (const p of proceeding) {
263	      console.log(`  ✓ ${p.project} (${p.priority}) — model: ${p.implementationModel}`);
264	    }
265	  }
266
267	  if (skipped.length > 0) {
268	    console.log("\nSkipped:");
269	    for (const p of skipped) {
270	      console.log(`  ✗ ${p.project} (${p.priority}) — ${p.reason}`);
271	    }
272	  }
273
274	  console.log("");
275	}
276
277	main();
278

/**
 * Portfolio Autopilot — quota reader + routing logic
 *
 * Reads CodexBar usage, computes burnable quota, and outputs a routing decision:
 * which projects are eligible to run and at what model tier.
 *
 * Usage:
 *   node autopilot.mjs              → prints routing summary to stdout
 *   node autopilot.mjs --json       → prints routing decision as JSON
 *   node autopilot.mjs --check      → prints only burnable pct (for scripts)
 */

// Bypass proxy for local Docker host
process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
  .filter(Boolean)
  .join(",");

import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(
  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
);

const JSON_MODE = process.argv.includes("--json");
const CHECK_MODE = process.argv.includes("--check");

// ─── Quota Reader ────────────────────────────────────────────────────────────

async function fetchUsage() {
  const res = await fetch(config.codexbar_url);
  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
  return res.json();
}

/**
 * Find the 5h and 7d windows from CodexBar response.
 * Returns { pct, resets_in_sec, duration_sec } for each.
 */
function extractWindows(data) {
  // CodexBar returns providers array; find Claude provider, then its windows
  const providers = data.providers ?? data;
  const claudeProvider = Array.isArray(providers)
    ? providers.find((p) => /claude/i.test(p.name ?? p.label ?? ""))
    : null;

  const windows = claudeProvider?.windows ?? data.windows ?? [];

  const w5h = windows.find(
    (w) => w.duration_sec <= 5 * 3600 + 60 && w.duration_sec >= 5 * 3600 - 60
  );
  const w7d = windows.find(
    (w) =>
      w.duration_sec <= 7 * 86400 + 300 && w.duration_sec >= 7 * 86400 - 300
  );

  if (!w5h || !w7d) {
    // Fallback: pick shortest and longest available windows
    const sorted = [...windows].sort((a, b) => a.duration_sec - b.duration_sec);
    return {
      w5h: sorted[0] ?? null,
      w7d: sorted[sorted.length - 1] ?? null,
      raw: windows,
    };
  }

  return { w5h, w7d, raw: windows };
}

// ─── Adaptive Reserve Formula ────────────────────────────────────────────────

/**
 * adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
 * burnable_pct = remaining_pct − adaptive_reserve_pct
 */
function computeBurnable(window, basePct) {
  if (!window) return 0;
  const remaining = window.pct ?? 100;
  if (remaining <= 0) return 0;
  // resets_in_sec <= 0 means window expired/resetting — treat as full timeRatio approaching 0
  const resets = Math.max(0, window.resets_in_sec);
  const timeRatio = resets / window.duration_sec;
  const adaptiveReserve = basePct * timeRatio;
  return Math.max(0, remaining - adaptiveReserve);
}

// ─── Tier Routing ────────────────────────────────────────────────────────────

/**
 * Given burnable %, return the quota tier label and which project priorities run.
 */
function quotaTier(burnablePct) {
  if (burnablePct <= 0) {
    return { tier: "zero", label: "No autonomous work", allowedPriorities: [] };
  }
  if (burnablePct < 5) {
    return {
      tier: "minimal",
      label: "Maintenance only (LM Studio)",
      allowedPriorities: ["P1"],
      implementationModel: "lm-studio",
    };
  }
  if (burnablePct < 20) {
    return {
      tier: "low",
      label: "P1 only — Gemini/Haiku",
      allowedPriorities: ["P1"],
      implementationModel: "gemini-or-haiku",
    };
  }
  if (burnablePct < 50) {
    return {
      tier: "medium",
      label: "P1+P2 — Claude Sonnet",
      allowedPriorities: ["P1", "P2"],
      implementationModel: "claude-sonnet",
    };
  }
  return {
    tier: "high",
    label: "P1+P2+P3 — Claude Sonnet",
    allowedPriorities: ["P1", "P2", "P3"],
    implementationModel: "claude-sonnet",
  };
}

/**
 * Sustainable burn rate gate for P2/P3.
 * Returns true if current burn rate is ahead of the smooth curve.
 */
function sustainableGatePasses(window) {
  if (!window) return false;
  const windowHours = window.duration_sec / 3600;
  const sustainablePctPerHour = 100 / windowHours;
  const elapsedHours = (window.duration_sec - window.resets_in_sec) / 3600;
  const expectedConsumed = sustainablePctPerHour * elapsedHours;
  const actualConsumed = 100 - (window.pct ?? 100);
  // Passes if actual consumption is BELOW expected (ahead of smooth curve)
  return actualConsumed < expectedConsumed;
}

// ─── Project Routing ─────────────────────────────────────────────────────────

function routeProjects(tier, burnablePct, w7d) {
  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;
  const projects = [];

  for (const [project, priority] of Object.entries(config.project_priorities)) {
    const allowed = tier.allowedPriorities.includes(priority);
    if (!allowed) {
      projects.push({ project, priority, decision: "skip", reason: "quota tier" });
      continue;
    }

    // P2/P3 require sustainable gate (P1 bypasses)
    if (priority !== "P1" && !sustainableGate) {
      projects.push({
        project,
        priority,
        decision: "skip",
        reason: "burn rate above sustainable pace",
      });
      continue;
    }

    projects.push({
      project,
      priority,
      decision: "proceed",
      implementationModel: tier.implementationModel,
    });
  }

  return projects;
}

// ─── Main ────────────────────────────────────────────────────────────────────

async function main() {
  let data;
  try {
    data = await fetchUsage();
  } catch (err) {
    const out = { error: `Failed to reach CodexBar: ${err.message}`, burnable: 0 };
    if (JSON_MODE) console.log(JSON.stringify(out));
    else console.error(out.error);
    process.exit(1);
  }

  const { w5h, w7d, raw } = extractWindows(data);

  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
  const burnable = Math.min(burnable5h, burnable7d);

  if (CHECK_MODE) {
    console.log(burnable.toFixed(1));
    return;
  }

  const tier = quotaTier(burnable);
  const projects = routeProjects(tier, burnable, w7d);

  const result = {
    timestamp: new Date().toISOString(),
    windows: {
      "5h": w5h
        ? {
            remaining_pct: w5h.pct,
            resets_in_sec: w5h.resets_in_sec,
            burnable_pct: +burnable5h.toFixed(1),
          }
        : null,
      "7d": w7d
        ? {
            remaining_pct: w7d.pct,
            resets_in_sec: w7d.resets_in_sec,
            burnable_pct: +burnable7d.toFixed(1),
          }
        : null,
    },
    burnable_pct: +burnable.toFixed(1),
    tier: tier.tier,
    tier_label: tier.label,
    projects,
  };

  if (JSON_MODE) {
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  // Human-readable summary
  console.log(`\n=== Portfolio Autopilot — ${result.timestamp} ===\n`);

  if (w5h) {
    const hrs5 = Math.round(w5h.resets_in_sec / 60);
    console.log(
      `5h window:  ${w5h.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${hrs5}m)  →  burnable: ${burnable5h.toFixed(1)}%`
    );
  }
  if (w7d) {
    const days7 = Math.round(w7d.resets_in_sec / 3600);
    console.log(
      `7d window:  ${w7d.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${days7}h)  →  burnable: ${burnable7d.toFixed(1)}%`
    );
  }

  console.log(`\nEffective burnable: ${burnable.toFixed(1)}%`);
  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}\n`);

  const proceeding = projects.filter((p) => p.decision === "proceed");
  const skipped = projects.filter((p) => p.decision === "skip");

  if (proceeding.length === 0) {
    console.log("No projects eligible to run.");
  } else {
    console.log("Eligible projects:");
    for (const p of proceeding) {
      console.log(`  ✓ ${p.project} (${p.priority}) — model: ${p.implementationModel}`);
    }
  }

  if (skipped.length > 0) {
    console.log("\nSkipped:");
    for (const p of skipped) {
      console.log(`  ✗ ${p.project} (${p.priority}) — ${p.reason}`);
    }
  }

  console.log("");
}

main();


Now I need to make the following changes to `autopilot.mjs`:

1. **Fix `pct` interpretation**: `pct` = percent **used**, so `remaining = 100 - pct`
2. **Show all providers**: Loop over all providers in the human-readable output (Claude + Codex + any others)
3. **Use `remaining_pct` correctly** in `computeBurnable` and `sustainableGatePasses`

For the `computeBurnable` function, I need to change `window.pct` to `100 - window.pct` (remaining).
For `sustainableGatePasses`, `actualConsumed` should be `window.pct` since that's the used percentage, while `expectedConsumed` remains unchanged. In the display output, I'll show `w5h.pct` as the used percentage and calculate remaining separately. I also need to investigate the `-1` value for `resets_in_sec` — Claude 5h shows `-1` while Codex 5h shows a proper reset time, suggesting the `-1` might indicate a special state or missing data for Claude.

The `-1` likely means the CodexBar app doesn't have valid reset tracking for that window yet, possibly due to a fresh session or sync delay, so I should treat it as a fully fresh window. For scheduling reset-wake tasks, the trigger script can't directly call APIs since it just returns JSON, but once the agent session starts (if `wakeAgent: true`), I can schedule one-shot tasks from within the agent with the reset time information.

The simpler approach is to let the 30-minute polling handle most cases—when a window resets and `resets_in_sec` hits zero, the next poll will trigger `wakeAgent: true` within 30 minutes, and the agent can then schedule the next reset-wake task. The cleanest solution is having the agent session handle all the wake scheduling logic. The trigger script can include `next_reset_at` in its response regardless of whether it wakes the agent, and then I'll add a prompt instruction telling the agent to schedule a one-shot task for that reset time after completing work. This way the agent handles the scheduling directly rather than relying on the trigger script to do it. way to handle this: have the trigger script return `wakeAgent: true` with a special `action: "schedule_reset_wake"` flag when quota is depleted but a reset time is known. This lets the agent schedule the next wake without doing unnecessary work, and the 30-minute poller acts as a safety net for missed resets.

Actually, that would burn quota tokens every time the window depletes, which could happen frequently. Instead, I'll keep it simpler: the trigger script includes `next_reset_at` in all outputs, the agent schedules a one-shot task at that time before finishing its work cycle, and the 30-minute polling continues as a baseline fallback.

To make this practical without needing new platform features, I'll focus on the concrete fixes: correcting the percentage calculation, showing all providers in the output, adding the reset timestamp to the trigger response, and updating the scheduled task prompt accordingly. I need to prioritize the fixes: first the percentage interpretation bug since it's causing incorrect values, then add the all-provider display, include the next reset time in the trigger output, and update the agent's prompt to schedule one-shot tasks that wake it exactly when the window resets rather than waiting for the 30-minute poll.


Write

/**
 * Portfolio Autopilot — quota reader + routing logic
 *
 * Reads CodexBar usage, computes burnable quota, and outputs a routing decision:
 * which projects are eligible to run and at what model tier.
 *
 * Usage:
 *   node autopilot.mjs              → prints routing summary to stdout
 *   node autopilot.mjs --json       → prints routing decision as JSON
 *   node autopilot.mjs --check      → prints only burnable pct (for scripts)
 *
 * NOTE: CodexBar `pct` field = percent USED (consumed). Remaining = 100 - pct.
 */

// Bypass proxy for local Docker host
process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
  .filter(Boolean)
  .join(",");

import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(
  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
);

const JSON_MODE = process.argv.includes("--json");
const CHECK_MODE = process.argv.includes("--check");

// ─── Quota Reader ────────────────────────────────────────────────────────────

async function fetchUsage() {
  const res = await fetch(config.codexbar_url);
  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
  return res.json();
}

/** pct = percent used; remaining = 100 - pct */
function remaining(window) {
  return 100 - (window?.pct ?? 100);
}

/**
 * Extract matching windows from a provider's windows array.
 * Returns { w5h, w7d } — each has .label, .pct (used%), .resets_in_sec, .duration_sec
 */
function extractWindows(windows = []) {
  const w5h = windows.find((w) => Math.abs(w.duration_sec - 18000) < 300) ?? null;
  const w7d = windows.find((w) => Math.abs(w.duration_sec - 604800) < 600) ?? null;
  return { w5h, w7d };
}

/** resets_in_sec = -1 means the window just reset (no timer established yet); treat as full duration */
function effectiveResetsIn(window) {
  if (!window) return 0;
  return window.resets_in_sec < 0 ? window.duration_sec : window.resets_in_sec;
}

/** ISO timestamp for when this window resets */
function nextResetAt(window) {
  if (!window) return null;
  const sec = effectiveResetsIn(window);
  return new Date(Date.now() + sec * 1000).toISOString();
}

// ─── Adaptive Reserve Formula ────────────────────────────────────────────────

/**
 * adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
 * burnable_pct         = remaining_pct − adaptive_reserve_pct
 */
function computeBurnable(window, basePct) {
  if (!window) return 0;
  const rem = remaining(window);
  if (rem <= 0) return 0;
  const timeRatio = effectiveResetsIn(window) / window.duration_sec;
  const adaptiveReserve = basePct * timeRatio;
  return Math.max(0, rem - adaptiveReserve);
}

// ─── Tier Routing ────────────────────────────────────────────────────────────

function quotaTier(burnablePct) {
  if (burnablePct <= 0)  return { tier: "zero",    label: "No autonomous work",           allowedPriorities: [],               implementationModel: null };
  if (burnablePct < 5)   return { tier: "minimal", label: "Maintenance only (LM Studio)", allowedPriorities: ["P1"],           implementationModel: "lm-studio" };
  if (burnablePct < 20)  return { tier: "low",     label: "P1 only — Gemini/Haiku",       allowedPriorities: ["P1"],           implementationModel: "gemini-or-haiku" };
  if (burnablePct < 50)  return { tier: "medium",  label: "P1+P2 — Claude Sonnet",        allowedPriorities: ["P1", "P2"],     implementationModel: "claude-sonnet" };
  return                        { tier: "high",    label: "P1+P2+P3 — Claude Sonnet",     allowedPriorities: ["P1", "P2", "P3"], implementationModel: "claude-sonnet" };
}

/**
 * Sustainable burn rate gate for P2/P3.
 * Passes if actual consumption is below the smooth-curve expected level.
 */
function sustainableGatePasses(window) {
  if (!window) return false;
  const windowHours = window.duration_sec / 3600;
  const resets = effectiveResetsIn(window);
  const elapsedHours = (window.duration_sec - resets) / 3600;
  const expectedConsumed = (100 / windowHours) * elapsedHours;
  const actualConsumed = window.pct ?? 0; // pct = used%
  return actualConsumed < expectedConsumed;
}

// ─── Project Routing ─────────────────────────────────────────────────────────

function routeProjects(tier, w7d) {
  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;
  const projects = [];

  for (const [project, priority] of Object.entries(config.project_priorities)) {
    if (!tier.allowedPriorities.includes(priority)) {
      projects.push({ project, priority, decision: "skip", reason: "quota tier" });
      continue;
    }
    if (priority !== "P1" && !sustainableGate) {
      projects.push({ project, priority, decision: "skip", reason: "burn rate above sustainable pace" });
      continue;
    }
    projects.push({ project, priority, decision: "proceed", implementationModel: tier.implementationModel });
  }

  return projects;
}

// ─── Main ────────────────────────────────────────────────────────────────────

async function main() {
  let data;
  try {
    data = await fetchUsage();
  } catch (err) {
    const out = { error: `Failed to reach CodexBar: ${err.message}`, burnable: 0 };
    if (JSON_MODE) console.log(JSON.stringify(out));
    else console.error(out.error);
    process.exit(1);
  }

  const providers = data.providers ?? [];

  // Claude is the constraint for autonomous work routing
  const claudeProvider = providers.find((p) => /claude/i.test(p.name ?? "")) ?? null;
  const { w5h, w7d } = extractWindows(claudeProvider?.windows ?? []);

  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
  const burnable = Math.min(burnable5h, burnable7d);

  if (CHECK_MODE) {
    process.stdout.write(burnable.toFixed(1) + "\n");
    return;
  }

  const tier = quotaTier(burnable);
  const projects = routeProjects(tier, w7d);

  // Reset time for the tighter window (5h if depleted, else whichever is constraining)
  const constraintWindow = burnable5h <= burnable7d ? w5h : w7d;
  const resetAt = nextResetAt(constraintWindow);

  const result = {
    timestamp: new Date().toISOString(),
    providers: providers.map((p) => {
      const { w5h: p5h, w7d: p7d } = extractWindows(p.windows ?? []);
      return {
        name: p.name,
        "5h": p5h ? {
          used_pct: p5h.pct,
          remaining_pct: remaining(p5h),
          resets_in_sec: p5h.resets_in_sec,
        } : null,
        "7d": p7d ? {
          used_pct: p7d.pct,
          remaining_pct: remaining(p7d),
          resets_in_sec: p7d.resets_in_sec,
        } : null,
      };
    }),
    claude: {
      "5h": w5h ? { used_pct: w5h.pct, remaining_pct: remaining(w5h), burnable_pct: +burnable5h.toFixed(1), resets_in_sec: w5h.resets_in_sec } : null,
      "7d": w7d ? { used_pct: w7d.pct, remaining_pct: remaining(w7d), burnable_pct: +burnable7d.toFixed(1), resets_in_sec: w7d.resets_in_sec } : null,
    },
    burnable_pct: +burnable.toFixed(1),
    tier: tier.tier,
    tier_label: tier.label,
    next_reset_at: resetAt,
    projects,
  };

  if (JSON_MODE) {
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  // Human-readable summary
  console.log(`\n=== Portfolio Autopilot — ${result.timestamp} ===\n`);

  for (const p of providers) {
    const { w5h: p5h, w7d: p7d } = extractWindows(p.windows ?? []);
    const rem5 = p5h ? remaining(p5h).toFixed(0) : "?";
    const rem7 = p7d ? remaining(p7d).toFixed(0) : "?";
    const r5 = p5h ? (p5h.resets_in_sec < 0 ? "just reset" : `resets in ${Math.round(p5h.resets_in_sec / 60)}m`) : "";
    const r7 = p7d ? `resets in ${Math.round(p7d.resets_in_sec / 3600)}h` : "";
    console.log(`${p.name.padEnd(8)}  5h: ${rem5.padStart(3)}% remaining (${r5})   7d: ${rem7.padStart(3)}% remaining (${r7})`);
  }

  console.log(`\nClaude burnable — 5h: ${burnable5h.toFixed(1)}%   7d: ${burnable7d.toFixed(1)}%   effective: ${burnable.toFixed(1)}%`);
  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}`);
  if (resetAt) console.log(`Next reset: ${resetAt}`);
  console.log();

  const proceeding = projects.filter((p) => p.decision === "proceed");
  const skipped = projects.filter((p) => p.decision === "skip");

  if (proceeding.length === 0) {
    console.log("No projects eligible to run.");
  } else {
    console.log("Eligible projects:");
    for (const p of proceeding) {
      console.log(`  ✓ ${p.project} (${p.priority}) — model: ${p.implementationModel}`);
    }
  }

  if (skipped.length > 0) {
    console.log("\nSkipped:");
    for (const p of skipped) {
      console.log(`  ✗ ${p.project} (${p.priority}) — ${p.reason}`);
    }
  }

  console.log("");
}

main();


The file /workspace/agent/autopilot.mjs has been updated successfully. (file state is current in your context — no need to Read it back)

/**
 * Portfolio Autopilot — quota reader + routing logic
 *
 * Reads CodexBar usage, computes burnable quota, and outputs a routing decision:
 * which projects are eligible to run and at what model tier.
 *
 * Usage:
 *   node autopilot.mjs              → prints routing summary to stdout
 *   node autopilot.mjs --json       → prints routing decision as JSON
 *   node autopilot.mjs --check      → prints only burnable pct (for scripts)
 *
 * NOTE: CodexBar `pct` field = percent USED (consumed). Remaining = 100 - pct.
 */

// Bypass proxy for local Docker host
process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
  .filter(Boolean)
  .join(",");

import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(
  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
);

const JSON_MODE = process.argv.includes("--json");
const CHECK_MODE = process.argv.includes("--check");

// ─── Quota Reader ────────────────────────────────────────────────────────────

async function fetchUsage() {
  const res = await fetch(config.codexbar_url);
  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
  return res.json();
}

/** pct = percent used; remaining = 100 - pct */
function remaining(window) {
  return 100 - (window?.pct ?? 100);
}

/**
 * Extract matching windows from a provider's windows array.
 * Returns { w5h, w7d } — each has .label, .pct (used%), .resets_in_sec, .duration_sec
 */
function extractWindows(windows = []) {
  const w5h = windows.find((w) => Math.abs(w.duration_sec - 18000) < 300) ?? null;
  const w7d = windows.find((w) => Math.abs(w.duration_sec - 604800) < 600) ?? null;
  return { w5h, w7d };
}

/** resets_in_sec = -1 means the window just reset (no timer established yet); treat as full duration */
function effectiveResetsIn(window) {
  if (!window) return 0;
  return window.resets_in_sec < 0 ? window.duration_sec : window.resets_in_sec;
}

/** ISO timestamp for when this window resets */
function nextResetAt(window) {
  if (!window) return null;
  const sec = effectiveResetsIn(window);
  return new Date(Date.now() + sec * 1000).toISOString();
}

// ─── Adaptive Reserve Formula ────────────────────────────────────────────────

/**
 * adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
 * burnable_pct         = remaining_pct − adaptive_reserve_pct
 */
function computeBurnable(window, basePct) {
  if (!window) return 0;
  const rem = remaining(window);
  if (rem <= 0) return 0;
  const timeRatio = effectiveResetsIn(window) / window.duration_sec;
  const adaptiveReserve = basePct * timeRatio;
  return Math.max(0, rem - adaptiveReserve);
}

// ─── Tier Routing ────────────────────────────────────────────────────────────

function quotaTier(burnablePct) {
  if (burnablePct <= 0)  return { tier: "zero",    label: "No autonomous work",           allowedPriorities: [],               implementationModel: null };
  if (burnablePct < 5)   return { tier: "minimal", label: "Maintenance only (LM Studio)", allowedPriorities: ["P1"],           implementationModel: "lm-studio" };
  if (burnablePct < 20)  return { tier: "low",     label: "P1 only — Gemini/Haiku",       allowedPriorities: ["P1"],           implementationModel: "gemini-or-haiku" };
  if (burnablePct < 50)  return { tier: "medium",  label: "P1+P2 — Claude Sonnet",        allowedPriorities: ["P1", "P2"],     implementationModel: "claude-sonnet" };
  return                        { tier: "high",    label: "P1+P2+P3 — Claude Sonnet",     allowedPriorities: ["P1", "P2", "P3"], implementationModel: "claude-sonnet" };
}

/**
 * Sustainable burn rate gate for P2/P3.
 * Passes if actual consumption is below the smooth-curve expected level.
 */
function sustainableGatePasses(window) {
  if (!window) return false;
  const windowHours = window.duration_sec / 3600;
  const resets = effectiveResetsIn(window);
  const elapsedHours = (window.duration_sec - resets) / 3600;
  const expectedConsumed = (100 / windowHours) * elapsedHours;
  const actualConsumed = window.pct ?? 0; // pct = used%
  return actualConsumed < expectedConsumed;
}

// ─── Project Routing ─────────────────────────────────────────────────────────

function routeProjects(tier, w7d) {
  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;
  const projects = [];

  for (const [project, priority] of Object.entries(config.project_priorities)) {
    if (!tier.allowedPriorities.includes(priority)) {
      projects.push({ project, priority, decision: "skip", reason: "quota tier" });
      continue;
    }
    if (priority !== "P1" && !sustainableGate) {
      projects.push({ project, priority, decision: "skip", reason: "burn rate above sustainable pace" });
      continue;
    }
    projects.push({ project, priority, decision: "proceed", implementationModel: tier.implementationModel });
  }

  return projects;
}

// ─── Main ────────────────────────────────────────────────────────────────────

async function main() {
  let data;
  try {
    data = await fetchUsage();
  } catch (err) {
    const out = { error: `Failed to reach CodexBar: ${err.message}`, burnable: 0 };
    if (JSON_MODE) console.log(JSON.stringify(out));
    else console.error(out.error);
    process.exit(1);
  }

  const providers = data.providers ?? [];

  // Claude is the constraint for autonomous work routing
  const claudeProvider = providers.find((p) => /claude/i.test(p.name ?? "")) ?? null;
  const { w5h, w7d } = extractWindows(claudeProvider?.windows ?? []);

  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
  const burnable = Math.min(burnable5h, burnable7d);

  if (CHECK_MODE) {
    process.stdout.write(burnable.toFixed(1) + "\n");
    return;
  }

  const tier = quotaTier(burnable);
  const projects = routeProjects(tier, w7d);

  // Reset time for the tighter window (5h if depleted, else whichever is constraining)
  const constraintWindow = burnable5h <= burnable7d ? w5h : w7d;
  const resetAt = nextResetAt(constraintWindow);

  const result = {
    timestamp: new Date().toISOString(),
    providers: providers.map((p) => {
      const { w5h: p5h, w7d: p7d } = extractWindows(p.windows ?? []);
      return {
        name: p.name,
        "5h": p5h ? {
          used_pct: p5h.pct,
          remaining_pct: remaining(p5h),
          resets_in_sec: p5h.resets_in_sec,
        } : null,
        "7d": p7d ? {
          used_pct: p7d.pct,
          remaining_pct: remaining(p7d),
          resets_in_sec: p7d.resets_in_sec,
        } : null,
      };
    }),
    claude: {
      "5h": w5h ? { used_pct: w5h.pct, remaining_pct: remaining(w5h), burnable_pct: +burnable5h.toFixed(1), resets_in_sec: w5h.resets_in_sec } : null,
      "7d": w7d ? { used_pct: w7d.pct, remaining_pct: remaining(w7d), burnable_pct: +burnable7d.toFixed(1), resets_in_sec: w7d.resets_in_sec } : null,
    },
    burnable_pct: +burnable.toFixed(1),
    tier: tier.tier,
    tier_label: tier.label,
    next_reset_at: resetAt,
    projects,
  };

  if (JSON_MODE) {
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  // Human-readable summary
  console.log(`\n=== Portfolio Autopilot — ${result.timestamp} ===\n`);

  for (const p of providers) {
    const { w5h: p5h, w7d: p7d } = extractWindows(p.windows ?? []);
    const rem5 = p5h ? remaining(p5h).toFixed(0) : "?";
    const rem7 = p7d ? remaining(p7d).toFixed(0) : "?";
    const r5 = p5h ? (p5h.resets_in_sec < 0 ? "just reset" : `resets in ${Math.round(p5h.resets_in_sec / 60)}m`) : "";
    const r7 = p7d ? `resets in ${Math.round(p7d.resets_in_sec / 3600)}h` : "";
    console.log(`${p.name.padEnd(8)}  5h: ${rem5.padStart(3)}% remaining (${r5})   7d: ${rem7.padStart(3)}% remaining (${r7})`);
  }

  console.log(`\nClaude burnable — 5h: ${burnable5h.toFixed(1)}%   7d: ${burnable7d.toFixed(1)}%   effective: ${burnable.toFixed(1)}%`);
  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}`);
  if (resetAt) console.log(`Next reset: ${resetAt}`);
  console.log();

  const proceeding = projects.filter((p) => p.decision === "proceed");
  const skipped = projects.filter((p) => p.decision === "skip");

  if (proceeding.length === 0) {
    console.log("No projects eligible to run.");
  } else {
    console.log("Eligible projects:");
    for (const p of proceeding) {
      console.log(`  ✓ ${p.project} (${p.priority}) — model: ${p.implementationModel}`);
    }
  }

  if (skipped.length > 0) {
    console.log("\nSkipped:");
    for (const p of skipped) {
      console.log(`  ✗ ${p.project} (${p.priority}) — ${p.reason}`);
    }
  }

  console.log("");
}

main();

*   node autopilot.mjs              → prints routing summary to stdout

+ * NOTE: CodexBar `pct` field = percent USED (consumed). Remaining = 100 - pct.

+/** pct = percent used; remaining = 100 - pct */

- * Find the 5h and 7d windows from CodexBar response.

- * Returns { pct, resets_in_sec, duration_sec } for each.

+ * Extract matching windows from a provider's windows array.

+ * Returns { w5h, w7d } — each has .label, .pct (used%), .resets_in_sec, .duration_sec

-  // CodexBar returns providers array; find Claude provider, then its windows

-  const providers = data.providers ?? data;

-  const claudeProvider = Array.isArray(providers)

-    ? providers.find((p) => /claude/i.test(p.name ?? p.label ?? ""))

+function extractWindows(windows = []) {

+  const w5h = windows.find((w) => Math.abs(w.duration_sec - 18000) < 300) ?? null;

+  const w7d = windows.find((w) => Math.abs(w.duration_sec - 604800) < 600) ?? null;

-  const windows = claudeProvider?.windows ?? data.windows ?? [];

+/** resets_in_sec = -1 means the window just reset (no timer established yet); treat as full duration */

+  return window.resets_in_sec < 0 ? window.duration_sec : window.resets_in_sec;

-    // Fallback: pick shortest and longest available windows

-    const sorted = [...windows].sort((a, b) => a.duration_sec - b.duration_sec);

-      w7d: sorted[sorted.length - 1] ?? null,

+/** ISO timestamp for when this window resets */

+  const sec = effectiveResetsIn(window);

+  return new Date(Date.now() + sec * 1000).toISOString();

- * burnable_pct = remaining_pct − adaptive_reserve_pct

+ * burnable_pct         = remaining_pct − adaptive_reserve_pct

function computeBurnable(window, basePct) {

-  // resets_in_sec <= 0 means window expired/resetting — treat as full timeRatio approaching 0

-  const resets = Math.max(0, window.resets_in_sec);

-  const timeRatio = resets / window.duration_sec;

+  const timeRatio = effectiveResetsIn(window) / window.duration_sec;

const adaptiveReserve = basePct * timeRatio;

-  return Math.max(0, remaining - adaptiveReserve);

+  return Math.max(0, rem - adaptiveReserve);

- * Given burnable %, return the quota tier label and which project priorities run.

-    return { tier: "zero", label: "No autonomous work", allowedPriorities: [] };

-      label: "Maintenance only (LM Studio)",

-      implementationModel: "lm-studio",

-      implementationModel: "gemini-or-haiku",

-      implementationModel: "claude-sonnet",

-    allowedPriorities: ["P1", "P2", "P3"],

-    implementationModel: "claude-sonnet",

+  if (burnablePct <= 0)  return { tier: "zero",    label: "No autonomous work",           allowedPriorities: [],               implementationModel: null };

+  if (burnablePct < 5)   return { tier: "minimal", label: "Maintenance only (LM Studio)", allowedPriorities: ["P1"],           implementationModel: "lm-studio" };

+  if (burnablePct < 20)  return { tier: "low",     label: "P1 only — Gemini/Haiku",       allowedPriorities: ["P1"],           implementationModel: "gemini-or-haiku" };

+  if (burnablePct < 50)  return { tier: "medium",  label: "P1+P2 — Claude Sonnet",        allowedPriorities: ["P1", "P2"],     implementationModel: "claude-sonnet" };

+  return                        { tier: "high",    label: "P1+P2+P3 — Claude Sonnet",     allowedPriorities: ["P1", "P2", "P3"], implementationModel: "claude-sonnet" };

- * Returns true if current burn rate is ahead of the smooth curve.

+ * Passes if actual consumption is below the smooth-curve expected level.

const windowHours = window.duration_sec / 3600;

-  const sustainablePctPerHour = 100 / windowHours;

-  const elapsedHours = (window.duration_sec - window.resets_in_sec) / 3600;

-  const expectedConsumed = sustainablePctPerHour * elapsedHours;

-  const actualConsumed = 100 - (window.pct ?? 100);

-  // Passes if actual consumption is BELOW expected (ahead of smooth curve)

+  const resets = effectiveResetsIn(window);

+  const elapsedHours = (window.duration_sec - resets) / 3600;

+  const expectedConsumed = (100 / windowHours) * elapsedHours;

+  const actualConsumed = window.pct ?? 0; // pct = used%

return actualConsumed < expectedConsumed;

-function routeProjects(tier, burnablePct, w7d) {

-    const allowed = tier.allowedPriorities.includes(priority);

+    if (!tier.allowedPriorities.includes(priority)) {

-    // P2/P3 require sustainable gate (P1 bypasses)

if (priority !== "P1" && !sustainableGate) {

-        reason: "burn rate above sustainable pace",

+      projects.push({ project, priority, decision: "skip", reason: "burn rate above sustainable pace" });

-      implementationModel: tier.implementationModel,

+    projects.push({ project, priority, decision: "proceed", implementationModel: tier.implementationModel });

-  const { w5h, w7d, raw } = extractWindows(data);

+  const providers = data.providers ?? [];

+  // Claude is the constraint for autonomous work routing

+  const claudeProvider = providers.find((p) => /claude/i.test(p.name ?? "")) ?? null;

+  const { w5h, w7d } = extractWindows(claudeProvider?.windows ?? []);

const burnable = Math.min(burnable5h, burnable7d);

+    process.stdout.write(burnable.toFixed(1) + "\n");

-  const projects = routeProjects(tier, burnable, w7d);

+  const projects = routeProjects(tier, w7d);

+  // Reset time for the tighter window (5h if depleted, else whichever is constraining)

+  const constraintWindow = burnable5h <= burnable7d ? w5h : w7d;

+  const resetAt = nextResetAt(constraintWindow);

-            resets_in_sec: w5h.resets_in_sec,

-            burnable_pct: +burnable5h.toFixed(1),

-            resets_in_sec: w7d.resets_in_sec,

-            burnable_pct: +burnable7d.toFixed(1),

+      const { w5h: p5h, w7d: p7d } = extractWindows(p.windows ?? []);

+          remaining_pct: remaining(p5h),

+          resets_in_sec: p5h.resets_in_sec,

+          remaining_pct: remaining(p7d),

+          resets_in_sec: p7d.resets_in_sec,

+      "5h": w5h ? { used_pct: w5h.pct, remaining_pct: remaining(w5h), burnable_pct: +burnable5h.toFixed(1), resets_in_sec: w5h.resets_in_sec } : null,

+      "7d": w7d ? { used_pct: w7d.pct, remaining_pct: remaining(w7d), burnable_pct: +burnable7d.toFixed(1), resets_in_sec: w7d.resets_in_sec } : null,

-    const hrs5 = Math.round(w5h.resets_in_sec / 60);

-      `5h window:  ${w5h.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${hrs5}m)  →  burnable: ${burnable5h.toFixed(1)}%`

+    const rem5 = p5h ? remaining(p5h).toFixed(0) : "?";

+    const rem7 = p7d ? remaining(p7d).toFixed(0) : "?";

+    const r5 = p5h ? (p5h.resets_in_sec < 0 ? "just reset" : `resets in ${Math.round(p5h.resets_in_sec / 60)}m`) : "";

+    const r7 = p7d ? `resets in ${Math.round(p7d.resets_in_sec / 3600)}h` : "";

+    console.log(`${p.name.padEnd(8)}  5h: ${rem5.padStart(3)}% remaining (${r5})   7d: ${rem7.padStart(3)}% remaining (${r7})`);

-    const days7 = Math.round(w7d.resets_in_sec / 3600);

-      `7d window:  ${w7d.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${days7}h)  →  burnable: ${burnable7d.toFixed(1)}%`

-  console.log(`\nEffective burnable: ${burnable.toFixed(1)}%`);

-  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}\n`);

+  console.log(`\nClaude burnable — 5h: ${burnable5h.toFixed(1)}%   7d: ${burnable7d.toFixed(1)}%   effective: ${burnable.toFixed(1)}%`);

+  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}`);

+  if (resetAt) console.log(`Next reset: ${resetAt}`);

/**
 * Portfolio Autopilot — quota reader + routing logic
 *
 * Reads CodexBar usage, computes burnable quota, and outputs a routing decision:
 * which projects are eligible to run and at what model tier.
 *
 * Usage:
 *   node autopilot.mjs              → prints routing summary to stdout
 *   node autopilot.mjs --json       → prints routing decision as JSON
 *   node autopilot.mjs --check      → prints only burnable pct (for scripts)
 */

// Bypass proxy for local Docker host
process.env.NO_PROXY = [process.env.NO_PROXY, "host.docker.internal"]
  .filter(Boolean)
  .join(",");

import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(
  readFileSync(join(__dirname, "autopilot-config.json"), "utf8")
);

const JSON_MODE = process.argv.includes("--json");
const CHECK_MODE = process.argv.includes("--check");

// ─── Quota Reader ────────────────────────────────────────────────────────────

async function fetchUsage() {
  const res = await fetch(config.codexbar_url);
  if (!res.ok) throw new Error(`CodexBar returned ${res.status}`);
  return res.json();
}

/**
 * Find the 5h and 7d windows from CodexBar response.
 * Returns { pct, resets_in_sec, duration_sec } for each.
 */
function extractWindows(data) {
  // CodexBar returns providers array; find Claude provider, then its windows
  const providers = data.providers ?? data;
  const claudeProvider = Array.isArray(providers)
    ? providers.find((p) => /claude/i.test(p.name ?? p.label ?? ""))
    : null;

  const windows = claudeProvider?.windows ?? data.windows ?? [];

  const w5h = windows.find(
    (w) => w.duration_sec <= 5 * 3600 + 60 && w.duration_sec >= 5 * 3600 - 60
  );
  const w7d = windows.find(
    (w) =>
      w.duration_sec <= 7 * 86400 + 300 && w.duration_sec >= 7 * 86400 - 300
  );

  if (!w5h || !w7d) {
    // Fallback: pick shortest and longest available windows
    const sorted = [...windows].sort((a, b) => a.duration_sec - b.duration_sec);
    return {
      w5h: sorted[0] ?? null,
      w7d: sorted[sorted.length - 1] ?? null,
      raw: windows,
    };
  }

  return { w5h, w7d, raw: windows };
}

// ─── Adaptive Reserve Formula ────────────────────────────────────────────────

/**
 * adaptive_reserve_pct = base_reserve_pct × (resets_in_sec / window_duration_sec)
 * burnable_pct = remaining_pct − adaptive_reserve_pct
 */
function computeBurnable(window, basePct) {
  if (!window) return 0;
  const remaining = window.pct ?? 100;
  if (remaining <= 0) return 0;
  // resets_in_sec <= 0 means window expired/resetting — treat as full timeRatio approaching 0
  const resets = Math.max(0, window.resets_in_sec);
  const timeRatio = resets / window.duration_sec;
  const adaptiveReserve = basePct * timeRatio;
  return Math.max(0, remaining - adaptiveReserve);
}

// ─── Tier Routing ────────────────────────────────────────────────────────────

/**
 * Given burnable %, return the quota tier label and which project priorities run.
 */
function quotaTier(burnablePct) {
  if (burnablePct <= 0) {
    return { tier: "zero", label: "No autonomous work", allowedPriorities: [] };
  }
  if (burnablePct < 5) {
    return {
      tier: "minimal",
      label: "Maintenance only (LM Studio)",
      allowedPriorities: ["P1"],
      implementationModel: "lm-studio",
    };
  }
  if (burnablePct < 20) {
    return {
      tier: "low",
      label: "P1 only — Gemini/Haiku",
      allowedPriorities: ["P1"],
      implementationModel: "gemini-or-haiku",
    };
  }
  if (burnablePct < 50) {
    return {
      tier: "medium",
      label: "P1+P2 — Claude Sonnet",
      allowedPriorities: ["P1", "P2"],
      implementationModel: "claude-sonnet",
    };
  }
  return {
    tier: "high",
    label: "P1+P2+P3 — Claude Sonnet",
    allowedPriorities: ["P1", "P2", "P3"],
    implementationModel: "claude-sonnet",
  };
}

/**
 * Sustainable burn rate gate for P2/P3.
 * Returns true if current burn rate is ahead of the smooth curve.
 */
function sustainableGatePasses(window) {
  if (!window) return false;
  const windowHours = window.duration_sec / 3600;
  const sustainablePctPerHour = 100 / windowHours;
  const elapsedHours = (window.duration_sec - window.resets_in_sec) / 3600;
  const expectedConsumed = sustainablePctPerHour * elapsedHours;
  const actualConsumed = 100 - (window.pct ?? 100);
  // Passes if actual consumption is BELOW expected (ahead of smooth curve)
  return actualConsumed < expectedConsumed;
}

// ─── Project Routing ─────────────────────────────────────────────────────────

function routeProjects(tier, burnablePct, w7d) {
  const sustainableGate = w7d ? sustainableGatePasses(w7d) : true;
  const projects = [];

  for (const [project, priority] of Object.entries(config.project_priorities)) {
    const allowed = tier.allowedPriorities.includes(priority);
    if (!allowed) {
      projects.push({ project, priority, decision: "skip", reason: "quota tier" });
      continue;
    }

    // P2/P3 require sustainable gate (P1 bypasses)
    if (priority !== "P1" && !sustainableGate) {
      projects.push({
        project,
        priority,
        decision: "skip",
        reason: "burn rate above sustainable pace",
      });
      continue;
    }

    projects.push({
      project,
      priority,
      decision: "proceed",
      implementationModel: tier.implementationModel,
    });
  }

  return projects;
}

// ─── Main ────────────────────────────────────────────────────────────────────

async function main() {
  let data;
  try {
    data = await fetchUsage();
  } catch (err) {
    const out = { error: `Failed to reach CodexBar: ${err.message}`, burnable: 0 };
    if (JSON_MODE) console.log(JSON.stringify(out));
    else console.error(out.error);
    process.exit(1);
  }

  const { w5h, w7d, raw } = extractWindows(data);

  const burnable5h = computeBurnable(w5h, config.reserve_5h_pct);
  const burnable7d = computeBurnable(w7d, config.reserve_7d_pct);
  const burnable = Math.min(burnable5h, burnable7d);

  if (CHECK_MODE) {
    console.log(burnable.toFixed(1));
    return;
  }

  const tier = quotaTier(burnable);
  const projects = routeProjects(tier, burnable, w7d);

  const result = {
    timestamp: new Date().toISOString(),
    windows: {
      "5h": w5h
        ? {
            remaining_pct: w5h.pct,
            resets_in_sec: w5h.resets_in_sec,
            burnable_pct: +burnable5h.toFixed(1),
          }
        : null,
      "7d": w7d
        ? {
            remaining_pct: w7d.pct,
            resets_in_sec: w7d.resets_in_sec,
            burnable_pct: +burnable7d.toFixed(1),
          }
        : null,
    },
    burnable_pct: +burnable.toFixed(1),
    tier: tier.tier,
    tier_label: tier.label,
    projects,
  };

  if (JSON_MODE) {
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  // Human-readable summary
  console.log(`\n=== Portfolio Autopilot — ${result.timestamp} ===\n`);

  if (w5h) {
    const hrs5 = Math.round(w5h.resets_in_sec / 60);
    console.log(
      `5h window:  ${w5h.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${hrs5}m)  →  burnable: ${burnable5h.toFixed(1)}%`
    );
  }
  if (w7d) {
    const days7 = Math.round(w7d.resets_in_sec / 3600);
    console.log(
      `7d window:  ${w7d.pct?.toFixed(1) ?? "?"}% remaining  (resets in ${days7}h)  →  burnable: ${burnable7d.toFixed(1)}%`
    );
  }

  console.log(`\nEffective burnable: ${burnable.toFixed(1)}%`);
  console.log(`Quota tier: ${tier.tier.toUpperCase()} — ${tier.label}\n`);

  const proceeding = projects.filter((p) => p.decision === "proceed");
  const skipped = projects.filter((p) => p.decision === "skip");

  if (proceeding.length === 0) {
    console.log("No projects eligible to run.");
  } else {
    console.log("Eligible projects:");
    for (const p of proceeding) {
      console.log(`  ✓ ${p.project} (${p.priority}) — model: ${p.implementationModel}`);
    }
  }

  if (skipped.length > 0) {
    console.log("\nSkipped:");
    for (const p of skipped) {
      console.log(`  ✗ ${p.project} (${p.priority}) — ${p.reason}`);
    }
  }

  console.log("");
}

main();