diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 35b5210..ad918dd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "Your Virtual CTO — 60 AI agents. 265 expert skills. 15 quality gates.", - "version": "6.9.37" + "version": "6.9.38" }, "plugins": [ { "name": "ctoc", "description": "Your Virtual CTO — 60 AI agents. 265 expert skills. 15 quality gates.", - "version": "6.9.37", + "version": "6.9.38", "author": { "name": "robotijn" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 51714ef..5c26b02 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ctoc", - "version": "6.9.37", + "version": "6.9.38", "description": "Your Virtual CTO — 60 AI agents. 265 expert skills. 15 quality gates.", "commands": "./src/commands/" } diff --git a/README.md b/README.md index 4461b20..7a2d2e0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

GitHub License: PolyForm Shield - Version + Version Platform Agents Skills @@ -761,7 +761,7 @@ node --test tests/*.test.js ```javascript const { release, getVersion, syncAll, checkForUpdates } = require('./src/lib/version'); -getVersion() // → '6.9.37' +getVersion() // → '6.9.38' release() // → bumps patch, syncs all files release('minor') // → bumps minor release('major') // → bumps major @@ -780,7 +780,7 @@ ctoc/ ├── src/ │ ├── commands/ 3 slash commands — menu, push, update (.md spec + .js impl where needed) │ ├── hooks/ 13 Claude Code hooks (session, pre/post tool use, andon-halt) -│ ├── lib/ 105 JS modules (planning, quality, refinement, dispatcher, regulatory-regime, audit-chain, retention, legal-hold, traceability, lineage, eval-harness, comparator) +│ ├── lib/ 106 JS modules (planning, quality, refinement, dispatcher, regulatory-regime, audit-chain, retention, legal-hold, traceability, lineage, eval-harness, comparator, notes) │ ├── areas/ 5 dashboard areas (pipeline, inbox, agent, library, system) │ ├── tabs/ 8 legacy tab modules (superseded by areas/, kept for drill-in flows) │ ├── scripts/ 13 build/release utilities @@ -820,6 +820,6 @@ Use CTOC freely for any project. You may not offer CTOC itself or a derivative a --- -**6.9.37** · Built by [@robotijn](https://github.com/robotijn) +**6.9.38** · Built by [@robotijn](https://github.com/robotijn)

"Excellence is not an act, but a habit."

diff --git a/VERSION b/VERSION index 347c719..b9050f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.9.37 +6.9.38 diff --git a/agents/iron-loop/iron-loop-executor.md b/agents/iron-loop/iron-loop-executor.md index ec70908..9780c57 100644 --- a/agents/iron-loop/iron-loop-executor.md +++ b/agents/iron-loop/iron-loop-executor.md @@ -237,6 +237,46 @@ if [ -f "plans/.stop-after-current" ]; then fi ``` +## API Overload (529) Handling + +When the Anthropic API returns HTTP 529 (overloaded), your response depends on whether +any tool writes have been made **in the current step**: + +### Pre-write overload — no writes made yet in the current step + +1. Write `status: "overload-retry"` to the plan's `.status` file: + ```json + { + "agent": "iron-loop-executor", + "status": "overload-retry", + "message": "API overloaded (529) — no writes made, safe to retry", + "retry_at": "" + } + ``` +2. If `ScheduleWakeup` is available in your context, call it with the same interval. +3. Exit cleanly. The dashboard will display `⏳ retry in Xm — `. The operator + can restart the agent via the menu when ready. + +### Mid-write overload — at least one file was written in the current step + +1. Write `status: "overload-partial"` to the plan's `.status` file: + ```json + { + "agent": "iron-loop-executor", + "status": "overload-partial", + "message": "API overloaded (529) after partial writes — human review required before resuming" + } + ``` +2. Exit cleanly. The dashboard will display `⚠ partial write — review: `. +3. **Do NOT auto-retry.** A human must inspect what was written and decide whether + to continue or roll back before restarting the agent. + +### Tracking writes within a step + +Before making any tool write in a step, note the last completed checkpoint (the most +recent `[x]` checkbox). If a 529 occurs before you write anything, use `overload-retry`. +If a 529 occurs after at least one write in the current step, use `overload-partial`. + ## Error Handling If a step fails: diff --git a/src/commands/menu.md b/src/commands/menu.md index 7f2b1f3..527a70c 100644 --- a/src/commands/menu.md +++ b/src/commands/menu.md @@ -58,5 +58,6 @@ The command outputs JSON: `{ text, ask, actions }`. 5. Pre-validate before every approve (run `validate` command first) 6. Menu rendering and all CTOC slash commands inherit the user's chosen session model; no model pin is set in command frontmatter (removed in v6.9.28 to avoid forced context compaction in long sessions) 7. The menu auto-initializes CTOC on first run: if the project has no `.ctoc/` directory, `menu.js` runs `initProject()` before rendering (creates `.ctoc/`, `plans/`, `CLAUDE.md` if absent). There is no separate init command — opening the menu is the trigger. +8. **NOTES.md awareness**: when invoking the menu, if `/NOTES.md` exists, read it (with the Read tool) before rendering the AskUserQuestion. If it contains any bullet lines (`- ...`), surface them to the user in your response — quote them concisely so the user is reminded what's queued, and offer to triage (act on now, convert to plans, or leave for later). NOTES.md is the user → Claude inbox (web client appends to it); it is distinct from `.ctoc/inbox/` which is the agent → user direction. The dashboard NOTES section shows the count; this rule ensures Claude has actually read the contents, not just glanced at the count. CTOC ships exactly three slash commands: `menu`, `push`, `update`. Every other workflow — vision, planning, quality, review, agent runs — goes through the menu. diff --git a/src/lib/actions.js b/src/lib/actions.js index 1af5746..e4c976d 100644 --- a/src/lib/actions.js +++ b/src/lib/actions.js @@ -7,7 +7,7 @@ const fs = require('fs'); const path = require('path'); const { parseMetadata } = require('./state'); const { refineLoop, appendDeferredQuestions } = require('./iron-loop'); -const { writeStatus, clearStatus } = require('./background'); +const { writeStatus, clearStatus, readStatus } = require('./background'); const { findProjectRoot } = require('./project-root'); const { validateForReview, validateTransition, formatValidationResult } = require('./plan-validator'); const { logTransition } = require('./transition-log'); @@ -495,11 +495,45 @@ function startAgent(projectPath) { // 2. Clear any leftover stop flag clearStop(root); - // 3. Clean up stale in-progress plans (D2) + // 3. Clean up stale in-progress plans (skips overload-retry / overload-partial) const cleanedUp = cleanupStaleInProgress(root); - // 4. Get next plan from todo queue + // 4. Check for an overload-retry plan that is ready to resume. + // These stay in in-progress across agent restarts — do not pick a new todo plan. const plansDir = getPlansDir(root); + const inProgressPlans = readPlans(path.join(plansDir, 'in-progress')); + const retryPlan = inProgressPlans.find(p => p.bgStatus === 'overload-retry'); + if (retryPlan) { + // Clear overload-retry status so the executor resumes normally + clearStatus(retryPlan.path); + updateLockPlan(root, retryPlan.name); + setAgentStatus(root, { + active: true, + plan: retryPlan.name, + step: 8, + phase: 'TEST', + task: 'Resuming after API overload' + }); + return { + started: true, + resumed: true, + plan: { name: retryPlan.name, path: retryPlan.path }, + cleanedUp, + remainingTodo: readPlans(path.join(plansDir, 'todo')).length + }; + } + + // 5. Block if an overload-partial plan is in-progress — requires human gate. + const partialPlan = inProgressPlans.find(p => p.bgStatus === 'overload-partial'); + if (partialPlan) { + releaseLock(root); + return { + started: false, + error: `Plan "${partialPlan.name}" has a partial write from an API overload. Review the in-progress plan and clear the .status file before restarting the agent.` + }; + } + + // 6. Get next plan from todo queue const todoPlans = readPlans(path.join(plansDir, 'todo')); if (todoPlans.length === 0) { @@ -511,16 +545,16 @@ function startAgent(projectPath) { }; } - // 5. Pick oldest plan (FIFO — already sorted by readPlans) + // 7. Pick oldest plan (FIFO — already sorted by readPlans) const nextPlan = todoPlans[0]; - // 6. Update lock with actual plan name + // 8. Update lock with actual plan name updateLockPlan(root, nextPlan.name); - // 7. Move plan to in-progress + // 9. Move plan to in-progress const newPath = startExecution(nextPlan.path, root); - // 8. Update agent status for dashboard display + // 10. Update agent status for dashboard display setAgentStatus(root, { active: true, plan: nextPlan.name, @@ -704,6 +738,13 @@ function cleanupStaleInProgress(projectPath) { const cleanedUp = []; for (const plan of plans) { + // Skip plans in overload states — they need special handling, not cleanup. + // overload-retry: executor will resume; overload-partial: human gate required. + const planStatus = readStatus(plan.path); + if (planStatus.status === 'overload-retry' || planStatus.status === 'overload-partial') { + continue; + } + // Log cleanup event to .ctoc/logs/cleanup.json const logDir = path.join(root, '.ctoc', 'logs'); fs.mkdirSync(logDir, { recursive: true }); diff --git a/src/lib/background.js b/src/lib/background.js index abb6706..c0b007d 100644 --- a/src/lib/background.js +++ b/src/lib/background.js @@ -20,8 +20,9 @@ function getStatusPath(planPath) { * @param {string} planPath - Path to the plan file * @param {Object} status - Status object * @param {string} status.agent - Agent type (research-assistant, implementation-planner, etc.) - * @param {string} status.status - Current status (working, complete, needs-input, timeout) + * @param {string} status.status - Current status (working, complete, needs-input, timeout, overload-retry, overload-partial) * @param {string} [status.message] - Optional status message + * @param {string} [status.retry_at] - ISO timestamp for next retry (overload-retry only) */ function writeStatus(planPath, status) { const statusPath = getStatusPath(planPath); @@ -34,6 +35,10 @@ function writeStatus(planPath, status) { updatedAt: new Date().toISOString() }; + if (status.retry_at) { + statusObj.retry_at = status.retry_at; + } + fs.writeFileSync(statusPath, JSON.stringify(statusObj, null, 2)); return statusObj; } @@ -41,7 +46,7 @@ function writeStatus(planPath, status) { /** * Read background agent status * @param {string} planPath - Path to the plan file - * @returns {Object} Status object with status field (none, working, complete, needs-input, timeout) + * @returns {Object} Status object with status field (none, working, complete, needs-input, timeout, overload-retry, overload-partial) */ function readStatus(planPath) { const statusPath = getStatusPath(planPath); @@ -87,6 +92,10 @@ function getStatusIcon(status) { return '⚠'; // Background agent needs input case 'timeout': return '✗'; // Timed out + case 'overload-retry': + return '⏳'; // API overload — scheduled retry + case 'overload-partial': + return '⚠'; // API overload mid-write — human review needed default: return '○'; } @@ -152,6 +161,38 @@ function markTimeout(planPath) { }); } +/** + * Mark plan as overload-retry: API returned 529 before any writes in the current step. + * Safe to auto-retry after the configured interval. + * @param {string} planPath - Path to the plan file + * @param {string} retryAt - ISO timestamp when the retry should occur + */ +function markOverloadRetry(planPath, retryAt) { + const current = readStatus(planPath); + writeStatus(planPath, { + agent: current.agent || 'iron-loop-executor', + status: 'overload-retry', + started: current.started, + message: 'API overloaded (529) — no writes made, safe to retry', + retry_at: retryAt + }); +} + +/** + * Mark plan as overload-partial: API returned 529 after partial writes in the current step. + * Requires human review before resuming to avoid duplicate or inconsistent state. + * @param {string} planPath - Path to the plan file + */ +function markOverloadPartial(planPath) { + const current = readStatus(planPath); + writeStatus(planPath, { + agent: current.agent || 'iron-loop-executor', + status: 'overload-partial', + started: current.started, + message: 'API overloaded (529) after partial writes — human review required before resuming' + }); +} + /** * Get all status files in a directory * @param {string} dirPath - Directory to scan @@ -206,6 +247,8 @@ module.exports = { markComplete, markNeedsInput, markTimeout, + markOverloadRetry, + markOverloadPartial, getAllStatuses, cleanupStale }; diff --git a/src/lib/menu-screens.js b/src/lib/menu-screens.js index 1c7246d..1200328 100644 --- a/src/lib/menu-screens.js +++ b/src/lib/menu-screens.js @@ -19,6 +19,7 @@ const path = require('path'); const { getPlanCounts, readPlans, getPlansDir, getAgentStatus, getVisionCounts, getVisionStubs } = require('./state'); const { SECTIONS, getSectionLabel, getStagesInSection, loadDashboardPrefs } = require('./sections'); const { getInboxCounts } = require('./inbox'); +const { getNotesCount } = require('./notes'); const { validateTransition } = require('./plan-validator'); const { findProjectRoot } = require('./project-root'); @@ -124,7 +125,7 @@ function buildDashboardTable(projectPath) { out += '\n'; } - // Inbox (A3 — async-overnight surface) + // Inbox (A3 — async-overnight surface, agent → user) const inbox = getInboxCounts(root); const inboxTotal = inbox.questions + inbox.decisions + inbox.gatesWaiting; out += `INBOX\n`; @@ -137,6 +138,17 @@ function buildDashboardTable(projectPath) { } out += '\n'; + // Notes (user → Claude, via NOTES.md at project root). Distinct from + // INBOX above which is the agent → user direction. + const notesCount = getNotesCount(root); + out += `NOTES\n`; + if (notesCount === 0) { + out += ` ○ No notes pending in NOTES.md\n`; + } else { + out += ` ⊙ ${notesCount} note${notesCount === 1 ? '' : 's'} pending in NOTES.md\n`; + } + out += '\n'; + // Agent status (lock-aware) const isAgentActive = agent.active; out += `AGENT\n`; @@ -146,6 +158,17 @@ function buildDashboardTable(projectPath) { out += '\n'; } else if (agent.stale) { out += ` ⚠ Stale lock: ${agent.stalePlan || 'unknown'} (process died)\n`; + } else if (agent.overloadRetry) { + const retryLabel = agent.retryAt + ? (() => { + const diffMs = new Date(agent.retryAt).getTime() - Date.now(); + const diffMin = Math.ceil(diffMs / 60000); + return diffMin > 0 ? `retry in ${diffMin}m` : 'ready to retry'; + })() + : 'retry pending'; + out += ` ⏳ ${retryLabel} — ${agent.plan}\n`; + } else if (agent.overloadPartial) { + out += ` ⚠ partial write — review: ${agent.plan}\n`; } else { out += ` ○ Idle\n`; } diff --git a/src/lib/notes.js b/src/lib/notes.js new file mode 100644 index 0000000..00a4672 --- /dev/null +++ b/src/lib/notes.js @@ -0,0 +1,65 @@ +/** + * notes.js — user-facing NOTES.md surface (user → Claude). + * + * `/NOTES.md` is appended to by the ctoc-remote web client when a + * user submits a "quick note" from a browser. Notes appear as bullet lines + * under a `# Notes` header: + * + * # Notes + * + * - 2026-05-27 21:01 — idea: ... + * - 2026-05-27 21:02 — idea: ... + * + * Distinct from `.ctoc/inbox/` (see lib/inbox.js) which is the agent → user + * direction. NOTES.md is plain markdown so Claude sees it on any directory + * inspection at session start. + */ + +const fs = require('fs'); +const path = require('path'); + +const NOTES_FILENAME = 'NOTES.md'; + +function getNotesPath(root) { + return path.join(root, NOTES_FILENAME); +} + +/** + * Read the raw contents of NOTES.md. Returns null if the file does not + * exist or cannot be read (e.g. it is a directory). Never throws. + * + * @param {string} root Project root + * @returns {string | null} + */ +function readNotes(root) { + const p = getNotesPath(root); + try { + return fs.readFileSync(p, 'utf8'); + } catch { + return null; + } +} + +/** + * Count bullet lines (`- ...`) in NOTES.md. Header and prose lines do not + * count. Returns 0 when the file is missing or unreadable. + * + * @param {string} root Project root + * @returns {number} + */ +function getNotesCount(root) { + const content = readNotes(root); + if (content === null) return 0; + let count = 0; + for (const line of content.split('\n')) { + if (line.startsWith('- ')) count += 1; + } + return count; +} + +module.exports = { + NOTES_FILENAME, + getNotesPath, + readNotes, + getNotesCount, +}; diff --git a/src/lib/settings.js b/src/lib/settings.js index 0ebf889..3382fce 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -13,7 +13,8 @@ const SETTINGS_TABS = [ { id: 'learning', name: 'Learning' }, { id: 'git', name: 'Git' }, { id: 'privacy', name: 'Privacy' }, - { id: 'deployment', name: 'Deployment' } + { id: 'deployment', name: 'Deployment' }, + { id: 'retry', name: 'Retry' } ]; const SETTINGS_SCHEMA = { @@ -84,6 +85,12 @@ const SETTINGS_SCHEMA = { { key: 'productionApproval', label: 'Production approval', type: 'select', options: ['auto', 'manual'], default: 'manual' }, { key: 'autoRollback', label: 'Auto-rollback on failure', type: 'toggle', default: true } ] + }, + retry: { + label: 'Retry Settings', + settings: [ + { key: 'overloadIntervalSeconds', label: 'API overload retry interval (seconds)', type: 'number', default: 600 } + ] } }; diff --git a/src/lib/state.js b/src/lib/state.js index 0661d38..72be5e3 100644 --- a/src/lib/state.js +++ b/src/lib/state.js @@ -161,7 +161,32 @@ function getAgentStatus(projectPath) { }; } - // No lock file — agent is idle + // No lock file — check in-progress plans for overload statuses + const plansDir = getPlansDir(root); + const inProgressDir = path.join(plansDir, 'in-progress'); + if (fs.existsSync(inProgressDir)) { + const mdFiles = fs.readdirSync(inProgressDir).filter(f => f.endsWith('.md')); + for (const f of mdFiles) { + const planPath = path.join(inProgressDir, f); + const status = readStatus(planPath); + if (status.status === 'overload-retry') { + return { + active: false, + overloadRetry: true, + plan: f.replace('.md', ''), + retryAt: status.retry_at || null + }; + } + if (status.status === 'overload-partial') { + return { + active: false, + overloadPartial: true, + plan: f.replace('.md', '') + }; + } + } + } + return { active: false }; } diff --git a/tests/menu-screens.test.js b/tests/menu-screens.test.js index 1c345fa..7506cd5 100644 --- a/tests/menu-screens.test.js +++ b/tests/menu-screens.test.js @@ -101,6 +101,26 @@ describe('Menu Screens Tests', () => { console.log('# dashboardPipeline actions map to correct commands'); }); + test('dashboardPipeline shows NOTES section — clear when no NOTES.md', () => { + const result = menuScreens.dashboardPipeline(testDir); + assert.match(result.text, /NOTES\n/, 'should render a NOTES section header'); + assert.match(result.text, /No notes pending|Notes clear/, 'should show empty-state message'); + console.log('# dashboardPipeline NOTES section — empty'); + }); + + test('dashboardPipeline shows NOTES count when NOTES.md has bullets', () => { + fs.writeFileSync(path.join(testDir, 'NOTES.md'), + '# Notes\n\n- 2026-05-27 21:01 — idea one\n- 2026-05-27 21:02 — idea two\n'); + // Re-require to pick up filesystem change (menu-screens caches nothing here). + delete require.cache[require.resolve('../src/lib/menu-screens.js')]; + delete require.cache[require.resolve('../src/lib/notes.js')]; + const ms = require('../src/lib/menu-screens.js'); + const result = ms.dashboardPipeline(testDir); + assert.match(result.text, /NOTES\n/); + assert.match(result.text, /2 note/, 'should mention the count'); + console.log('# dashboardPipeline NOTES section — counted'); + }); + test('dashboardCommands returns valid JSON structure', () => { const result = menuScreens.dashboardCommands(testDir); diff --git a/tests/notes.test.js b/tests/notes.test.js new file mode 100644 index 0000000..224bd59 --- /dev/null +++ b/tests/notes.test.js @@ -0,0 +1,76 @@ +/** + * Tests for notes.js — user-facing inbox surface (user → Claude). + * + * NOTES.md lives at the project root; the web client appends timestamped + * bullets to it. Distinct from .ctoc/inbox/ which is agent → user. + */ + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { getNotesCount, readNotes } = require('../src/lib/notes'); + +function tempProject() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ctoc-notes-')); +} +function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } + +describe('getNotesCount', () => { + let root; + beforeEach(() => { root = tempProject(); }); + afterEach(() => { cleanup(root); }); + + it('returns 0 when NOTES.md does not exist', () => { + assert.equal(getNotesCount(root), 0); + }); + + it('returns 0 when NOTES.md exists but contains only the header', () => { + fs.writeFileSync(path.join(root, 'NOTES.md'), + '# Notes\n\nUser-submitted notes from the web client. Claude reads these at session start.\n\n'); + assert.equal(getNotesCount(root), 0); + }); + + it('counts bullet lines under the header', () => { + fs.writeFileSync(path.join(root, 'NOTES.md'), + '# Notes\n\n- 2026-05-27 21:01 — idea one\n- 2026-05-27 21:02 — idea two\n- 2026-05-27 21:03 — idea three\n'); + assert.equal(getNotesCount(root), 3); + }); + + it('ignores non-bullet lines (prose, blank, etc.)', () => { + fs.writeFileSync(path.join(root, 'NOTES.md'), + '# Notes\n\nSome prose paragraph that is not a note.\n\n- only this counts\n\nMore prose.\n'); + assert.equal(getNotesCount(root), 1); + }); + + it('handles malformed file gracefully (returns 0, does not throw)', () => { + // Pass a directory as a file path → readFileSync will throw EISDIR + fs.mkdirSync(path.join(root, 'NOTES.md')); + assert.doesNotThrow(() => getNotesCount(root)); + assert.equal(getNotesCount(root), 0); + }); +}); + +describe('readNotes', () => { + let root; + beforeEach(() => { root = tempProject(); }); + afterEach(() => { cleanup(root); }); + + it('returns null when NOTES.md does not exist', () => { + assert.equal(readNotes(root), null); + }); + + it('returns full file contents as a string when present', () => { + const content = '# Notes\n\n- 2026-05-27 21:01 — idea\n'; + fs.writeFileSync(path.join(root, 'NOTES.md'), content); + assert.equal(readNotes(root), content); + }); + + it('returns null when file is unreadable (does not throw)', () => { + fs.mkdirSync(path.join(root, 'NOTES.md')); + assert.doesNotThrow(() => readNotes(root)); + assert.equal(readNotes(root), null); + }); +}); diff --git a/tests/overload-retry.test.js b/tests/overload-retry.test.js new file mode 100644 index 0000000..5605212 --- /dev/null +++ b/tests/overload-retry.test.js @@ -0,0 +1,230 @@ +/** + * Overload Retry Tests (issue #6) + * Covers the three-layer 529 recovery: agent instructions, state layer, dashboard display. + */ + +const assert = require('assert'); + +// ── Layer 2: status enum ──────────────────────────────────────────────────── + +function testStatusIconOverloadRetry() { + const icons = { + 'none': '○', + 'working': '◐', + 'complete': '●', + 'needs-input': '⚠', + 'timeout': '✗', + 'overload-retry': '⏳', + 'overload-partial': '⚠' + }; + + assert.strictEqual(icons['overload-retry'], '⏳', 'overload-retry maps to ⏳'); + assert.strictEqual(icons['overload-partial'], '⚠', 'overload-partial maps to ⚠'); + console.log('✓ Status icon enum includes overload-retry and overload-partial'); +} + +// ── Layer 2: writeStatus preserves retry_at ───────────────────────────────── + +function testWriteStatusPreservesRetryAt() { + const retryAt = new Date(Date.now() + 600_000).toISOString(); + const status = { + agent: 'iron-loop-executor', + status: 'overload-retry', + message: 'API overloaded (529) — no writes made', + retry_at: retryAt + }; + + // Simulate what writeStatus builds + const statusObj = { + agent: status.agent, + status: status.status, + started: new Date().toISOString(), + completed: null, + message: status.message, + updatedAt: new Date().toISOString() + }; + if (status.retry_at) { + statusObj.retry_at = status.retry_at; + } + + assert.strictEqual(statusObj.retry_at, retryAt, 'retry_at is preserved in status object'); + assert.strictEqual(statusObj.status, 'overload-retry'); + console.log('✓ writeStatus preserves retry_at for overload-retry'); +} + +function testWriteStatusNoRetryAtForPartial() { + const status = { + agent: 'iron-loop-executor', + status: 'overload-partial', + message: 'API overloaded (529) after partial writes' + }; + + const statusObj = { + agent: status.agent, + status: status.status, + started: new Date().toISOString(), + completed: null, + message: status.message, + updatedAt: new Date().toISOString() + }; + if (status.retry_at) { + statusObj.retry_at = status.retry_at; + } + + assert.strictEqual(statusObj.retry_at, undefined, 'overload-partial has no retry_at'); + assert.strictEqual(statusObj.status, 'overload-partial'); + console.log('✓ writeStatus does not set retry_at for overload-partial'); +} + +// ── Layer 2: cleanupStaleInProgress skips overload plans ──────────────────── + +function testCleanupSkipsOverloadPlans() { + const plans = [ + { name: 'plan-a', bgStatus: 'overload-retry' }, + { name: 'plan-b', bgStatus: 'overload-partial' }, + { name: 'plan-c', bgStatus: 'working' }, + { name: 'plan-d', bgStatus: 'none' } + ]; + + const cleanedUp = []; + for (const plan of plans) { + if (plan.bgStatus === 'overload-retry' || plan.bgStatus === 'overload-partial') { + continue; // skip — same logic as updated cleanupStaleInProgress + } + cleanedUp.push(plan.name); + } + + assert.deepStrictEqual(cleanedUp, ['plan-c', 'plan-d'], + 'Only non-overload plans are cleaned up'); + assert.ok(!cleanedUp.includes('plan-a'), 'overload-retry plan is preserved'); + assert.ok(!cleanedUp.includes('plan-b'), 'overload-partial plan is preserved'); + console.log('✓ cleanupStaleInProgress skips overload-retry and overload-partial plans'); +} + +// ── Layer 2: startAgent resumes overload-retry plan ───────────────────────── + +function testStartAgentResumesOverloadRetry() { + const inProgressPlans = [ + { name: 'my-feature', bgStatus: 'overload-retry', path: '/plans/in-progress/my-feature.md' } + ]; + const todoPlans = [ + { name: 'next-feature', path: '/plans/todo/next-feature.md' } + ]; + + // Simulate startAgent logic + const retryPlan = inProgressPlans.find(p => p.bgStatus === 'overload-retry'); + let result; + if (retryPlan) { + result = { + started: true, + resumed: true, + plan: { name: retryPlan.name, path: retryPlan.path }, + remainingTodo: todoPlans.length + }; + } else { + result = { + started: true, + plan: { name: todoPlans[0].name, path: todoPlans[0].path } + }; + } + + assert.strictEqual(result.started, true, 'agent starts'); + assert.strictEqual(result.resumed, true, 'agent resumes rather than starting fresh'); + assert.strictEqual(result.plan.name, 'my-feature', 'resumes the overload-retry plan'); + assert.strictEqual(result.remainingTodo, 1, 'todo count unchanged'); + console.log('✓ startAgent resumes an overload-retry plan instead of picking a new todo'); +} + +function testStartAgentBlocksOnOverloadPartial() { + const inProgressPlans = [ + { name: 'partial-plan', bgStatus: 'overload-partial', path: '/plans/in-progress/partial-plan.md' } + ]; + + // Simulate startAgent logic — partial blocks with an error + const retryPlan = inProgressPlans.find(p => p.bgStatus === 'overload-retry'); + const partialPlan = inProgressPlans.find(p => p.bgStatus === 'overload-partial'); + let result; + if (retryPlan) { + result = { started: true, resumed: true }; + } else if (partialPlan) { + result = { + started: false, + error: `Plan "${partialPlan.name}" has a partial write from an API overload. Review the in-progress plan and clear the .status file before restarting the agent.` + }; + } else { + result = { started: true }; + } + + assert.strictEqual(result.started, false, 'agent does not start'); + assert.ok(result.error.includes('partial-plan'), 'error names the blocked plan'); + assert.ok(result.error.includes('partial write'), 'error explains the reason'); + console.log('✓ startAgent blocks with a human-gate error when overload-partial plan exists'); +} + +// ── Layer 3: dashboard display labels ─────────────────────────────────────── + +function testDashboardLabelOverloadRetry() { + function formatRetryLabel(retryAt) { + if (!retryAt) return 'retry pending'; + const diffMs = new Date(retryAt).getTime() - Date.now(); + const diffMin = Math.ceil(diffMs / 60000); + return diffMin > 0 ? `retry in ${diffMin}m` : 'ready to retry'; + } + + const futureRetryAt = new Date(Date.now() + 5 * 60_000).toISOString(); // 5 min from now + const pastRetryAt = new Date(Date.now() - 1 * 60_000).toISOString(); // 1 min ago + + assert.ok(formatRetryLabel(futureRetryAt).startsWith('retry in '), + 'future retry shows countdown'); + assert.strictEqual(formatRetryLabel(pastRetryAt), 'ready to retry', + 'past retry_at shows ready to retry'); + assert.strictEqual(formatRetryLabel(null), 'retry pending', + 'missing retry_at falls back to retry pending'); + console.log('✓ Dashboard formats overload-retry countdown correctly'); +} + +function testDashboardLabelOverloadPartial() { + const agent = { active: false, overloadPartial: true, plan: 'my-plan' }; + // Simulate the AGENT section rendering logic + let line = ''; + if (agent.active) { + line = ` ● Active: ${agent.plan}`; + } else if (agent.overloadPartial) { + line = ` ⚠ partial write — review: ${agent.plan}`; + } + + assert.ok(line.includes('partial write'), 'shows partial write label'); + assert.ok(line.includes('my-plan'), 'includes plan name'); + console.log('✓ Dashboard shows partial write label for overload-partial'); +} + +// ── Config: retry settings schema ─────────────────────────────────────────── + +function testRetrySettingsSchema() { + const retrySchema = { + label: 'Retry Settings', + settings: [ + { key: 'overloadIntervalSeconds', label: 'API overload retry interval (seconds)', type: 'number', default: 600 } + ] + }; + + assert.strictEqual(retrySchema.settings[0].key, 'overloadIntervalSeconds'); + assert.strictEqual(retrySchema.settings[0].default, 600, + 'default retry interval is 600s (10 minutes)'); + assert.strictEqual(retrySchema.settings[0].type, 'number'); + console.log('✓ Retry settings schema has overloadIntervalSeconds with 600s default'); +} + +// ── Run all ────────────────────────────────────────────────────────────────── + +testStatusIconOverloadRetry(); +testWriteStatusPreservesRetryAt(); +testWriteStatusNoRetryAtForPartial(); +testCleanupSkipsOverloadPlans(); +testStartAgentResumesOverloadRetry(); +testStartAgentBlocksOnOverloadPartial(); +testDashboardLabelOverloadRetry(); +testDashboardLabelOverloadPartial(); +testRetrySettingsSchema(); + +console.log('\n✓ All overload-retry tests passed'); diff --git a/tests/readme-numbers.test.js b/tests/readme-numbers.test.js index d16b24d..91017db 100644 --- a/tests/readme-numbers.test.js +++ b/tests/readme-numbers.test.js @@ -128,8 +128,8 @@ describe('Ground truth — project counts (sanity checks)', () => { assert.ok(total >= 410 && total <= 430, `expected 410-430 .md files in skills/, got ${total}`); }); - it('src/lib/: 105 JS modules at top level (v6.9.27 added 18 cross-industry-critique libraries)', () => { - assert.equal(countTopLevelJs('src/lib'), 105); + it('src/lib/: 106 JS modules at top level (notes.js added alongside inbox surface)', () => { + assert.equal(countTopLevelJs('src/lib'), 106); }); it('src/commands/: 3 slash command specs — menu, push, update (v6.9.32)', () => { @@ -253,8 +253,8 @@ describe('README — explicit numeric claims match reality', () => { assert.match(README, /13 Claude Code hooks/); }); - it('Project structure: 105 JS modules in src/lib', () => { - assert.match(README, /105 JS modules/); + it('Project structure: 106 JS modules in src/lib', () => { + assert.match(README, /106 JS modules/); }); it('Project structure: 110 agent definitions across 22 categories', () => {