Write

/**
 * Per-rung quota ledger (Phase 2, docs/provider-routing-plan.md §5.2).
 *
 * Persists quota state for each REST rung between container restarts so that
 * exhausted rungs are skipped silently rather than probed and failing loudly.
 *
 * State machine per rung:
 *   ok ──quota-error──► exhausted (until exhaustedUntil timestamp)
 *   exhausted ──past exhaustedUntil──► ok (auto-expire on next read)
 *   ok/exhausted ──success──► ok (markRevived)
 *
 * Phase 2 scope: JSON file ledger + quota error detection + silent skip.
 * Deferred: CodeBar-ESP32 5h/7d integration, scheduled re-probe tasks.
 */

import fs from 'fs';
import path from 'path';

export interface LedgerEntry {
  state: 'ok' | 'exhausted';
  exhaustedUntil?: number; // ms since epoch; absent when state is 'ok'
  lastError?: string;
  lastUpdated: number; // ms since epoch
}

export type QuotaLedger = Record<string, LedgerEntry>;

const BACKOFF_MS = 30 * 60 * 1000; // 30 min default backoff on quota error

function ledgerPath(): string {
  const dir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
  return path.join(dir, 'quota-ledger.json');
}

export function loadLedger(): QuotaLedger {
  try {
    const raw = fs.readFileSync(ledgerPath(), 'utf8');
    return JSON.parse(raw) as QuotaLedger;
  } catch {
    return {};
  }
}

function saveLedger(ledger: QuotaLedger): void {
  const file = ledgerPath();
  try {
    fs.mkdirSync(path.dirname(file), { recursive: true });
    fs.writeFileSync(file, JSON.stringify(ledger, null, 2) + '\n');
  } catch {
    // Best-effort — a write failure must never break the routing path.
  }
}

/**
 * Returns true if the named rung is currently quota-exhausted.
 * Auto-expires stale entries: if exhaustedUntil has passed, the entry is
 * treated as 'ok' and pruned from the ledger.
 */
export function isExhausted(rungName: string, ledger?: QuotaLedger): boolean {
  const l = ledger ?? loadLedger();
  const entry = l[rungName];
  if (!entry || entry.state !== 'exhausted') return false;
  if (entry.exhaustedUntil === undefined || Date.now() >= entry.exhaustedUntil) {
    // Auto-expire: quietly clean up and treat as ok.
    delete l[rungName];
    saveLedger(l);
    return false;
  }
  return true;
}

/** Mark a rung as quota-exhausted for backoffMs (default 30 min). */
export function markExhausted(rungName: string, lastError: string, backoffMs = BACKOFF_MS): void {
  const l = loadLedger();
  l[rungName] = {
    state: 'exhausted',
    exhaustedUntil: Date.now() + backoffMs,
    lastError: lastError.slice(0, 300),
    lastUpdated: Date.now(),
  };
  saveLedger(l);
}

/** Mark a rung as healthy (call after a successful response). */
export function markRevived(rungName: string): void {
  const l = loadLedger();
  if (!l[rungName] || l[rungName].state === 'ok') return; // already ok, no write needed
  l[rungName] = { state: 'ok', lastUpdated: Date.now() };
  saveLedger(l);
}

/**
 * Heuristic: does this error message look like a quota / rate-limit failure
 * (as opposed to a connectivity or protocol error)?
 *
 * Patterns observed in production (docs/observations.md):
 *   - "Credit balance is too low" (Claude billing)
 *   - "rate_limit" / "429" (HTTP layer)
 *   - "out of extra usage" (Claude Pro cap message)
 *   - "RESOURCE_EXHAUSTED" (Google/Antigravity)
 *   - "quota exceeded" / "quota_exceeded"
 */
export function isQuotaError(message: string): boolean {
  const lower = message.toLowerCase();
  return (
    lower.includes('credit balance') ||
    lower.includes('rate_limit') ||
    lower.includes('rate limit') ||
    lower.includes('429') ||
    lower.includes('out of extra usage') ||
    lower.includes('resource_exhausted') ||
    lower.includes('quota exceeded') ||
    lower.includes('quota_exceeded') ||
    lower.includes('billing') ||
    lower.includes('subscription') && lower.includes('limit')
  );
}


File created successfully at: /workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/quota-ledger.ts (file state is current in your context — no need to Read it back)

/**
 * Per-rung quota ledger (Phase 2, docs/provider-routing-plan.md §5.2).
 *
 * Persists quota state for each REST rung between container restarts so that
 * exhausted rungs are skipped silently rather than probed and failing loudly.
 *
 * State machine per rung:
 *   ok ──quota-error──► exhausted (until exhaustedUntil timestamp)
 *   exhausted ──past exhaustedUntil──► ok (auto-expire on next read)
 *   ok/exhausted ──success──► ok (markRevived)
 *
 * Phase 2 scope: JSON file ledger + quota error detection + silent skip.
 * Deferred: CodeBar-ESP32 5h/7d integration, scheduled re-probe tasks.
 */

import fs from 'fs';
import path from 'path';

export interface LedgerEntry {
  state: 'ok' | 'exhausted';
  exhaustedUntil?: number; // ms since epoch; absent when state is 'ok'
  lastError?: string;
  lastUpdated: number; // ms since epoch
}

export type QuotaLedger = Record<string, LedgerEntry>;

const BACKOFF_MS = 30 * 60 * 1000; // 30 min default backoff on quota error

function ledgerPath(): string {
  const dir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
  return path.join(dir, 'quota-ledger.json');
}

export function loadLedger(): QuotaLedger {
  try {
    const raw = fs.readFileSync(ledgerPath(), 'utf8');
    return JSON.parse(raw) as QuotaLedger;
  } catch {
    return {};
  }
}

function saveLedger(ledger: QuotaLedger): void {
  const file = ledgerPath();
  try {
    fs.mkdirSync(path.dirname(file), { recursive: true });
    fs.writeFileSync(file, JSON.stringify(ledger, null, 2) + '\n');
  } catch {
    // Best-effort — a write failure must never break the routing path.
  }
}

/**
 * Returns true if the named rung is currently quota-exhausted.
 * Auto-expires stale entries: if exhaustedUntil has passed, the entry is
 * treated as 'ok' and pruned from the ledger.
 */
export function isExhausted(rungName: string, ledger?: QuotaLedger): boolean {
  const l = ledger ?? loadLedger();
  const entry = l[rungName];
  if (!entry || entry.state !== 'exhausted') return false;
  if (entry.exhaustedUntil === undefined || Date.now() >= entry.exhaustedUntil) {
    // Auto-expire: quietly clean up and treat as ok.
    delete l[rungName];
    saveLedger(l);
    return false;
  }
  return true;
}

/** Mark a rung as quota-exhausted for backoffMs (default 30 min). */
export function markExhausted(rungName: string, lastError: string, backoffMs = BACKOFF_MS): void {
  const l = loadLedger();
  l[rungName] = {
    state: 'exhausted',
    exhaustedUntil: Date.now() + backoffMs,
    lastError: lastError.slice(0, 300),
    lastUpdated: Date.now(),
  };
  saveLedger(l);
}

/** Mark a rung as healthy (call after a successful response). */
export function markRevived(rungName: string): void {
  const l = loadLedger();
  if (!l[rungName] || l[rungName].state === 'ok') return; // already ok, no write needed
  l[rungName] = { state: 'ok', lastUpdated: Date.now() };
  saveLedger(l);
}

/**
 * Heuristic: does this error message look like a quota / rate-limit failure
 * (as opposed to a connectivity or protocol error)?
 *
 * Patterns observed in production (docs/observations.md):
 *   - "Credit balance is too low" (Claude billing)
 *   - "rate_limit" / "429" (HTTP layer)
 *   - "out of extra usage" (Claude Pro cap message)
 *   - "RESOURCE_EXHAUSTED" (Google/Antigravity)
 *   - "quota exceeded" / "quota_exceeded"
 */
export function isQuotaError(message: string): boolean {
  const lower = message.toLowerCase();
  return (
    lower.includes('credit balance') ||
    lower.includes('rate_limit') ||
    lower.includes('rate limit') ||
    lower.includes('429') ||
    lower.includes('out of extra usage') ||
    lower.includes('resource_exhausted') ||
    lower.includes('quota exceeded') ||
    lower.includes('quota_exceeded') ||
    lower.includes('billing') ||
    lower.includes('subscription') && lower.includes('limit')
  );
}


Reset autopilot after quota window reset


Now integrate into the routing loop in `gemini-openai-claude.ts`:


Edit

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

import { ClaudeProvider } from './claude.js';
import { registerProvider } from './provider-registry.js';
import { isExhausted, isQuotaError, markExhausted, markRevived } from './quota-ledger.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';


The file /workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/gemini-openai-claude.ts has been updated successfully. (file state is current in your context — no need to Read it back)

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

import { ClaudeProvider } from './claude.js';
import { registerProvider } from './provider-registry.js';
import { isExhausted, isQuotaError, markExhausted, markRevived } from './quota-ledger.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';

+import { isExhausted, isQuotaError, markExhausted, markRevived } from './quota-ledger.js';


Now update the rung loop to use the ledger:


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

      // Filter quota-exhausted rungs before we start so they are skipped
      // silently (no provider_notice spam). The ledger auto-expires entries
      // whose backoff window has elapsed.
      const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);

      for (let i = 0; i < activeRungs.length; i++) {
        const rung = activeRungs[i];
        const next = activeRungs[i + 1];
        try {
          yield* rung.run(!next);
          // Successful reply — revive any prior exhausted state for this rung.
          if (!rung.isClaude) markRevived(rung.name);
          return;
        } catch (err) {
          if (aborted) return;
          if (rung.isClaude && claudeDelivered) throw err;
          const errMsg = err instanceof Error ? err.message : String(err);
          log(`${rung.name} failed: ${errMsg}`);
          // Quota errors get a 30-min backoff so the next turn skips silently.
          if (!rung.isClaude && isQuotaError(errMsg)) {
            markExhausted(rung.name, errMsg);
            log(`${rung.name} marked exhausted in quota ledger (30 min backoff)`);
          }
          if (!next) throw err;
          yield {
            type: 'provider_notice',
            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
          };
        }
      }


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

      // Filter quota-exhausted rungs before we start so they are skipped
      // silently (no provider_notice spam). The ledger auto-expires entries
      // whose backoff window has elapsed.
      const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);

      for (let i = 0; i < activeRungs.length; i++) {
        const rung = activeRungs[i];
        const next = activeRungs[i + 1];
        try {
          yield* rung.run(!next);
          // Successful reply — revive any prior exhausted state for this rung.
          if (!rung.isClaude) markRevived(rung.name);
          return;
        } catch (err) {
          if (aborted) return;
          if (rung.isClaude && claudeDelivered) throw err;
          const errMsg = err instanceof Error ? err.message : String(err);
          log(`${rung.name} failed: ${errMsg}`);
          // Quota errors get a 30-min backoff so the next turn skips silently.
          if (!rung.isClaude && isQuotaError(errMsg)) {
            markExhausted(rung.name, errMsg);
            log(`${rung.name} marked exhausted in quota ledger (30 min backoff)`);
          }
          if (!next) throw err;
          yield {
            type: 'provider_notice',
            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
          };
        }
      }

