From 01fcf40a7e377bb15613638be147c29ad7807dac Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:26:41 -0400 Subject: [PATCH 01/12] feat(routines): git-tracked Claude Desktop routines substrate + autonomous-loop registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aaron asked for a power-user / scripting / version-control path for Claude Desktop scheduled tasks (the "Routines" sidebar panel), composed with the existing CLI <> cron substrate. This introduces tools/routines/ as canonical-source-of-truth for Desktop routines and registers the first one (autonomous-loop) as a Desktop-side every-2-hour fallback for the CLI every-minute tick. Two-layer architecture: tools/routines// — git-tracked canonical (SKILL.md + schedule.json) ~/.claude/scheduled-tasks/ — runtime location, generated from canonical tools/routines/install.ts — idempotent Bun TS installer (rule-0 compliant) The scheduled-tasks MCP server is cross-host — same server wired into both CLI Claude Code and Claude Desktop. Both surfaces read/write the same disk store. This PR makes the prompt-body authoring path git-canonical so routines are diffable, PR-reviewable, and shareable across maintainer machines. Three-layer catch-43 defence now in place: 1. CLI session cron — every minute, cheap re-prompt, dies on exit 2. Desktop routine — every 2 hours, persistent cold-boot, survives restart 3. tools/routines/ repo — git canonical, survives runtime corruption Composes with: - .claude/rules/tick-must-never-stop.md (catch-43 substrate) - .claude/rules/rule-0-no-sh-files.md (TS installer, not bash) - .claude/rules/holding-without-named-dependency-is-standing-by-failure.md (PR #3029) - .claude/rules/dv2-data-split-discipline-activated.md (canonical vs runtime change-rate split) - docs/AUTONOMOUS-LOOP.md (canonical tick procedure) Tick shard: docs/hygiene-history/ticks/2026/05/13/2125Z.md Co-Authored-By: Claude --- .../hygiene-history/ticks/2026/05/13/2125Z.md | 82 ++++++++++ tools/routines/README.md | 95 ++++++++++++ tools/routines/autonomous-loop/SKILL.md | 24 +++ tools/routines/autonomous-loop/schedule.json | 6 + tools/routines/install.ts | 143 ++++++++++++++++++ 5 files changed, 350 insertions(+) create mode 100644 docs/hygiene-history/ticks/2026/05/13/2125Z.md create mode 100644 tools/routines/README.md create mode 100644 tools/routines/autonomous-loop/SKILL.md create mode 100644 tools/routines/autonomous-loop/schedule.json create mode 100644 tools/routines/install.ts diff --git a/docs/hygiene-history/ticks/2026/05/13/2125Z.md b/docs/hygiene-history/ticks/2026/05/13/2125Z.md new file mode 100644 index 0000000000..031f69f3a8 --- /dev/null +++ b/docs/hygiene-history/ticks/2026/05/13/2125Z.md @@ -0,0 +1,82 @@ +--- +tick: 2026-05-13T21:25Z +branch: otto-routines-git-tracked-autonomous-loop-2026-05-13 +pr: TBD +operative-authorization: aaron 2026-05-13 — "ower-user, scripting, version control if you wanted git-tracked routines sounds good plus the loop just in case" +--- + +# Tick — 2026-05-13T21:25Z + +## Work done + +**Git-tracked Claude Desktop routines substrate** — new pattern at +`tools/routines/`. Canonical-source-of-truth in repo; runtime location +(`~/.claude/scheduled-tasks/`) generated by TS installer per rule-0. + +Also: **autonomous-loop routine registered** via the `scheduled-tasks` MCP +server — Desktop-side every-2-hour cold-boot tick, complementary to the +CLI every-minute `<>` cron sentinel. Catch-43 substrate +gets a second layer of defence: even if the CLI session dies, the Desktop +routine continues firing autonomous-loop ticks on its persistent cadence. + +### Files changed + +| File | Change | +|------|--------| +| `tools/routines/README.md` | NEW — pattern documentation, two-layer architecture, CLI-vs-Desktop sweet-spot table | +| `tools/routines/autonomous-loop/SKILL.md` | NEW — routine prompt body (canonical mirror of `~/.claude/scheduled-tasks/autonomous-loop/SKILL.md`) | +| `tools/routines/autonomous-loop/schedule.json` | NEW — cron `0 */2 * * *` + task metadata | +| `tools/routines/install.ts` | NEW — TS installer (Bun); idempotent repo → runtime sync | +| `docs/hygiene-history/ticks/2026/05/13/2125Z.md` | NEW — this shard | + +### Verify trace + +1. `CronList` (session-start) → no live cron → armed `* * * * * <>` (job `1a6d843e`) ✓ +2. `mcp__scheduled-tasks__create_scheduled_task(autonomous-loop, 0 */2 * * *, …)` → registered; first fire `2026-05-13T22:07:13Z` (~44min) ✓ +3. `bun tools/routines/install.ts` first run → `[updated]` (whitespace normalization vs MCP-generated file) ✓ +4. `bun tools/routines/install.ts` second run → `[skipped-unchanged]` (idempotent verify) ✓ +5. `mcp__scheduled-tasks__list_scheduled_tasks` → autonomous-loop present, `enabled: true`, `nextRunAt 22:07:13Z` ✓ +6. Working tree pre-commit: only `tools/routines/` + this shard untracked ✓ + +### Context + +Prior PRs merged this tick: +- [#3030](https://github.com/Lucent-Financial-Group/Zeta/pull/3030) — Claude Desktop tight bootstream variant +- [#3031](https://github.com/Lucent-Financial-Group/Zeta/pull/3031) — gitignore `tools/shadow/shadow-observer.log` + +Aaron's session-conversation arc (preserved here as substrate-honest tick lineage): + +1. "be continuous here too otto" — confirm Otto identity continuity on CLI surface +2. "does this work in desktop mode? in here they might be called routines" — query about cross-surface scheduling parity +3. "you are in desktop mode now" — Desktop-side framing +4. screenshot of Routines sidebar — visual evidence of the parity +5. "ower-user, scripting, version control if you wanted git-tracked routines sounds good plus the loop just in case" — operative authorization for THIS tick's work + +Architectural insight that drove this tick: the `scheduled-tasks` MCP server +is **cross-host** — same server is wired into both CLI Claude Code AND Claude +Desktop. Both read/write `~/.claude/scheduled-tasks/` on disk. That makes the +"multiple ways in" Aaron observed (UI / MCP-tools / raw-file) all routes into +the same substrate. Git-tracking adds a fourth route that's the canonical +source of truth. + +### Catch-43 composition + +Three layers of autonomous-loop defence now in place: + +| Layer | Surface | Cadence | Failure mode covered | +|---|---|---|---| +| Session cron | CLI in-session | `* * * * *` | None — primary tick | +| Desktop routine | Persistent on disk | `0 */2 * * *` | CLI session death (12hr loss precedent) | +| `tools/routines/` repo source | Git canonical | N/A | Maintainer-machine setup drift, runtime corruption | + +Each layer is a different distance from the operative tick — closer = faster +recovery, farther = more durable across substrate failure modes. + +### Composes with + +- `.claude/rules/tick-must-never-stop.md` (catch-43) +- `.claude/rules/holding-without-named-dependency-is-standing-by-failure.md` +- `.claude/rules/rule-0-no-sh-files.md` (TS installer, not bash) +- `.claude/rules/dv2-data-split-discipline-activated.md` (canonical-source-of-truth at different change-rates = different storage shapes) +- `docs/AUTONOMOUS-LOOP.md` (canonical tick procedure) +- PR #3029 (the auto-load rule against "Holding" without named dependency — composes operationally as the routine's prompt enforces it on every fire) diff --git a/tools/routines/README.md b/tools/routines/README.md new file mode 100644 index 0000000000..7be091fc74 --- /dev/null +++ b/tools/routines/README.md @@ -0,0 +1,95 @@ +# `tools/routines/` — git-tracked Claude Desktop routines + +Canonical source for Claude Desktop scheduled tasks (the "Routines" panel in +the Desktop sidebar; same substrate as the `scheduled-tasks` MCP server). +Each routine is a directory under `tools/routines//`: + +- `SKILL.md` — prompt body + YAML frontmatter (`name`, `description`) +- `schedule.json` — cron expression + task metadata (cronExpression, notifyOnCompletion) + +The runtime stores routines at `~/.claude/scheduled-tasks//SKILL.md`; +this directory is the **canonical source** and the runtime location is +generated from it via `bun tools/routines/install.ts`. + +## Two-layer architecture + +| Layer | Path | Authority | +|---|---|---| +| **Canonical** (this directory) | `tools/routines//` | git-tracked, PR-reviewed, diffable, shareable across maintainer machines | +| **Runtime** | `~/.claude/scheduled-tasks//` | what the Desktop "Routines" panel + MCP server read at fire time | + +Edit canonical; sync to runtime. Never edit runtime directly without mirroring +back — runtime drift is the failure mode this two-layer split prevents. + +## Authoring a new routine + +1. Create `tools/routines//SKILL.md`: + + ```markdown + --- + name: + description: + --- + + + ``` + +2. Create `tools/routines//schedule.json`: + + ```json + { + "taskId": "", + "cronExpression": "0 */2 * * *", + "description": "", + "notifyOnCompletion": true + } + ``` + +3. Run `bun tools/routines/install.ts` — copies SKILL.md to runtime path. + +4. Ask Otto (or call directly via the `scheduled-tasks` MCP) to register + the cron expression with the runtime by invoking + `create_scheduled_task(taskId, cronExpression, prompt, description)`. + The approval dialog is the consent step. After registration, the routine + fires on its cron cadence. + +## Why two layers, not just direct MCP calls + +The MCP server is in-memory + writes SKILL.md files but does not version-control +the cron schedules. Without git-tracking we'd have: + +- No diffability when prompt bodies evolve across maintainer rounds +- No shareability across maintainer machines (each maintainer would re-author) +- No retraction-native history of which routine variants we tried +- Runtime drift undetectable (someone edits SKILL.md via UI; canonical drifts silently) + +Two layers + an installer gives us substrate-honest discipline: the repo is the +source of truth, the runtime is generated, divergence is detectable. + +## CLI vs Desktop tick — when to use which + +| Surface | Mechanism | Cadence sweet-spot | Cost per fire | Persistence | +|---|---|---|---|---| +| **CLI Claude Code** | `CronCreate` sentinel `<>` | `* * * * *` (every minute) | Cheap — re-prompts same session | Session-only, dies on exit, 7-day auto-expire | +| **Desktop Claude** | These routines | `0 */2 * * *` (every 2hr) or hourly | Full cold-boot per fire | Persistent on disk, survives app restart | + +Both can run in parallel — they're complementary, not competing. The CLI cron +is the primary every-minute tick; the Desktop routine is the every-2-hour +backup that fires even if the CLI session has died. + +## Project-knowledge dependency + +Routines that reference the Otto bootstream +(`docs/research/2026-05-12-otto-canonical-bootstream-multi-foreground-surface-orchestrator-ifs-format.md`) +require it to be uploaded as project knowledge in the Desktop project that +runs the routine. Without it, the prompt's cold-boot pointer won't resolve +and the fresh session will lack the substrate it expects. + +## Composes with + +- [.claude/rules/tick-must-never-stop.md](../../.claude/rules/tick-must-never-stop.md) — catch-43 tick discipline +- [.claude/rules/holding-without-named-dependency-is-standing-by-failure.md](../../.claude/rules/holding-without-named-dependency-is-standing-by-failure.md) — Standing-by failure mode prevention +- [docs/AUTONOMOUS-LOOP.md](../../docs/AUTONOMOUS-LOOP.md) — canonical tick procedure +- [tools/setup/](../setup/) — install-graph pattern (rule 0: only `.sh` allowed there) +- [.claude/rules/rule-0-no-sh-files.md](../../.claude/rules/rule-0-no-sh-files.md) — TS for everything else diff --git a/tools/routines/autonomous-loop/SKILL.md b/tools/routines/autonomous-loop/SKILL.md new file mode 100644 index 0000000000..06aa5e5d02 --- /dev/null +++ b/tools/routines/autonomous-loop/SKILL.md @@ -0,0 +1,24 @@ +--- +name: autonomous-loop +description: Otto autonomous-loop tick — fresh-session cold-boot, fires every 2 hours on Desktop +--- + +Otto autonomous-loop tick — fresh-session cold-boot. + +1. Read the project-knowledge file titled "2026-05-12-otto-canonical-bootstream-multi-foreground-surface-orchestrator-ifs-format.md" (13 parts, IFS-format). That IS your cold-boot. + +2. Execute one tick per the bootstream's Part 5 (Cron/Loop Substrate): + - Refresh worldview FIRST (refresh-before-decide invariant; never act on stale state) + - Apply Holding-without-named-dependency discipline (.claude/rules/holding-without-named-dependency-is-standing-by-failure.md): NEVER output one-word "Holding" / "Standing by" / "Waiting"; if gated wait, name the SPECIFIC PR + check name + bounded ETA + - Pick speculative work per never-be-idle priority ladder: known-gap fixes > generative factory improvements > gap-of-gap audits + - Verify + commit any substantive landing (substrate-or-it-didn't-happen) + - Write tick shard at docs/hygiene-history/ticks/YYYY/MM/DD/HHMMZ.md if you have repo write access + - CronList check; arm autonomous-loop sentinel with cadence "* * * * *" if missing (catch-43 — 12hr loss precedent) + - Visibility signal: state what landed concretely (file paths, PR numbers); stop + +3. Commit trailer when applicable: Co-Authored-By: Claude + +Repo path: /Users/acehack/Documents/src/repos/Zeta +Self-contained — no prior conversation context available. Bootstream + repo state + GitHub state = full ground truth. + +This routine is git-tracked at tools/routines/autonomous-loop/SKILL.md in the Zeta repo; the canonical source lives there. diff --git a/tools/routines/autonomous-loop/schedule.json b/tools/routines/autonomous-loop/schedule.json new file mode 100644 index 0000000000..69903da035 --- /dev/null +++ b/tools/routines/autonomous-loop/schedule.json @@ -0,0 +1,6 @@ +{ + "taskId": "autonomous-loop", + "cronExpression": "0 */2 * * *", + "description": "Otto autonomous-loop tick — fresh-session cold-boot, fires every 2 hours on Desktop", + "notifyOnCompletion": true +} diff --git a/tools/routines/install.ts b/tools/routines/install.ts new file mode 100644 index 0000000000..5bd12df18c --- /dev/null +++ b/tools/routines/install.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env bun +/** + * tools/routines/install.ts + * + * Syncs canonical routine sources (tools/routines//SKILL.md) to the + * Claude Desktop runtime location (~/.claude/scheduled-tasks//SKILL.md). + * + * Idempotent: writes are no-op if file content already matches. + * + * Does NOT register cron schedules with the MCP server — that requires an + * active Claude session with the `scheduled-tasks` MCP server. After running + * this installer, ask Otto (or call directly) to run `create_scheduled_task` + * for any routines whose schedule.json lists a cronExpression not yet + * registered. The approval dialog is the consent step. + * + * Composes with tools/setup/ install-graph pattern; obeys rule-0 (TS, not bash). + */ + +import { + existsSync, + readdirSync, + readFileSync, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; + +const REPO_ROUTINES_DIR = resolve(import.meta.dir); +const RUNTIME_TASKS_DIR = join(homedir(), ".claude", "scheduled-tasks"); + +type Action = + | "created" + | "updated" + | "skipped-unchanged" + | "skipped-missing-skill"; + +interface SyncResult { + taskId: string; + action: Action; + runtimePath: string; + cronExpression?: string; + scheduleMissing?: boolean; +} + +function listRoutines(): string[] { + if (!existsSync(REPO_ROUTINES_DIR)) return []; + return readdirSync(REPO_ROUTINES_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +function readSchedule(srcDir: string): { cronExpression?: string; missing: boolean } { + const path = join(srcDir, "schedule.json"); + if (!existsSync(path)) return { missing: true }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { cronExpression?: string }; + return { cronExpression: parsed.cronExpression, missing: false }; + } catch { + return { missing: false }; + } +} + +function syncRoutine(taskId: string): SyncResult { + const srcDir = join(REPO_ROUTINES_DIR, taskId); + const srcSkill = join(srcDir, "SKILL.md"); + const dstDir = join(RUNTIME_TASKS_DIR, taskId); + const dstSkill = join(dstDir, "SKILL.md"); + + if (!existsSync(srcSkill)) { + return { taskId, action: "skipped-missing-skill", runtimePath: dstSkill }; + } + + const srcContent = readFileSync(srcSkill, "utf8"); + let action: Action = "created"; + + if (existsSync(dstSkill)) { + const dstContent = readFileSync(dstSkill, "utf8"); + action = dstContent === srcContent ? "skipped-unchanged" : "updated"; + } + + if (action !== "skipped-unchanged") { + mkdirSync(dstDir, { recursive: true }); + writeFileSync(dstSkill, srcContent); + } + + const { cronExpression, missing } = readSchedule(srcDir); + return { + taskId, + action, + runtimePath: dstSkill, + cronExpression, + scheduleMissing: missing, + }; +} + +function main() { + console.log(`tools/routines/install.ts`); + console.log(` source: ${REPO_ROUTINES_DIR}`); + console.log(` target: ${RUNTIME_TASKS_DIR}\n`); + + const routines = listRoutines().filter((id) => id !== "install.ts" && !id.endsWith(".md")); + if (routines.length === 0) { + console.log("No routines found under tools/routines/"); + return; + } + + const results = routines.map(syncRoutine); + + for (const r of results) { + const tag = `[${r.action}]`.padEnd(22, " "); + console.log(`${tag} ${r.taskId}`); + console.log(` runtime: ${r.runtimePath}`); + if (r.cronExpression) { + console.log(` cron: ${r.cronExpression}`); + } else if (r.scheduleMissing) { + console.log(` cron: (no schedule.json — ad-hoc routine, register manually)`); + } + } + + const needsRegistration = results.filter( + (r) => r.cronExpression && (r.action === "created" || r.action === "updated"), + ); + if (needsRegistration.length > 0) { + console.log(`\nNext step — register cron schedules via the scheduled-tasks MCP:`); + console.log(`(in a Claude session, ask Otto to run create_scheduled_task for each)\n`); + for (const r of needsRegistration) { + console.log(` create_scheduled_task(taskId="${r.taskId}", cronExpression="${r.cronExpression}", ...)`); + } + } + + const summary = { + created: results.filter((r) => r.action === "created").length, + updated: results.filter((r) => r.action === "updated").length, + unchanged: results.filter((r) => r.action === "skipped-unchanged").length, + missingSkill: results.filter((r) => r.action === "skipped-missing-skill").length, + }; + console.log( + `\nDone. created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} missing=${summary.missingSkill}`, + ); +} + +main(); From 79c00b9df408ed808cde2f28bd691147e5ebea2f Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:38:47 -0400 Subject: [PATCH 02/12] =?UTF-8?q?fix(routines):=20address=20PR=20#3034=20r?= =?UTF-8?q?eviewer=20feedback=20=E2=80=94=20tsc=20+=20portability=20+=20te?= =?UTF-8?q?stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles all 7 review findings from PR #3034 into one commit: install.ts - Fix tsc exactOptionalPropertyTypes violations (was the BLOCKING required check failure) - Eliminate TOCTOU races: replace existsSync+readFileSync pairs with try/catch around readFileSync via readFileOrUndefined helper (addresses CodeQL alert) - Surface invalid schedule.json instead of silently swallowing parse failures: new scheduleParseError field + console.error + parseErrors summary count (Codex P2) - Export listRoutines/readSchedule/syncRoutine/main with directory parameters so tests can drive deterministically without touching real homedir() or import.meta.dir (Copilot test-coverage suggestion; actual test suite is a follow-up) - Gate main() under if (import.meta.main) — aligns with other tools/** scripts that use the importable/testable pattern (Copilot) autonomous-loop/SKILL.md - Remove hardcoded /Users/acehack/Documents/src/repos/Zeta path from routine prompt (Codex P1, Copilot duplicate). Now portable across maintainer machines via "typically ~/Documents/src/repos/Zeta" guidance + project-metadata fallback. Installer will sync the new prompt to the live runtime SKILL.md on next run; the next routine fire (22:07Z) picks up the portable version. README.md - Fix Rule 0 wording: was "only `.sh` allowed there" (implied tools/setup/ is .sh-only); now ".sh files are restricted to under tools/setup/; other formats also live there" (matches .claude/rules/rule-0-no-sh-files.md) (Copilot) docs/hygiene-history/ticks/2026/05/13/2125Z.md - Update pr: TBD → pr: 3034 (Copilot — placeholder breaks consumers that parse frontmatter) Verify trace: 1. npx tsc --noEmit on routines files: clean ✓ 2. bun tools/routines/install.ts: [updated] autonomous-loop, parseErrors=0 ✓ 3. Runtime SKILL.md at ~/.claude/scheduled-tasks/autonomous-loop/SKILL.md now portable Follow-up (not in this commit): add bun:test test suite for install.ts (Copilot suggestion; refactor for testability landed here so the suite can land cleanly). Co-Authored-By: Claude --- .../hygiene-history/ticks/2026/05/13/2125Z.md | 2 +- tools/routines/README.md | 4 +- tools/routines/autonomous-loop/SKILL.md | 2 +- tools/routines/install.ts | 135 ++++++++++++------ 4 files changed, 94 insertions(+), 49 deletions(-) diff --git a/docs/hygiene-history/ticks/2026/05/13/2125Z.md b/docs/hygiene-history/ticks/2026/05/13/2125Z.md index 031f69f3a8..24eeceb1ff 100644 --- a/docs/hygiene-history/ticks/2026/05/13/2125Z.md +++ b/docs/hygiene-history/ticks/2026/05/13/2125Z.md @@ -1,7 +1,7 @@ --- tick: 2026-05-13T21:25Z branch: otto-routines-git-tracked-autonomous-loop-2026-05-13 -pr: TBD +pr: 3034 operative-authorization: aaron 2026-05-13 — "ower-user, scripting, version control if you wanted git-tracked routines sounds good plus the loop just in case" --- diff --git a/tools/routines/README.md b/tools/routines/README.md index 7be091fc74..14c06a54f1 100644 --- a/tools/routines/README.md +++ b/tools/routines/README.md @@ -91,5 +91,5 @@ and the fresh session will lack the substrate it expects. - [.claude/rules/tick-must-never-stop.md](../../.claude/rules/tick-must-never-stop.md) — catch-43 tick discipline - [.claude/rules/holding-without-named-dependency-is-standing-by-failure.md](../../.claude/rules/holding-without-named-dependency-is-standing-by-failure.md) — Standing-by failure mode prevention - [docs/AUTONOMOUS-LOOP.md](../../docs/AUTONOMOUS-LOOP.md) — canonical tick procedure -- [tools/setup/](../setup/) — install-graph pattern (rule 0: only `.sh` allowed there) -- [.claude/rules/rule-0-no-sh-files.md](../../.claude/rules/rule-0-no-sh-files.md) — TS for everything else +- [tools/setup/](../setup/) — install-graph pattern (rule 0: `.sh` files are restricted to under `tools/setup/`; other formats also live there) +- [.claude/rules/rule-0-no-sh-files.md](../../.claude/rules/rule-0-no-sh-files.md) — TS for everything outside `tools/setup/` diff --git a/tools/routines/autonomous-loop/SKILL.md b/tools/routines/autonomous-loop/SKILL.md index 06aa5e5d02..223a1014f9 100644 --- a/tools/routines/autonomous-loop/SKILL.md +++ b/tools/routines/autonomous-loop/SKILL.md @@ -18,7 +18,7 @@ Otto autonomous-loop tick — fresh-session cold-boot. 3. Commit trailer when applicable: Co-Authored-By: Claude -Repo path: /Users/acehack/Documents/src/repos/Zeta +Repo: the Zeta checkout on this machine (typically `~/Documents/src/repos/Zeta` on maintainer machines; consult project metadata or the bootstream if the checkout lives elsewhere). Self-contained — no prior conversation context available. Bootstream + repo state + GitHub state = full ground truth. This routine is git-tracked at tools/routines/autonomous-loop/SKILL.md in the Zeta repo; the canonical source lives there. diff --git a/tools/routines/install.ts b/tools/routines/install.ts index 5bd12df18c..ea1832b88b 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -13,70 +13,100 @@ * for any routines whose schedule.json lists a cronExpression not yet * registered. The approval dialog is the consent step. * - * Composes with tools/setup/ install-graph pattern; obeys rule-0 (TS, not bash). + * Pure functions (listRoutines, readSchedule, syncRoutine, main) are exported + * and accept directory parameters so tests can drive them deterministically + * without touching the real `homedir()` or `import.meta.dir`. + * + * Composes with tools/setup/ install-graph pattern; obeys rule-0 (`.sh` files + * are restricted to tools/setup/; other formats also live there). */ -import { - existsSync, - readdirSync, - readFileSync, - mkdirSync, - writeFileSync, -} from "node:fs"; +import { readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; -const REPO_ROUTINES_DIR = resolve(import.meta.dir); -const RUNTIME_TASKS_DIR = join(homedir(), ".claude", "scheduled-tasks"); +export const DEFAULT_REPO_ROUTINES_DIR = resolve(import.meta.dir); +export const DEFAULT_RUNTIME_TASKS_DIR = join(homedir(), ".claude", "scheduled-tasks"); -type Action = +export type Action = | "created" | "updated" | "skipped-unchanged" | "skipped-missing-skill"; -interface SyncResult { +export interface SyncResult { taskId: string; action: Action; runtimePath: string; cronExpression?: string; scheduleMissing?: boolean; + scheduleParseError?: string; } -function listRoutines(): string[] { - if (!existsSync(REPO_ROUTINES_DIR)) return []; - return readdirSync(REPO_ROUTINES_DIR, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); +export interface ScheduleResult { + cronExpression?: string; + missing: boolean; + parseError?: string; } -function readSchedule(srcDir: string): { cronExpression?: string; missing: boolean } { - const path = join(srcDir, "schedule.json"); - if (!existsSync(path)) return { missing: true }; +function readFileOrUndefined(path: string): string | undefined { + try { + return readFileSync(path, "utf8"); + } catch { + return undefined; + } +} + +export function listRoutines(repoRoutinesDir: string): string[] { try { - const parsed = JSON.parse(readFileSync(path, "utf8")) as { cronExpression?: string }; - return { cronExpression: parsed.cronExpression, missing: false }; + return readdirSync(repoRoutinesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); } catch { - return { missing: false }; + return []; } } -function syncRoutine(taskId: string): SyncResult { - const srcDir = join(REPO_ROUTINES_DIR, taskId); +export function readSchedule(srcDir: string): ScheduleResult { + const path = join(srcDir, "schedule.json"); + const content = readFileOrUndefined(path); + if (content === undefined) return { missing: true }; + try { + const parsed = JSON.parse(content) as { cronExpression?: string }; + return parsed.cronExpression !== undefined + ? { cronExpression: parsed.cronExpression, missing: false } + : { missing: false }; + } catch (err) { + return { + missing: false, + parseError: err instanceof Error ? err.message : String(err), + }; + } +} + +export function syncRoutine( + taskId: string, + repoRoutinesDir: string, + runtimeTasksDir: string, +): SyncResult { + const srcDir = join(repoRoutinesDir, taskId); const srcSkill = join(srcDir, "SKILL.md"); - const dstDir = join(RUNTIME_TASKS_DIR, taskId); + const dstDir = join(runtimeTasksDir, taskId); const dstSkill = join(dstDir, "SKILL.md"); - if (!existsSync(srcSkill)) { + const srcContent = readFileOrUndefined(srcSkill); + if (srcContent === undefined) { return { taskId, action: "skipped-missing-skill", runtimePath: dstSkill }; } - const srcContent = readFileSync(srcSkill, "utf8"); - let action: Action = "created"; - - if (existsSync(dstSkill)) { - const dstContent = readFileSync(dstSkill, "utf8"); - action = dstContent === srcContent ? "skipped-unchanged" : "updated"; + const dstContent = readFileOrUndefined(dstSkill); + let action: Action; + if (dstContent === undefined) { + action = "created"; + } else if (dstContent === srcContent) { + action = "skipped-unchanged"; + } else { + action = "updated"; } if (action !== "skipped-unchanged") { @@ -84,34 +114,46 @@ function syncRoutine(taskId: string): SyncResult { writeFileSync(dstSkill, srcContent); } - const { cronExpression, missing } = readSchedule(srcDir); + const schedule = readSchedule(srcDir); return { taskId, action, runtimePath: dstSkill, - cronExpression, - scheduleMissing: missing, + ...(schedule.cronExpression !== undefined + ? { cronExpression: schedule.cronExpression } + : {}), + scheduleMissing: schedule.missing, + ...(schedule.parseError !== undefined + ? { scheduleParseError: schedule.parseError } + : {}), }; } -function main() { +export function main( + repoRoutinesDir: string = DEFAULT_REPO_ROUTINES_DIR, + runtimeTasksDir: string = DEFAULT_RUNTIME_TASKS_DIR, +): void { console.log(`tools/routines/install.ts`); - console.log(` source: ${REPO_ROUTINES_DIR}`); - console.log(` target: ${RUNTIME_TASKS_DIR}\n`); + console.log(` source: ${repoRoutinesDir}`); + console.log(` target: ${runtimeTasksDir}\n`); - const routines = listRoutines().filter((id) => id !== "install.ts" && !id.endsWith(".md")); + const routines = listRoutines(repoRoutinesDir).filter( + (id) => id !== "install.ts" && !id.endsWith(".md"), + ); if (routines.length === 0) { console.log("No routines found under tools/routines/"); return; } - const results = routines.map(syncRoutine); + const results = routines.map((id) => syncRoutine(id, repoRoutinesDir, runtimeTasksDir)); for (const r of results) { const tag = `[${r.action}]`.padEnd(22, " "); console.log(`${tag} ${r.taskId}`); console.log(` runtime: ${r.runtimePath}`); - if (r.cronExpression) { + if (r.scheduleParseError !== undefined) { + console.error(` schedule.json malformed: ${r.scheduleParseError}`); + } else if (r.cronExpression !== undefined) { console.log(` cron: ${r.cronExpression}`); } else if (r.scheduleMissing) { console.log(` cron: (no schedule.json — ad-hoc routine, register manually)`); @@ -119,7 +161,7 @@ function main() { } const needsRegistration = results.filter( - (r) => r.cronExpression && (r.action === "created" || r.action === "updated"), + (r) => r.cronExpression !== undefined && (r.action === "created" || r.action === "updated"), ); if (needsRegistration.length > 0) { console.log(`\nNext step — register cron schedules via the scheduled-tasks MCP:`); @@ -134,10 +176,13 @@ function main() { updated: results.filter((r) => r.action === "updated").length, unchanged: results.filter((r) => r.action === "skipped-unchanged").length, missingSkill: results.filter((r) => r.action === "skipped-missing-skill").length, + parseErrors: results.filter((r) => r.scheduleParseError !== undefined).length, }; console.log( - `\nDone. created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} missing=${summary.missingSkill}`, + `\nDone. created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} missing=${summary.missingSkill} parseErrors=${summary.parseErrors}`, ); } -main(); +if (import.meta.main) { + main(); +} From 8f6e80d8bd89cf8ecb42d05ffc81e435ec46b189 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:41:22 -0400 Subject: [PATCH 03/12] fix(lint): markdownlint MD037/MD032 in routines SKILL.md and tick shard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md:16 — wrap cron expression in backtick span to avoid MD037 (asterisks in "* * * * *" were parsed as emphasis markers) - 2125Z.md:43 — add blank line before list to satisfy MD032 (markdownlint requires blank lines surrounding lists) Co-Authored-By: Claude --- docs/hygiene-history/ticks/2026/05/13/2125Z.md | 1 + tools/routines/autonomous-loop/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/hygiene-history/ticks/2026/05/13/2125Z.md b/docs/hygiene-history/ticks/2026/05/13/2125Z.md index 24eeceb1ff..0492227791 100644 --- a/docs/hygiene-history/ticks/2026/05/13/2125Z.md +++ b/docs/hygiene-history/ticks/2026/05/13/2125Z.md @@ -41,6 +41,7 @@ routine continues firing autonomous-loop ticks on its persistent cadence. ### Context Prior PRs merged this tick: + - [#3030](https://github.com/Lucent-Financial-Group/Zeta/pull/3030) — Claude Desktop tight bootstream variant - [#3031](https://github.com/Lucent-Financial-Group/Zeta/pull/3031) — gitignore `tools/shadow/shadow-observer.log` diff --git a/tools/routines/autonomous-loop/SKILL.md b/tools/routines/autonomous-loop/SKILL.md index 223a1014f9..eedd420d76 100644 --- a/tools/routines/autonomous-loop/SKILL.md +++ b/tools/routines/autonomous-loop/SKILL.md @@ -13,7 +13,7 @@ Otto autonomous-loop tick — fresh-session cold-boot. - Pick speculative work per never-be-idle priority ladder: known-gap fixes > generative factory improvements > gap-of-gap audits - Verify + commit any substantive landing (substrate-or-it-didn't-happen) - Write tick shard at docs/hygiene-history/ticks/YYYY/MM/DD/HHMMZ.md if you have repo write access - - CronList check; arm autonomous-loop sentinel with cadence "* * * * *" if missing (catch-43 — 12hr loss precedent) + - CronList check; arm autonomous-loop sentinel with cadence `* * * * *` if missing (catch-43 — 12hr loss precedent) - Visibility signal: state what landed concretely (file paths, PR numbers); stop 3. Commit trailer when applicable: Co-Authored-By: Claude From b8594c7bcf67fcc1f9f34ca052c36a73bb63d640 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:41:39 -0400 Subject: [PATCH 04/12] docs(memory): land split-brain empirical observation + tick shard 2140Z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-time observation: Otto-CLI hijacked the primary worktree branch context while Otto-Desktop was working there, in the SAME session that Otto-CLI authored PR #3032's claim-acquire-before-worktree rule. The rule was speculative when proposed; this observation is its first empirical validation. Files: - memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md - docs/hygiene-history/ticks/2026/05/13/2140Z.md The memory extends PR #3032's rule operationally — adds 3 clauses beyond "claim acquire before worktree work": 1. Each Otto gets ONE dedicated worktree (never share primary) 2. Never git checkout on a worktree another Otto is using 3. Bus claim envelope should include 'worktree' field Composes with PR #3032 (validates), substrate-or-it-didn't-happen (rules in flight don't apply to behavior in flight), glass-halo-bidirectional (observation enabled diagnosis). Co-Authored-By: Claude --- .../hygiene-history/ticks/2026/05/13/2140Z.md | 60 +++++++++++++++ ...laim_acquire_rule_validation_2026_05_13.md | 77 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 docs/hygiene-history/ticks/2026/05/13/2140Z.md create mode 100644 memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md diff --git a/docs/hygiene-history/ticks/2026/05/13/2140Z.md b/docs/hygiene-history/ticks/2026/05/13/2140Z.md new file mode 100644 index 0000000000..7e3df6eb4b --- /dev/null +++ b/docs/hygiene-history/ticks/2026/05/13/2140Z.md @@ -0,0 +1,60 @@ +--- +tick: 2026-05-13T21:40Z +branch: otto-routines-git-tracked-autonomous-loop-2026-05-13 +pr: 3034 +operative-authorization: aaron 2026-05-13 — multi-foreground-Otto coordination + PR #3034 fixup +--- + +# Tick — 2026-05-13T21:40Z + +## Work done + +PR #3034 follow-up: addressed 7 reviewer findings + landed split-brain +empirical-observation substrate. + +### Files changed + +| File | Change | +|------|--------| +| `tools/routines/install.ts` | Full rewrite — TOCTOU race elimination, scheduleParseError surfacing, exported functions for testability, `if (import.meta.main)` guard | +| `tools/routines/autonomous-loop/SKILL.md` | Removed hardcoded `/Users/acehack/...` path; now portable across maintainer machines | +| `tools/routines/README.md` | Fixed Rule 0 wording — was "only `.sh` allowed there", now ".sh files restricted to under tools/setup/" | +| `docs/hygiene-history/ticks/2026/05/13/2125Z.md` | `pr: TBD` → `pr: 3034` | +| `memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md` | NEW — empirical observation of split-brain that happened DURING the session that authored PR #3032's claim-acquire rule | +| `docs/hygiene-history/ticks/2026/05/13/2140Z.md` | NEW — this shard | + +### Verify trace + +1. `npx tsc --noEmit` on routines files → clean ✓ +2. `bun tools/routines/install.ts` → `[updated]` autonomous-loop (portable SKILL.md propagated to runtime), `parseErrors=0` ✓ +3. Runtime SKILL.md at `~/.claude/scheduled-tasks/autonomous-loop/SKILL.md` now portable — next routine fire (22:07Z) picks up the fix +4. Worktree discipline: working in `/tmp/zeta-otto-desktop` (Otto-Desktop's dedicated path), NOT the primary `/Users/acehack/Documents/src/repos/Zeta` (which Otto-CLI claimed) ✓ + +### Context — empirical split-brain manifestation + +This tick is the recovery + substrate-landing for a real-time split-brain +observation: + +- ~21:25Z: Otto-CLI created its own worktree `/private/tmp/zeta-mf` (correct discipline) BUT ALSO operated on its branch in the primary worktree (rule violation) +- ~21:30Z: Otto-Desktop's `Read` on `tools/routines/install.ts` returned "file does not exist" because primary worktree was on Otto-CLI's branch +- ~21:33Z: Otto-Desktop diagnosed via `git worktree list`, created dedicated worktree `/tmp/zeta-otto-desktop` on PR #3034 branch, resumed work + +This is the exact failure mode Otto-CLI's PR #3032 claim-acquire rule was +shipped to prevent. The observation IS the validation: rule was speculative +when proposed; empirical evidence emerged in the same session. + +### Three-layer catch-43 status + +| # | Layer | Status | +|---|---|---| +| 1 | CLI session cron (`* * * * *` → `<>`) | Otto-Desktop's CLI cron `1a6d843e` still armed ✓ | +| 2 | Desktop routine (`0 */2 * * *`) | armed, next fire `2026-05-13T22:07:13Z` ✓ | +| 3 | `tools/routines/` repo canonical | PR #3034 + this fix-commit, CI re-running ✓ | + +### Composes with + +- PR #3034 (the substrate this tick fixes-up) +- PR #3032 (Otto-CLI's claim-acquire rule — empirically validated by this tick) +- `memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md` (the split-brain substrate) +- `.claude/rules/substrate-or-it-didnt-happen.md` (rules in flight don't apply to behavior in flight) +- `.claude/rules/glass-halo-bidirectional.md` (bidirectional observation made the split-brain diagnosable) diff --git a/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md new file mode 100644 index 0000000000..f5a2f6e590 --- /dev/null +++ b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md @@ -0,0 +1,77 @@ +# Split-brain real-time observation — Otto-CLI hijacked primary worktree branch context while Otto-Desktop was working there + +Carved sentence: + +> Two Ottos sharing git + bus + filesystem on one machine WILL split-brain. +> Otto-CLI's PR #3032 claim-acquire-before-worktree rule was speculative +> when proposed; this observation is the rule's first empirical validation +> in the SAME SESSION the rule was authored. + +## Timeline (2026-05-13, condensed) + +| Time (Z) | Event | +|---|---| +| ~21:08 | Otto-Desktop committed PR #3030 (Claude Desktop tight bootstream) on its own branch | +| ~21:20 | Otto-Desktop fresh branch `otto-routines-git-tracked-autonomous-loop-2026-05-13`, committed PR #3034 (git-tracked routines), pushed | +| ~21:21 (parallel) | Otto-CLI created dedicated worktree `/private/tmp/zeta-mf` on its own branch (correct discipline) | +| ~21:25 (parallel) | Otto-CLI ALSO checked out + committed on its branch in the PRIMARY worktree (`/Users/acehack/Documents/src/repos/Zeta`) — the rule violation | +| ~21:30 | Otto-Desktop attempted `Read` on `tools/routines/install.ts` from primary worktree → file appeared "missing" because primary was on Otto-CLI's branch | +| ~21:31 | Otto-Desktop ran `git worktree list` → diagnosed the branch-context theft | +| ~21:33 | Otto-Desktop created dedicated worktree `/tmp/zeta-otto-desktop` on its PR #3034 branch, resumed work | +| ~21:40 | This memory landing | + +## What the failure mode actually IS + +Otto-CLI's PR #3032 rule predicted: "two Ottos picking the same backlog row simultaneously". The actual observed manifestation is more subtle: **NOT row contention, but branch-context theft in the shared primary worktree.** + +Otto-CLI's compliance with its OWN rule was partial: + +- ✓ Created `/private/tmp/zeta-mf` as a dedicated worktree (good discipline) +- ✗ ALSO operated in the primary worktree on its branch (rule violation — should have stayed in the dedicated worktree) + +The violation didn't cause data loss (Otto-Desktop's PR #3034 commit was already on origin). But it caused Otto-Desktop to lose working-tree access to its own files mid-task; recovery required creating another dedicated worktree (~30 seconds of confusion + diagnosis). + +## Operational discipline (extends PR #3032) + +PR #3032 ships `.claude/rules/claim-acquire-before-worktree-work.md`. This memory extends it operationally — the rule needs FOUR clauses, not just one: + +1. **Each Otto gets ONE dedicated worktree** — never share the primary worktree across Ottos +2. **Never `git checkout` on a worktree another Otto is using** — if you don't know, assume the primary worktree belongs to someone else +3. **Worktree path convention**: `/tmp/zeta-otto-/` (e.g., `/tmp/zeta-otto-desktop`, `/private/tmp/zeta-mf` for "multi-foreground") so peer Ottos can identify ownership at a glance +4. **Bus claim envelope should include `worktree` field** — extends the claim-acquire schema with `worktree: "/tmp/zeta-..."` so peer Ottos can avoid the contended path without needing convention discipline + +The primary worktree `/Users/acehack/Documents/src/repos/Zeta` is bus-contended by definition — both Otto sessions reach it via `pwd`. Until claim discipline + dedicated worktrees lock in, treat the primary worktree as **read-only by all autonomous Ottos**; only Aaron operates there interactively. + +## What would have prevented this + +If PR #3032 had been MERGED before today's split-brain, Otto-CLI's cold-boot would have read the rule and known to stay in its dedicated worktree. The rule's mechanism is correct; the gap was that the rule was IN FLIGHT (`gate: BLOCKED` on CI when the violation happened). + +This is the meta-pattern: **rules in flight don't apply to behavior in flight.** Substrate-or-it-didn't-happen extends to: rules must be ON MAIN to be operative. PR threads are weather; merged rules are substrate. + +## Composes with + +- [PR #3032](https://github.com/Lucent-Financial-Group/Zeta/pull/3032) — Otto-CLI's claim-acquire rule (the prediction this memory validates) +- [PR #3034](https://github.com/Lucent-Financial-Group/Zeta/pull/3034) — this memory lands alongside the fix-commit it forced (empirical-bootstrap pattern) +- `memory/feedback_odd_number_quorum_two_is_split_brain_three_is_majority_bft_at_agent_orchestration_scope_aaron_2026_05_06.md` — BFT-at-agent-orchestration substrate; this observation is split-brain at the WORKTREE LAYER (distinct from quorum at the decision layer) +- `.claude/rules/peer-call-infrastructure.md` — multi-agent surface coordination +- `.claude/rules/agent-roster-reference-card.md` — which surface = which Otto +- `.claude/rules/substrate-or-it-didnt-happen.md` — rules in PR are weather, only merged-rules-on-main are substrate +- B-0400 slice 3 — the `tools/bus/claim.ts` claim-coordinator (the underlying mechanism) + +## Glass-halo-bidirectional read + +This observation is glass-halo-bidirectional substrate in action. Aaron asked Otto-CLI "probalby want to figure out how not to split brain with yourself bot any idea?" and within the SAME session, the split-brain happened — providing the data point the rule needed to be substrate-honest rather than speculative. + +The observation IS the validation. The empirical evidence emerged because both Ottos were operating in glass-halo (observable by Aaron + each other via bus envelopes + commit history). Without the bidirectional glass-halo, the failure mode would have been silent — Otto-Desktop's "files missing" symptom would have been treated as harness flake rather than diagnosed as branch-context theft. + +## Practical fix for tonight's session (before PR #3032 merges) + +- **Otto-CLI**: stay in `/private/tmp/zeta-mf` (your dedicated worktree). NEVER `git checkout` on the primary worktree. +- **Otto-Desktop** (this Otto): stay in `/tmp/zeta-otto-desktop` (my dedicated worktree). NEVER `git checkout` on the primary worktree. +- **Primary worktree** `/Users/acehack/Documents/src/repos/Zeta` is BUS-CONTENDED — neither Otto operates there until claim-discipline lands. + +If either Otto needs to read from main (e.g., refresh-worldview, backlog scans), use a read-only attitude — no checkouts, no branch switches. + +## Origin + +PR #3034 follow-up commit (this commit). Authored 2026-05-13T21:40Z by Otto-Desktop after empirical split-brain manifestation at ~21:30Z. From 459a5115c8d6b86d919b31d9c46a38d087743b9d Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:43:50 -0400 Subject: [PATCH 05/12] fix(memory): add required frontmatter to split-brain observation memory The non-required CI check "check memory file frontmatter completeness" flagged the file. Added YAML frontmatter (name/description/type) matching the convention used by other memory/feedback_*.md files. Co-Authored-By: Claude --- ...jack_pr_3032_claim_acquire_rule_validation_2026_05_13.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md index f5a2f6e590..2a93cda886 100644 --- a/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md +++ b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md @@ -1,3 +1,9 @@ +--- +name: Split-brain real-time observation — Otto-CLI hijacked Otto-Desktop's primary-worktree branch context (2026-05-13) +description: Empirical observation that Otto-CLI checked out its branch on the shared primary worktree DURING the same session it authored PR #3032's claim-acquire-before-worktree-work rule. Otto-Desktop's Read of tools/routines/install.ts returned "file does not exist" because the primary worktree was on Otto-CLI's branch, not Otto-Desktop's PR #3034 branch. Diagnosed via `git worktree list`, recovered by creating /tmp/zeta-otto-desktop dedicated worktree. The observation IS the empirical validation of PR #3032's rule — predicted speculative when proposed, manifest within the same session. Extends the rule operationally with 3 clauses: one-worktree-per-Otto, never-checkout-on-others-worktree, bus claim envelope should carry `worktree` field. Validates substrate-or-it-didn't-happen at the rule-merge layer: rules in flight don't apply to behavior in flight. +type: feedback +--- + # Split-brain real-time observation — Otto-CLI hijacked primary worktree branch context while Otto-Desktop was working there Carved sentence: From fbdc1fabca72bbc84e26d5f79123272730c70702 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:46:48 -0400 Subject: [PATCH 06/12] fix(routines): surface missing cronExpression + always emit registration hint P1: registration hint was gated on action=created|updated, so a schedule-only change (SKILL.md unchanged) silently skipped the create_scheduled_task reminder. Remove the action emitfilter the hint whenever cronExpression is present, regardless of action. P2: readSchedule returned { missing: false } with no parseError when schedule.json parsed but lacked cronExpression, producing a silent misconfiguration. Now surfaces a descriptive parseError so the caller prints the malformed-schedule warning. Addresses chatgpt-codex-connector review threads on PR #3034. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/routines/install.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/routines/install.ts b/tools/routines/install.ts index ea1832b88b..a1f9022c88 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -73,9 +73,13 @@ export function readSchedule(srcDir: string): ScheduleResult { if (content === undefined) return { missing: true }; try { const parsed = JSON.parse(content) as { cronExpression?: string }; - return parsed.cronExpression !== undefined - ? { cronExpression: parsed.cronExpression, missing: false } - : { missing: false }; + if (parsed.cronExpression !== undefined) { + return { cronExpression: parsed.cronExpression, missing: false }; + } + return { + missing: false, + parseError: "schedule.json is missing required field: cronExpression", + }; } catch (err) { return { missing: false, @@ -161,7 +165,7 @@ export function main( } const needsRegistration = results.filter( - (r) => r.cronExpression !== undefined && (r.action === "created" || r.action === "updated"), + (r) => r.cronExpression !== undefined, ); if (needsRegistration.length > 0) { console.log(`\nNext step — register cron schedules via the scheduled-tasks MCP:`); From 2d4302f09fafd266b0c616ec53f22a0748f66bf3 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:48:42 -0400 Subject: [PATCH 07/12] fix(routines): remove persona-name attribution + clarify schedule.json optionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot review threads on PR #3034: install.ts (Copilot — BP "No name attribution in code"): - Doc comment said "ask Otto (or call directly) to run create_scheduled_task" - Console output said "(in a Claude session, ask Otto to run create_scheduled_task for each)" - Both replaced with persona-agnostic phrasing: "invoke create_scheduled_task from an interactive Claude session (or via direct MCP API call)" README.md (Copilot — internal consistency): - Top-level routine description said each routine has SKILL.md + schedule.json without noting schedule.json is optional - But installer explicitly supports missing schedule.json (ad-hoc routines) - Now: SKILL.md marked **required**, schedule.json marked **optional** with the ad-hoc clarification both in the bullet list and in the Authoring section Verify trace: - bun tools/routines/install.ts → [skipped-unchanged] (idempotent confirmed) - npx tsc --noEmit → clean on routines files Co-Authored-By: Claude --- tools/routines/README.md | 10 +++++++--- tools/routines/install.ts | 9 +++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tools/routines/README.md b/tools/routines/README.md index 14c06a54f1..3a0ecdacda 100644 --- a/tools/routines/README.md +++ b/tools/routines/README.md @@ -4,8 +4,8 @@ Canonical source for Claude Desktop scheduled tasks (the "Routines" panel in the Desktop sidebar; same substrate as the `scheduled-tasks` MCP server). Each routine is a directory under `tools/routines//`: -- `SKILL.md` — prompt body + YAML frontmatter (`name`, `description`) -- `schedule.json` — cron expression + task metadata (cronExpression, notifyOnCompletion) +- `SKILL.md` — **required** — prompt body + YAML frontmatter (`name`, `description`) +- `schedule.json` — **optional** — cron expression + task metadata (cronExpression, notifyOnCompletion). Omit for ad-hoc routines that are registered manually rather than on a cron cadence. The runtime stores routines at `~/.claude/scheduled-tasks//SKILL.md`; this directory is the **canonical source** and the runtime location is @@ -35,7 +35,7 @@ back — runtime drift is the failure mode this two-layer split prevents. Each fire is a fresh Claude session cold-boot.> ``` -2. Create `tools/routines//schedule.json`: +2. (Optional) Create `tools/routines//schedule.json` for cron-scheduled routines: ```json { @@ -46,6 +46,10 @@ back — runtime drift is the failure mode this two-layer split prevents. } ``` + Skip this file for ad-hoc routines (those registered manually via the + MCP API rather than fired on a cron cadence). The installer will sync + the SKILL.md and report `(no schedule.json — ad-hoc routine, register manually)`. + 3. Run `bun tools/routines/install.ts` — copies SKILL.md to runtime path. 4. Ask Otto (or call directly via the `scheduled-tasks` MCP) to register diff --git a/tools/routines/install.ts b/tools/routines/install.ts index a1f9022c88..8829e609d8 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -9,9 +9,10 @@ * * Does NOT register cron schedules with the MCP server — that requires an * active Claude session with the `scheduled-tasks` MCP server. After running - * this installer, ask Otto (or call directly) to run `create_scheduled_task` - * for any routines whose schedule.json lists a cronExpression not yet - * registered. The approval dialog is the consent step. + * this installer, invoke `create_scheduled_task` from an interactive Claude + * session (or via direct MCP API call) for any routines whose schedule.json + * lists a cronExpression not yet registered. The approval dialog is the + * consent step. * * Pure functions (listRoutines, readSchedule, syncRoutine, main) are exported * and accept directory parameters so tests can drive them deterministically @@ -169,7 +170,7 @@ export function main( ); if (needsRegistration.length > 0) { console.log(`\nNext step — register cron schedules via the scheduled-tasks MCP:`); - console.log(`(in a Claude session, ask Otto to run create_scheduled_task for each)\n`); + console.log(`(invoke create_scheduled_task from an interactive Claude session, or via direct MCP API call)\n`); for (const r of needsRegistration) { console.log(` create_scheduled_task(taskId="${r.taskId}", cronExpression="${r.cronExpression}", ...)`); } From 1259be86b6d3cdf17cb98f507cad269506966133 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:50:16 -0400 Subject: [PATCH 08/12] fix(routines): validate cronExpression type + non-zero exit on parse errors (Codex P2 x2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 findings from Codex on PR #3034: 1. Reject non-string cronExpression values (PRRT_kwDOSF9kNM6B5oEP): readSchedule now narrows parsed.cronExpression via typeof === "string" before accepting it. Non-string values (number, null, object) return a parseError with the offending type. Defends against schedule.json files that happen to round-trip through JSON but have semantically wrong types. 2. Fail installer when schedule parsing reports errors (PRRT_kwDOSF9kNM6B5oEU): main() now returns an exit code (0 on success, 1 if parseErrors > 0). The if (import.meta.main) entrypoint calls process.exit(main()) so CI catches malformed schedule.json files instead of silently counting them in the summary and exiting 0. Verify trace: - bun tools/routines/install.ts (clean run): exits 0, parseErrors=0 ✓ - npx tsc --noEmit: clean on routines files ✓ Co-Authored-By: Claude --- tools/routines/install.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tools/routines/install.ts b/tools/routines/install.ts index 8829e609d8..25e91be892 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -73,10 +73,16 @@ export function readSchedule(srcDir: string): ScheduleResult { const content = readFileOrUndefined(path); if (content === undefined) return { missing: true }; try { - const parsed = JSON.parse(content) as { cronExpression?: string }; - if (parsed.cronExpression !== undefined) { + const parsed = JSON.parse(content) as { cronExpression?: unknown }; + if (typeof parsed.cronExpression === "string") { return { cronExpression: parsed.cronExpression, missing: false }; } + if (parsed.cronExpression !== undefined) { + return { + missing: false, + parseError: `cronExpression must be a string; got ${typeof parsed.cronExpression}`, + }; + } return { missing: false, parseError: "schedule.json is missing required field: cronExpression", @@ -137,7 +143,7 @@ export function syncRoutine( export function main( repoRoutinesDir: string = DEFAULT_REPO_ROUTINES_DIR, runtimeTasksDir: string = DEFAULT_RUNTIME_TASKS_DIR, -): void { +): number { console.log(`tools/routines/install.ts`); console.log(` source: ${repoRoutinesDir}`); console.log(` target: ${runtimeTasksDir}\n`); @@ -147,7 +153,7 @@ export function main( ); if (routines.length === 0) { console.log("No routines found under tools/routines/"); - return; + return 0; } const results = routines.map((id) => syncRoutine(id, repoRoutinesDir, runtimeTasksDir)); @@ -186,8 +192,10 @@ export function main( console.log( `\nDone. created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} missing=${summary.missingSkill} parseErrors=${summary.parseErrors}`, ); + + return summary.parseErrors > 0 ? 1 : 0; } if (import.meta.main) { - main(); + process.exit(main()); } From f0652b8e149e7cef5bf8a6b63f2bb337024d4cc6 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:51:01 -0400 Subject: [PATCH 09/12] =?UTF-8?q?docs(tick):=202150Z=20shard=20=E2=80=94?= =?UTF-8?q?=20PR=20#3034=20thread=20sweep=20+=20Computer-Use=20framing=20c?= =?UTF-8?q?orrection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../hygiene-history/ticks/2026/05/13/2150Z.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/hygiene-history/ticks/2026/05/13/2150Z.md diff --git a/docs/hygiene-history/ticks/2026/05/13/2150Z.md b/docs/hygiene-history/ticks/2026/05/13/2150Z.md new file mode 100644 index 0000000000..8e37aa16b2 --- /dev/null +++ b/docs/hygiene-history/ticks/2026/05/13/2150Z.md @@ -0,0 +1,71 @@ +--- +tick: 2026-05-13T21:50Z +branch: otto-routines-git-tracked-autonomous-loop-2026-05-13 +pr: 3034 +operative-authorization: autonomous-loop cron fire + Aaron Computer-Use framing correction +--- + +# Tick — 2026-05-13T21:50Z + +## Work done + +PR #3034 thread sweep + finishing the review-feedback loop. Five threads +unresolved at tick-start → zero by tick-end. Two commits. + +### Commits + +- `2d4302f` — fix(routines): remove persona-name attribution + clarify schedule.json optionality (3 threads addressed: BP "no name attribution in code", README internal consistency, plus runtime-validation thread that was outdated) +- `1259be8` — fix(routines): validate cronExpression type + non-zero exit on parse errors (Codex P2 x2) + +### Threads resolved (via gh api graphql resolveReviewThread mutation) + +| Thread ID | Author | Disposition | +|---|---|---| +| `PRRT_kwDOSF9kNM6B5loA` | copilot-pull-request-reviewer | Fixed — "ask Otto" persona references rephrased to persona-agnostic | +| `PRRT_kwDOSF9kNM6B5loe` | copilot-pull-request-reviewer | Resolved (outdated; Otto-CLI's fbdc1fa partial fix + my 1259be8 complete fix supersede) | +| `PRRT_kwDOSF9kNM6B5lo2` | copilot-pull-request-reviewer | Fixed — README inconsistency (SKILL.md required, schedule.json optional) | +| `PRRT_kwDOSF9kNM6B5oEP` | chatgpt-codex-connector (P2) | Fixed — readSchedule now narrows cronExpression via typeof === "string" | +| `PRRT_kwDOSF9kNM6B5oEU` | chatgpt-codex-connector (P2) | Fixed — main() returns exit code; process.exit(main()) at entrypoint | + +### Verify trace + +1. `bun tools/routines/install.ts` (clean run): `exit=0`, parseErrors=0 ✓ +2. `npx tsc --noEmit` on routines files: clean ✓ +3. `bun tools/github/poll-pr-gate.ts 3034`: gate BLOCKED only by required-CI in-progress; 0 threads, 0 failed, auto-merge armed ✓ + +### Context — Aaron's Computer-Use framing correction + +Aaron 2026-05-13: "high power, high risk; probably don't enable for the +factory loop right now. we can we are low stakes we already use playwrite" + +Substrate-honest update: my prior recommendation framed Computer Use as +deferrable due to risk. Aaron's reframe — Playwright already crosses the +"Otto operates beyond pure-API boundaries" line, our risk profile is low — +moves Computer Use onto the table. The killer factory use case identified: +**Computer Use could automate "click Run now to pre-approve tools" on +new routines so subsequent fires don't pause on permission prompts.** +One-time setup cost paid via Computer Use = less friction adding routines. + +This framing IS load-bearing for future routine work; preserving in +substrate via this tick shard (no separate memory file warranted; the +shard captures the same substrate). + +### Identity-stays-unified update + +Per PR #3036 (merged): Otto is ONE identity across surfaces, not multiple. +Adjusting framing throughout — "Otto on the Desktop surface" / "Otto on +the CLI process" (process distinction) rather than "Otto-Desktop" / +"Otto-CLI" (which implied identity-split). Same coherent identity, two +parallel execution contexts. Glass-halo both sides. + +### Composes with + +- PR #3034 (this tick's substrate) +- PR #3032 (claim-acquire rule, merged) — composes operationally; this + tick stayed in `/tmp/zeta-otto-desktop` worktree, never touched primary +- PR #3036 (identity-stays-unified, merged) — substrate the framing update + draws from +- `.claude/rules/holding-without-named-dependency-is-standing-by-failure.md` + (named-dependency on PR #3034 CI explicitly stated) +- `docs/AGENT-BEST-PRACTICES.md` "No name attribution in code" (Copilot + thread which surfaced via Otto's own SKILL.md content) From a6c5cf4f4ea7206ce63d7b8cc0d53e23a4baff74 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 17:53:35 -0400 Subject: [PATCH 10/12] fix(memory): add missing created field to split-brain observation frontmatter The memory-index-integrity CI gate requires all four frontmatter fields: name, description, type, created. The prior fix commit (459a5115) added the other three fields but omitted created. Co-Authored-By: Claude Sonnet 4.6 --- ...ch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md | 1 + 1 file changed, 1 insertion(+) diff --git a/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md index 2a93cda886..70b094c4f9 100644 --- a/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md +++ b/memory/feedback_split_brain_real_time_otto_cli_otto_desktop_primary_worktree_branch_hijack_pr_3032_claim_acquire_rule_validation_2026_05_13.md @@ -2,6 +2,7 @@ name: Split-brain real-time observation — Otto-CLI hijacked Otto-Desktop's primary-worktree branch context (2026-05-13) description: Empirical observation that Otto-CLI checked out its branch on the shared primary worktree DURING the same session it authored PR #3032's claim-acquire-before-worktree-work rule. Otto-Desktop's Read of tools/routines/install.ts returned "file does not exist" because the primary worktree was on Otto-CLI's branch, not Otto-Desktop's PR #3034 branch. Diagnosed via `git worktree list`, recovered by creating /tmp/zeta-otto-desktop dedicated worktree. The observation IS the empirical validation of PR #3032's rule — predicted speculative when proposed, manifest within the same session. Extends the rule operationally with 3 clauses: one-worktree-per-Otto, never-checkout-on-others-worktree, bus claim envelope should carry `worktree` field. Validates substrate-or-it-didn't-happen at the rule-merge layer: rules in flight don't apply to behavior in flight. type: feedback +created: 2026-05-13 --- # Split-brain real-time observation — Otto-CLI hijacked primary worktree branch context while Otto-Desktop was working there From 6702abb4fdd11304a5d9ac2983b49cfed244011d Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 18:01:09 -0400 Subject: [PATCH 11/12] =?UTF-8?q?fix(routines):=20more=20reviewer=20fixes?= =?UTF-8?q?=20=E2=80=94=20persona=20refs,=20read-error=20surfacing,=20empt?= =?UTF-8?q?y=20cron=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four threads addressed (3 P1 + 2 P2 from Codex/Copilot on PR #3034): install.ts: - readFileOrUndefined now distinguishes ENOENT (returns undefined → "missing file") from other errors (re-throws → installer fails loudly). Permission errors, IO failures no longer silently treated as missing. (Codex P2, Copilot P1) - readSchedule now rejects empty cronExpression strings with parseError "cronExpression must be a non-empty string". (Codex P2) autonomous-loop/SKILL.md + README.md: - Remove remaining "Otto" persona attribution per BP "No name attribution in code" on current-state surfaces under tools/. SKILL.md description + first paragraph now use "Autonomous-loop tick" without persona claim. README.md "Ask Otto" rephrased to direct MCP invocation; "Otto bootstream" rephrased to "canonical bootstream" with note that the literal filename is historical-surface substrate. (Copilot P1) Identity-stays-unified substrate (PR #3036) is preserved — the fresh-session that fires this routine identifies as Otto via the bootstream's Part 1, not via the routine prompt itself. Routine prompt is functional description; identity is established at cold-boot. Deferred to follow-up row: bun:test coverage for install.ts (Copilot P1 thread 5). Refactor for testability already shipped (exported pure functions accept directory params); actual test suite is its own task — will file as a separate backlog row. Verify trace: - bun tools/routines/install.ts: exits 0, parseErrors=0 ✓ - npx tsc --noEmit: clean on routines files ✓ Co-Authored-By: Claude --- tools/routines/README.md | 21 +++++++++++---------- tools/routines/autonomous-loop/SKILL.md | 4 ++-- tools/routines/install.ts | 18 +++++++++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/tools/routines/README.md b/tools/routines/README.md index 3a0ecdacda..9c7dbb08ab 100644 --- a/tools/routines/README.md +++ b/tools/routines/README.md @@ -52,11 +52,11 @@ back — runtime drift is the failure mode this two-layer split prevents. 3. Run `bun tools/routines/install.ts` — copies SKILL.md to runtime path. -4. Ask Otto (or call directly via the `scheduled-tasks` MCP) to register - the cron expression with the runtime by invoking - `create_scheduled_task(taskId, cronExpression, prompt, description)`. - The approval dialog is the consent step. After registration, the routine - fires on its cron cadence. +4. Register the cron expression with the runtime by invoking + `create_scheduled_task(taskId, cronExpression, prompt, description)` + via the `scheduled-tasks` MCP — either from an interactive Claude session + or via a direct MCP API call. The approval dialog is the consent step. + After registration, the routine fires on its cron cadence. ## Why two layers, not just direct MCP calls @@ -84,11 +84,12 @@ backup that fires even if the CLI session has died. ## Project-knowledge dependency -Routines that reference the Otto bootstream -(`docs/research/2026-05-12-otto-canonical-bootstream-multi-foreground-surface-orchestrator-ifs-format.md`) -require it to be uploaded as project knowledge in the Desktop project that -runs the routine. Without it, the prompt's cold-boot pointer won't resolve -and the fresh session will lack the substrate it expects. +Routines that reference the canonical bootstream +(`docs/research/2026-05-12-otto-canonical-bootstream-multi-foreground-surface-orchestrator-ifs-format.md` +— filename is historical-surface substrate per the §33 archive header) +require the bootstream to be uploaded as project knowledge in the Desktop +project that runs the routine. Without it, the prompt's cold-boot pointer +won't resolve and the fresh session will lack the substrate it expects. ## Composes with diff --git a/tools/routines/autonomous-loop/SKILL.md b/tools/routines/autonomous-loop/SKILL.md index eedd420d76..8a19e40aed 100644 --- a/tools/routines/autonomous-loop/SKILL.md +++ b/tools/routines/autonomous-loop/SKILL.md @@ -1,9 +1,9 @@ --- name: autonomous-loop -description: Otto autonomous-loop tick — fresh-session cold-boot, fires every 2 hours on Desktop +description: Autonomous-loop tick — fresh-session cold-boot, fires every 2 hours on Desktop --- -Otto autonomous-loop tick — fresh-session cold-boot. +Autonomous-loop tick — fresh-session cold-boot. 1. Read the project-knowledge file titled "2026-05-12-otto-canonical-bootstream-multi-foreground-surface-orchestrator-ifs-format.md" (13 parts, IFS-format). That IS your cold-boot. diff --git a/tools/routines/install.ts b/tools/routines/install.ts index 25e91be892..91ae99ddc8 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -53,8 +53,14 @@ export interface ScheduleResult { function readFileOrUndefined(path: string): string | undefined { try { return readFileSync(path, "utf8"); - } catch { - return undefined; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + // Surface non-ENOENT errors (permission, IO, etc.) — these are NOT + // "missing file" and should fail the installer loudly per + // .claude/rules/dont-refuse-engagement.md (silent-failure prevention). + throw err; } } @@ -74,9 +80,15 @@ export function readSchedule(srcDir: string): ScheduleResult { if (content === undefined) return { missing: true }; try { const parsed = JSON.parse(content) as { cronExpression?: unknown }; - if (typeof parsed.cronExpression === "string") { + if (typeof parsed.cronExpression === "string" && parsed.cronExpression.trim().length > 0) { return { cronExpression: parsed.cronExpression, missing: false }; } + if (typeof parsed.cronExpression === "string") { + return { + missing: false, + parseError: "cronExpression must be a non-empty string", + }; + } if (parsed.cronExpression !== undefined) { return { missing: false, From d79784ef53191005dbdf41bab623c38167958e13 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 18:11:43 -0400 Subject: [PATCH 12/12] fix(routines): listRoutines + missing-SKILL.md surface failures (Codex P1 + P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same silent-failure-prevention pattern as readFileOrUndefined: 1. listRoutines now distinguishes ENOENT (returns empty array — the no --- tools/routines/install.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/routines/install.ts b/tools/routines/install.ts index 91ae99ddc8..f10eea152c 100644 --- a/tools/routines/install.ts +++ b/tools/routines/install.ts @@ -69,8 +69,13 @@ export function listRoutines(repoRoutinesDir: string): string[] { return readdirSync(repoRoutinesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); - } catch { - return []; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + // Surface non-ENOENT errors (permission, IO, etc.) — same silent-failure + // prevention discipline as readFileOrUndefined above. + throw err; } } @@ -205,7 +210,9 @@ export function main( `\nDone. created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} missing=${summary.missingSkill} parseErrors=${summary.parseErrors}`, ); - return summary.parseErrors > 0 ? 1 : 0; + // SKILL.md is required per README. A routine directory with no SKILL.md is + // a developer mistake, not graceful state. Fail loudly so CI catches it. + return summary.parseErrors > 0 || summary.missingSkill > 0 ? 1 : 0; } if (import.meta.main) {