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 @@
-
+
@@ -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', () => {