{ name: 'Anthropic', run: claudeEvents, isClaude: true },

-      for (let i = 0; i < rungs.length; i++) {

+      // Filter quota-exhausted rungs before we start so they are skipped

+      // silently (no provider_notice spam). The ledger auto-expires entries

+      // whose backoff window has elapsed.

+      const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);

+      for (let i = 0; i < activeRungs.length; i++) {

+        const next = activeRungs[i + 1];

+          // Successful reply — revive any prior exhausted state for this rung.

+          if (!rung.isClaude) markRevived(rung.name);

-          log(`${rung.name} failed: ${err instanceof Error ? err.message : String(err)}`);

+          const errMsg = err instanceof Error ? err.message : String(err);

+          log(`${rung.name} failed: ${errMsg}`);

+          // Quota errors get a 30-min backoff so the next turn skips silently.

+          if (!rung.isClaude && isQuotaError(errMsg)) {

+            markExhausted(rung.name, errMsg);

+            log(`${rung.name} marked exhausted in quota ledger (30 min backoff)`);


Wait, I have a bug in the `activeRungs` filter. Let me look at it:

```typescript
const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);
```

Due to operator precedence, this is:
```
(!r.isClaude && !isExhausted(r.name)) || r.isClaude
```

Which means:
- If `r.isClaude`: `true` → keep (correct, Claude always included)
- If `!r.isClaude && !isExhausted(r.name)`: `true` → keep (correct, REST rung not exhausted)
- If `!r.isClaude && isExhausted(r.name)`: `false` → skip (correct)

Actually, let me trace through this more carefully with the operator precedence. For Claude rungs, the condition evaluates to true. For REST rungs that aren't exhausted, it's also true. For exhausted REST rungs, it's false. So the logic is working as intended.

I can simplify this though—instead of the complex boolean expression, I can just filter for Claude rungs or non-exhausted rungs directly. Let me refactor that to be clearer.


That filter expression has a precedence issue — let me simplify it:


const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);

      // Claude is never skipped (no quota ledger for the subscription rung).
      const activeRungs = rungs.filter((r) => r.isClaude || !isExhausted(r.name));


      // Claude is never skipped (no quota ledger for the subscription rung).
      const activeRungs = rungs.filter((r) => r.isClaude || !isExhausted(r.name));

// Filter quota-exhausted rungs before we start so they are skipped

// silently (no provider_notice spam). The ledger auto-expires entries

-      const activeRungs = rungs.filter((r) => !r.isClaude && !isExhausted(r.name) || r.isClaude);

+      // Claude is never skipped (no quota ledger for the subscription rung).

+      const activeRungs = rungs.filter((r) => r.isClaude || !isExhausted(r.name));

for (let i = 0; i < activeRungs.length; i++) {


Now write the test file for the quota ledger:


Write

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { isExhausted, isQuotaError, markExhausted, markRevived, loadLedger } from './quota-ledger.js';

const testDir = path.join(import.meta.dirname || __dirname, '../../.tmp-quota-ledger-test');

beforeEach(() => {
  fs.mkdirSync(testDir, { recursive: true });
  process.env.PROVIDER_HISTORY_DIR = testDir;
});

afterEach(() => {
  try {
    fs.rmSync(testDir, { recursive: true, force: true });
  } catch {
    // ignore
  }
  delete process.env.PROVIDER_HISTORY_DIR;
});

describe('quota-ledger state machine', () => {
  it('1. fresh ledger — no rung is exhausted', () => {
    expect(isExhausted('Codex')).toBe(false);
    expect(isExhausted('LM Studio')).toBe(false);
  });

  it('2. markExhausted → isExhausted returns true within backoff window', () => {
    markExhausted('Codex', 'rate_limit', 60_000);
    expect(isExhausted('Codex')).toBe(true);
    expect(isExhausted('LM Studio')).toBe(false); // other rungs unaffected
  });

  it('3. markRevived clears exhausted state', () => {
    markExhausted('Codex', 'rate_limit', 60_000);
    expect(isExhausted('Codex')).toBe(true);
    markRevived('Codex');
    expect(isExhausted('Codex')).toBe(false);
  });

  it('4. expired backoff → isExhausted auto-expires and returns false', () => {
    markExhausted('LM Studio', 'rate limit hit', -1); // already expired (backoffMs = -1)
    expect(isExhausted('LM Studio')).toBe(false);
    // Entry should be pruned from ledger
    expect(loadLedger()['LM Studio']).toBeUndefined();
  });

  it('5. ledger persists across loadLedger calls', () => {
    markExhausted('Codex', 'credit balance too low', 60_000);
    const l = loadLedger();
    expect(l['Codex']?.state).toBe('exhausted');
    expect(l['Codex']?.lastError).toBe('credit balance too low');
  });

  it('6. markRevived on already-ok rung does not write (no-op)', () => {
    const ledgerFile = path.join(testDir, 'quota-ledger.json');
    markRevived('Codex'); // no prior exhausted state
    expect(fs.existsSync(ledgerFile)).toBe(false); // no write happened
  });
});

describe('isQuotaError detection', () => {
  it('7. detects Claude Pro credit exhaustion', () => {
    expect(isQuotaError('Credit balance is too low to run this request')).toBe(true);
  });

  it('8. detects HTTP 429', () => {
    expect(isQuotaError('LM Studio request failed (429 Too Many Requests)')).toBe(true);
  });

  it('9. detects rate_limit keyword', () => {
    expect(isQuotaError('You have exceeded your rate_limit for this model')).toBe(true);
  });

  it('10. detects out of extra usage (Claude Pro cap)', () => {
    expect(isQuotaError('You are out of extra usage for this billing period')).toBe(true);
  });

  it('11. detects RESOURCE_EXHAUSTED (Google/Antigravity)', () => {
    expect(isQuotaError('RESOURCE_EXHAUSTED: quota limit reached')).toBe(true);
  });

  it('12. does NOT flag connectivity errors as quota', () => {
    expect(isQuotaError('connection refused')).toBe(false);
    expect(isQuotaError('ECONNREFUSED')).toBe(false);
    expect(isQuotaError('fetch failed')).toBe(false);
    expect(isQuotaError('LM Studio returned no message content')).toBe(false);
  });
});


File created successfully at: /workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/quota-ledger.test.ts (file state is current in your context — no need to Read it back)

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { isExhausted, isQuotaError, markExhausted, markRevived, loadLedger } from './quota-ledger.js';

const testDir = path.join(import.meta.dirname || __dirname, '../../.tmp-quota-ledger-test');

beforeEach(() => {
  fs.mkdirSync(testDir, { recursive: true });
  process.env.PROVIDER_HISTORY_DIR = testDir;
});

afterEach(() => {
  try {
    fs.rmSync(testDir, { recursive: true, force: true });
  } catch {
    // ignore
  }
  delete process.env.PROVIDER_HISTORY_DIR;
});

describe('quota-ledger state machine', () => {
  it('1. fresh ledger — no rung is exhausted', () => {
    expect(isExhausted('Codex')).toBe(false);
    expect(isExhausted('LM Studio')).toBe(false);
  });

  it('2. markExhausted → isExhausted returns true within backoff window', () => {
    markExhausted('Codex', 'rate_limit', 60_000);
    expect(isExhausted('Codex')).toBe(true);
    expect(isExhausted('LM Studio')).toBe(false); // other rungs unaffected
  });

  it('3. markRevived clears exhausted state', () => {
    markExhausted('Codex', 'rate_limit', 60_000);
    expect(isExhausted('Codex')).toBe(true);
    markRevived('Codex');
    expect(isExhausted('Codex')).toBe(false);
  });

  it('4. expired backoff → isExhausted auto-expires and returns false', () => {
    markExhausted('LM Studio', 'rate limit hit', -1); // already expired (backoffMs = -1)
    expect(isExhausted('LM Studio')).toBe(false);
    // Entry should be pruned from ledger
    expect(loadLedger()['LM Studio']).toBeUndefined();
  });

  it('5. ledger persists across loadLedger calls', () => {
    markExhausted('Codex', 'credit balance too low', 60_000);
    const l = loadLedger();
    expect(l['Codex']?.state).toBe('exhausted');
    expect(l['Codex']?.lastError).toBe('credit balance too low');
  });

  it('6. markRevived on already-ok rung does not write (no-op)', () => {
    const ledgerFile = path.join(testDir, 'quota-ledger.json');
    markRevived('Codex'); // no prior exhausted state
    expect(fs.existsSync(ledgerFile)).toBe(false); // no write happened
  });
});

describe('isQuotaError detection', () => {
  it('7. detects Claude Pro credit exhaustion', () => {
    expect(isQuotaError('Credit balance is too low to run this request')).toBe(true);
  });

  it('8. detects HTTP 429', () => {
    expect(isQuotaError('LM Studio request failed (429 Too Many Requests)')).toBe(true);
  });

  it('9. detects rate_limit keyword', () => {
    expect(isQuotaError('You have exceeded your rate_limit for this model')).toBe(true);
  });

  it('10. detects out of extra usage (Claude Pro cap)', () => {
    expect(isQuotaError('You are out of extra usage for this billing period')).toBe(true);
  });

  it('11. detects RESOURCE_EXHAUSTED (Google/Antigravity)', () => {
    expect(isQuotaError('RESOURCE_EXHAUSTED: quota limit reached')).toBe(true);
  });

  it('12. does NOT flag connectivity errors as quota', () => {
    expect(isQuotaError('connection refused')).toBe(false);
    expect(isQuotaError('ECONNREFUSED')).toBe(false);
    expect(isQuotaError('fetch failed')).toBe(false);
    expect(isQuotaError('LM Studio returned no message content')).toBe(false);
  });
});


Add NON_BASH_TOOL_CEILING_MS and skip kill when non-Bash tool is in flight

Add RED tests for Agent-tool-in-flight ceiling behavior

Improve ceiling-kill log message to include activeClaims and currentTool

Update board and card, log run, schedule reset-wake


Bash

cd /workspace/extra/projects/nanoclaw && npx tsc --noEmit 2>&1 | grep -v "Warning\|UNDICI\|Use node"

TypeScript check after quota-ledger integration


(Use `node --trace-warnings ...` to show where the warning was created)


TypeScript is clean. Now let me also update the provider-routing-plan.md to reflect Phase 2 progress, and then commit.

Actually, let me also update the `observations.md` — is there an open observation about quota exhaustion behavior? Looking back at the observations file:

In Notes section:
"The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default)..."

There's no specific observation about quota spam being fixed. Let me add a note that Phase 2 quota ledger was implemented.
I need to check if there's an existing test that verifies the rung skip behavior for exhausted rungs. The existing tests assume no quota ledger state, so they should still pass since the ledger is empty by default. The tests set `PROVIDER_HISTORY_DIR` to a temporary directory in `beforeAll`, and the quota-ledger module reads from that same environment variable, so the test isolation should work correctly. I need to set up a test that verifies the exhausted rung skipping behavior, which means writing a ledger file to the test directory before running the test so that `isExhausted()` returns true for Codex. I'm creating a test case that marks Codex as quota-exhausted and then verifies the routing skips it and uses the next provider instead.

Writing test case... Finishing the test assertions and then realizing there's a potential issue with test isolation—if other tests run while Codex is marked as exhausted, they could be affected by that state. The cleanup with `markRevived` or the `afterAll` teardown should handle it, but there's a timing concern if tests execute concurrently.

I'm thinking about adding a `beforeEach` hook to reset the quota ledger state between tests, either by calling `markRevived` on all providers or by deleting the ledger file directly from the filesystem. This is getting complex though, so I'm considering a simpler approach.

Actually, I should move the exhausted-rung test into quota-ledger.test.ts where it belongs as a unit test of the state machine, rather than trying to test it as an integration test. The routing behavior is already covered by the existing tests in gemini-openai-claude.test.ts, which all pass since the ledger starts empty. Let me just commit what I have since the TypeScript is clean and the quota-ledger tests cover the key behaviors.

Now I'm double-checking the `isQuotaError` function logic—specifically the operator precedence in the condition that checks for both 'subscription' and 'limit' keywords. Checking the remaining conditions confirms test 12 returns false as expected, so the implementation is solid. I'll commit the code and tests now, keeping the scope focused.


Clean. Let me also add a routing-level integration test for the exhausted-rung skip behavior in `gemini-openai-claude.test.ts`:


1	import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
2	import fs from 'fs';
3	import path from 'path';
4	import { GeminiOpenAiClaudeProvider, classifyTask, resolveClaudeModel } from './gemini-openai-claude.js';
5	import { ClaudeProvider } from './claude.js';
6	import type { QueryInput, ProviderEvent } from './types.js';
7
8	// Chain order:
9	//   plain chat → Codex → LM Studio → Claude
10	//   tool work  → Codex → Claude → LM Studio
11	describe('GeminiOpenAiClaudeProvider Fallback Path', () => {
12	  let originalFetch: typeof fetch;
13	  let originalClaudeQuery: typeof ClaudeProvider.prototype.query;
14	  let fetchedUrls: string[] = [];
15	  let fetchedBodies: any[] = [];
16	  let fetchResult: (url: string, init?: RequestInit) => Promise<Response>;
17	  let claudeQueryResult: () => { events: AsyncIterable<ProviderEvent> };
18	  const testTmpDir = path.join(import.meta.dirname || __dirname, '../../.tmp-test-history');
19
20	  beforeAll(() => {
21	    originalFetch = globalThis.fetch;
22	    originalClaudeQuery = ClaudeProvider.prototype.query;
23
24	    process.env.PROVIDER_HISTORY_DIR = testTmpDir;
25	    fs.mkdirSync(testTmpDir, { recursive: true });
26
27	    globalThis.fetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
28	      const urlStr = typeof url === 'string' ? url : url.toString();
29	      fetchedUrls.push(urlStr);
30	      if (init?.body) {

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { GeminiOpenAiClaudeProvider, classifyTask, resolveClaudeModel } from './gemini-openai-claude.js';
import { ClaudeProvider } from './claude.js';
import type { QueryInput, ProviderEvent } from './types.js';

// Chain order:
//   plain chat → Codex → LM Studio → Claude
//   tool work  → Codex → Claude → LM Studio
describe('GeminiOpenAiClaudeProvider Fallback Path', () => {
  let originalFetch: typeof fetch;
  let originalClaudeQuery: typeof ClaudeProvider.prototype.query;
  let fetchedUrls: string[] = [];
  let fetchedBodies: any[] = [];
  let fetchResult: (url: string, init?: RequestInit) => Promise<Response>;
  let claudeQueryResult: () => { events: AsyncIterable<ProviderEvent> };
  const testTmpDir = path.join(import.meta.dirname || __dirname, '../../.tmp-test-history');

  beforeAll(() => {
    originalFetch = globalThis.fetch;
    originalClaudeQuery = ClaudeProvider.prototype.query;

    process.env.PROVIDER_HISTORY_DIR = testTmpDir;
    fs.mkdirSync(testTmpDir, { recursive: true });

    globalThis.fetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
      const urlStr = typeof url === 'string' ? url : url.toString();
      fetchedUrls.push(urlStr);
      if (init?.body) {


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:

**Completed this session:**
- Multiple autopilot cycles (9:00 PM all-blocked, 9:32 PM + 9:10 PM + 1:30 AM combined → nanoclaw Phase 1 classifyTask commit, 2:00 AM → nanoclaw Phase 1.5 per-class model commit)
- Resent StageHand 5 toolchain questions to James via Telegram (unanswered since Jul 9)
- nanoclaw Phase 1: `classifyTask(): TaskClass` enum refactor (commit 3aa7f25)
- nanoclaw Phase 1.5: per-class model selection via `CLAUDE_PLANNING_MODEL` env var (commit 8648c70)

**Current task (2:30 AM cycle, in progress):**
- <task from="telegram-mg-17796" time="Jul 27, 2026, 2:30 AM"> high tier (53% burnable) autopilot cycle; eligible: StageHand (P1), nanoclaw (P1); next_reset_at: 2026-07-27T06:49:59.763Z</task>
- Implementing nanoclaw Phase 2 quota ledger — NOT YET committed; running lock is still held

2. Key Technical Concepts:

- **nanoclaw provider routing**: `gemini-openai-claude.ts` chains Codex → Claude → LM Studio (tool work) or Codex → LM Studio → Claude (chat)
- **TaskClass enum** (Phase 1): `'planning' | 'tool-work' | 'chat'` replacing boolean `needsClaudeCode`
- **Planning detection**: `<task>` + autopilot/portfolio/brief/spec/plan/orchestrat keywords → `'planning'`
- **Per-class model selection** (Phase 1.5): `CLAUDE_PLANNING_MODEL` env var (default `'opus'`) for planning tasks
- **Phase 2 quota ledger**: Persistent JSON file tracking per-rung quota state; silent skip of exhausted rungs; 30-min backoff on quota errors
- **`isQuotaError()`**: Detects credit balance, rate_limit, 429, out of extra usage, RESOURCE_EXHAUSTED patterns
- **Auto-expiry**: `isExhausted()` auto-clears stale entries (exhaustedUntil < now)
- **Provider routing plan phases**: 0 (done) → 0.5 (done) → 0.6 (done) → 1 (done) → 1.5 (done) → 2 (in progress) → 3 (Antigravity, deferred)
- **Deploy queue**: 6 commits awaiting SSH to Mac Studio (5f98659, 349ed2e, 0ac74eb, d4ef752, 3aa7f25, 8648c70)
- **`PROVIDER_HISTORY_DIR`**: Env var controlling where history + quota ledger files live
- **autopilot running lock**: `autopilot-running-lock.json` — written at turn-start, removed at turn-end

3. Files and Code Sections:

- **`/workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/gemini-openai-claude.ts`** (MODIFIED across commits 3aa7f25 + 8648c70 + current in-progress)
  - Commit 3aa7f25: replaced `needsClaudeCode(): boolean` with `classifyTask(): TaskClass`
  - Commit 8648c70: added `resolveClaudeModel()`, per-class ClaudeProvider selection
  - Current (uncommitted): imported quota-ledger, updated rung loop to skip exhausted rungs

  Key exported types/functions:
  ```typescript
  export type TaskClass = 'planning' | 'tool-work' | 'chat';

  export function classifyTask(input: QueryInput): TaskClass {
    const text = input.prompt.toLowerCase();
    if (/<task\b/.test(input.prompt)) {
      return /\b(autopilot|portfolio|brief|spec|plan|planning|orchestrat)\b/.test(text)
        ? 'planning' : 'tool-work';
    }
    const sourceLink = /https?:\/\/\S+/.test(text);
    const needsTools = sourceLink ||
      /\b(ingest|youtube|podcast|instagram|repo|repository|transcript|wiki|second-brain|autopilot|portfolio|brief|write|update|append|git|commit|push|stage changes|merge|rebase|checkout|tool-recommendations|\/workspace|\/workspace\/extra)\b/.test(text) ||
      /\b(?:create|edit|read|open)\b.{0,15}\bfile\b/.test(text);
    return needsTools ? 'tool-work' : 'chat';
  }

  export function resolveClaudeModel(taskClass: TaskClass, baseModel: string, planningModel: string): string {
    return taskClass === 'planning' ? planningModel : baseModel;
  }
  ```

  Constructor (8648c70):
  ```typescript
  private readonly baseModel: string;
  private readonly planningModel: string;
  private readonly providerOpts: ProviderOptions;

  constructor(options: ProviderOptions = {}) {
    const env = options.env ?? {};
    this.baseModel = env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || options.model || ANTHROPIC_MODEL;
    this.planningModel = env.CLAUDE_PLANNING_MODEL || 'opus';
    this.providerOpts = options;
    this.claude = new ClaudeProvider({ ...options, model: this.baseModel });
    // ...
  }
  ```

  query() early classification + effectiveClaude (8648c70):
  ```typescript
  const taskClass = classifyTask(input);
  const claudeModel = resolveClaudeModel(taskClass, self.baseModel, self.planningModel);
  const effectiveClaude =
    claudeModel !== self.baseModel
      ? new ClaudeProvider({ ...self.providerOpts, model: claudeModel })
      : self.claude;
  ```

  Rung loop (current, uncommitted — quota ledger integration):
  ```typescript
  // Claude is never skipped (no quota ledger for the subscription rung).
  const activeRungs = rungs.filter((r) => r.isClaude || !isExhausted(r.name));

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

  Import added (uncommitted):
  ```typescript
  import { isExhausted, isQuotaError, markExhausted, markRevived } from './quota-ledger.js';
  ```

- **`/workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/quota-ledger.ts`** (NEW FILE, created but NOT YET committed)
  - Phase 2 quota ledger module
  - Full content:
  ```typescript
  export interface LedgerEntry {
    state: 'ok' | 'exhausted';
    exhaustedUntil?: number;
    lastError?: string;
    lastUpdated: number;
  }
  export type QuotaLedger = Record<string, LedgerEntry>;

  const BACKOFF_MS = 30 * 60 * 1000; // 30 min

  function ledgerPath(): string {
    const dir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
    return path.join(dir, 'quota-ledger.json');
  }

  export function loadLedger(): QuotaLedger { /* reads JSON file */ }
  function saveLedger(ledger: QuotaLedger): void { /* writes JSON file, best-effort */ }

  export function isExhausted(rungName: string, ledger?: QuotaLedger): boolean {
    // Auto-expires stale entries (exhaustedUntil < now)
  }

  export function markExhausted(rungName: string, lastError: string, backoffMs = BACKOFF_MS): void { }
  export function markRevived(rungName: string): void { /* no-op if already ok */ }

  export function isQuotaError(message: string): boolean {
    // Detects: credit balance, rate_limit, rate limit, 429, out of extra usage,
    // resource_exhausted, quota exceeded, billing, subscription+limit
  }
  ```

- **`/workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/quota-ledger.test.ts`** (NEW FILE, created but NOT YET committed)
  - 12 tests covering state machine (tests 1-6) and isQuotaError detection (tests 7-12)
  - Uses `PROVIDER_HISTORY_DIR` set to temp dir, cleaned up in afterEach

- **`/workspace/extra/projects/nanoclaw/container/agent-runner/src/providers/gemini-openai-claude.test.ts`** (MODIFIED)
  - Import updated to include `classifyTask, resolveClaudeModel`
  - Tests 23-25: direct `classifyTask()` unit tests (autopilot planning, implementation tool-work, plain chat)
  - Tests 26-29: `resolveClaudeModel()` unit tests (planning→opus, tool-work→sonnet, chat→sonnet, same-model no-op)

- **`/workspace/extra/second-brain/portfolio/projects/nanoclaw.md`** (MODIFIED)
  - Next action updated to: "Deploy to Studio (6 commits: 5f98659→8648c70); run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric"
  - Notes updated with 3aa7f25 and 8648c70 entries

- **`/workspace/extra/second-brain/portfolio/BOARD.md`** (MODIFIED)
  - nanoclaw row updated with 6 commits reference
  - Recently shipped updated with both new commits

- **`/workspace/agent/autopilot-run-log.jsonl`** (MODIFIED)
  - Entries for: 9:00 PM all-blocked, 2:32 AM combined (3aa7f25), 7:00 AM (8648c70)
  - 2:30 AM cycle NOT YET logged

- **`/workspace/agent/autopilot-running-lock.json`** (WRITTEN — still present)
  - `{"started_at":"2026-07-27T07:30:00.000Z"}` — must be removed at end of cycle

4. Errors and Fixes:

- **SSH unavailable from container**: `ssh: command not found` — SSH binary not installed in container. Deploy of nanoclaw commits requires access from Mac Studio host. Workaround: implement code work autonomously, accumulate commits for batch deploy.
- **Filter expression precedence bug**: Initial `activeRungs` filter was `!r.isClaude && !isExhausted(r.name) || r.isClaude` — while functionally correct due to operator precedence (`&&` > `||`), it was confusing. Fixed to: `r.isClaude || !isExhausted(r.name)` — cleaner and equivalent.
- **Todo list staleness**: Todo items 3 (improve ceiling-kill log) and 4 (update board/card) are from prior work. Item 3 was already implemented in 0ac74eb (activeClaims + currentTool already in the log). Todo list is stale.

5. Problem Solving:

- **Deploy queue growing without testing**: 6 commits queued (5f98659→8648c70). Each cycle implements next Phase from the routing plan. Risk acknowledged — Phase 1 changes can't be live-tested until SSH deploy. Mitigation: all TypeScript checks pass; unit tests cover behavior.
- **Finding autonomous nanoclaw work when deploy is blocked**: Each cycle progresses through documented Phases in `docs/provider-routing-plan.md` — Phase 1 (classifyTask enum) → Phase 1.5 (per-class model) → Phase 2 (quota ledger). Work is independent of the deploy.
- **StageHand 5 questions unanswered 17 days**: Resent to James via Telegram at ~2:00 AM CT Jul 27.
- **activeRungs for all-exhausted edge case**: If all non-Claude rungs are exhausted, `activeRungs` still contains the Claude rung (never skipped), so there's always a fallback.

6. All User Messages:

- <task from="telegram-mg-17796" time="Jul 26, 2026, 9:00 PM"> medium tier (49% burnable) autopilot cycle; eligible: StageHand (P1), nanoclaw (P1); next_reset_at: 2026-07-27T01:59:59.986Z; CRITICAL: Respond with TEXT ONLY (summary request)</task>
- <task from="telegram-mg-17796" time="Jul 26, 2026, 9:32 PM"> high tier reset-wake autopilot cycle — quota has reset. Eligible: StageHand (P1), nanoclaw (P1). nanoclaw next action: deploy 5f98659+349ed2e+0ac74eb+d4ef752 to Studio (requires SSH — check if available), then run `pnpm test` and restart service; confirm metric. StageHand: 5 toolchain questions unanswered since Jul 9 — resend if no response yet.</task>
- <task from="telegram-mg-17796" time="Jul 26, 2026, 9:10 PM"> (Script output: 70% burnable, high tier, next_reset_at: 2026-07-27T07:00:01.668Z) — Portfolio Autopilot reset-wake: check quota and run one autopilot cycle.</task>
- <task from="telegram-mg-17796" time="Jul 27, 2026, 1:30 AM"> (Script output: 70% burnable, high tier, next_reset_at: 2026-07-27T07:00:01.550Z) — Portfolio Autopilot run triggered.</task>
- <task from="telegram-mg-17796" time="Jul 27, 2026, 2:00 AM"> (Script output: 57% burnable, high tier, 5h at 83%, next_reset_at: 2026-07-27T06:49:58.766Z) — Portfolio Autopilot run triggered.</task>
- <task from="telegram-mg-17796" time="Jul 27, 2026, 2:30 AM"> (Script output: 53% burnable, high tier, 5h at 76%, next_reset_at: 2026-07-27T06:49:59.763Z) — Portfolio Autopilot run triggered.</task>
- CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. [current summary request]

7. Pending Tasks:

- **Finish the 2:30 AM cycle** (in progress):
  1. Commit the quota ledger files (quota-ledger.ts, quota-ledger.test.ts, gemini-openai-claude.ts changes) — TypeScript already verified clean
  2. Update nanoclaw card `Next action` and add dated note
  3. Update `BOARD.md` Last activity and Recently shipped
  4. Log run to `autopilot-run-log.jsonl`
  5. Schedule reset-wake for `2026-07-27T06:49:59.763Z`
  6. Remove running lock (`/workspace/agent/autopilot-running-lock.json`)
  7. Report to `slack-infrastructure`
- **nanoclaw deploy**: 7 commits will be queued after current cycle commit — awaiting SSH to Studio; after deploy add `CLAUDE_PLANNING_MODEL=opus` to `.env`
- **nanoclaw metric confirm**: "decisions answered per week from daily brief" — James to confirm
- **StageHand 5 questions**: resent Jul 27 ~2:00 AM CT — awaiting James's answers
- **RoadSense 5 launch questions**: sent Jul 8, no reply in 19 days
- **congressional-trading threshold**: Best config identified (11.5/LOW/edge≥2), awaiting go/no-go

8. Current Work:

Implementing nanoclaw Phase 2 quota ledger (2:30 AM cycle). Running lock is held (`{"started_at":"2026-07-27T07:30:00.000Z"}`).

**Files created/modified but NOT YET committed:**

`quota-ledger.ts` (new) — state machine for per-rung quota tracking with `isExhausted()`, `markExhausted()`, `markRevived()`, `isQuotaError()`, and `loadLedger()`/`saveLedger()` backed by `{PROVIDER_HISTORY_DIR}/quota-ledger.json`.

`quota-ledger.test.ts` (new) — 12 tests covering state machine transitions and quota error detection patterns.

`gemini-openai-claude.ts` (modified) — imported quota-ledger functions; updated rung loop to:
1. Pre-filter exhausted REST rungs silently (`activeRungs = rungs.filter(r => r.isClaude || !isExhausted(r.name))`)
2. Call `markRevived(rung.name)` on successful response
3. Call `markExhausted(rung.name, errMsg)` on quota-shaped errors
4. Log when a rung is marked exhausted

TypeScript check passed clean. Next step is to commit these changes.

9. Optional Next Step:

Commit the Phase 2 quota ledger implementation, then complete the cycle bookkeeping. The commit should cover three files: `quota-ledger.ts`, `quota-ledger.test.ts`, and the updated `gemini-openai-claude.ts`. After committing, update the nanoclaw card/board, log the run, schedule the reset-wake for `2026-07-27T06:49:59.763Z`, remove the running lock, and report to `slack-infrastructure`.

The current state is exactly where the 2:30 AM autopilot cycle left off — TypeScript verified clean, awaiting the git commit before wrapping up the cycle.

---

You MUST wrap all responses in <message to="name">...</message> blocks.
Available destinations: `cli-local`, `cw-coder`, `ingest-into-wiki`, `slack-agenticlever-website`, `slack-infrastructure`, `slack-mg-17835`, `slack-mg-17835-2`, `slack-mg-17835-3`, `slack-mg-17835-4`, `slack-mg-17835-5`, `slack-mg-17835-6`, `slack-qip-qien`, `slack-roadsense`, `slack-stagehand`, `slack-tradingbot25`, `telegram-mg-17796`, `telegram-mg-17800`.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/node/.claude/projects/-workspace-agent/281d2e96-ca9e-44dc-bf42-409fa842646d.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


# 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: Studio deploy + metric confirm
- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric

## Notes
- 2026-07-06 — Migrated Mini → Studio (see nanoclaw memory: machine-migration-gotchas). Daily brief + portfolio board went live today.
- 2026-07-06 — Reddit ingestion merged 2026-06-13 but non-functional: unauthenticated `.json` API is 403-blocked. **Dropped** 2026-07-07 — Reddit now requires manual approval for new OAuth apps (scraping crackdown). James doesn't find Reddit content high-signal enough to pursue. Dead feature, no further action.
- 2026-07-08 — Autopilot cycle (medium tier): built `/workspace/agent/autopilot-summary.mjs` — outcome tracker for autopilot run log. Reads autopilot-run-log.jsonl, prints stats by outcome/project/tier/model + recent run history. Supports `--json` and `--last N` flags.
- 2026-07-08 — Autopilot cycle (minimal tier): built `/workspace/agent/autopilot-calibrate.mjs` — weekly calibration script. Computes 7-day stats (runs, success rate, active projects), flags stalled autopilot or low success rate. Scheduled weekly Mondays 08:00 CT (task-1783485079941-19i8vt).
- 2026-07-08 — Autopilot cycle (high tier): added skip-lock mechanism to `autopilot-trigger.mjs` — checks `autopilot-skip-lock.json` before waking; agent writes lock after all-blocked cycles to suppress repeated 30-min trigger fires. Eliminated ~6 wasted cycles per blocked period. Also queued nanoclaw metric proposal in Decision queue.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(router) — mention-sticky session scoping now respects wiring's `session_mode`. Previously Slack shared-mode wirings were coerced to per-thread, causing bot to go silent after initial @mention. RED→GREEN regression test added.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(provider) — hallucinated tool-call tag detection on REST rungs. Adds retry + honestNoToolsReply fallback. Both commits pushed to GitHub (846ce6b..b724c2a). Awaiting Mini deploy (SSH unavailable from container).
- 2026-07-19 — Autopilot cycle (high tier): designed push() queuing fix for REST rungs. Root cause: push() delegates to activeClaudeQuery?.push() which is null for LM Studio/Codex, silently dropping follow-up messages. Fix: restPendingQueue in query() closure; push() enqueues when Claude not active; eventsFor() drains queue as sequential follow-up turns after REST completion. Plan + red-team sent to James via #infrastructure. 3 design questions pending answers.
- 2026-07-20 — Autopilot cycle (high tier): implemented push() fix autonomously (d4ef752). Design decisions: (1) follow-up-turn UX = yes, better than silent drop; (2) Claude rung also drains as safety net = yes; (3) tests = yes, 3 RED→GREEN. 135/135 tests pass. Awaiting Studio deploy.
- 2026-07-20 — Autopilot cycle (high tier): fixed skip-lock bypass bug. Root cause: tasks pre-queued before skip-lock was written would still fire. Fix: added running-lock (`autopilot-running-lock.json`) written at turn-start, checked by trigger (< 45 min = agent still running → wakeAgent: false). Eliminates the race window. Also added running-lock protocol to CLAUDE.local.md for permanent adherence.
- 2026-07-20 — Autopilot cycle (high tier): applied calibration recommendation — raised `reserve_5h_pct` from 25% to 30% in autopilot-config.json. Weekly calibration data showed 5h headroom averaging 0% remaining after runs (floor=25% was insufficient).
- 2026-07-25 — Autopilot cycle (high tier): fixed false-positive ceiling kills on Agent sub-agent runs (0ac74eb). `decideStuckAction()` in `host-sweep.ts` now exempts non-Bash tools (e.g. `Agent`) from the ceiling kill when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min); heartbeat staleness during sub-agent execution is expected, not a fault. `NON_BASH_TOOL_CEILING_MS` exported; two new tests added (ok + kill-ceiling paths). Tests must run on Mac Studio (`pnpm test`). Closed open observation.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1.5 per-class model selection committed (8648c70). `CLAUDE_PLANNING_MODEL` env var (default: `opus`) routes planning tasks to a more capable model; tool-work and chat keep the base model (sonnet). `resolveClaudeModel(taskClass, baseModel, planningModel)` exported for unit testing; 4 new tests (26–29). ClaudeProvider instantiated per-query only when model changes (common path reuses default instance). After deploy: add `CLAUDE_PLANNING_MODEL=opus` to `.env` on Mac Studio.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1 classifyTask committed (3aa7f25). `needsClaudeCode(): boolean` replaced with exported `classifyTask(): TaskClass` ('planning' | 'tool-work' | 'chat'). Autopilot/portfolio `<task>` messages now classified as 'planning' (same rung order as tool-work; distinction reserved for per-class model/effort in Phase 1.5). 3 new unit tests (23–25). TypeScript clean; awaiting Studio deploy.
- 2026-07-26 — Autopilot cycle (high tier): Phase 0.6 + Phase 1 classifier committed (5f98659). Phase 0.6: Codex-first default provider, Codex→Claude→LM Studio tool-work order, split pnpm test into host/container suites. Phase 1 classifier: `<task>` messages always route tools-first; file operations match "read the file" / "edit this file"; added autopilot/portfolio/brief keywords. 4 RED→GREEN tests. TypeScript clean; tests need Mac Studio. Bundled with closed observations (349ed2e SqliteError, 0ac74eb ceiling kills).
- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.


# Portfolio Board

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

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

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-24 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix shipped (ee84254 pushed 2026-07-25); confirm trial health | none | 2026-07-25 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | Await threshold decision; weekly precision monitor running (Mondays 9am) | threshold decision | 2026-07-21 |
| [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 + next feature direction | confirm goal | 2026-07-20 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; headless-browser gallery capture if Local Recs green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-21 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-21 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (6 commits: 5f98659→8648c70); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

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

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **nanoclaw push() fix deploy (2026-07-20):** Fix implemented (d4ef752), 135 tests pass. Awaiting Studio deploy. Design decisions made autonomously: (1) follow-up-turn UX = yes; (2) Claude safety net = yes; (3) tests = yes.

## Recently shipped

- **nanoclaw** 2026-07-27 — Phase 1.5 per-class model selection committed (8648c70): `CLAUDE_PLANNING_MODEL` env var routes planning tasks (autopilot cycles) to opus; base model (sonnet) used for tool-work and chat. `resolveClaudeModel()` exported + 4 unit tests (26–29). To activate: add `CLAUDE_PLANNING_MODEL=opus` to `.env` on Studio after deploy.
- **nanoclaw** 2026-07-27 — Phase 1 classifyTask committed (3aa7f25): `needsClaudeCode(): boolean` replaced with `classifyTask(): TaskClass` ('planning' | 'tool-work' | 'chat'); autopilot/portfolio `<task>` messages now classified as 'planning'; 3 new direct unit tests (23–25). TypeScript clean; awaiting Studio deploy.
- **nanoclaw** 2026-07-26 — Phase 0.6+1 committed (5f98659): Codex-first default provider, task/file/autopilot classifier fixes, 4 RED→GREEN tests; bundled pending Phase 0.6 routing + test-split changes. Awaiting Studio deploy.
- **qip-qien** 2026-07-25 — SEV-1 auth fix pushed to GitHub (ee84254): admin routes now use `requireAdminApiUser` on 4 endpoints; approved by James, pushed by Nano.
- **nanoclaw** 2026-07-26 — SqliteError delivery bug fixed (349ed2e): `writeOutboundDirect` now uses read-write opener; poll loops isolate per-session errors. TypeScript clean; awaiting Studio deploy + tests.
- **nanoclaw** 2026-07-25 — false-positive ceiling kills on Agent sub-agent runs fixed (0ac74eb): `decideStuckAction()` exempts non-Bash tools from the 30-min heartbeat ceiling when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min). Two new tests.
- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed


<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. Your destinations: cli-local, cw-coder, ingest-into-wiki, slack-agenticlever-website, slack-infrastructure, slack-mg-17835, slack-mg-17835-2, slack-mg-17835-3, slack-mg-17835-4, slack-mg-17835-5, slack-mg-17835-6, slack-qip-qien, slack-roadsense, slack-stagehand, slack-tradingbot25, telegram-mg-17796, telegram-mg-17800. Please re-send your response with the correct wrapping.</system>


Let me resume the 2:30 AM autopilot cycle. The summary tells me:

1. Phase 2 quota ledger implementation is complete but NOT YET committed
2. Files modified/created:
   - `quota-ledger.ts` (new)
   - `quota-ledger.test.ts` (new)
   - `gemini-openai-claude.ts` (modified)
3. TypeScript check passed clean
4. Running lock is still held

My next steps:
1. Commit the Phase 2 quota ledger files
2. Update nanoclaw card Next action and add dated note
3. Update BOARD.md with the latest activity and shipped items
4. Log this run to the autopilot run log
5. Schedule the next wake cycle for 2026-07-27T06:49:59.763Z
6. Release the running lock
7. Send a report to slack-infrastructure

Starting with the commit now.


git status --short && git diff --stat HEAD

Check git status of nanoclaw repo


 M container/agent-runner/src/providers/gemini-openai-claude.ts
?? .claude/settings.local.json
?? container/agent-runner/src/providers/quota-ledger.test.ts
?? container/agent-runner/src/providers/quota-ledger.ts
 .../src/providers/gemini-openai-claude.ts          | 23 ++++++++++++++++++----
 1 file changed, 19 insertions(+), 4 deletions(-)


Prompt: <system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. Your destinations: cli-local, cw-coder, ingest-into-wiki, slack-agenticlever-website, slack-infrastructure, slack-mg-17835, slack-mg-17835-2, slack-mg-17835-3, slack-mg-17835-4, slack-mg-17835-5, slack-mg-17835-6, slack-qip-qien, slack-roadsense, slack-stagehand, slack-tradingbot25, telegram-mg-17796, telegram-mg-17800. Please re-send your response with the correct wrapping.</system>


git add container/agent-runner/src/providers/quota-ledger.ts container/agent-runner/src/providers/quota-ledger.test.ts container/agent-runner/src/providers/gemini-openai-claude.ts && git commit -m "$(cat <<'EOF'
feat(providers): Phase 2 quota ledger — silent skip of exhausted REST rungs

Adds quota-ledger.ts: per-rung state machine backed by
{PROVIDER_HISTORY_DIR}/quota-ledger.json. isExhausted() auto-expires
entries past their backoff window (30 min default). markRevived() is a
no-op when the rung is already ok (no spurious writes).

isQuotaError() detects rate_limit/429/credit balance/RESOURCE_EXHAUSTED
patterns so the rung loop can distinguish quota failures from transient
connectivity errors.

gemini-openai-claude.ts: pre-filters exhausted rungs before iterating
(Claude rung always included — no ledger for the subscription rung);
marks revived on success, marks exhausted on quota-shaped errors.

12 new tests (quota-ledger.test.ts); TypeScript clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit Phase 2 quota ledger implementation


(node:33962) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)

> nanoclaw@2.0.70 format:fix /workspace/extra/projects/nanoclaw
> prettier --write "src/**/*.ts"

(node:33975) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
src/attachment-naming.test.ts 40ms (unchanged)
src/attachment-naming.ts 10ms (unchanged)
src/attachment-safety.ts 2ms (unchanged)
src/backfill-container-configs.ts 9ms (unchanged)
src/channels/adapter.ts 9ms (unchanged)
src/channels/ask-question.ts 3ms (unchanged)
src/channels/channel-registry.test.ts 16ms (unchanged)
src/channels/channel-registry.ts 7ms (unchanged)
src/channels/chat-sdk-bridge.test.ts 16ms (unchanged)
src/channels/chat-sdk-bridge.ts 37ms (unchanged)
src/channels/cli.ts 10ms (unchanged)
src/channels/index.ts 1ms (unchanged)
src/channels/slack.ts 1ms (unchanged)
src/channels/telegram-markdown-sanitize.test.ts 2ms (unchanged)
src/channels/telegram-markdown-sanitize.ts 2ms (unchanged)
src/channels/telegram-pairing.test.ts 12ms (unchanged)
src/channels/telegram-pairing.ts 10ms (unchanged)
src/channels/telegram.ts 7ms (unchanged)
src/circuit-breaker.test.ts 7ms (unchanged)
src/circuit-breaker.ts 3ms (unchanged)
src/claude-md-compose.ts 6ms (unchanged)
src/cli/client.ts 2ms (unchanged)
src/cli/commands/help.ts 5ms (unchanged)
src/cli/commands/index.ts 0ms (unchanged)
src/cli/crud.ts 10ms (unchanged)
src/cli/delivery-action.ts 2ms (unchanged)
src/cli/dispatch.test.ts 23ms (unchanged)
src/cli/dispatch.ts 9ms (unchanged)
src/cli/format.ts 4ms (unchanged)
src/cli/frame.ts 2ms (unchanged)
src/cli/registry.ts 2ms (unchanged)
src/cli/resources/approvals.ts 2ms (unchanged)
src/cli/resources/destinations.test.ts 5ms (unchanged)
src/cli/resources/destinations.ts 5ms (unchanged)
src/cli/resources/dropped-messages.ts 2ms (unchanged)
src/cli/resources/groups.test.ts 10ms (unchanged)
src/cli/resources/groups.ts 10ms (unchanged)
src/cli/resources/index.ts 0ms (unchanged)
src/cli/resources/members.ts 2ms (unchanged)
src/cli/resources/messaging-groups.ts 1ms (unchanged)
src/cli/resources/roles.ts 2ms (unchanged)
src/cli/resources/sessions.ts 1ms (unchanged)
src/cli/resources/user-dms.ts 1ms (unchanged)
src/cli/resources/users.ts 1ms (unchanged)
src/cli/resources/wirings.ts 1ms (unchanged)
src/cli/socket-client.ts 3ms (unchanged)
src/cli/socket-server.ts 4ms (unchanged)
src/cli/transport-errors.test.ts 2ms (unchanged)
src/cli/transport-errors.ts 1ms (unchanged)
src/cli/transport.ts 1ms (unchanged)
src/command-gate.ts 2ms (unchanged)
src/config.ts 3ms (unchanged)
src/container-config.ts 3ms (unchanged)
src/container-restart.test.ts 6ms (unchanged)
src/container-restart.ts 2ms (unchanged)
src/container-runner.test.ts 2ms (unchanged)
src/container-runner.ts 16ms (unchanged)
src/container-runtime.test.ts 4ms (unchanged)
src/container-runtime.ts 2ms (unchanged)
src/db/agent-groups.ts 2ms (unchanged)
src/db/connection.ts 1ms (unchanged)
src/db/container-configs.ts 2ms (unchanged)
src/db/db-v2.test.ts 8ms (unchanged)
src/db/dropped-messages.ts 1ms (unchanged)
src/db/index.ts 1ms (unchanged)
src/db/messaging-groups.ts 4ms (unchanged)
src/db/migrations/001-initial.ts 1ms (unchanged)
src/db/migrations/002-chat-sdk-state.ts 1ms (unchanged)
src/db/migrations/008-dropped-messages.ts 1ms (unchanged)
src/db/migrations/009-drop-pending-credentials.ts 0ms (unchanged)
src/db/migrations/010-engage-modes.ts 2ms (unchanged)
src/db/migrations/011-pending-sender-approvals.ts 1ms (unchanged)
src/db/migrations/012-channel-registration.ts 1ms (unchanged)
src/db/migrations/013-approval-render-metadata.ts 1ms (unchanged)
src/db/migrations/014-container-configs.ts 1ms (unchanged)
src/db/migrations/015-cli-scope.ts 0ms (unchanged)
src/db/migrations/index.ts 2ms (unchanged)
src/db/migrations/module-agent-to-agent-destinations.ts 2ms (unchanged)
src/db/migrations/module-approvals-pending-approvals.ts 1ms (unchanged)
src/db/migrations/module-approvals-title-options.ts 1ms (unchanged)
src/db/schema.ts 1ms (unchanged)
src/db/session-db.test.ts 2ms (unchanged)
src/db/session-db.ts 9ms (unchanged)
src/db/sessions.ts 7ms (unchanged)
src/delivery.test.ts 7ms (unchanged)
src/delivery.ts 7ms (unchanged)
src/env.ts 1ms (unchanged)
src/group-folder.test.ts 1ms (unchanged)
src/group-folder.ts 1ms (unchanged)
src/group-init.ts 3ms (unchanged)
src/host-core.test.ts 28ms (unchanged)
src/host-sweep.test.ts 6ms (unchanged)
src/host-sweep.ts 6ms (unchanged)
src/index.ts 4ms (unchanged)
src/install-slug.ts 1ms (unchanged)
src/log.ts 3ms (unchanged)
src/modules/agent-to-agent/agent-route.test.ts 11ms (unchanged)
src/modules/agent-to-agent/agent-route.ts 5ms (unchanged)
src/modules/agent-to-agent/create-agent.ts 3ms (unchanged)
src/modules/agent-to-agent/db/agent-destinations.ts 2ms (unchanged)
src/modules/agent-to-agent/index.ts 0ms (unchanged)
src/modules/agent-to-agent/write-destinations.ts 1ms (unchanged)
src/modules/approvals/index.ts 1ms (unchanged)
src/modules/approvals/onecli-approvals.ts 5ms (unchanged)
src/modules/approvals/picks.test.ts 3ms (unchanged)
src/modules/approvals/primitive.ts 4ms (unchanged)
src/modules/approvals/response-handler.ts 2ms (unchanged)
src/modules/index.ts 0ms (unchanged)
src/modules/instagram-ingestion/index.test.ts 2ms (unchanged)
src/modules/instagram-ingestion/index.ts 9ms (unchanged)
src/modules/interactive/index.ts 2ms (unchanged)
src/modules/mount-security/index.ts 6ms (unchanged)
src/modules/permissions/access.ts 1ms (unchanged)
src/modules/permissions/channel-approval.test.ts 13ms (unchanged)
src/modules/permissions/channel-approval.ts 5ms (unchanged)
src/modules/permissions/db/agent-group-members.ts 1ms (unchanged)
src/modules/permissions/db/pending-channel-approvals.ts 1ms (unchanged)
src/modules/permissions/db/pending-sender-approvals.ts 1ms (unchanged)
src/modules/permissions/db/user-dms.ts 1ms (unchanged)
src/modules/permissions/db/user-roles.ts 2ms (unchanged)
src/modules/permissions/db/users.ts 1ms (unchanged)
src/modules/permissions/index.ts 13ms (unchanged)
src/modules/permissions/permissions.test.ts 8ms (unchanged)
src/modules/permissions/sender-approval.test.ts 7ms (unchanged)
src/modules/permissions/sender-approval.ts 2ms (unchanged)
src/modules/permissions/user-dm.ts 2ms (unchanged)
src/modules/reddit-ingestion/index.test.ts 4ms (unchanged)
src/modules/reddit-ingestion/index.ts 12ms (unchanged)
src/modules/scheduling/actions.ts 3ms (unchanged)
src/modules/scheduling/db.test.ts 9ms (unchanged)
src/modules/scheduling/db.ts 3ms (unchanged)
src/modules/scheduling/index.ts 1ms (unchanged)
src/modules/scheduling/recurrence.test.ts 3ms (unchanged)
src/modules/scheduling/recurrence.ts 2ms (unchanged)
src/modules/self-mod/apply.ts 3ms (unchanged)
src/modules/self-mod/index.ts 0ms (unchanged)
src/modules/self-mod/request.ts 2ms (unchanged)
src/modules/twitter-ingestion/index.test.ts 3ms (unchanged)
src/modules/twitter-ingestion/index.ts 20ms (unchanged)
src/modules/typing/index.ts 3ms (unchanged)
src/platform-id.ts 1ms (unchanged)
src/providers/claude.ts 1ms (unchanged)
src/providers/gemini-openai-claude.ts 1ms (unchanged)
src/providers/index.ts 0ms (unchanged)
src/providers/provider-container-registry.ts 1ms (unchanged)
src/response-registry.ts 1ms (unchanged)
src/router.enrichment.test.ts 2ms (unchanged)
src/router.ts 10ms (unchanged)
src/session-manager.ts 9ms (unchanged)
src/state-sqlite.ts 6ms (unchanged)
src/timezone.test.ts 2ms (unchanged)
src/timezone.ts 1ms (unchanged)
src/types.ts 3ms (unchanged)
src/webhook-server.ts 4ms (unchanged)
[main 519ec56] feat(providers): Phase 2 quota ledger — silent skip of exhausted REST rungs
 3 files changed, 225 insertions(+), 4 deletions(-)
 create mode 100644 container/agent-runner/src/providers/quota-ledger.test.ts
 create mode 100644 container/agent-runner/src/providers/quota-ledger.ts


Good, commit `519ec56` is done. Now I need to:
1. Update nanoclaw card Next action and add dated note
2. Update BOARD.md Last activity and Recently shipped
3. Log run to autopilot-run-log.jsonl
4. Schedule reset-wake for 2026-07-27T06:49:59.763Z
5. Remove running lock
6. Report to slack-infrastructure

Let me now update the nanoclaw card and BOARD.md. First let me read what the current state is.


Commit `519ec56` is in. Now updating the board and card.


1	# nanoclaw
2
3	- Repo: `~/projects/nanoclaw` · https://github.com/jamesjlopez/nanoclaw
4	- Goal: the agent platform itself — powers this whole operating model
5	- Stage: build
6	- Guard: autonomous-ok
7	- Metric: _undefined — James to name (candidate: decision questions James actually answers/week — proxy for brief usefulness)_
8	- Blocked on James: Studio deploy + metric confirm
9	- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric
10

# 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: Studio deploy + metric confirm
- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric


Edit

- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric

- Next action: Deploy to Studio (7 commits: 5f98659→519ec56); run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric


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: Studio deploy + metric confirm
- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric

## Notes
- 2026-07-06 — Migrated Mini → Studio (see nanoclaw memory: machine-migration-gotchas). Daily brief + portfolio board went live today.
- 2026-07-06 — Reddit ingestion merged 2026-06-13 but non-functional: unauthenticated `.json` API is 403-blocked. **Dropped** 2026-07-07 — Reddit now requires manual approval for new OAuth apps (scraping crackdown). James doesn't find Reddit content high-signal enough to pursue. Dead feature, no further action.
- 2026-07-08 — Autopilot cycle (medium tier): built `/workspace/agent/autopilot-summary.mjs` — outcome tracker for autopilot run log. Reads autopilot-run-log.jsonl, prints stats by outcome/project/tier/model + recent run history. Supports `--json` and `--last N` flags.
- 2026-07-08 — Autopilot cycle (minimal tier): built `/workspace/agent/autopilot-calibrate.mjs` — weekly calibration script. Computes 7-day stats (runs, success rate, active projects), flags stalled autopilot or low success rate. Scheduled weekly Mondays 08:00 CT (task-1783485079941-19i8vt).
- 2026-07-08 — Autopilot cycle (high tier): added skip-lock mechanism to `autopilot-trigger.mjs` — checks `autopilot-skip-lock.json` before waking; agent writes lock after all-blocked cycles to suppress repeated 30-min trigger fires. Eliminated ~6 wasted cycles per blocked period. Also queued nanoclaw metric proposal in Decision queue.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(router) — mention-sticky session scoping now respects wiring's `session_mode`. Previously Slack shared-mode wirings were coerced to per-thread, causing bot to go silent after initial @mention. RED→GREEN regression test added.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(provider) — hallucinated tool-call tag detection on REST rungs. Adds retry + honestNoToolsReply fallback. Both commits pushed to GitHub (846ce6b..b724c2a). Awaiting Mini deploy (SSH unavailable from container).
- 2026-07-19 — Autopilot cycle (high tier): designed push() queuing fix for REST rungs. Root cause: push() delegates to activeClaudeQuery?.push() which is null for LM Studio/Codex, silently dropping follow-up messages. Fix: restPendingQueue in query() closure; push() enqueues when Claude not active; eventsFor() drains queue as sequential follow-up turns after REST completion. Plan + red-team sent to James via #infrastructure. 3 design questions pending answers.
- 2026-07-20 — Autopilot cycle (high tier): implemented push() fix autonomously (d4ef752). Design decisions: (1) follow-up-turn UX = yes, better than silent drop; (2) Claude rung also drains as safety net = yes; (3) tests = yes, 3 RED→GREEN. 135/135 tests pass. Awaiting Studio deploy.
- 2026-07-20 — Autopilot cycle (high tier): fixed skip-lock bypass bug. Root cause: tasks pre-queued before skip-lock was written would still fire. Fix: added running-lock (`autopilot-running-lock.json`) written at turn-start, checked by trigger (< 45 min = agent still running → wakeAgent: false). Eliminates the race window. Also added running-lock protocol to CLAUDE.local.md for permanent adherence.
- 2026-07-20 — Autopilot cycle (high tier): applied calibration recommendation — raised `reserve_5h_pct` from 25% to 30% in autopilot-config.json. Weekly calibration data showed 5h headroom averaging 0% remaining after runs (floor=25% was insufficient).
- 2026-07-25 — Autopilot cycle (high tier): fixed false-positive ceiling kills on Agent sub-agent runs (0ac74eb). `decideStuckAction()` in `host-sweep.ts` now exempts non-Bash tools (e.g. `Agent`) from the ceiling kill when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min); heartbeat staleness during sub-agent execution is expected, not a fault. `NON_BASH_TOOL_CEILING_MS` exported; two new tests added (ok + kill-ceiling paths). Tests must run on Mac Studio (`pnpm test`). Closed open observation.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1.5 per-class model selection committed (8648c70). `CLAUDE_PLANNING_MODEL` env var (default: `opus`) routes planning tasks to a more capable model; tool-work and chat keep the base model (sonnet). `resolveClaudeModel(taskClass, baseModel, planningModel)` exported for unit testing; 4 new tests (26–29). ClaudeProvider instantiated per-query only when model changes (common path reuses default instance). After deploy: add `CLAUDE_PLANNING_MODEL=opus` to `.env` on Mac Studio.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1 classifyTask committed (3aa7f25). `needsClaudeCode(): boolean` replaced with exported `classifyTask(): TaskClass` ('planning' | 'tool-work' | 'chat'). Autopilot/portfolio `<task>` messages now classified as 'planning' (same rung order as tool-work; distinction reserved for per-class model/effort in Phase 1.5). 3 new unit tests (23–25). TypeScript clean; awaiting Studio deploy.
- 2026-07-26 — Autopilot cycle (high tier): Phase 0.6 + Phase 1 classifier committed (5f98659). Phase 0.6: Codex-first default provider, Codex→Claude→LM Studio tool-work order, split pnpm test into host/container suites. Phase 1 classifier: `<task>` messages always route tools-first; file operations match "read the file" / "edit this file"; added autopilot/portfolio/brief keywords. 4 RED→GREEN tests. TypeScript clean; tests need Mac Studio. Bundled with closed observations (349ed2e SqliteError, 0ac74eb ceiling kills).
- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.

-- Next action: Deploy to Studio (6 commits: 5f98659 Phase 0.6+1, 349ed2e delivery fix, 0ac74eb ceiling fix, d4ef752 push() fix, 3aa7f25 classifyTask, 8648c70 per-class model) — run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric

+- Next action: Deploy to Studio (7 commits: 5f98659→519ec56); run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric


26	- 2026-07-26 — Autopilot cycle (high tier): Phase 0.6 + Phase 1 classifier committed (5f98659). Phase 0.6: Codex-first default provider, Codex→Claude→LM Studio tool-work order, split pnpm test into host/container suites. Phase 1 classifier: `<task>` messages always route tools-first; file operations match "read the file" / "edit this file"; added autopilot/portfolio/brief keywords. 4 RED→GREEN tests. TypeScript clean; tests need Mac Studio. Bundled with closed observations (349ed2e SqliteError, 0ac74eb ceiling kills).
27	- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.
28

- 2026-07-26 — Autopilot cycle (high tier): Phase 0.6 + Phase 1 classifier committed (5f98659). Phase 0.6: Codex-first default provider, Codex→Claude→LM Studio tool-work order, split pnpm test into host/container suites. Phase 1 classifier: `<task>` messages always route tools-first; file operations match "read the file" / "edit this file"; added autopilot/portfolio/brief keywords. 4 RED→GREEN tests. TypeScript clean; tests need Mac Studio. Bundled with closed observations (349ed2e SqliteError, 0ac74eb ceiling kills).
- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.


- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.

- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.
- 2026-07-27 — Autopilot cycle (high tier): Phase 2 quota ledger committed (519ec56). Per-rung state machine backed by `{PROVIDER_HISTORY_DIR}/quota-ledger.json`. Exhausted REST rungs are silently skipped (pre-filtered before rung loop); `isQuotaError()` distinguishes quota failures from connectivity errors; auto-expire on `exhaustedUntil` with ledger pruning; `markRevived()` no-op if rung already ok. 12 new tests (quota-ledger.test.ts). TypeScript clean; awaiting Studio deploy.


- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.
- 2026-07-27 — Autopilot cycle (high tier): Phase 2 quota ledger committed (519ec56). Per-rung state machine backed by `{PROVIDER_HISTORY_DIR}/quota-ledger.json`. Exhausted REST rungs are silently skipped (pre-filtered before rung loop); `isQuotaError()` distinguishes quota failures from connectivity errors; auto-expire on `exhaustedUntil` with ledger pruning; `markRevived()` no-op if rung already ok. 12 new tests (quota-ledger.test.ts). TypeScript clean; awaiting Studio deploy.

# 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: Studio deploy + metric confirm
- Next action: Deploy to Studio (7 commits: 5f98659→519ec56); run `pnpm test` + restart; add `CLAUDE_PLANNING_MODEL=opus` to `.env`; confirm metric

## Notes
- 2026-07-06 — Migrated Mini → Studio (see nanoclaw memory: machine-migration-gotchas). Daily brief + portfolio board went live today.
- 2026-07-06 — Reddit ingestion merged 2026-06-13 but non-functional: unauthenticated `.json` API is 403-blocked. **Dropped** 2026-07-07 — Reddit now requires manual approval for new OAuth apps (scraping crackdown). James doesn't find Reddit content high-signal enough to pursue. Dead feature, no further action.
- 2026-07-08 — Autopilot cycle (medium tier): built `/workspace/agent/autopilot-summary.mjs` — outcome tracker for autopilot run log. Reads autopilot-run-log.jsonl, prints stats by outcome/project/tier/model + recent run history. Supports `--json` and `--last N` flags.
- 2026-07-08 — Autopilot cycle (minimal tier): built `/workspace/agent/autopilot-calibrate.mjs` — weekly calibration script. Computes 7-day stats (runs, success rate, active projects), flags stalled autopilot or low success rate. Scheduled weekly Mondays 08:00 CT (task-1783485079941-19i8vt).
- 2026-07-08 — Autopilot cycle (high tier): added skip-lock mechanism to `autopilot-trigger.mjs` — checks `autopilot-skip-lock.json` before waking; agent writes lock after all-blocked cycles to suppress repeated 30-min trigger fires. Eliminated ~6 wasted cycles per blocked period. Also queued nanoclaw metric proposal in Decision queue.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(router) — mention-sticky session scoping now respects wiring's `session_mode`. Previously Slack shared-mode wirings were coerced to per-thread, causing bot to go silent after initial @mention. RED→GREEN regression test added.
- 2026-07-08 — Autopilot cycle (medium tier): committed fix(provider) — hallucinated tool-call tag detection on REST rungs. Adds retry + honestNoToolsReply fallback. Both commits pushed to GitHub (846ce6b..b724c2a). Awaiting Mini deploy (SSH unavailable from container).
- 2026-07-19 — Autopilot cycle (high tier): designed push() queuing fix for REST rungs. Root cause: push() delegates to activeClaudeQuery?.push() which is null for LM Studio/Codex, silently dropping follow-up messages. Fix: restPendingQueue in query() closure; push() enqueues when Claude not active; eventsFor() drains queue as sequential follow-up turns after REST completion. Plan + red-team sent to James via #infrastructure. 3 design questions pending answers.
- 2026-07-20 — Autopilot cycle (high tier): implemented push() fix autonomously (d4ef752). Design decisions: (1) follow-up-turn UX = yes, better than silent drop; (2) Claude rung also drains as safety net = yes; (3) tests = yes, 3 RED→GREEN. 135/135 tests pass. Awaiting Studio deploy.
- 2026-07-20 — Autopilot cycle (high tier): fixed skip-lock bypass bug. Root cause: tasks pre-queued before skip-lock was written would still fire. Fix: added running-lock (`autopilot-running-lock.json`) written at turn-start, checked by trigger (< 45 min = agent still running → wakeAgent: false). Eliminates the race window. Also added running-lock protocol to CLAUDE.local.md for permanent adherence.
- 2026-07-20 — Autopilot cycle (high tier): applied calibration recommendation — raised `reserve_5h_pct` from 25% to 30% in autopilot-config.json. Weekly calibration data showed 5h headroom averaging 0% remaining after runs (floor=25% was insufficient).
- 2026-07-25 — Autopilot cycle (high tier): fixed false-positive ceiling kills on Agent sub-agent runs (0ac74eb). `decideStuckAction()` in `host-sweep.ts` now exempts non-Bash tools (e.g. `Agent`) from the ceiling kill when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min); heartbeat staleness during sub-agent execution is expected, not a fault. `NON_BASH_TOOL_CEILING_MS` exported; two new tests added (ok + kill-ceiling paths). Tests must run on Mac Studio (`pnpm test`). Closed open observation.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1.5 per-class model selection committed (8648c70). `CLAUDE_PLANNING_MODEL` env var (default: `opus`) routes planning tasks to a more capable model; tool-work and chat keep the base model (sonnet). `resolveClaudeModel(taskClass, baseModel, planningModel)` exported for unit testing; 4 new tests (26–29). ClaudeProvider instantiated per-query only when model changes (common path reuses default instance). After deploy: add `CLAUDE_PLANNING_MODEL=opus` to `.env` on Mac Studio.
- 2026-07-27 — Autopilot cycle (high tier): Phase 1 classifyTask committed (3aa7f25). `needsClaudeCode(): boolean` replaced with exported `classifyTask(): TaskClass` ('planning' | 'tool-work' | 'chat'). Autopilot/portfolio `<task>` messages now classified as 'planning' (same rung order as tool-work; distinction reserved for per-class model/effort in Phase 1.5). 3 new unit tests (23–25). TypeScript clean; awaiting Studio deploy.
- 2026-07-26 — Autopilot cycle (high tier): Phase 0.6 + Phase 1 classifier committed (5f98659). Phase 0.6: Codex-first default provider, Codex→Claude→LM Studio tool-work order, split pnpm test into host/container suites. Phase 1 classifier: `<task>` messages always route tools-first; file operations match "read the file" / "edit this file"; added autopilot/portfolio/brief keywords. 4 RED→GREEN tests. TypeScript clean; tests need Mac Studio. Bundled with closed observations (349ed2e SqliteError, 0ac74eb ceiling kills).
- 2026-07-26 — Autopilot cycle (high tier): fixed SqliteError delivery bug (349ed2e). Root cause: `writeOutboundDirect` opened outbound.db with readonly opener then tried INSERT — always threw "attempt to write a readonly database". Fixed: use `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one bad session doesn't block delivery for all others. TypeScript clean. Tests must run on Mac Studio (Node 22). Closed open observation. `decideStuckAction()` now bypasses the 30-min ceiling kill when a non-Bash tool is in flight and `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min) — heartbeat staleness during Agent execution is expected. Two new tests cover both paths. Tests must run on Mac Studio (`pnpm test` with Node 22). Closed open observation.

+- 2026-07-27 — Autopilot cycle (high tier): Phase 2 quota ledger committed (519ec56). Per-rung state machine backed by `{PROVIDER_HISTORY_DIR}/quota-ledger.json`. Exhausted REST rungs are silently skipped (pre-filtered before rung loop); `isQuotaError()` distinguishes quota failures from connectivity errors; auto-expire on `exhaustedUntil` with ledger pruning; `markRevived()` no-op if rung already ok. 12 new tests (quota-ledger.test.ts). TypeScript clean; awaiting Studio deploy.


Now update BOARD.md:


| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (6 commits: 5f98659→8648c70); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |

| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (7 commits: 5f98659→519ec56); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |


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

# Portfolio Board

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

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

| Project | Stage | Guard | Next action | Blocked on James | Last activity |
|---|---|---|---|---|---|
| [portfolio-autopilot](projects/portfolio-autopilot.md) | build | autonomous-ok | Monitor headroom log; calibration surfaces reserve suggestions after 5+ snapshots | none | 2026-07-24 |
| [qip-qien](projects/qip-qien.md) | feedback | review-required | SEV-1 fix shipped (ee84254 pushed 2026-07-25); confirm trial health | none | 2026-07-25 |
| [congressional-trading](projects/congressional-trading.md) | test | autonomous-ok | Await threshold decision; weekly precision monitor running (Mondays 9am) | threshold decision | 2026-07-21 |
| [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 + next feature direction | confirm goal | 2026-07-20 |
| [pool-monitor](projects/pool-monitor.md) | maintain | autonomous-ok | None — steady state? confirm | confirm card | 2026-06-17 |
| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; headless-browser gallery capture if Local Recs green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-21 |
| [MentorCore](projects/MentorCore.md) | research | autonomous-ok | Parked — no bandwidth | none | 2026-06-11 |
| [RoadSense](projects/RoadSense.md) | spec | autonomous-ok | Answer 5 launch questions → unblock implementation | auth model, city, GIS license, creator posture, metric | 2026-07-21 |
| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (6 commits: 5f98659→8648c70); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |
| [projects-monorepo-infra](projects/projects-monorepo-infra.md) | build | review-required | Name the token-spend-avoided metric | metric decision | 2026-07-16 |
| cortex *(no card yet)* | maintain | review-required | Decide: fill Reddit OAuth creds, or drop Reddit ingest (project's own docs call the API "increasingly restrictive") | Reddit creds / keep-or-drop call | 2026-07-12 |
| CodexBar *(no card yet)* | maintain | review-required | Decide upstream-sync strategy (1056 commits behind steipete/CodexBar); scrub steipete paths/bundle-ID from docs | sync strategy | 2026-07-12 |
| CodexBarLocal *(no card yet)* | maintain | review-required | 34 uncommitted changes incl. a stray Xcode scaffold — commit, stash, or discard | commit/stash/discard call | 2026-07-12 |
| CodexBar-ESP32 *(no card yet)* | maintain | autonomous-ok | None right now — mDNS doc gap fixed today; 3 minor observations items open (untested pace-formula dup, lock-ordering) | none | 2026-07-12 |
| localsky_radar *(no card yet)* | build | review-required | `git init` + first commit (2,227 lines, zero version control); rewrite CLAUDE.md (wrongly claims unimplemented) | confirm git-init | 2026-07-12 |

## Decision queue

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

- **nanoclaw metric:** Proposed metric = "decisions James answers per week from daily brief" (proxy for brief usefulness). Confirm or rename? (Card currently undefined.)
- **RoadSense launch questions (5):** auth model, generalization city, GIS license, creator posture, 30-day success metric. Sent via Telegram 2026-07-08.
- **nanoclaw push() fix deploy (2026-07-20):** Fix implemented (d4ef752), 135 tests pass. Awaiting Studio deploy. Design decisions made autonomously: (1) follow-up-turn UX = yes; (2) Claude safety net = yes; (3) tests = yes.

## Recently shipped

- **nanoclaw** 2026-07-27 — Phase 1.5 per-class model selection committed (8648c70): `CLAUDE_PLANNING_MODEL` env var routes planning tasks (autopilot cycles) to opus; base model (sonnet) used for tool-work and chat. `resolveClaudeModel()` exported + 4 unit tests (26–29). To activate: add `CLAUDE_PLANNING_MODEL=opus` to `.env` on Studio after deploy.
- **nanoclaw** 2026-07-27 — Phase 1 classifyTask committed (3aa7f25): `needsClaudeCode(): boolean` replaced with `classifyTask(): TaskClass` ('planning' | 'tool-work' | 'chat'); autopilot/portfolio `<task>` messages now classified as 'planning'; 3 new direct unit tests (23–25). TypeScript clean; awaiting Studio deploy.
- **nanoclaw** 2026-07-26 — Phase 0.6+1 committed (5f98659): Codex-first default provider, task/file/autopilot classifier fixes, 4 RED→GREEN tests; bundled pending Phase 0.6 routing + test-split changes. Awaiting Studio deploy.
- **qip-qien** 2026-07-25 — SEV-1 auth fix pushed to GitHub (ee84254): admin routes now use `requireAdminApiUser` on 4 endpoints; approved by James, pushed by Nano.
- **nanoclaw** 2026-07-26 — SqliteError delivery bug fixed (349ed2e): `writeOutboundDirect` now uses read-write opener; poll loops isolate per-session errors. TypeScript clean; awaiting Studio deploy + tests.
- **nanoclaw** 2026-07-25 — false-positive ceiling kills on Agent sub-agent runs fixed (0ac74eb): `decideStuckAction()` exempts non-Bash tools from the 30-min heartbeat ceiling when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min). Two new tests.
- **Portfolio audit** 2026-07-12 — parallel survey across 20 projects; auto-fixed 10 AGENTS.md/CLAUDE.md symmetry gaps, an mDNS doc gap (CodexBar-ESP32), a stale phase claim (MentorCore), a wrong-hostname ntfy-link bug (pool-monitor, verified via 76 passing tests), a test-isolation gap (AgenticLever, verified via 32 passing tests), and this file's own Tailscale hostname drift.
- **nanoclaw** 2026-07-08 — fix(router): mention-sticky Slack session scoping committed

-| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (6 commits: 5f98659→8648c70); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |

+| [nanoclaw](projects/nanoclaw.md) | build | autonomous-ok | Deploy to Studio (7 commits: 5f98659→519ec56); run `pnpm test`; add `CLAUDE_PLANNING_MODEL=opus` to .env; confirm metric | deploy, metric | 2026-07-27 |