From 013111ec27b2cfcb008b342fae72aecbf06f1e35 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 24 Jun 2026 01:28:31 +0000 Subject: [PATCH 1/9] docs: spec for scheduled boards (agent-refreshed, host-agnostic) --- .../2026-06-24-scheduled-boards-design.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-scheduled-boards-design.md diff --git a/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md b/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md new file mode 100644 index 00000000..1d35235a --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md @@ -0,0 +1,237 @@ +# Scheduled Boards — Design + +**Date:** 2026-06-24 +**Status:** Approved (brainstorm) — pending spec review +**Branch:** worktree-scheduled-boards + +## Problem + +termchart is a push-based system: an *agent* (Claude, `agy`, any CLI caller) gathers +data, decides a layout, and pushes static JSON to a board (a `project/agent` scope on +the viewer). The viewer is a dumb display that live-updates over SSE. Today a board only +changes when a human triggers an agent to push. + +We want **boards that refresh themselves on a schedule** — generically enough to cover: + +- A **daily morning board** ("today's tasks, calendar, open PRs") that rebuilds at 8am. +- A **fast job-tracker** that refreshes every couple of minutes while a job runs, then + stops when the job finishes. + +These differ in cadence and lifetime but share one shape: *on a schedule, an agent +re-runs a saved intent and pushes the result to a fixed board.* + +## Decisions (from brainstorming) + +| Question | Decision | +|---|---| +| What produces refreshed content? | **An agent re-runs** a saved prompt (not a fixed data pull). Most flexible; covers all cases. | +| What drives the schedule? | **Host-agnostic.** termchart owns a board *definition* + a `run` primitive; any scheduler calls it. | +| First-class host in v1 | **Claude Code session cron** (`CronCreate`), wired by the skill. | +| Where do definitions live? | **Committed repo files** — portable, diffable, a GH Actions runner gets them on checkout. | +| v1 scope | **Core + GitHub Actions scaffolding.** (Viewer badge deferred.) | +| Definition file format | **YAML** (`.termchart/boards/.yaml`). | + +### Non-goals + +- No cron engine inside the viewer server (it runs on Cloud Run — scales to zero, + ephemeral; a server-side scheduler there is unreliable and contradicts repo-file + definitions). +- No fixed/no-LLM data-pull mode in v1 (the refresh engine is always an agent; a board's + prompt can *tell* the agent to run a command, which covers the deterministic case). +- No viewer "scheduled" badge in v1 (passive label is a clean follow-up). + +## Architecture + +Three cooperating pieces, each in its existing termchart home: + +``` +.termchart/boards/.yaml ── board definition (committed, portable) + │ + ├── packages/cli ── `termchart board` CRUD + `termchart run ` (host-agnostic primitive) + │ + `termchart board scaffold-workflow ` (GH Actions YAML) + │ + └── plugin skill ── `/termchart:schedule-board` guides the agent to author a + definition and register it with a scheduler (CronCreate v1) +``` + +The unifying primitive is the **assembled prompt**: for any board, `termchart board prompt +` prints the exact text handed to an agent. Every host path uses that same text, so an +in-session cron and a headless GH Actions run produce identical behaviour. + +### Data flow per refresh + +1. A scheduler fires (session cron in-process, or GH Actions / system cron calling + `termchart run `). +2. The board's assembled prompt is handed to an agent (`agentCommand`). +3. The agent gathers data with its own tools and `termchart push`es to the board's + `target` (`project/agent`) on the viewer. +4. The viewer broadcasts the update over SSE; connected browsers refresh instantly — + exactly the existing push path. **No new viewer code is required for v1.** + +## Component 1 — Board definition + +One YAML file per board at `.termchart/boards/.yaml`: + +```yaml +id: daily-standup # also the filename stem; [a-z0-9-] +description: "Morning board: today's tasks, calendar, open PRs" +schedule: "0 8 * * 1-5" # 5-field cron +tz: "America/Los_Angeles" # optional; default = system tz. Used for cron interpretation. +target: + project: me + agent: daily # the board scope the push updates +prompt: | + Gather my open tasks, today's calendar, and assigned PRs. + Build a component board grouped by priority and push it to project=me agent=daily. +agentCommand: "claude -p" # agent-agnostic; prompt delivered on stdin (see below) +host: claude-session # claude-session | github-actions | cron +lifecycle: + enabled: true + until: null # ISO-8601; stop scheduling after this instant + maxRuns: null # stop after N successful runs +``` + +- `prompt` is the saved intent. It is self-contained: it names the `target` so a headless + agent with no plugin context still knows where to push. +- `target` reuses the existing `project/agent` scope model — **a scheduled board is just a + normal board an agent keeps refreshing.** +- `agentCommand` keeps the system agent-agnostic (Claude, `agy --print`, or a test stub), + honouring the project's cross-tool integration stance. +- `lifecycle` gives the job-tracker case a natural end (`until` / `maxRuns`), and lets a + board be paused (`enabled: false`). + +### Validation + +A `validateBoardDef()` in `packages/core` (sibling to the existing content validators): +checks `id` shape, required fields, a 5-field cron, valid `tz`, `target.project/agent` +non-empty, `host` enum. Surfaces precise errors used by every CLI subcommand. + +## Component 2 — CLI surface (`packages/cli`) + +Mirrors the existing `termchart template ` sub-dispatch pattern. + +| Command | Behaviour | +|---|---| +| `termchart board create` | Scaffold a `.termchart/boards/.yaml` from flags (`--id --schedule --project --agent --prompt --host --tz`). No LLM. | +| `termchart board list` | List defined boards: id · schedule · target · host · enabled. `--json`. | +| `termchart board show ` | Print the parsed/validated definition + computed next-run. `--json`. | +| `termchart board delete ` | Remove the file. | +| `termchart board prompt ` | Print the fully-assembled agent prompt (the canonical text every host hands to the agent). | +| `termchart run ` | **Host-agnostic run primitive.** Load + validate def, assemble prompt, execute `agentCommand` with the prompt on **stdin**, return its exit code. The agent does the push. | +| `termchart board scaffold-workflow ` | Emit `.github/workflows/termchart-.yml` (`schedule:` cron + `workflow_dispatch`) that runs `termchart run `. | + +### Prompt delivery (robustness) + +`termchart run` delivers the assembled prompt to `agentCommand` via **stdin**, not as an +argv string. This avoids the `E2BIG` argv-overflow class of failure and all shell-quoting +hazards. `agentCommand` is tokenised (not run through a shell) and the prompt is piped in; +`claude -p` and `agy --print` both accept a prompt on stdin. A `{prompt}` placeholder is +*also* supported for commands that need it inline, but stdin is the default and documented +path. + +### Assembled prompt + +`termchart board prompt ` composes: + +1. The board's `prompt` (the saved intent). +2. An explicit **push instruction**: the exact `target` (`project`/`agent`) and a reminder + to push with the termchart CLI, so a context-free headless agent still succeeds. +3. A **lifecycle instruction** when relevant: e.g. "If the tracked job has finished, + unschedule this board (`CronDelete` in-session, or disable the workflow)." This is how + the job-tracker self-terminates. + +## Component 3 — Scheduling / host integration + +### v1 first-class — Claude Code session cron + +The `/termchart:schedule-board` skill, after authoring the definition, registers a +`CronCreate` whose prompt is the output of `termchart board prompt `. It fires +**in-session** (no subprocess) — ideal for "watch this running job while I work." The skill +records the cron id in the board file (`sessionCronId`, written back) so it can be updated +or `CronDelete`d. Session crons inherit the harness's 7-day auto-expiry; `lifecycle.until` +/`maxRuns` are enforced by the assembled prompt instructing the agent to self-unschedule. + +### v1 turnkey — GitHub Actions + +`termchart board scaffold-workflow ` generates: + +```yaml +name: termchart board +on: + schedule: + - cron: "" # comment notes the source tz + workflow_dispatch: {} +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20 } + - run: npx -y @ivanmkc/termchart run + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + TERMCHART_VIEWER_URL: ${{ secrets.TERMCHART_VIEWER_URL }} + TERMCHART_VIEWER_TOKEN: ${{ secrets.TERMCHART_VIEWER_TOKEN }} +``` + +Caveats the scaffolder prints: +- **GH Actions cron is UTC.** The scaffolder converts `schedule` from `tz` to a UTC cron + and annotates it with a comment; it warns that DST shifts the wall-clock time (a fixed + offset is used — exact DST-aware scheduling is out of scope). +- **A cloud runner can't see local files.** Boards whose data is local-only must use + `host: claude-session` or a local `cron` host. The scaffolder refuses (with a clear + message) only if it can detect an obviously local-only intent; otherwise it warns. +- The agent in the runner needs the termchart CLI (via `npx`) and an API key; the assembled + prompt is self-contained re: where to push. + +### Compatible — system cron + +Documented, not scaffolded in v1 beyond the `run` primitive: a crontab line runs +`termchart run `. Same primitive, same prompt. + +## Lifecycle & stop conditions + +- `enabled: false` → schedulers skip the board; `run` exits 0 with a notice. +- `until` / `maxRuns` → enforced by the assembled prompt (agent self-unschedules) and + surfaced by `board show` (computed next-run / remaining runs). +- Job-tracker pattern: a short-cadence session-cron board whose prompt ends with "if the + job is done, unschedule" — it cleans itself up. + +## Viewer surfacing (deferred) + +Out of scope for v1. Follow-up: scheduled pushes set a `source: "schedule:"` marker so +the viewer can badge "auto-updated 08:03". Passive label only — still no server scheduler. + +## Testing strategy + +Pure, deterministic units (no LLM, no network) cover the core: + +- `validateBoardDef()` — happy path + each failure (bad id, bad cron, bad tz, missing + target, bad host enum). +- `board create/list/show/delete` — round-trip a YAML file in a temp dir. +- `board prompt` — golden assembled-prompt output (intent + push instruction + lifecycle). +- `run` with a **stub `agentCommand`** (e.g. a script that records its stdin) — asserts the + prompt is delivered on stdin, exit code propagates, `enabled:false` short-circuits. +- `scaffold-workflow` — golden YAML; tz→UTC conversion; local-data warning. + +End-to-end (live verification surface): `termchart run ` with a real `agentCommand` +against the live viewer → confirm the target board updates over SSE. + +## Release channels + +Per termchart's independent channels: + +- **CLI** (`@ivanmkc/termchart`) — new `board` + `run` commands, YAML dep, version bump, + publish. +- **Core** (`@ivanmkc/termchart-core`) — `validateBoardDef()`. +- **Plugin** (marketplace) — new `/termchart:schedule-board` skill/command, version bump. +- **Viewer** — unchanged in v1. + +## Open implementation notes + +- Add a YAML parser to the CLI bundle (`yaml`); esbuild bundles it like existing deps. +- `agentCommand` is tokenised and spawned without a shell (`spawn`, not `exec`) — no shell + injection, prompt on stdin. +- `tz`→UTC cron conversion uses a fixed current-offset translation with an explicit DST + caveat in v1. From 9e02a45faf61191fc370129a02508ab6a27b23f7 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 24 Jun 2026 02:20:25 +0000 Subject: [PATCH 2/9] feat(scheduled-boards): define & schedule auto-refreshing boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheduled board is a normal project/agent board that an agent re-refreshes on a schedule. termchart owns two portable pieces — a committed YAML definition (.termchart/boards/.yaml) and a host-agnostic `termchart run ` primitive that hands the board's assembled prompt to an agent which gathers data and pushes. - core: validateBoardDef() — pure, dependency-free structural validator - cli: `termchart board create|list|show|prompt|scaffold-workflow|delete` `termchart run ` — assemble prompt + spawn agentCommand (prompt on stdin to avoid E2BIG/shell-quoting), exit-code propagation, disabled skip - cli: scaffold-workflow generates a GitHub Actions workflow; tz->UTC cron conversion for the single-hour case with DST/day-cross warnings - plugin: /termchart:schedule-board command + scheduled-boards skill (session-cron v1, GH Actions turnkey, system cron compatible) Build: esbuild ESM bundle now injects a createRequire banner so bundled CJS deps (yaml) resolve node builtins instead of throwing "Dynamic require". Version bumps: cli 0.5.0->0.6.0, plugin 0.12.0->0.13.0, marketplace ->0.13.0. Tests: core validator (10), board CRUD, prompt assembly, run primitive, scaffold-workflow + tz conversion, and a built-bundle regression guard. --- .claude-plugin/marketplace.json | 2 +- package-lock.json | 11 +- packages/cli/package.json | 8 +- packages/cli/src/board-prompt.ts | 34 ++++ packages/cli/src/board-store.ts | 65 ++++++++ packages/cli/src/board.ts | 168 ++++++++++++++++++++ packages/cli/src/cli.ts | 12 +- packages/cli/src/run.ts | 66 ++++++++ packages/cli/src/scaffold-workflow.ts | 98 ++++++++++++ packages/cli/test/board-bundle.test.ts | 38 +++++ packages/cli/test/board-prompt.test.ts | 68 ++++++++ packages/cli/test/board.test.ts | 116 ++++++++++++++ packages/cli/test/run.test.ts | 93 +++++++++++ packages/cli/test/scaffold-workflow.test.ts | 84 ++++++++++ packages/core/package.json | 6 + packages/core/src/board.ts | 98 ++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/validate-board.test.ts | 70 ++++++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/commands/schedule-board.md | 51 ++++++ plugin/skills/scheduled-boards/SKILL.md | 112 +++++++++++++ 21 files changed, 1194 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/board-prompt.ts create mode 100644 packages/cli/src/board-store.ts create mode 100644 packages/cli/src/board.ts create mode 100644 packages/cli/src/run.ts create mode 100644 packages/cli/src/scaffold-workflow.ts create mode 100644 packages/cli/test/board-bundle.test.ts create mode 100644 packages/cli/test/board-prompt.test.ts create mode 100644 packages/cli/test/board.test.ts create mode 100644 packages/cli/test/run.test.ts create mode 100644 packages/cli/test/scaffold-workflow.test.ts create mode 100644 packages/core/src/board.ts create mode 100644 packages/core/test/validate-board.test.ts create mode 100644 plugin/commands/schedule-board.md create mode 100644 plugin/skills/scheduled-boards/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6f54bf54..d873311a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "termchart", "source": "./plugin", "description": "A live canvas your AI draws on — instead of walls of text, your agent pushes rich, native visuals to a browser / iPad / second screen in real time: React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes, with animated edges and live status overlays (toasts, progress bars, loaders). Deterministic Mermaid → ASCII/Unicode is the terminal fallback. Persona recipes, a showcase gallery, and fullscreen panes.", - "version": "0.12.3", + "version": "0.13.0", "license": "MIT", "keywords": [ "mermaid", diff --git a/package-lock.json b/package-lock.json index ef319a51..c3d240fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3354,7 +3354,6 @@ }, "node_modules/yaml": { "version": "2.9.0", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -3436,8 +3435,11 @@ }, "packages/cli": { "name": "@ivanmkc/termchart", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", + "dependencies": { + "yaml": "^2.9.0" + }, "bin": { "termchart": "dist/cli.js" }, @@ -3455,7 +3457,10 @@ "packages/core": { "name": "@ivanmkc/termchart-core", "version": "0.1.0", - "license": "MIT" + "license": "MIT", + "devDependencies": { + "vitest": "^1.6.0" + } }, "packages/viewer": { "name": "@ivanmkc/termchart-viewer", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d5ac0f3..1293ba94 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@ivanmkc/termchart", - "version": "0.5.0", + "version": "0.6.0", "description": "Push native diagrams, charts, dashboards & live status to the termchart viewer (browser/iPad) in real time — the CLI for the live canvas your AI draws on; also renders deterministic Mermaid → ASCII/Unicode in the terminal.", "type": "module", "bin": { @@ -17,12 +17,14 @@ }, "scripts": { "build:tsc": "tsc -p tsconfig.json", - "build": "npm run build:tsc && esbuild build/cli.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js", + "build": "npm run build:tsc && esbuild build/cli.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js=\"import { createRequire as __tcCreateRequire } from 'module'; const require = __tcCreateRequire(import.meta.url);\"", "prepublishOnly": "npm run build", "test": "vitest run", "test:watch": "vitest" }, - "dependencies": {}, + "dependencies": { + "yaml": "^2.9.0" + }, "devDependencies": { "@types/node": "^20.0.0", "beautiful-mermaid": "^1.1.3", diff --git a/packages/cli/src/board-prompt.ts b/packages/cli/src/board-prompt.ts new file mode 100644 index 00000000..05c7dcab --- /dev/null +++ b/packages/cli/src/board-prompt.ts @@ -0,0 +1,34 @@ +// Assemble the canonical agent prompt for a scheduled board. Every host path — a Claude Code +// session cron firing in-process, or `termchart run ` shelling out to a headless agent in +// GitHub Actions — hands the agent THIS exact text, so behaviour is identical across hosts. +// +// The prompt is self-contained: it restates the push target and command so a context-free +// headless agent (no termchart plugin loaded) still knows where to send the board. + +import type { BoardDef } from "@ivanmkc/termchart-core"; + +export function assembleBoardPrompt(def: BoardDef): string { + const { project, agent } = def.target; + const parts: string[] = [def.prompt.trim()]; + + parts.push( + `When the board is ready, push it to the termchart viewer for project="${project}" agent="${agent}" ` + + `with: termchart push --project ${project} --agent ${agent} --type --description "". ` + + `This is a recurring board, so this push replaces the previous version of the same project/agent.`, + ); + + const lc = def.lifecycle; + if (lc && (lc.until != null || lc.maxRuns != null)) { + const bound = [ + lc.until != null ? `after ${lc.until}` : null, + lc.maxRuns != null ? `after ${lc.maxRuns} run(s)` : null, + ].filter(Boolean).join(" or "); + parts.push( + `Lifecycle: this board is meant to stop ${bound}. If the work it tracks is complete (or that ` + + `limit is reached), unschedule it — CronDelete the job in a Claude Code session, or disable the ` + + `GitHub Actions workflow.`, + ); + } + + return parts.join("\n\n") + "\n"; +} diff --git a/packages/cli/src/board-store.ts b/packages/cli/src/board-store.ts new file mode 100644 index 00000000..a92ea25f --- /dev/null +++ b/packages/cli/src/board-store.ts @@ -0,0 +1,65 @@ +// Read/write scheduled-board definitions stored as committed repo files at +// `/.termchart/boards/.yaml`. Pure fs + YAML; the structural rules live in +// @ivanmkc/termchart-core (validateBoardDef). Shared by the `board` command and `run`. + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { validateBoardDef, type BoardDef } from "@ivanmkc/termchart-core"; + +export const BOARDS_SUBDIR = join(".termchart", "boards"); + +export function boardsDir(root: string): string { + return join(root, BOARDS_SUBDIR); +} + +export function boardPath(root: string, id: string): string { + return join(boardsDir(root), `${id}.yaml`); +} + +/** Parse + validate a board file. Returns the def, or throws with a path-pointed message. */ +export function loadBoard(root: string, id: string): BoardDef { + const file = boardPath(root, id); + if (!existsSync(file)) throw new Error(`no such board: ${id} (expected ${file})`); + const def = parseYaml(readFileSync(file, "utf8")) as unknown; + const err = validateBoardDef(def); + if (err) throw new Error(`invalid board ${id}: ${err}`); + return def as BoardDef; +} + +/** All valid board defs under the root, sorted by id. Invalid/unparseable files are skipped. */ +export function listBoards(root: string): BoardDef[] { + const dir = boardsDir(root); + if (!existsSync(dir)) return []; + const out: BoardDef[] = []; + for (const name of readdirSync(dir).sort()) { + if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue; + try { + const def = parseYaml(readFileSync(join(dir, name), "utf8")) as unknown; + if (!validateBoardDef(def)) out.push(def as BoardDef); + } catch { + // skip unparseable files — `board show ` surfaces the specific error + } + } + return out; +} + +export function boardExists(root: string, id: string): boolean { + return existsSync(boardPath(root, id)); +} + +/** Write a validated def to its file (creating the boards dir). Throws if invalid. */ +export function saveBoard(root: string, def: BoardDef): void { + const err = validateBoardDef(def); + if (err) throw new Error(err); + mkdirSync(boardsDir(root), { recursive: true }); + writeFileSync(boardPath(root, def.id), stringifyYaml(def)); +} + +/** Delete a board file. Returns false if it didn't exist. */ +export function deleteBoard(root: string, id: string): boolean { + const file = boardPath(root, id); + if (!existsSync(file)) return false; + rmSync(file); + return true; +} diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts new file mode 100644 index 00000000..fc97713e --- /dev/null +++ b/packages/cli/src/board.ts @@ -0,0 +1,168 @@ +// `termchart board ` — manage scheduled-board +// definitions stored as committed `.termchart/boards/.yaml` files. Definitions are portable: a +// GitHub Actions runner gets them on checkout, and `termchart run ` reads them anywhere. + +import { validateBoardDef, type BoardDef, type BoardHost } from "@ivanmkc/termchart-core"; +import { boardExists, deleteBoard, listBoards, loadBoard, saveBoard } from "./board-store.js"; +import { assembleBoardPrompt } from "./board-prompt.js"; +import { writeWorkflow } from "./scaffold-workflow.js"; + +export interface BoardDeps { + cwd?: string; + env?: Record; +} + +const SUBCOMMANDS = ["create", "list", "show", "delete", "prompt", "scaffold-workflow"]; + +interface CreateArgs { + id?: string; + schedule?: string; + project?: string; + agent?: string; + prompt?: string; + description?: string; + host?: string; + tz?: string; + agentCommand?: string; + force: boolean; + error?: string; +} + +function parseCreate(argv: string[]): CreateArgs { + const a: CreateArgs = { force: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--id") a.id = argv[++i]; + else if (arg === "--schedule") a.schedule = argv[++i]; + else if (arg === "--project") a.project = argv[++i]; + else if (arg === "--agent") a.agent = argv[++i]; + else if (arg === "--prompt") a.prompt = argv[++i]; + else if (arg === "--description") a.description = argv[++i]; + else if (arg === "--host") a.host = argv[++i]; + else if (arg === "--tz") a.tz = argv[++i]; + else if (arg === "--agent-command") a.agentCommand = argv[++i]; + else if (arg === "--force") a.force = true; + else a.error = `Unknown flag: ${arg}`; + } + return a; +} + +function create(argv: string[], root: string): number { + const a = parseCreate(argv); + if (a.error) { process.stderr.write(a.error + "\n"); return 3; } + if (!a.id || !a.schedule || !a.project || !a.agent || !a.prompt) { + process.stderr.write("create needs --id, --schedule, --project, --agent, and --prompt\n"); + return 3; + } + const def: BoardDef = { + id: a.id, + schedule: a.schedule, + target: { project: a.project, agent: a.agent }, + prompt: a.prompt, + }; + if (a.description) def.description = a.description; + if (a.tz) def.tz = a.tz; + if (a.host) def.host = a.host as BoardHost; + if (a.agentCommand) def.agentCommand = a.agentCommand; + + const err = validateBoardDef(def); + if (err) { process.stderr.write(`invalid board: ${err}\n`); return 3; } + if (boardExists(root, a.id) && !a.force) { + process.stderr.write(`board "${a.id}" already exists — pass --force to overwrite\n`); + return 3; + } + saveBoard(root, def); + process.stdout.write(`created board ${a.id} — schedule it with /termchart:schedule-board, or run now: termchart run ${a.id}\n`); + return 0; +} + +function list(argv: string[], root: string): number { + const json = argv.includes("--json"); + const boards = listBoards(root); + if (json) { process.stdout.write(JSON.stringify(boards, null, 2) + "\n"); return 0; } + if (boards.length === 0) { + process.stdout.write("no boards defined — create one with `termchart board create`\n"); + return 0; + } + for (const b of boards) { + const enabled = b.lifecycle?.enabled === false ? " (disabled)" : ""; + process.stdout.write(`${b.id} ${b.schedule} → ${b.target.project}/${b.target.agent} [${b.host ?? "claude-session"}]${enabled}\n`); + } + return 0; +} + +function show(argv: string[], root: string): number { + const id = argv.find((x) => !x.startsWith("-")); + const json = argv.includes("--json"); + if (!id) { process.stderr.write("show needs a board id\n"); return 3; } + let def: BoardDef; + try { + def = loadBoard(root, id); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 1; + } + if (json) { process.stdout.write(JSON.stringify(def, null, 2) + "\n"); return 0; } + process.stdout.write(`${def.id}${def.description ? ` — ${def.description}` : ""}\n`); + process.stdout.write(` schedule: ${def.schedule}${def.tz ? ` (${def.tz})` : ""}\n`); + process.stdout.write(` target: ${def.target.project}/${def.target.agent}\n`); + process.stdout.write(` host: ${def.host ?? "claude-session"}\n`); + if (def.lifecycle) process.stdout.write(` lifecycle: ${JSON.stringify(def.lifecycle)}\n`); + process.stdout.write(` prompt: ${def.prompt}\n`); + return 0; +} + +function promptCmd(argv: string[], root: string): number { + const id = argv.find((x) => !x.startsWith("-")); + if (!id) { process.stderr.write("prompt needs a board id\n"); return 3; } + let def: BoardDef; + try { + def = loadBoard(root, id); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 1; + } + process.stdout.write(assembleBoardPrompt(def)); + return 0; +} + +function scaffoldWorkflow(argv: string[], root: string): number { + const id = argv.find((x) => !x.startsWith("-")); + if (!id) { process.stderr.write("scaffold-workflow needs a board id\n"); return 3; } + let def: BoardDef; + try { + def = loadBoard(root, id); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 1; + } + const { path, warnings } = writeWorkflow(root, def); + for (const w of warnings) process.stderr.write(`warning: ${w}\n`); + process.stdout.write(`wrote ${path}\n`); + process.stdout.write("set repo secrets ANTHROPIC_API_KEY, TERMCHART_VIEWER_URL, TERMCHART_VIEWER_TOKEN to enable it.\n"); + return 0; +} + +function del(argv: string[], root: string): number { + const id = argv.find((x) => !x.startsWith("-")); + if (!id) { process.stderr.write("delete needs a board id\n"); return 3; } + if (!deleteBoard(root, id)) { process.stderr.write(`no such board: ${id}\n`); return 1; } + process.stdout.write(`deleted board ${id}\n`); + return 0; +} + +export async function board(argv: string[], deps: BoardDeps): Promise { + const sub = argv[0]; + const rest = argv.slice(1); + const root = deps.cwd ?? process.cwd(); + if (!sub || !SUBCOMMANDS.includes(sub)) { + process.stderr.write(`usage: termchart board <${SUBCOMMANDS.join("|")}> …\n`); + return 3; + } + if (sub === "create") return create(rest, root); + if (sub === "list") return list(rest, root); + if (sub === "show") return show(rest, root); + if (sub === "delete") return del(rest, root); + if (sub === "prompt") return promptCmd(rest, root); + return scaffoldWorkflow(rest, root); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 56195b73..cf8bbd86 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -21,7 +21,7 @@ import { lint } from "./lint.js"; const DEFAULT_NON_TTY_WIDTH = 80; -const VERSION = "0.5.0"; +const VERSION = "0.6.0"; const USAGE = `termchart — deterministic Mermaid → ASCII/Unicode for terminals @@ -40,6 +40,8 @@ Usage: --follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop) termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item) termchart template Reusable diagram templates: save --project --agent --name | list | get | delete + termchart board Scheduled boards (auto-refreshing): create --id --schedule --project --agent --prompt | list | show | prompt | scaffold-workflow | delete + termchart run Refresh a scheduled board now — assemble its prompt + run its agent, which pushes the board termchart --version Render flags: @@ -381,6 +383,14 @@ if (isEntryPoint()) { import("./template.js") .then(({ template }) => template(argv.slice(1), { env: process.env }).then((code) => process.exit(code))) .catch((e) => { process.stderr.write(`template failed: ${e?.message ?? e}\n`); process.exit(1); }); + } else if (argv[0] === "board") { + import("./board.js") + .then(({ board }) => board(argv.slice(1), { env: process.env }).then((code) => process.exit(code))) + .catch((e) => { process.stderr.write(`board failed: ${e?.message ?? e}\n`); process.exit(1); }); + } else if (argv[0] === "run") { + import("./run.js") + .then(({ run }) => run(argv.slice(1), { env: process.env }).then((code) => process.exit(code))) + .catch((e) => { process.stderr.write(`run failed: ${e?.message ?? e}\n`); process.exit(1); }); } else { process.exit(main(argv)); } diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts new file mode 100644 index 00000000..77d6c304 --- /dev/null +++ b/packages/cli/src/run.ts @@ -0,0 +1,66 @@ +// `termchart run ` — the host-agnostic run primitive. Loads + validates a board definition, +// assembles its canonical prompt, and hands that prompt (on stdin) to an agent which gathers the +// data and pushes the board to the viewer. Every scheduler — a Claude Code session cron, a GitHub +// Actions workflow, a system crontab — ultimately calls this same command, so refresh behaviour is +// identical regardless of who pulled the trigger. + +import { spawn as nodeSpawn } from "node:child_process"; +import { loadBoard } from "./board-store.js"; +import { assembleBoardPrompt } from "./board-prompt.js"; + +/** Run an agent: command + args, with `input` delivered on stdin and `env` inherited. Resolves to its exit code. */ +export type AgentSpawn = ( + cmd: string, + args: string[], + input: string, + env: Record, +) => Promise; + +export interface RunDeps { + cwd?: string; + env?: Record; + spawn?: AgentSpawn; // injectable for tests; defaults to a real child process +} + +const DEFAULT_AGENT_COMMAND = "claude -p"; + +// Deliver the prompt on stdin (never as an argv string) to dodge E2BIG and shell-quoting hazards; +// the agent command is tokenised and spawned without a shell. +const defaultSpawn: AgentSpawn = (cmd, args, input, env) => + new Promise((resolve) => { + const child = nodeSpawn(cmd, args, { env, stdio: ["pipe", "inherit", "inherit"] }); + child.on("error", (e) => { + process.stderr.write(`run: cannot launch agent "${cmd}": ${e.message}\n`); + resolve(127); + }); + if (child.stdin) { + child.stdin.on("error", () => {}); // ignore EPIPE if the agent exits early + child.stdin.write(input); + child.stdin.end(); + } + child.on("close", (code) => resolve(code ?? 0)); + }); + +export async function run(argv: string[], deps: RunDeps): Promise { + const id = argv.find((x) => !x.startsWith("-")); + if (!id) { process.stderr.write("usage: termchart run \n"); return 3; } + + const root = deps.cwd ?? process.cwd(); + let def; + try { + def = loadBoard(root, id); + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 1; + } + + if (def.lifecycle?.enabled === false) { + process.stdout.write(`board ${id} is disabled — skipping\n`); + return 0; + } + + const [cmd, ...args] = (def.agentCommand ?? DEFAULT_AGENT_COMMAND).trim().split(/\s+/); + const prompt = assembleBoardPrompt(def); + const spawn = deps.spawn ?? defaultSpawn; + return spawn(cmd, args, prompt, deps.env ?? process.env); +} diff --git a/packages/cli/src/scaffold-workflow.ts b/packages/cli/src/scaffold-workflow.ts new file mode 100644 index 00000000..083b6faf --- /dev/null +++ b/packages/cli/src/scaffold-workflow.ts @@ -0,0 +1,98 @@ +// `termchart board scaffold-workflow ` — generate a GitHub Actions workflow that runs +// `termchart run ` on the board's schedule. GitHub cron is UTC-only, so we convert the board's +// (tz-local) schedule to UTC for the common single-hour case and warn loudly about what we can't +// safely convert. Exact DST-aware scheduling is out of scope for v1 (a fixed-offset translation, +// computed at scaffold time, is used). + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { BoardDef } from "@ivanmkc/termchart-core"; + +export interface BuildOpts { + now?: Date; // reference instant for the tz offset (injectable for deterministic tests) +} + +/** Minutes east of UTC for `tz` at instant `at` (e.g. America/Los_Angeles in winter → -480). */ +function tzOffsetMinutes(tz: string, at: Date): number { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: tz, hour12: false, + year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + const m: Record = {}; + for (const p of dtf.formatToParts(at)) m[p.type] = p.value; + // Intl renders 24:00 as "24"; normalise to 0 so Date.UTC stays in range. + const hour = m.hour === "24" ? 0 : Number(m.hour); + const asUTC = Date.UTC(Number(m.year), Number(m.month) - 1, Number(m.day), hour, Number(m.minute), Number(m.second)); + return Math.round((asUTC - at.getTime()) / 60000); +} + +/** + * Convert a 5-field cron from a tz to UTC. Only the common single-integer minute+hour case is + * converted; anything else passes through with a warning. Returns the (possibly unchanged) cron and + * any caveats. + */ +export function cronToUtc(schedule: string, tz: string | undefined, at: Date): { cron: string; warnings: string[] } { + const warnings: string[] = []; + const fields = schedule.trim().split(/\s+/); + if (!tz) { + warnings.push("No tz set — GitHub Actions cron is UTC; the schedule is used as-is."); + return { cron: schedule.trim(), warnings }; + } + const [min, hour, dom, mon, dow] = fields; + if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) { + warnings.push(`Could not convert "${schedule}" from ${tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`); + return { cron: schedule.trim(), warnings }; + } + const offset = tzOffsetMinutes(tz, at); + const totalUtc = Number(hour) * 60 + Number(min) - offset; + const dayDelta = Math.floor(totalUtc / 1440); + const norm = ((totalUtc % 1440) + 1440) % 1440; + const utcHour = Math.floor(norm / 60); + const utcMin = norm % 60; + if (dayDelta !== 0 && !(dom === "*" && dow === "*")) { + warnings.push( + `Converting ${tz}→UTC shifts this run across midnight; the day-of-week/day-of-month fields ` + + `(${dom} ${dow}) are NOT shifted, so the UTC run may land one day off. Verify the schedule.`, + ); + } + return { cron: `${utcMin} ${utcHour} ${dom} ${mon} ${dow}`, warnings }; +} + +/** Build the workflow YAML for a board. Returns the file text and any scheduling caveats. */ +export function buildWorkflowYaml(def: BoardDef, opts: BuildOpts = {}): { yaml: string; warnings: string[] } { + const at = opts.now ?? new Date(); + const { cron, warnings } = cronToUtc(def.schedule, def.tz, at); + const tzNote = def.tz ? ` (source: ${def.schedule} ${def.tz})` : ""; + const yaml = `# Generated by \`termchart board scaffold-workflow ${def.id}\`. +# Set repo secrets: ANTHROPIC_API_KEY, TERMCHART_VIEWER_URL, TERMCHART_VIEWER_TOKEN. +name: termchart board ${def.id} +on: + schedule: + - cron: "${cron}"${tzNote ? ` #${tzNote}` : ""} + workflow_dispatch: {} +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npx -y @ivanmkc/termchart run ${def.id} + env: + ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} + TERMCHART_VIEWER_URL: \${{ secrets.TERMCHART_VIEWER_URL }} + TERMCHART_VIEWER_TOKEN: \${{ secrets.TERMCHART_VIEWER_TOKEN }} +`; + return { yaml, warnings }; +} + +/** Write the workflow file under the repo root. Returns its path. */ +export function writeWorkflow(root: string, def: BoardDef, opts: BuildOpts = {}): { path: string; warnings: string[] } { + const { yaml, warnings } = buildWorkflowYaml(def, opts); + const dir = join(root, ".github", "workflows"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `termchart-${def.id}.yml`); + writeFileSync(path, yaml); + return { path, warnings }; +} diff --git a/packages/cli/test/board-bundle.test.ts b/packages/cli/test/board-bundle.test.ts new file mode 100644 index 00000000..da9cf30b --- /dev/null +++ b/packages/cli/test/board-bundle.test.ts @@ -0,0 +1,38 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve, join } from "node:path"; +import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const BIN = resolve(here, "../dist/cli.js"); +// The board commands depend on `yaml`, which is bundled into the ESM output. If esbuild's +// CJS→ESM `require` shim isn't satisfied, the built binary throws "Dynamic require of ...". +// These tests run the *built* binary so that regression can never return silently. +const maybe = existsSync(BIN) ? describe : describe.skip; + +maybe("board commands work in the built bundle", () => { + let cwd: string; + beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-bundle-")); }); + afterEach(() => rmSync(cwd, { recursive: true, force: true })); + + const run = (args: string[]) => + execFileSync("node", [BIN, ...args], { cwd, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } }); + + it("create writes a parseable YAML board (no dynamic-require crash)", () => { + run(["board", "create", "--id", "daily", "--schedule", "0 8 * * 1-5", "--project", "me", "--agent", "daily", "--prompt", "Build it."]); + const file = join(cwd, ".termchart", "boards", "daily.yaml"); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, "utf8")).toContain("schedule: 0 8 * * 1-5"); + expect(run(["board", "list"])).toContain("me/daily"); + }); + + it("run feeds the assembled prompt to the agent on stdin", () => { + // Use `cat` as the agent: it echoes its stdin, so we can see the assembled prompt. + run(["board", "create", "--id", "j", "--schedule", "* * * * *", "--project", "ops", "--agent", "j1", "--prompt", "Report metrics.", "--agent-command", "cat"]); + const out = run(["run", "j"]); + expect(out).toContain("Report metrics."); + expect(out).toContain("termchart push --project ops --agent j1"); + }); +}); diff --git a/packages/cli/test/board-prompt.test.ts b/packages/cli/test/board-prompt.test.ts new file mode 100644 index 00000000..3644e1c6 --- /dev/null +++ b/packages/cli/test/board-prompt.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as stringifyYaml } from "yaml"; +import type { BoardDef } from "@ivanmkc/termchart-core"; +import { assembleBoardPrompt } from "../src/board-prompt.js"; +import { board } from "../src/board.js"; + +const base: BoardDef = { + id: "daily", + schedule: "0 8 * * 1-5", + target: { project: "me", agent: "daily" }, + prompt: "Gather my open tasks and build a component board.", +}; + +describe("assembleBoardPrompt", () => { + it("includes the board's own intent verbatim", () => { + expect(assembleBoardPrompt(base)).toContain("Gather my open tasks and build a component board."); + }); + + it("tells the agent exactly where to push (project + agent + push command)", () => { + const p = assembleBoardPrompt(base); + expect(p).toContain("me"); // project + expect(p).toContain("daily"); // agent + expect(p).toMatch(/termchart push/); + expect(p).toMatch(/--project me/); + expect(p).toMatch(/--agent daily/); + }); + + it("adds a self-unschedule instruction when lifecycle bounds the board", () => { + const p = assembleBoardPrompt({ ...base, lifecycle: { maxRuns: 5 } }); + expect(p.toLowerCase()).toContain("unschedule"); + }); + + it("omits the self-unschedule instruction for an open-ended board", () => { + expect(assembleBoardPrompt(base).toLowerCase()).not.toContain("unschedule"); + }); +}); + +describe("board prompt", () => { + let cwd: string; + beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-bp-")); }); + afterEach(() => rmSync(cwd, { recursive: true, force: true })); + const captureOut = () => { + const out: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + return { done: () => { spy.mockRestore(); return out.join(""); } }; + }; + const writeDef = (def: BoardDef) => { + mkdirSync(join(cwd, ".termchart", "boards"), { recursive: true }); + writeFileSync(join(cwd, ".termchart", "boards", `${def.id}.yaml`), stringifyYaml(def)); + }; + + it("prints the assembled prompt for a board (exit 0)", async () => { + writeDef(base); + const cap = captureOut(); + const code = await board(["prompt", "daily"], { cwd }); + const text = cap.done(); + expect(code).toBe(0); + expect(text).toContain("Gather my open tasks"); + expect(text).toContain("termchart push"); + }); + + it("returns 1 for an unknown id", async () => { + expect(await board(["prompt", "nope"], { cwd })).toBe(1); + }); +}); diff --git a/packages/cli/test/board.test.ts b/packages/cli/test/board.test.ts new file mode 100644 index 00000000..26efd9a8 --- /dev/null +++ b/packages/cli/test/board.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { board } from "../src/board.js"; + +let cwd: string; +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "tc-board-")); +}); +afterEach(() => rmSync(cwd, { recursive: true, force: true })); + +const boardsDir = () => join(cwd, ".termchart", "boards"); +const captureOut = () => { + const out: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + return { out, done: () => { spy.mockRestore(); return out.join(""); } }; +}; +const writeDef = (id: string, body: Record) => { + mkdirSync(boardsDir(), { recursive: true }); + // minimal valid def, merged with overrides + const def = { id, schedule: "0 8 * * *", target: { project: "p", agent: "a" }, prompt: "go", ...body }; + writeFileSync(join(boardsDir(), `${id}.yaml`), Object.entries(def).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n")); +}; + +describe("board create", () => { + const createArgs = ["create", "--id", "daily", "--schedule", "0 8 * * 1-5", "--project", "me", "--agent", "daily", "--prompt", "Gather tasks and push."]; + + it("writes a valid .termchart/boards/.yaml and exits 0", async () => { + const cap = captureOut(); + const code = await board(createArgs, { cwd }); + cap.done(); + expect(code).toBe(0); + const file = join(boardsDir(), "daily.yaml"); + expect(existsSync(file)).toBe(true); + const def = parseYaml(readFileSync(file, "utf8")); + expect(def).toMatchObject({ id: "daily", schedule: "0 8 * * 1-5", target: { project: "me", agent: "daily" }, prompt: "Gather tasks and push." }); + }); + + it("rejects an invalid definition (bad cron) with exit 3 and writes no file", async () => { + const code = await board(["create", "--id", "bad", "--schedule", "0 8 * *", "--project", "me", "--agent", "d", "--prompt", "x"], { cwd }); + expect(code).toBe(3); + expect(existsSync(join(boardsDir(), "bad.yaml"))).toBe(false); + }); + + it("requires id, schedule, project, agent, and prompt (exit 3)", async () => { + expect(await board(["create", "--id", "x"], { cwd })).toBe(3); + }); + + it("refuses to overwrite an existing board unless --force", async () => { + expect(await board(createArgs, { cwd })).toBe(0); + expect(await board(createArgs, { cwd })).toBe(3); // exists + expect(await board([...createArgs, "--force"], { cwd })).toBe(0); // force ok + }); +}); + +describe("board list", () => { + it("prints each board's id, schedule, and target", async () => { + writeDef("daily", { schedule: "0 8 * * 1-5", target: { project: "me", agent: "daily" } }); + writeDef("job", { schedule: "*/2 * * * *", target: { project: "ops", agent: "job1" } }); + const cap = captureOut(); + const code = await board(["list"], { cwd }); + const text = cap.done(); + expect(code).toBe(0); + expect(text).toContain("daily"); + expect(text).toContain("0 8 * * 1-5"); + expect(text).toContain("me/daily"); + expect(text).toContain("job"); + expect(text).toContain("ops/job1"); + }); + + it("prints a friendly message when there are no boards (exit 0)", async () => { + const cap = captureOut(); + const code = await board(["list"], { cwd }); + expect(code).toBe(0); + expect(cap.done().toLowerCase()).toContain("no "); + }); +}); + +describe("board show", () => { + it("--json prints the parsed definition", async () => { + writeDef("daily", { description: "Morning" }); + const cap = captureOut(); + const code = await board(["show", "daily", "--json"], { cwd }); + const obj = JSON.parse(cap.done()); + expect(code).toBe(0); + expect(obj.id).toBe("daily"); + expect(obj.description).toBe("Morning"); + }); + + it("returns 1 for an unknown id", async () => { + expect(await board(["show", "nope"], { cwd })).toBe(1); + }); +}); + +describe("board delete", () => { + it("removes the file and exits 0", async () => { + writeDef("daily", {}); + const cap = captureOut(); + const code = await board(["delete", "daily"], { cwd }); + cap.done(); + expect(code).toBe(0); + expect(existsSync(join(boardsDir(), "daily.yaml"))).toBe(false); + }); + + it("returns 1 for an unknown id", async () => { + expect(await board(["delete", "nope"], { cwd })).toBe(1); + }); +}); + +describe("board dispatch", () => { + it("rejects an unknown subcommand with usage (exit 3)", async () => { + expect(await board(["frobnicate"], { cwd })).toBe(3); + }); +}); diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts new file mode 100644 index 00000000..ba7d0022 --- /dev/null +++ b/packages/cli/test/run.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as stringifyYaml } from "yaml"; +import type { BoardDef } from "@ivanmkc/termchart-core"; +import { run, type AgentSpawn } from "../src/run.js"; + +let cwd: string; +beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-run-")); }); +afterEach(() => rmSync(cwd, { recursive: true, force: true })); + +const writeDef = (def: BoardDef) => { + mkdirSync(join(cwd, ".termchart", "boards"), { recursive: true }); + writeFileSync(join(cwd, ".termchart", "boards", `${def.id}.yaml`), stringifyYaml(def)); +}; +const base: BoardDef = { + id: "daily", + schedule: "0 8 * * 1-5", + target: { project: "me", agent: "daily" }, + prompt: "Build the morning board.", +}; + +// A spawner stub that records what it was asked to run. +function recordingSpawn(exitCode = 0) { + const calls: { cmd: string; args: string[]; input: string; env: Record }[] = []; + const spawn: AgentSpawn = async (cmd, args, input, env) => { calls.push({ cmd, args, input, env }); return exitCode; }; + return { calls, spawn }; +} +const silence = () => vi.spyOn(process.stderr, "write").mockImplementation(() => true); + +describe("run", () => { + it("assembles the prompt and feeds it to the agent on stdin", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + const code = await run(["daily"], { cwd, env: {}, spawn }); + expect(code).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0].input).toContain("Build the morning board."); + expect(calls[0].input).toContain("termchart push"); + }); + + it("defaults agentCommand to `claude -p` (tokenised, no shell)", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + await run(["daily"], { cwd, env: {}, spawn }); + expect(calls[0].cmd).toBe("claude"); + expect(calls[0].args).toEqual(["-p"]); + }); + + it("honours a custom agentCommand", async () => { + writeDef({ ...base, agentCommand: "agy --dangerously-skip-permissions --print" }); + const { calls, spawn } = recordingSpawn(0); + await run(["daily"], { cwd, env: {}, spawn }); + expect(calls[0].cmd).toBe("agy"); + expect(calls[0].args).toEqual(["--dangerously-skip-permissions", "--print"]); + }); + + it("passes the process env through to the agent", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + await run(["daily"], { cwd, env: { TERMCHART_VIEWER_URL: "http://x/w/ws1", TERMCHART_VIEWER_TOKEN: "t" }, spawn }); + expect(calls[0].env.TERMCHART_VIEWER_URL).toBe("http://x/w/ws1"); + expect(calls[0].env.TERMCHART_VIEWER_TOKEN).toBe("t"); + }); + + it("propagates the agent's non-zero exit code", async () => { + writeDef(base); + const { spawn } = recordingSpawn(2); + silence(); + expect(await run(["daily"], { cwd, env: {}, spawn })).toBe(2); + }); + + it("short-circuits a disabled board without spawning (exit 0)", async () => { + writeDef({ ...base, lifecycle: { enabled: false } }); + const { calls, spawn } = recordingSpawn(0); + const code = await run(["daily"], { cwd, env: {}, spawn }); + expect(code).toBe(0); + expect(calls).toHaveLength(0); + }); + + it("returns 1 for an unknown board", async () => { + silence(); + const { spawn } = recordingSpawn(0); + expect(await run(["nope"], { cwd, env: {}, spawn })).toBe(1); + }); + + it("returns 3 when no board id is given", async () => { + silence(); + const { spawn } = recordingSpawn(0); + expect(await run([], { cwd, env: {}, spawn })).toBe(3); + }); +}); diff --git a/packages/cli/test/scaffold-workflow.test.ts b/packages/cli/test/scaffold-workflow.test.ts new file mode 100644 index 00000000..b5207860 --- /dev/null +++ b/packages/cli/test/scaffold-workflow.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as stringifyYaml } from "yaml"; +import type { BoardDef } from "@ivanmkc/termchart-core"; +import { buildWorkflowYaml } from "../src/scaffold-workflow.js"; +import { board } from "../src/board.js"; + +// A winter instant so America/Los_Angeles is PST (UTC-8) deterministically (no DST ambiguity). +const WINTER = new Date("2026-01-15T12:00:00Z"); + +const base: BoardDef = { + id: "daily", + schedule: "0 8 * * 1-5", + tz: "America/Los_Angeles", + target: { project: "me", agent: "daily" }, + prompt: "Build the morning board.", +}; + +describe("buildWorkflowYaml", () => { + it("emits a workflow that runs `termchart run ` on schedule + manual dispatch", () => { + const { yaml } = buildWorkflowYaml(base, { now: WINTER }); + expect(yaml).toContain("schedule:"); + expect(yaml).toContain("workflow_dispatch:"); + expect(yaml).toMatch(/termchart run daily/); + expect(yaml).toContain("ANTHROPIC_API_KEY"); + expect(yaml).toContain("TERMCHART_VIEWER_URL"); + expect(yaml).toContain("TERMCHART_VIEWER_TOKEN"); + }); + + it("converts a single-hour schedule from the board tz to UTC", () => { + // 08:00 PST (UTC-8) -> 16:00 UTC + const { yaml } = buildWorkflowYaml(base, { now: WINTER }); + expect(yaml).toContain('cron: "0 16 * * 1-5"'); + }); + + it("handles half-hour offset timezones", () => { + // 09:30 IST (UTC+5:30) -> 04:00 UTC + const def = { ...base, tz: "Asia/Kolkata", schedule: "30 9 * * *" }; + const { yaml } = buildWorkflowYaml(def, { now: WINTER }); + expect(yaml).toContain('cron: "0 4 * * *"'); + }); + + it("passes the schedule through unchanged and warns when no tz is set", () => { + const def = { ...base, tz: undefined }; + const { yaml, warnings } = buildWorkflowYaml(def, { now: WINTER }); + expect(yaml).toContain('cron: "0 8 * * 1-5"'); + expect(warnings.join(" ")).toMatch(/UTC/); + }); + + it("warns when the UTC conversion crosses midnight against a restricted weekday set", () => { + // 20:00 PST -> 04:00 next-day UTC; weekday set 1-5 would actually shift by a day. + const def = { ...base, schedule: "0 20 * * 1-5" }; + const { warnings } = buildWorkflowYaml(def, { now: WINTER }); + expect(warnings.join(" ").toLowerCase()).toMatch(/day|weekday/); + }); +}); + +describe("board scaffold-workflow", () => { + let cwd: string; + beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-wf-")); }); + afterEach(() => rmSync(cwd, { recursive: true, force: true })); + const writeDef = (def: BoardDef) => { + mkdirSync(join(cwd, ".termchart", "boards"), { recursive: true }); + writeFileSync(join(cwd, ".termchart", "boards", `${def.id}.yaml`), stringifyYaml(def)); + }; + const silence = () => vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + it("writes .github/workflows/termchart-.yml and exits 0", async () => { + writeDef(base); + silence(); + const code = await board(["scaffold-workflow", "daily"], { cwd }); + expect(code).toBe(0); + const file = join(cwd, ".github", "workflows", "termchart-daily.yml"); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, "utf8")).toMatch(/termchart run daily/); + }); + + it("returns 1 for an unknown id", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + expect(await board(["scaffold-workflow", "nope"], { cwd })).toBe(1); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 25ea1825..95640292 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,5 +11,11 @@ "files": [ "src" ], + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^1.6.0" + }, "license": "MIT" } diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts new file mode 100644 index 00000000..1f017035 --- /dev/null +++ b/packages/core/src/board.ts @@ -0,0 +1,98 @@ +// Structural validation of a scheduled-board definition. Like validate.ts, this is shared and +// dependency-free: the CLI runs it after parsing a `.termchart/boards/.yaml` (offline fast-fail), +// and any future viewer/host surface can reuse the same rules. It validates an already-parsed object +// — YAML/fs parsing lives in the consumer, not here. + +export type BoardHost = "claude-session" | "github-actions" | "cron"; +export const BOARD_HOSTS: readonly BoardHost[] = ["claude-session", "github-actions", "cron"]; + +export interface BoardTarget { + project: string; + agent: string; +} + +export interface BoardLifecycle { + enabled?: boolean; + until?: string | null; // ISO-8601 instant after which scheduling stops + maxRuns?: number | null; // stop after N successful runs +} + +export interface BoardDef { + id: string; + description?: string; + schedule: string; // 5-field cron + tz?: string; // IANA timezone; default = system tz + target: BoardTarget; + prompt: string; + agentCommand?: string; + host?: BoardHost; + lifecycle?: BoardLifecycle; + sessionCronId?: string; // written back when registered with a Claude Code session cron +} + +const ID_RE = /^[a-z0-9][a-z0-9-]*$/; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Is `tz` a timezone the runtime accepts? Uses the built-in Intl DB (no dependency). */ +function validTimeZone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * Validate a parsed board definition. Returns an error message (field-pointed) or null when valid. + * Optional fields are only checked when present. + */ +export function validateBoardDef(obj: unknown): string | null { + if (!isPlainObject(obj)) return "board definition must be an object"; + + if (typeof obj.id !== "string" || !ID_RE.test(obj.id)) + return `id must be a lowercase slug matching ${ID_RE} (got ${JSON.stringify(obj.id)})`; + + if (typeof obj.schedule !== "string" || obj.schedule.trim().split(/\s+/).length !== 5) + return `schedule must be a 5-field cron string (got ${JSON.stringify(obj.schedule)})`; + + if (obj.tz !== undefined) { + if (typeof obj.tz !== "string" || !validTimeZone(obj.tz)) + return `tz must be a valid IANA timezone (got ${JSON.stringify(obj.tz)})`; + } + + if (!isPlainObject(obj.target)) return "target must be an object with project and agent"; + if (typeof obj.target.project !== "string" || obj.target.project.length === 0) + return "target.project must be a non-empty string"; + if (typeof obj.target.agent !== "string" || obj.target.agent.length === 0) + return "target.agent must be a non-empty string"; + + if (typeof obj.prompt !== "string" || obj.prompt.trim().length === 0) + return "prompt must be a non-empty string"; + + if (obj.agentCommand !== undefined && (typeof obj.agentCommand !== "string" || obj.agentCommand.trim().length === 0)) + return "agentCommand, when set, must be a non-empty string"; + + if (obj.host !== undefined && !BOARD_HOSTS.includes(obj.host as BoardHost)) + return `host must be one of ${BOARD_HOSTS.join(", ")} (got ${JSON.stringify(obj.host)})`; + + if (obj.lifecycle !== undefined) { + if (!isPlainObject(obj.lifecycle)) return "lifecycle must be an object"; + const lc = obj.lifecycle; + if (lc.enabled !== undefined && typeof lc.enabled !== "boolean") + return "lifecycle.enabled must be a boolean"; + if (lc.maxRuns !== undefined && lc.maxRuns !== null) { + if (typeof lc.maxRuns !== "number" || !Number.isInteger(lc.maxRuns) || lc.maxRuns < 1) + return "lifecycle.maxRuns must be a positive integer"; + } + if (lc.until !== undefined && lc.until !== null) { + if (typeof lc.until !== "string" || Number.isNaN(Date.parse(lc.until))) + return "lifecycle.until must be an ISO-8601 date string"; + } + } + + return null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b62e67f3..91e10a63 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ // @ivanmkc/termchart-core — shared, dependency-free structural validation for termchart push // payloads. Public surface: the validator. Consumers (viewer server, CLI) bundle the source. export * from "./validate.js"; +export * from "./board.js"; diff --git a/packages/core/test/validate-board.test.ts b/packages/core/test/validate-board.test.ts new file mode 100644 index 00000000..41f77fca --- /dev/null +++ b/packages/core/test/validate-board.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { validateBoardDef } from "../src/index.js"; + +// A minimal, fully-valid board definition. Each test mutates a clone to isolate one rule. +const valid = () => ({ + id: "daily-standup", + description: "Morning board", + schedule: "0 8 * * 1-5", + tz: "America/Los_Angeles", + target: { project: "me", agent: "daily" }, + prompt: "Gather my tasks and push.", + agentCommand: "claude -p", + host: "claude-session", + lifecycle: { enabled: true, until: null, maxRuns: null }, +}); + +describe("validateBoardDef", () => { + it("accepts a fully-valid definition", () => { + expect(validateBoardDef(valid())).toBeNull(); + }); + + it("accepts a minimal definition (only required fields)", () => { + expect( + validateBoardDef({ id: "x", schedule: "* * * * *", target: { project: "p", agent: "a" }, prompt: "go" }), + ).toBeNull(); + }); + + it("rejects a non-object", () => { + expect(validateBoardDef(null)).toMatch(/object/i); + expect(validateBoardDef("nope")).toMatch(/object/i); + }); + + it("rejects a missing or malformed id", () => { + expect(validateBoardDef({ ...valid(), id: undefined })).toMatch(/id/); + expect(validateBoardDef({ ...valid(), id: "Has Spaces" })).toMatch(/id/); + expect(validateBoardDef({ ...valid(), id: "-leading" })).toMatch(/id/); + }); + + it("rejects a schedule that is not 5 cron fields", () => { + expect(validateBoardDef({ ...valid(), schedule: "0 8 * *" })).toMatch(/schedule|cron/i); + expect(validateBoardDef({ ...valid(), schedule: "" })).toMatch(/schedule|cron/i); + }); + + it("rejects an unknown timezone but accepts a valid IANA one", () => { + expect(validateBoardDef({ ...valid(), tz: "Mars/Phobos" })).toMatch(/tz|timezone/i); + expect(validateBoardDef({ ...valid(), tz: "UTC" })).toBeNull(); + }); + + it("requires a target with non-empty project and agent", () => { + expect(validateBoardDef({ ...valid(), target: undefined })).toMatch(/target/); + expect(validateBoardDef({ ...valid(), target: { project: "p" } })).toMatch(/agent/); + expect(validateBoardDef({ ...valid(), target: { project: "", agent: "a" } })).toMatch(/project/); + }); + + it("requires a non-empty prompt", () => { + expect(validateBoardDef({ ...valid(), prompt: "" })).toMatch(/prompt/); + expect(validateBoardDef({ ...valid(), prompt: undefined })).toMatch(/prompt/); + }); + + it("rejects an unknown host", () => { + expect(validateBoardDef({ ...valid(), host: "kubernetes" })).toMatch(/host/); + }); + + it("validates lifecycle fields when present", () => { + expect(validateBoardDef({ ...valid(), lifecycle: { enabled: "yes" } })).toMatch(/enabled/); + expect(validateBoardDef({ ...valid(), lifecycle: { maxRuns: 0 } })).toMatch(/maxRuns/); + expect(validateBoardDef({ ...valid(), lifecycle: { until: "not-a-date" } })).toMatch(/until/); + expect(validateBoardDef({ ...valid(), lifecycle: { until: "2026-07-01T00:00:00Z", maxRuns: 5 } })).toBeNull(); + }); +}); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 9e625a0f..c3e1b078 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "termchart", "displayName": "termchart", "description": "A live canvas your AI draws on. Instead of walls of text, your agent pushes rich, native visuals to a browser tab / iPad / second screen in real time — React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine UI dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes — with animated edges and live status overlays (toasts, progress bars, loaders). Deterministic Mermaid→ASCII/Unicode is the terminal fallback. Ships persona recipes, a showcase gallery, fullscreen panes, and a collapsible sidebar. Adds /termchart commands + skills, backed by the termchart CLI.", - "version": "0.12.3", + "version": "0.13.0", "author": { "name": "Ivan Cheung" }, diff --git a/plugin/commands/schedule-board.md b/plugin/commands/schedule-board.md new file mode 100644 index 00000000..c52678ad --- /dev/null +++ b/plugin/commands/schedule-board.md @@ -0,0 +1,51 @@ +--- +description: Define & schedule a termchart board that auto-refreshes (daily morning board, running-job tracker, hourly dashboard) +argument-hint: [what the board should show + how often, e.g. "my open tasks every weekday at 8am"] +--- + +Create a **scheduled board** — a termchart board that an agent re-refreshes on a schedule. Load the +**`scheduled-boards`** skill for the full model (definition format, host trade-offs, the +data-reachability rule, lifecycle/self-termination). + +Input: +$ARGUMENTS + +Do this: + +1. **Pin down the board** with the human if unclear (ask only what you can't infer): + - **What it shows** — this becomes the saved `prompt`. Decide the diagram type via the + `diagram-recipes` skill's "Choose the diagram" router (tasks/PRs → `component`; metrics → + `vegalite`/`component`; etc.). + - **Cadence** — a 5-field cron (e.g. `0 8 * * 1-5` weekday 8am; `*/2 * * * *` every 2 min). + - **Target scope** — `--project` (this repo) and `--agent` (a stable id for this board). + - **Host** — `claude-session` (watch a job now / local data), `github-actions` (durable, + unattended, cloud-reachable data), or `cron`. Mind the **data-reachability rule**: a cloud + runner can't see local files. + +2. **Write the definition:** + ``` + termchart board create --id --schedule "" --project --agent \ + --prompt "" [--tz ] [--host ] [--description ""] + ``` + Make the `--prompt` self-sufficient (what data, what diagram, grouped/sorted how). For a job + tracker, end it with "if the job has finished, unschedule this board." + +3. **Verify it runs** before scheduling — `termchart run ` once and confirm the board appears on + the viewer (this is the real test that the prompt + push work). + +4. **Schedule it for the chosen host:** + - **`claude-session`:** get the prompt with `termchart board prompt `, then register a + **`CronCreate`** with that prompt and the board's cron. It fires in-session; the agent gathers + + pushes. (Ephemeral — dies with the session; ~7-day cap.) + - **`github-actions`:** `termchart board scaffold-workflow `, then tell the human to set repo + secrets `ANTHROPIC_API_KEY`, `TERMCHART_VIEWER_URL`, `TERMCHART_VIEWER_TOKEN`. Note the UTC/DST + caveat printed by the scaffolder. + - **`cron`:** give the human a crontab line running `termchart run ` from the repo root with + the viewer env set. + +5. **Tell the human** what was created: the board id, its scope, the schedule (and tz), the host, and + how to pause it (`lifecycle.enabled: false` in the YAML, or delete the cron/workflow). Manage + later with `termchart board list` / `show` / `delete`. + +No viewer configured? `termchart run`/`push` exits 4 — run `termchart serve` (or point +`TERMCHART_VIEWER_URL`/`TERMCHART_VIEWER_TOKEN` at a deployed viewer), then retry. diff --git a/plugin/skills/scheduled-boards/SKILL.md b/plugin/skills/scheduled-boards/SKILL.md new file mode 100644 index 00000000..b8f282b1 --- /dev/null +++ b/plugin/skills/scheduled-boards/SKILL.md @@ -0,0 +1,112 @@ +--- +name: scheduled-boards +description: Use when the human wants a termchart board that refreshes itself on a schedule — "a daily morning board with my tasks", "a board that tracks this running job and updates every few minutes", "auto-update this dashboard each hour", "schedule this view". Explains how to define a board (committed YAML), pick a scheduler host (Claude Code session cron now; GitHub Actions / system cron via the run primitive), and wire it up. Pairs with /termchart:schedule-board. +--- + +# Scheduled boards — boards that refresh themselves + +A **scheduled board** is just a normal termchart board (a `project/agent` scope on the viewer) +that an **agent re-refreshes on a schedule**. On each tick, an agent is handed a saved prompt; it +gathers the data, builds the diagram, and `termchart push`es it to the board's scope — exactly the +normal push path. Nothing new runs inside the viewer. + +Two shapes this covers, both with one mechanism: + +- **Daily morning board** — "today's tasks, calendar, open PRs", rebuilt at 8am on weekdays. +- **Job tracker** — "track this running job's metrics", refreshed every couple of minutes, then + stops when the job finishes. + +## The model: definition + run primitive + a scheduler + +termchart owns two portable pieces; the scheduler is pluggable on top. + +1. **Definition** — a committed YAML file at `.termchart/boards/.yaml` (prompt, cron, target, + lifecycle). It's diffable, reviewable, and a GitHub Actions runner gets it on checkout. +2. **Run primitive** — `termchart run ` loads the definition, assembles the agent prompt, and + runs the board's `agentCommand` with that prompt **on stdin**. The agent pushes the board. Every + host ultimately calls this same primitive, so behaviour is identical everywhere. + +``` +.termchart/boards/.yaml ──► termchart run ──► agent gathers data + termchart push ──► viewer + (committed) (host-agnostic) (the board's saved prompt) +``` + +## Definition format + +```yaml +id: daily-standup # filename stem; lowercase slug +description: "Morning board: tasks, calendar, open PRs" +schedule: "0 8 * * 1-5" # 5-field cron, interpreted in `tz` +tz: "America/Los_Angeles" # optional; default = system tz +target: + project: me # the board scope the push updates + agent: daily +prompt: | # the saved intent handed to the agent each run + Gather my open tasks, today's calendar, and assigned PRs. + Build a component board grouped by priority. +agentCommand: "claude -p" # optional; agent-agnostic (claude -p | agy --print | …) +host: claude-session # claude-session | github-actions | cron +lifecycle: + enabled: true # set false to pause + until: null # ISO-8601 instant to stop after (optional) + maxRuns: null # stop after N runs (optional) +``` + +The `prompt` is self-contained: `termchart board prompt ` appends the exact push target and +command, so even a context-free headless agent knows where to send the board. + +## Pick a scheduler host + +| Host | When | How it fires | +|---|---|---| +| **`claude-session`** (v1 first-class) | "watch this job while I work"; short-lived; data is local | A Claude Code **session cron** (`CronCreate`) fires the assembled prompt **in-session**. Dies when the session ends. | +| **`github-actions`** | durable, unattended (daily board); data is cloud-reachable (repo, APIs, the viewer) | A generated workflow runs `termchart run ` on a UTC cron. | +| **`cron`** | durable on a machine you keep running; local data | A crontab line runs `termchart run `. | + +**Data-reachability rule:** a GitHub Actions runner is a clean cloud box — it can reach the repo, +HTTP APIs, and the viewer, but **not your laptop's files**. Boards whose data is local-only must use +`claude-session` or a local `cron` host. + +## Wiring it up + +### Claude Code session cron (v1) + +After writing the definition: + +1. Get the prompt the cron should fire: `termchart board prompt `. +2. Register it with the harness `CronCreate` tool — cron = the board's `schedule`, prompt = that + output. It fires in-session and the agent gathers + pushes. +3. **Job trackers self-terminate:** the assembled prompt already instructs the agent to + `CronDelete` the job when the tracked work is done or `lifecycle` limits are reached. Keep the + cadence sane (every 2–5 min for a job tracker, not every few seconds). + +Session crons are ephemeral (they die with the session, and the harness expires them after ~7 days). +For a board that must fire unattended every morning, prefer GitHub Actions. + +### GitHub Actions (turnkey) + +``` +termchart board scaffold-workflow +``` + +Writes `.github/workflows/termchart-.yml` running `termchart run ` on the schedule +(converted to UTC). Then set repo secrets: `ANTHROPIC_API_KEY`, `TERMCHART_VIEWER_URL`, +`TERMCHART_VIEWER_TOKEN`. **Caveat:** GitHub cron is UTC and the conversion is a fixed offset — DST +can shift the wall-clock time; the generated file annotates the source schedule. + +### System cron (compatible) + +Add a crontab line that runs `termchart run ` from the repo root, with the viewer env set. Same +primitive, same prompt. + +## CLI reference + +``` +termchart board create --id --schedule "" --project

--agent --prompt "" [--tz ] [--host ] [--agent-command ""] [--description ""] +termchart board list # id · schedule · target · host +termchart board show [--json] # parsed definition +termchart board prompt # the assembled agent prompt (feed this to a scheduler) +termchart board scaffold-workflow # generate a GitHub Actions workflow +termchart board delete +termchart run # refresh now: assemble prompt + run agent → push +``` From d2efd039cfaf6cae2470d22e3b5bf8f8e7cffcd6 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Tue, 30 Jun 2026 02:08:18 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix(scheduled-boards):=20address=20new-user?= =?UTF-8?q?=20testing=20=E2=80=94=20lifecycle,=20GH=20Actions,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-tester dogfooding (4 fresh "new user" agents) surfaced real gaps; fixes: Correctness / honesty: - Lifecycle is now actually enforced by `termchart run`: skip past `until`; persistent `lifecycle.runs` counter enforces `maxRuns` (written back to YAML). assembleBoardPrompt now always includes a self-unschedule clause for bounded OR frequent (sub-daily) boards — the job-tracker case the docs promised. - `run` pre-flights the viewer env and exits 4 (was: swallowed the failure in the agent subprocess and returned 0). Rejects unknown flags; one-line verbosity. - Version: bump 0.6.0 -> 0.7.0 (0.6.0 was already published to npm without this feature); scaffolded workflow pins `@ivanmkc/termchart@0.7.0`. GitHub Actions turnkey + DST-correct: - DST handled via two seasonal UTC crons + a local-time guard step (GH cron is UTC-only) so the board fires at the intended local time year-round. - Day-of-week is correctly shifted on UTC midnight crossing (was: emitted a knowingly-wrong cron with a warning); restricted day-of-month is warned, not mis-shifted. - Workflow now installs the agent CLI, sets permissions + GH_TOKEN, timeout- minutes, and a concurrency guard. Non-claude agentCommand omits the Anthropic secret + claude install (gets a TODO note). CLI/UX: - Per-subcommand `--help`; `--enabled/--until/--max-runs` flags; create always writes a visible lifecycle block. `board list` warns about skipped invalid files instead of hiding them. Unknown top-level command says so (instead of trying to open it as a file); unknown flag names the flag, not its value; missing-required-flag names which; `board delete` shows the expected path; YAML parse errors carry the board id. Docs: skill + command truthed-up (lifecycle enforcement + GH-Actions maxRuns caveat, turnkey workflow, component payload pointer, run exit-4). Tests: core 11, cli 228 (incl. built-bundle smoke); re-verified live against the remote viewer (pre-flight exit 4, maxRuns stop, DST workflow, full push loop). --- .claude-plugin/marketplace.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/board-prompt.ts | 23 ++- packages/cli/src/board-store.ts | 39 ++++- packages/cli/src/board.ts | 125 ++++++++++---- packages/cli/src/cli.ts | 17 +- packages/cli/src/run.ts | 56 ++++++- packages/cli/src/scaffold-workflow.ts | 175 +++++++++++++++----- packages/cli/src/version.ts | 3 + packages/cli/test/board-bundle.test.ts | 6 +- packages/cli/test/board-prompt.test.ts | 8 +- packages/cli/test/board.test.ts | 78 ++++++++- packages/cli/test/cli.smoke.test.ts | 18 ++ packages/cli/test/run.test.ts | 101 +++++++++-- packages/cli/test/scaffold-workflow.test.ts | 85 +++++++--- packages/core/src/board.ts | 5 + packages/core/test/validate-board.test.ts | 7 + plugin/.claude-plugin/plugin.json | 2 +- plugin/commands/schedule-board.md | 18 +- plugin/skills/scheduled-boards/SKILL.md | 97 +++++++---- 20 files changed, 678 insertions(+), 189 deletions(-) create mode 100644 packages/cli/src/version.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d873311a..b3457047 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "termchart", "source": "./plugin", "description": "A live canvas your AI draws on — instead of walls of text, your agent pushes rich, native visuals to a browser / iPad / second screen in real time: React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes, with animated edges and live status overlays (toasts, progress bars, loaders). Deterministic Mermaid → ASCII/Unicode is the terminal fallback. Persona recipes, a showcase gallery, and fullscreen panes.", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "keywords": [ "mermaid", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1293ba94..1f51b4a6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@ivanmkc/termchart", - "version": "0.6.0", + "version": "0.7.0", "description": "Push native diagrams, charts, dashboards & live status to the termchart viewer (browser/iPad) in real time — the CLI for the live canvas your AI draws on; also renders deterministic Mermaid → ASCII/Unicode in the terminal.", "type": "module", "bin": { diff --git a/packages/cli/src/board-prompt.ts b/packages/cli/src/board-prompt.ts index 05c7dcab..4d001b5d 100644 --- a/packages/cli/src/board-prompt.ts +++ b/packages/cli/src/board-prompt.ts @@ -18,15 +18,26 @@ export function assembleBoardPrompt(def: BoardDef): string { ); const lc = def.lifecycle; - if (lc && (lc.until != null || lc.maxRuns != null)) { + const bounded = lc != null && (lc.until != null || lc.maxRuns != null); + // A board whose hour field is "*" fires many times a day — treat it as a tracker that should stop + // itself when its subject is done, even if no explicit lifecycle bound is set. + const hourField = def.schedule.trim().split(/\s+/)[1]; + const frequent = hourField === "*"; + + if (bounded) { const bound = [ - lc.until != null ? `after ${lc.until}` : null, - lc.maxRuns != null ? `after ${lc.maxRuns} run(s)` : null, + lc!.until != null ? `after ${lc!.until}` : null, + lc!.maxRuns != null ? `after ${lc!.maxRuns} run(s)` : null, ].filter(Boolean).join(" or "); parts.push( - `Lifecycle: this board is meant to stop ${bound}. If the work it tracks is complete (or that ` + - `limit is reached), unschedule it — CronDelete the job in a Claude Code session, or disable the ` + - `GitHub Actions workflow.`, + `Lifecycle: this board is bounded — \`termchart run\` stops it ${bound}. If the work it tracks ` + + `finishes earlier, unschedule it now — CronDelete the job in a Claude Code session, or disable ` + + `the GitHub Actions workflow.`, + ); + } else if (frequent) { + parts.push( + `This board refreshes frequently. If the work it tracks is complete, unschedule it so it stops ` + + `refreshing — CronDelete the job in a Claude Code session, or disable the GitHub Actions workflow.`, ); } diff --git a/packages/cli/src/board-store.ts b/packages/cli/src/board-store.ts index a92ea25f..6871bbcc 100644 --- a/packages/cli/src/board-store.ts +++ b/packages/cli/src/board-store.ts @@ -21,27 +21,48 @@ export function boardPath(root: string, id: string): string { export function loadBoard(root: string, id: string): BoardDef { const file = boardPath(root, id); if (!existsSync(file)) throw new Error(`no such board: ${id} (expected ${file})`); - const def = parseYaml(readFileSync(file, "utf8")) as unknown; + let def: unknown; + try { + def = parseYaml(readFileSync(file, "utf8")); + } catch (e) { + throw new Error(`invalid board ${id}: could not parse ${file} — ${(e as Error).message}`); + } const err = validateBoardDef(def); if (err) throw new Error(`invalid board ${id}: ${err}`); return def as BoardDef; } -/** All valid board defs under the root, sorted by id. Invalid/unparseable files are skipped. */ -export function listBoards(root: string): BoardDef[] { +export interface BoardIssue { + file: string; + error: string; +} + +/** + * Scan every board file under the root. Returns the valid defs (sorted by id) and a list of the + * files that failed to parse/validate, so callers can surface skipped boards instead of hiding them. + */ +export function scanBoards(root: string): { boards: BoardDef[]; issues: BoardIssue[] } { const dir = boardsDir(root); - if (!existsSync(dir)) return []; - const out: BoardDef[] = []; + if (!existsSync(dir)) return { boards: [], issues: [] }; + const boards: BoardDef[] = []; + const issues: BoardIssue[] = []; for (const name of readdirSync(dir).sort()) { if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue; try { const def = parseYaml(readFileSync(join(dir, name), "utf8")) as unknown; - if (!validateBoardDef(def)) out.push(def as BoardDef); - } catch { - // skip unparseable files — `board show ` surfaces the specific error + const err = validateBoardDef(def); + if (err) issues.push({ file: name, error: err }); + else boards.push(def as BoardDef); + } catch (e) { + issues.push({ file: name, error: (e as Error).message }); } } - return out; + return { boards, issues }; +} + +/** All valid board defs under the root, sorted by id. Invalid/unparseable files are skipped. */ +export function listBoards(root: string): BoardDef[] { + return scanBoards(root).boards; } export function boardExists(root: string, id: string): boolean { diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts index fc97713e..d22aede8 100644 --- a/packages/cli/src/board.ts +++ b/packages/cli/src/board.ts @@ -2,8 +2,8 @@ // definitions stored as committed `.termchart/boards/.yaml` files. Definitions are portable: a // GitHub Actions runner gets them on checkout, and `termchart run ` reads them anywhere. -import { validateBoardDef, type BoardDef, type BoardHost } from "@ivanmkc/termchart-core"; -import { boardExists, deleteBoard, listBoards, loadBoard, saveBoard } from "./board-store.js"; +import { validateBoardDef, type BoardDef, type BoardHost, type BoardLifecycle } from "@ivanmkc/termchart-core"; +import { boardExists, deleteBoard, loadBoard, saveBoard, scanBoards } from "./board-store.js"; import { assembleBoardPrompt } from "./board-prompt.js"; import { writeWorkflow } from "./scaffold-workflow.js"; @@ -14,18 +14,51 @@ export interface BoardDeps { const SUBCOMMANDS = ["create", "list", "show", "delete", "prompt", "scaffold-workflow"]; +const GENERAL_USAGE = `usage: termchart board <${SUBCOMMANDS.join("|")}> + + create Define a board → .termchart/boards/.yaml + list List defined boards (id · schedule · target · host) + show Show a board's definition (--json) + prompt Print the assembled agent prompt for a board + scaffold-workflow Generate a GitHub Actions workflow for a board + delete Remove a board definition + +Run a board now with: termchart run +`; + +const CREATE_USAGE = `usage: termchart board create --id --schedule "" --project

--agent --prompt "" [options] + + --id Board id (lowercase slug; also the filename stem) + --schedule "" 5-field cron, interpreted in --tz + --project

Target board scope (project) + --agent Target board scope (agent) + --prompt "" What the agent should gather/build/push each run + --tz IANA timezone (default: system tz) + --host claude-session | github-actions | cron (default: claude-session) + --agent-command "" Agent to run (default: claude -p) + --description "" Short summary + --enabled Start enabled (default: true) + --until Stop scheduling after this ISO-8601 instant + --max-runs Stop after N successful runs + --force Overwrite an existing board +`; + +const SUB_USAGE: Record = { + create: CREATE_USAGE, + list: "usage: termchart board list [--json]\n", + show: "usage: termchart board show [--json]\n", + prompt: "usage: termchart board prompt \n", + "scaffold-workflow": "usage: termchart board scaffold-workflow \n", + delete: "usage: termchart board delete \n", +}; + +const wantsHelp = (argv: string[]) => argv.includes("-h") || argv.includes("--help"); + interface CreateArgs { - id?: string; - schedule?: string; - project?: string; - agent?: string; - prompt?: string; - description?: string; - host?: string; - tz?: string; - agentCommand?: string; - force: boolean; - error?: string; + id?: string; schedule?: string; project?: string; agent?: string; prompt?: string; + description?: string; host?: string; tz?: string; agentCommand?: string; + enabled?: string; until?: string; maxRuns?: string; + force: boolean; error?: string; } function parseCreate(argv: string[]): CreateArgs { @@ -41,17 +74,25 @@ function parseCreate(argv: string[]): CreateArgs { else if (arg === "--host") a.host = argv[++i]; else if (arg === "--tz") a.tz = argv[++i]; else if (arg === "--agent-command") a.agentCommand = argv[++i]; + else if (arg === "--enabled") a.enabled = argv[++i]; + else if (arg === "--until") a.until = argv[++i]; + else if (arg === "--max-runs") a.maxRuns = argv[++i]; else if (arg === "--force") a.force = true; - else a.error = `Unknown flag: ${arg}`; + else if (!a.error) a.error = `Unknown flag: ${arg}`; // first unknown wins (its value, if any, is ignored) } return a; } function create(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(CREATE_USAGE); return 0; } const a = parseCreate(argv); if (a.error) { process.stderr.write(a.error + "\n"); return 3; } if (!a.id || !a.schedule || !a.project || !a.agent || !a.prompt) { - process.stderr.write("create needs --id, --schedule, --project, --agent, and --prompt\n"); + const missing = [ + !a.id && "--id", !a.schedule && "--schedule", !a.project && "--project", + !a.agent && "--agent", !a.prompt && "--prompt", + ].filter(Boolean).join(", "); + process.stderr.write(`create is missing required flag(s): ${missing}\n`); return 3; } const def: BoardDef = { @@ -65,6 +106,15 @@ function create(argv: string[], root: string): number { if (a.host) def.host = a.host as BoardHost; if (a.agentCommand) def.agentCommand = a.agentCommand; + // Always write a visible lifecycle block so it can be edited (e.g. to pause) without guessing keys. + const lifecycle: BoardLifecycle = { enabled: a.enabled ? a.enabled !== "false" : true }; + if (a.until) lifecycle.until = a.until; + if (a.maxRuns !== undefined) { + const n = Number(a.maxRuns); + lifecycle.maxRuns = Number.isInteger(n) ? n : (a.maxRuns as unknown as number); // let validation report a bad value + } + def.lifecycle = lifecycle; + const err = validateBoardDef(def); if (err) { process.stderr.write(`invalid board: ${err}\n`); return 3; } if (boardExists(root, a.id) && !a.force) { @@ -72,26 +122,31 @@ function create(argv: string[], root: string): number { return 3; } saveBoard(root, def); - process.stdout.write(`created board ${a.id} — schedule it with /termchart:schedule-board, or run now: termchart run ${a.id}\n`); + process.stdout.write(`created board ${a.id} — verify with: termchart run ${a.id}, then schedule via /termchart:schedule-board\n`); return 0; } function list(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.list); return 0; } const json = argv.includes("--json"); - const boards = listBoards(root); - if (json) { process.stdout.write(JSON.stringify(boards, null, 2) + "\n"); return 0; } - if (boards.length === 0) { + const { boards, issues } = scanBoards(root); + if (json) { process.stdout.write(JSON.stringify(boards, null, 2) + "\n"); } + else if (boards.length === 0 && issues.length === 0) { process.stdout.write("no boards defined — create one with `termchart board create`\n"); - return 0; + } else { + for (const b of boards) { + const enabled = b.lifecycle?.enabled === false ? " (disabled)" : ""; + process.stdout.write(`${b.id} ${b.schedule} → ${b.target.project}/${b.target.agent} [${b.host ?? "claude-session"}]${enabled}\n`); + } } - for (const b of boards) { - const enabled = b.lifecycle?.enabled === false ? " (disabled)" : ""; - process.stdout.write(`${b.id} ${b.schedule} → ${b.target.project}/${b.target.agent} [${b.host ?? "claude-session"}]${enabled}\n`); + for (const issue of issues) { + process.stderr.write(`warning: skipped invalid board file ${issue.file} — ${issue.error}\n`); } return 0; } function show(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.show); return 0; } const id = argv.find((x) => !x.startsWith("-")); const json = argv.includes("--json"); if (!id) { process.stderr.write("show needs a board id\n"); return 3; } @@ -104,15 +159,17 @@ function show(argv: string[], root: string): number { } if (json) { process.stdout.write(JSON.stringify(def, null, 2) + "\n"); return 0; } process.stdout.write(`${def.id}${def.description ? ` — ${def.description}` : ""}\n`); - process.stdout.write(` schedule: ${def.schedule}${def.tz ? ` (${def.tz})` : ""}\n`); - process.stdout.write(` target: ${def.target.project}/${def.target.agent}\n`); - process.stdout.write(` host: ${def.host ?? "claude-session"}\n`); - if (def.lifecycle) process.stdout.write(` lifecycle: ${JSON.stringify(def.lifecycle)}\n`); - process.stdout.write(` prompt: ${def.prompt}\n`); + process.stdout.write(` schedule: ${def.schedule}${def.tz ? ` (${def.tz})` : ""}\n`); + process.stdout.write(` target: ${def.target.project}/${def.target.agent}\n`); + process.stdout.write(` host: ${def.host ?? "claude-session"}\n`); + process.stdout.write(` agentCommand: ${def.agentCommand ?? "claude -p"}\n`); + if (def.lifecycle) process.stdout.write(` lifecycle: ${JSON.stringify(def.lifecycle)}\n`); + process.stdout.write(` prompt: ${def.prompt}\n`); return 0; } function promptCmd(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.prompt); return 0; } const id = argv.find((x) => !x.startsWith("-")); if (!id) { process.stderr.write("prompt needs a board id\n"); return 3; } let def: BoardDef; @@ -127,6 +184,7 @@ function promptCmd(argv: string[], root: string): number { } function scaffoldWorkflow(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE["scaffold-workflow"]); return 0; } const id = argv.find((x) => !x.startsWith("-")); if (!id) { process.stderr.write("scaffold-workflow needs a board id\n"); return 3; } let def: BoardDef; @@ -144,9 +202,13 @@ function scaffoldWorkflow(argv: string[], root: string): number { } function del(argv: string[], root: string): number { + if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.delete); return 0; } const id = argv.find((x) => !x.startsWith("-")); if (!id) { process.stderr.write("delete needs a board id\n"); return 3; } - if (!deleteBoard(root, id)) { process.stderr.write(`no such board: ${id}\n`); return 1; } + if (!deleteBoard(root, id)) { + process.stderr.write(`no such board: ${id} (expected ${root}/.termchart/boards/${id}.yaml)\n`); + return 1; + } process.stdout.write(`deleted board ${id}\n`); return 0; } @@ -155,8 +217,9 @@ export async function board(argv: string[], deps: BoardDeps): Promise { const sub = argv[0]; const rest = argv.slice(1); const root = deps.cwd ?? process.cwd(); - if (!sub || !SUBCOMMANDS.includes(sub)) { - process.stderr.write(`usage: termchart board <${SUBCOMMANDS.join("|")}> …\n`); + if (!sub || sub === "-h" || sub === "--help") { process.stdout.write(GENERAL_USAGE); return 0; } + if (!SUBCOMMANDS.includes(sub)) { + process.stderr.write(`unknown subcommand: ${sub}\n${GENERAL_USAGE}`); return 3; } if (sub === "create") return create(rest, root); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cf8bbd86..58f49c81 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,14 +14,14 @@ * 4 no viewer reachable (run `termchart serve`). */ -import { readFileSync, realpathSync } from "node:fs"; +import { readFileSync, realpathSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { render, renderFit, type ColorMode, THEME_NAMES } from "./render.js"; import { lint } from "./lint.js"; +import { VERSION } from "./version.js"; const DEFAULT_NON_TTY_WIDTH = 80; -const VERSION = "0.6.0"; const USAGE = `termchart — deterministic Mermaid → ASCII/Unicode for terminals @@ -40,8 +40,9 @@ Usage: --follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop) termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item) termchart template Reusable diagram templates: save --project --agent --name | list | get | delete - termchart board Scheduled boards (auto-refreshing): create --id --schedule --project --agent --prompt | list | show | prompt | scaffold-workflow | delete - termchart run Refresh a scheduled board now — assemble its prompt + run its agent, which pushes the board + termchart board Scheduled boards (auto-refreshing): create | list | show | prompt | scaffold-workflow | delete (try \`board --help\`) + create flags: --id --schedule --project --agent --prompt [--tz --host --agent-command --description --enabled --until --max-runs --force] + termchart run Refresh a scheduled board now — enforce lifecycle, assemble its prompt + run its agent, which pushes the board termchart --version Render flags: @@ -392,6 +393,14 @@ if (isEntryPoint()) { .then(({ run }) => run(argv.slice(1), { env: process.env }).then((code) => process.exit(code))) .catch((e) => { process.stderr.write(`run failed: ${e?.message ?? e}\n`); process.exit(1); }); } else { + // A non-flag first arg that's neither a known command nor an existing file is almost certainly a + // mistyped command — say so, instead of failing later with an opaque "cannot read file" error. + const KNOWN = ["lint", "serve", "begin", "push", "status", "clear", "pull", "list", "patch", "inbox", "suggest", "template", "board", "run"]; + const first = argv[0]; + if (first && !first.startsWith("-") && !KNOWN.includes(first) && !existsSync(first)) { + process.stderr.write(`unknown command "${first}". Run \`termchart --help\` for the command list.\n`); + process.exit(3); + } process.exit(main(argv)); } } diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 77d6c304..ac7e0016 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -1,12 +1,12 @@ // `termchart run ` — the host-agnostic run primitive. Loads + validates a board definition, -// assembles its canonical prompt, and hands that prompt (on stdin) to an agent which gathers the -// data and pushes the board to the viewer. Every scheduler — a Claude Code session cron, a GitHub -// Actions workflow, a system crontab — ultimately calls this same command, so refresh behaviour is -// identical regardless of who pulled the trigger. +// enforces its lifecycle, assembles its canonical prompt, and hands that prompt (on stdin) to an +// agent which gathers the data and pushes the board to the viewer. Every scheduler — a Claude Code +// session cron, a GitHub Actions workflow, a system crontab — ultimately calls this same command. import { spawn as nodeSpawn } from "node:child_process"; -import { loadBoard } from "./board-store.js"; +import { loadBoard, saveBoard } from "./board-store.js"; import { assembleBoardPrompt } from "./board-prompt.js"; +import { EXIT_NO_VIEWER, missingConfigMessage } from "./viewer-detect.js"; /** Run an agent: command + args, with `input` delivered on stdin and `env` inherited. Resolves to its exit code. */ export type AgentSpawn = ( @@ -20,9 +20,11 @@ export interface RunDeps { cwd?: string; env?: Record; spawn?: AgentSpawn; // injectable for tests; defaults to a real child process + now?: Date; // injectable clock for lifecycle.until (tests) } const DEFAULT_AGENT_COMMAND = "claude -p"; +const USAGE = "usage: termchart run \n Refresh a scheduled board now: assemble its prompt, run its agent, which pushes the board.\n"; // Deliver the prompt on stdin (never as an argv string) to dodge E2BIG and shell-quoting hazards; // the agent command is tokenised and spawned without a shell. @@ -42,8 +44,22 @@ const defaultSpawn: AgentSpawn = (cmd, args, input, env) => }); export async function run(argv: string[], deps: RunDeps): Promise { + if (argv.includes("-h") || argv.includes("--help")) { + process.stdout.write(USAGE); + return 0; + } + // Only a single positional board id is allowed — reject stray flags so typos aren't silently dropped. + const flags = argv.filter((x) => x.startsWith("-")); + if (flags.length) { process.stderr.write(`Unknown flag: ${flags[0]}\n${USAGE}`); return 3; } const id = argv.find((x) => !x.startsWith("-")); - if (!id) { process.stderr.write("usage: termchart run \n"); return 3; } + if (!id) { process.stderr.write(USAGE); return 3; } + + // A board exists to push to the viewer — fail fast and clearly if there's nowhere to push. + const env = deps.env ?? process.env; + if (!env.TERMCHART_VIEWER_URL || !env.TERMCHART_VIEWER_TOKEN) { + process.stderr.write(missingConfigMessage()); + return EXIT_NO_VIEWER; + } const root = deps.cwd ?? process.cwd(); let def; @@ -54,13 +70,37 @@ export async function run(argv: string[], deps: RunDeps): Promise { return 1; } - if (def.lifecycle?.enabled === false) { + const lc = def.lifecycle; + if (lc?.enabled === false) { process.stdout.write(`board ${id} is disabled — skipping\n`); return 0; } + const now = deps.now ?? new Date(); + if (lc?.until != null && now.getTime() > Date.parse(lc.until)) { + process.stdout.write(`board ${id} reached its until (${lc.until}) — skipping\n`); + return 0; + } + if (lc?.maxRuns != null && (lc.runs ?? 0) >= lc.maxRuns) { + process.stdout.write(`board ${id} reached maxRuns (${lc.maxRuns}) — skipping\n`); + return 0; + } const [cmd, ...args] = (def.agentCommand ?? DEFAULT_AGENT_COMMAND).trim().split(/\s+/); const prompt = assembleBoardPrompt(def); const spawn = deps.spawn ?? defaultSpawn; - return spawn(cmd, args, prompt, deps.env ?? process.env); + process.stdout.write(`running board ${id}: ${cmd} ${args.join(" ")} → ${def.target.project}/${def.target.agent}\n`); + + const code = await spawn(cmd, args, prompt, env); + + if (code === 0) { + // Persist the run counter at the source (write-time) so maxRuns is enforceable across runs. + if (def.lifecycle?.maxRuns != null) { + def.lifecycle.runs = (def.lifecycle.runs ?? 0) + 1; + saveBoard(root, def); + } + process.stdout.write(`board ${id} refreshed${def.lifecycle?.maxRuns != null ? ` (run ${def.lifecycle.runs}/${def.lifecycle.maxRuns})` : ""}\n`); + } else { + process.stderr.write(`board ${id}: agent exited ${code} — board not refreshed\n`); + } + return code; } diff --git a/packages/cli/src/scaffold-workflow.ts b/packages/cli/src/scaffold-workflow.ts index 083b6faf..418d625c 100644 --- a/packages/cli/src/scaffold-workflow.ts +++ b/packages/cli/src/scaffold-workflow.ts @@ -1,15 +1,20 @@ -// `termchart board scaffold-workflow ` — generate a GitHub Actions workflow that runs -// `termchart run ` on the board's schedule. GitHub cron is UTC-only, so we convert the board's -// (tz-local) schedule to UTC for the common single-hour case and warn loudly about what we can't -// safely convert. Exact DST-aware scheduling is out of scope for v1 (a fixed-offset translation, -// computed at scaffold time, is used). +// `termchart board scaffold-workflow ` — generate a turnkey GitHub Actions workflow that runs +// `termchart run ` on the board's schedule. +// +// GitHub cron is UTC-only and can't express a timezone, so DST would otherwise drift the wall-clock +// fire time by an hour for half the year. We handle that robustly: for a DST timezone we emit TWO +// seasonal UTC crons (one per offset) plus a local-time guard step that no-ops the off-season run, +// so the board fires at the intended LOCAL time year-round. Day-of-week is shifted correctly when a +// conversion crosses UTC midnight. Restricted day-of-month + midnight crossing is warned (not auto- +// shifted, since month lengths make it unsafe). import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { BoardDef } from "@ivanmkc/termchart-core"; +import { VERSION } from "./version.js"; export interface BuildOpts { - now?: Date; // reference instant for the tz offset (injectable for deterministic tests) + now?: Date; // reference instant; only its YEAR is used (to sample winter/summer offsets) } /** Minutes east of UTC for `tz` at instant `at` (e.g. America/Los_Angeles in winter → -480). */ @@ -20,69 +25,157 @@ function tzOffsetMinutes(tz: string, at: Date): number { }); const m: Record = {}; for (const p of dtf.formatToParts(at)) m[p.type] = p.value; - // Intl renders 24:00 as "24"; normalise to 0 so Date.UTC stays in range. const hour = m.hour === "24" ? 0 : Number(m.hour); const asUTC = Date.UTC(Number(m.year), Number(m.month) - 1, Number(m.day), hour, Number(m.minute), Number(m.second)); return Math.round((asUTC - at.getTime()) / 60000); } -/** - * Convert a 5-field cron from a tz to UTC. Only the common single-integer minute+hour case is - * converted; anything else passes through with a warning. Returns the (possibly unchanged) cron and - * any caveats. - */ -export function cronToUtc(schedule: string, tz: string | undefined, at: Date): { cron: string; warnings: string[] } { - const warnings: string[] = []; - const fields = schedule.trim().split(/\s+/); - if (!tz) { - warnings.push("No tz set — GitHub Actions cron is UTC; the schedule is used as-is."); - return { cron: schedule.trim(), warnings }; +const pad = (n: number) => String(n).padStart(2, "0"); +const mod7 = (d: number) => ((d % 7) + 7) % 7; + +/** Shift a numeric cron day-of-week field by `delta` days. Returns null if the field isn't numeric. */ +function shiftDow(field: string, delta: number): string | null { + const out: string[] = []; + for (const tok of field.split(",")) { + if (tok === "*") { out.push("*"); continue; } + const range = tok.match(/^(\d+)-(\d+)$/); + if (range) { out.push(`${mod7(Number(range[1]) + delta)}-${mod7(Number(range[2]) + delta)}`); continue; } + if (/^\d+$/.test(tok)) { out.push(String(mod7(Number(tok) + delta))); continue; } + return null; // names (MON) or steps (*/2) — bail, let the caller warn } + return out.join(","); +} + +interface Converted { cron: string; convertible: boolean; warnings: string[] } + +/** Convert a 5-field cron to UTC for a fixed offset (minutes east of UTC). */ +function convertForOffset(fields: string[], offsetMin: number): Converted { const [min, hour, dom, mon, dow] = fields; + const warnings: string[] = []; if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) { - warnings.push(`Could not convert "${schedule}" from ${tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`); - return { cron: schedule.trim(), warnings }; + return { cron: fields.join(" "), convertible: false, warnings }; } - const offset = tzOffsetMinutes(tz, at); - const totalUtc = Number(hour) * 60 + Number(min) - offset; - const dayDelta = Math.floor(totalUtc / 1440); - const norm = ((totalUtc % 1440) + 1440) % 1440; - const utcHour = Math.floor(norm / 60); - const utcMin = norm % 60; - if (dayDelta !== 0 && !(dom === "*" && dow === "*")) { - warnings.push( - `Converting ${tz}→UTC shifts this run across midnight; the day-of-week/day-of-month fields ` + - `(${dom} ${dow}) are NOT shifted, so the UTC run may land one day off. Verify the schedule.`, - ); + const total = Number(hour) * 60 + Number(min) - offsetMin; + const dayDelta = Math.floor(total / 1440); + const norm = ((total % 1440) + 1440) % 1440; + let outDom = dom; + let outDow = dow; + if (dayDelta !== 0) { + if (dom === "*" && dow !== "*") { + const shifted = shiftDow(dow, dayDelta); + if (shifted == null) warnings.push(`day-of-week field "${dow}" is not numeric, so it was not shifted across the UTC midnight crossing — verify the schedule.`); + else outDow = shifted; + } else if (dom !== "*") { + warnings.push(`schedule restricts day-of-month (${dom}) and crosses midnight in UTC; day-of-month is not shifted (month lengths vary) — verify the schedule.`); + } } - return { cron: `${utcMin} ${utcHour} ${dom} ${mon} ${dow}`, warnings }; + return { cron: `${norm % 60} ${Math.floor(norm / 60)} ${outDom} ${mon} ${outDow}`, convertible: true, warnings }; +} + +/** Single-offset conversion at instant `at` — kept as a small, testable helper. */ +export function cronToUtc(schedule: string, tz: string | undefined, at: Date): { cron: string; warnings: string[] } { + if (!tz) return { cron: schedule.trim(), warnings: ["No tz set — GitHub Actions cron is UTC; the schedule is used as-is."] }; + const r = convertForOffset(schedule.trim().split(/\s+/), tzOffsetMinutes(tz, at)); + if (!r.convertible) return { cron: schedule.trim(), warnings: [`Could not convert "${schedule}" from ${tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`] }; + return { cron: r.cron, warnings: r.warnings }; +} + +interface Plan { crons: string[]; guard: boolean; localTime: string | null; warnings: string[] } + +/** Compute the UTC cron(s) + whether a DST guard is needed. */ +function planSchedule(def: BoardDef, at: Date): Plan { + const fields = def.schedule.trim().split(/\s+/); + if (!def.tz) return { crons: [def.schedule.trim()], guard: false, localTime: null, warnings: ["No tz set — GitHub Actions cron is UTC; the schedule is used as-is."] }; + + const year = at.getUTCFullYear(); + const offWinter = tzOffsetMinutes(def.tz, new Date(Date.UTC(year, 0, 15, 12))); + const offSummer = tzOffsetMinutes(def.tz, new Date(Date.UTC(year, 6, 15, 12))); + const offsets = offWinter === offSummer ? [offWinter] : [offWinter, offSummer]; + + const crons: string[] = []; + const warnings = new Set(); + for (const off of offsets) { + const r = convertForOffset(fields, off); + if (!r.convertible) { + warnings.add(`Could not convert "${def.schedule}" from ${def.tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`); + return { crons: [def.schedule.trim()], guard: false, localTime: null, warnings: [...warnings] }; + } + r.warnings.forEach((w) => warnings.add(w)); + if (!crons.includes(r.cron)) crons.push(r.cron); + } + // A guard is only meaningful (and possible) when there are two seasonal crons and a fixed local time. + const numeric = /^\d+$/.test(fields[0]) && /^\d+$/.test(fields[1]); + const guard = crons.length > 1 && numeric; + const localTime = numeric ? `${pad(Number(fields[1]))}:${pad(Number(fields[0]))}` : null; + return { crons, guard, localTime, warnings: [...warnings] }; } /** Build the workflow YAML for a board. Returns the file text and any scheduling caveats. */ export function buildWorkflowYaml(def: BoardDef, opts: BuildOpts = {}): { yaml: string; warnings: string[] } { const at = opts.now ?? new Date(); - const { cron, warnings } = cronToUtc(def.schedule, def.tz, at); - const tzNote = def.tz ? ` (source: ${def.schedule} ${def.tz})` : ""; - const yaml = `# Generated by \`termchart board scaffold-workflow ${def.id}\`. -# Set repo secrets: ANTHROPIC_API_KEY, TERMCHART_VIEWER_URL, TERMCHART_VIEWER_TOKEN. + const { crons, guard, localTime, warnings } = planSchedule(def, at); + + const agentCommand = def.agentCommand ?? "claude -p"; + const isClaude = /^claude(\s|$)/.test(agentCommand); + + const scheduleLines = crons + .map((c, i) => ` - cron: "${c}"${i === 0 && def.tz ? ` # ${def.schedule} ${def.tz}` : ""}`) + .join("\n"); + + const installStep = isClaude + ? ` - name: Install agent CLI + run: npm i -g @anthropic-ai/claude-code` + : ` # NOTE: ensure your agent command "${agentCommand}" is installed and authenticated on the runner, + # e.g. add an install step here and the matching secret(s) to the env block below.`; + + const guardPrefix = guard && localTime + ? ` # DST-safe: two seasonal crons fire; proceed only at the intended local time. + if [ "$(TZ='${def.tz}' date +%H:%M)" != "${localTime}" ]; then + echo "Local time $(TZ='${def.tz}' date +%H:%M) != ${localTime}; skipping (off-season cron)."; exit 0 + fi +` + : ""; + + const secretLines = [ + isClaude ? " ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}" : null, + " TERMCHART_VIEWER_URL: ${{ secrets.TERMCHART_VIEWER_URL }}", + " TERMCHART_VIEWER_TOKEN: ${{ secrets.TERMCHART_VIEWER_TOKEN }}", + " GH_TOKEN: ${{ github.token }}", + ].filter(Boolean).join("\n"); + + const secretsList = isClaude + ? "ANTHROPIC_API_KEY, TERMCHART_VIEWER_URL, TERMCHART_VIEWER_TOKEN" + : "TERMCHART_VIEWER_URL, TERMCHART_VIEWER_TOKEN (plus your agent's secret)"; + + const yaml = `# Generated by \`termchart board scaffold-workflow ${def.id}\` (termchart ${VERSION}). +# Set repo secrets: ${secretsList}. name: termchart board ${def.id} on: schedule: - - cron: "${cron}"${tzNote ? ` #${tzNote}` : ""} +${scheduleLines} workflow_dispatch: {} +permissions: + contents: read + issues: read + pull-requests: read +concurrency: + group: termchart-board-${def.id} + cancel-in-progress: false jobs: refresh: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - - run: npx -y @ivanmkc/termchart run ${def.id} +${installStep} + - name: Refresh board + run: | +${guardPrefix} npx -y @ivanmkc/termchart@${VERSION} run ${def.id} env: - ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} - TERMCHART_VIEWER_URL: \${{ secrets.TERMCHART_VIEWER_URL }} - TERMCHART_VIEWER_TOKEN: \${{ secrets.TERMCHART_VIEWER_TOKEN }} +${secretLines} `; return { yaml, warnings }; } diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 00000000..988880b3 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,3 @@ +// Single source of truth for the CLI version. Imported by cli.ts (--version) and +// scaffold-workflow.ts (so generated workflows pin the exact version that wrote them). +export const VERSION = "0.7.0"; diff --git a/packages/cli/test/board-bundle.test.ts b/packages/cli/test/board-bundle.test.ts index da9cf30b..edcc6d2a 100644 --- a/packages/cli/test/board-bundle.test.ts +++ b/packages/cli/test/board-bundle.test.ts @@ -17,8 +17,12 @@ maybe("board commands work in the built bundle", () => { beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-bundle-")); }); afterEach(() => rmSync(cwd, { recursive: true, force: true })); + // run requires viewer env; a dummy URL is fine because the agentCommand here is `cat` (no real push). const run = (args: string[]) => - execFileSync("node", [BIN, ...args], { cwd, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } }); + execFileSync("node", [BIN, ...args], { + cwd, encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", TERMCHART_VIEWER_URL: "http://x/w/ws1", TERMCHART_VIEWER_TOKEN: "t" }, + }); it("create writes a parseable YAML board (no dynamic-require crash)", () => { run(["board", "create", "--id", "daily", "--schedule", "0 8 * * 1-5", "--project", "me", "--agent", "daily", "--prompt", "Build it."]); diff --git a/packages/cli/test/board-prompt.test.ts b/packages/cli/test/board-prompt.test.ts index 3644e1c6..384b0f64 100644 --- a/packages/cli/test/board-prompt.test.ts +++ b/packages/cli/test/board-prompt.test.ts @@ -33,7 +33,13 @@ describe("assembleBoardPrompt", () => { expect(p.toLowerCase()).toContain("unschedule"); }); - it("omits the self-unschedule instruction for an open-ended board", () => { + it("adds a self-unschedule instruction for a frequent (sub-daily) board — the job-tracker case", () => { + // hour field is "*" → fires many times a day → likely tracks something that ends + const p = assembleBoardPrompt({ ...base, schedule: "*/2 * * * *" }); + expect(p.toLowerCase()).toContain("unschedule"); + }); + + it("omits the self-unschedule instruction for an open-ended daily board", () => { expect(assembleBoardPrompt(base).toLowerCase()).not.toContain("unschedule"); }); }); diff --git a/packages/cli/test/board.test.ts b/packages/cli/test/board.test.ts index 26efd9a8..dd59aafb 100644 --- a/packages/cli/test/board.test.ts +++ b/packages/cli/test/board.test.ts @@ -109,8 +109,82 @@ describe("board delete", () => { }); }); -describe("board dispatch", () => { - it("rejects an unknown subcommand with usage (exit 3)", async () => { +describe("board create — lifecycle flags", () => { + const baseArgs = ["create", "--id", "b", "--schedule", "0 8 * * *", "--project", "p", "--agent", "a", "--prompt", "go"]; + + it("writes a lifecycle block with enabled:true by default", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + await board(baseArgs, { cwd }); + const def = parseYaml(readFileSync(join(boardsDir(), "b.yaml"), "utf8")); + expect(def.lifecycle).toMatchObject({ enabled: true }); + }); + + it("populates --enabled, --until, and --max-runs", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + await board([...baseArgs, "--enabled", "false", "--until", "2026-12-31T00:00:00Z", "--max-runs", "5"], { cwd }); + const def = parseYaml(readFileSync(join(boardsDir(), "b.yaml"), "utf8")); + expect(def.lifecycle).toMatchObject({ enabled: false, until: "2026-12-31T00:00:00Z", maxRuns: 5 }); + }); + + it("reports the offending FLAG (not its value) for an unknown flag", async () => { + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + expect(await board([...baseArgs, "--bogus", "x"], { cwd })).toBe(3); + expect(errs.join("")).toContain("--bogus"); + expect(errs.join("")).not.toMatch(/Unknown flag: x/); + }); +}); + +describe("board list — invalid files", () => { + it("lists valid boards and warns (stderr) about skipped invalid ones", async () => { + writeDef("good", {}); + mkdirSync(boardsDir(), { recursive: true }); + writeFileSync(join(boardsDir(), "broken.yaml"), "id: broken\nschedule: not-a-cron\n"); // invalid + const out: string[] = []; const errs: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + const code = await board(["list"], { cwd }); + expect(code).toBe(0); + expect(out.join("")).toContain("good"); + expect(errs.join("")).toMatch(/broken/); + }); +}); + +describe("board help + errors", () => { + it("board --help prints usage (exit 0)", async () => { + const out: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + expect(await board(["--help"], { cwd })).toBe(0); + expect(out.join("")).toMatch(/create/); + }); + + it("board create --help prints create's flags (exit 0)", async () => { + const out: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + expect(await board(["create", "--help"], { cwd })).toBe(0); + expect(out.join("")).toMatch(/--schedule/); + }); + + it("names the unknown subcommand (exit 3)", async () => { + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); expect(await board(["frobnicate"], { cwd })).toBe(3); + expect(errs.join("")).toContain("frobnicate"); + }); + + it("delete of an unknown id shows the expected path (like its siblings)", async () => { + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + expect(await board(["delete", "nope"], { cwd })).toBe(1); + expect(errs.join("")).toMatch(/expected/); + }); + + it("wraps a malformed-YAML parse error with the board id", async () => { + mkdirSync(boardsDir(), { recursive: true }); + writeFileSync(join(boardsDir(), "bad.yaml"), 'prompt: "unterminated\n'); + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + expect(await board(["show", "bad"], { cwd })).toBe(1); + expect(errs.join("")).toContain("bad"); }); }); diff --git a/packages/cli/test/cli.smoke.test.ts b/packages/cli/test/cli.smoke.test.ts index fcaa781b..11f0fc07 100644 --- a/packages/cli/test/cli.smoke.test.ts +++ b/packages/cli/test/cli.smoke.test.ts @@ -95,4 +95,22 @@ maybe("cli smoke (built binary)", () => { expect(code).toBe(3); expect(stderr).toMatch(/rounded/i); }); + + it("reports an unknown command instead of trying to open it as a file", () => { + const { code, stderr } = run(["frobnicate"]); + expect(code).toBe(3); + expect(stderr).toMatch(/unknown command/i); + }); + + it("board --help prints usage (exit 0)", () => { + const { code, stdout } = run(["board", "--help"]); + expect(code).toBe(0); + expect(stdout).toMatch(/create/); + }); + + it("run --help prints usage (exit 0)", () => { + const { code, stdout } = run(["run", "--help"]); + expect(code).toBe(0); + expect(stdout).toMatch(/run/); + }); }); diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index ba7d0022..d978e94c 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { stringify as stringifyYaml } from "yaml"; +import { stringify as stringifyYaml, parse as parseYaml } from "yaml"; import type { BoardDef } from "@ivanmkc/termchart-core"; import { run, type AgentSpawn } from "../src/run.js"; @@ -10,30 +10,34 @@ let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-run-")); }); afterEach(() => rmSync(cwd, { recursive: true, force: true })); +const boardFile = (id: string) => join(cwd, ".termchart", "boards", `${id}.yaml`); const writeDef = (def: BoardDef) => { mkdirSync(join(cwd, ".termchart", "boards"), { recursive: true }); - writeFileSync(join(cwd, ".termchart", "boards", `${def.id}.yaml`), stringifyYaml(def)); + writeFileSync(boardFile(def.id), stringifyYaml(def)); }; +const readDef = (id: string) => parseYaml(readFileSync(boardFile(id), "utf8")) as BoardDef; const base: BoardDef = { id: "daily", schedule: "0 8 * * 1-5", target: { project: "me", agent: "daily" }, prompt: "Build the morning board.", }; +// Viewer env is required for run; most tests provide it. +const VENV = { TERMCHART_VIEWER_URL: "http://x/w/ws1", TERMCHART_VIEWER_TOKEN: "t" }; -// A spawner stub that records what it was asked to run. function recordingSpawn(exitCode = 0) { const calls: { cmd: string; args: string[]; input: string; env: Record }[] = []; const spawn: AgentSpawn = async (cmd, args, input, env) => { calls.push({ cmd, args, input, env }); return exitCode; }; return { calls, spawn }; } -const silence = () => vi.spyOn(process.stderr, "write").mockImplementation(() => true); +const silence = () => { vi.spyOn(process.stderr, "write").mockImplementation(() => true); vi.spyOn(process.stdout, "write").mockImplementation(() => true); }; describe("run", () => { it("assembles the prompt and feeds it to the agent on stdin", async () => { writeDef(base); const { calls, spawn } = recordingSpawn(0); - const code = await run(["daily"], { cwd, env: {}, spawn }); + silence(); + const code = await run(["daily"], { cwd, env: VENV, spawn }); expect(code).toBe(0); expect(calls).toHaveLength(1); expect(calls[0].input).toContain("Build the morning board."); @@ -43,7 +47,8 @@ describe("run", () => { it("defaults agentCommand to `claude -p` (tokenised, no shell)", async () => { writeDef(base); const { calls, spawn } = recordingSpawn(0); - await run(["daily"], { cwd, env: {}, spawn }); + silence(); + await run(["daily"], { cwd, env: VENV, spawn }); expect(calls[0].cmd).toBe("claude"); expect(calls[0].args).toEqual(["-p"]); }); @@ -51,7 +56,8 @@ describe("run", () => { it("honours a custom agentCommand", async () => { writeDef({ ...base, agentCommand: "agy --dangerously-skip-permissions --print" }); const { calls, spawn } = recordingSpawn(0); - await run(["daily"], { cwd, env: {}, spawn }); + silence(); + await run(["daily"], { cwd, env: VENV, spawn }); expect(calls[0].cmd).toBe("agy"); expect(calls[0].args).toEqual(["--dangerously-skip-permissions", "--print"]); }); @@ -59,35 +65,102 @@ describe("run", () => { it("passes the process env through to the agent", async () => { writeDef(base); const { calls, spawn } = recordingSpawn(0); - await run(["daily"], { cwd, env: { TERMCHART_VIEWER_URL: "http://x/w/ws1", TERMCHART_VIEWER_TOKEN: "t" }, spawn }); + silence(); + await run(["daily"], { cwd, env: VENV, spawn }); expect(calls[0].env.TERMCHART_VIEWER_URL).toBe("http://x/w/ws1"); - expect(calls[0].env.TERMCHART_VIEWER_TOKEN).toBe("t"); }); it("propagates the agent's non-zero exit code", async () => { writeDef(base); const { spawn } = recordingSpawn(2); silence(); - expect(await run(["daily"], { cwd, env: {}, spawn })).toBe(2); + expect(await run(["daily"], { cwd, env: VENV, spawn })).toBe(2); + }); + + // --- viewer pre-flight --- + it("exits 4 with a viewer-config message when the viewer env is unset (before spawning)", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + const code = await run(["daily"], { cwd, env: {}, spawn }); + expect(code).toBe(4); + expect(calls).toHaveLength(0); // never spawned + expect(errs.join("")).toMatch(/viewer/i); }); + // --- lifecycle enforcement --- it("short-circuits a disabled board without spawning (exit 0)", async () => { writeDef({ ...base, lifecycle: { enabled: false } }); const { calls, spawn } = recordingSpawn(0); - const code = await run(["daily"], { cwd, env: {}, spawn }); + silence(); + expect(await run(["daily"], { cwd, env: VENV, spawn })).toBe(0); + expect(calls).toHaveLength(0); + }); + + it("skips (exit 0, no spawn) when past lifecycle.until", async () => { + writeDef({ ...base, lifecycle: { until: "2020-01-01T00:00:00Z" } }); + const { calls, spawn } = recordingSpawn(0); + silence(); + const code = await run(["daily"], { cwd, env: VENV, spawn, now: new Date("2026-01-01T00:00:00Z") }); expect(code).toBe(0); expect(calls).toHaveLength(0); }); + it("still runs before lifecycle.until", async () => { + writeDef({ ...base, lifecycle: { until: "2026-12-31T00:00:00Z" } }); + const { calls, spawn } = recordingSpawn(0); + silence(); + await run(["daily"], { cwd, env: VENV, spawn, now: new Date("2026-06-01T00:00:00Z") }); + expect(calls).toHaveLength(1); + }); + + it("enforces maxRuns with a persistent counter written back to the YAML", async () => { + writeDef({ ...base, lifecycle: { maxRuns: 2 } }); + const { calls, spawn } = recordingSpawn(0); + silence(); + expect(await run(["daily"], { cwd, env: VENV, spawn })).toBe(0); // run 1 + expect(readDef("daily").lifecycle?.runs).toBe(1); + expect(await run(["daily"], { cwd, env: VENV, spawn })).toBe(0); // run 2 + expect(readDef("daily").lifecycle?.runs).toBe(2); + expect(await run(["daily"], { cwd, env: VENV, spawn })).toBe(0); // run 3 → skipped + expect(calls).toHaveLength(2); // only the first two actually spawned + expect(readDef("daily").lifecycle?.runs).toBe(2); + }); + + it("does not increment the counter when the agent fails", async () => { + writeDef({ ...base, lifecycle: { maxRuns: 3 } }); + const { spawn } = recordingSpawn(1); + silence(); + await run(["daily"], { cwd, env: VENV, spawn }); + expect(readDef("daily").lifecycle?.runs ?? 0).toBe(0); + }); + + // --- usage --- it("returns 1 for an unknown board", async () => { silence(); const { spawn } = recordingSpawn(0); - expect(await run(["nope"], { cwd, env: {}, spawn })).toBe(1); + expect(await run(["nope"], { cwd, env: VENV, spawn })).toBe(1); }); it("returns 3 when no board id is given", async () => { silence(); const { spawn } = recordingSpawn(0); - expect(await run([], { cwd, env: {}, spawn })).toBe(3); + expect(await run([], { cwd, env: VENV, spawn })).toBe(3); + }); + + it("rejects an unknown flag after the id (exit 3, no spawn)", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + silence(); + expect(await run(["daily", "--nope"], { cwd, env: VENV, spawn })).toBe(3); + expect(calls).toHaveLength(0); + }); + + it("prints --help and exits 0", async () => { + const out: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => (out.push(String(s)), true)); + expect(await run(["--help"], { cwd, env: VENV })).toBe(0); + expect(out.join("")).toMatch(/run/); }); }); diff --git a/packages/cli/test/scaffold-workflow.test.ts b/packages/cli/test/scaffold-workflow.test.ts index b5207860..033044f5 100644 --- a/packages/cli/test/scaffold-workflow.test.ts +++ b/packages/cli/test/scaffold-workflow.test.ts @@ -4,11 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify as stringifyYaml } from "yaml"; import type { BoardDef } from "@ivanmkc/termchart-core"; -import { buildWorkflowYaml } from "../src/scaffold-workflow.js"; +import { buildWorkflowYaml, cronToUtc } from "../src/scaffold-workflow.js"; +import { VERSION } from "../src/version.js"; import { board } from "../src/board.js"; -// A winter instant so America/Los_Angeles is PST (UTC-8) deterministically (no DST ambiguity). -const WINTER = new Date("2026-01-15T12:00:00Z"); +// Any 2026 instant — buildWorkflowYaml derives seasonal offsets from the year, not this exact date. +const NOW = new Date("2026-06-15T12:00:00Z"); const base: BoardDef = { id: "daily", @@ -18,46 +19,76 @@ const base: BoardDef = { prompt: "Build the morning board.", }; -describe("buildWorkflowYaml", () => { - it("emits a workflow that runs `termchart run ` on schedule + manual dispatch", () => { - const { yaml } = buildWorkflowYaml(base, { now: WINTER }); - expect(yaml).toContain("schedule:"); +describe("buildWorkflowYaml — structure & provisioning", () => { + it("runs a version-pinned `termchart run ` with all the provisioning a runner needs", () => { + const { yaml } = buildWorkflowYaml(base, { now: NOW }); expect(yaml).toContain("workflow_dispatch:"); - expect(yaml).toMatch(/termchart run daily/); + expect(yaml).toContain(`@ivanmkc/termchart@${VERSION} run daily`); // pinned version + expect(yaml).toContain("permissions:"); + expect(yaml).toContain("timeout-minutes:"); + expect(yaml).toContain("concurrency:"); + expect(yaml).toContain("npm i -g @anthropic-ai/claude-code"); // default agent install expect(yaml).toContain("ANTHROPIC_API_KEY"); expect(yaml).toContain("TERMCHART_VIEWER_URL"); expect(yaml).toContain("TERMCHART_VIEWER_TOKEN"); + expect(yaml).toMatch(/GH_TOKEN|github\.token/); // so `gh` can read PRs/issues }); - it("converts a single-hour schedule from the board tz to UTC", () => { - // 08:00 PST (UTC-8) -> 16:00 UTC - const { yaml } = buildWorkflowYaml(base, { now: WINTER }); + it("for a custom non-claude agentCommand, does not install claude or hardcode the Anthropic secret", () => { + const { yaml } = buildWorkflowYaml({ ...base, agentCommand: "agy --print" }, { now: NOW }); + expect(yaml).not.toContain("npm i -g @anthropic-ai/claude-code"); + expect(yaml).not.toContain("ANTHROPIC_API_KEY"); + expect(yaml.toLowerCase()).toContain("agy"); // a note about the custom agent + }); +}); + +describe("buildWorkflowYaml — DST-robust scheduling", () => { + it("emits TWO seasonal UTC crons + a local-time guard for a DST timezone", () => { + // 08:00 LA → 16:00 UTC in winter (PST -8), 15:00 UTC in summer (PDT -7) + const { yaml } = buildWorkflowYaml(base, { now: NOW }); expect(yaml).toContain('cron: "0 16 * * 1-5"'); + expect(yaml).toContain('cron: "0 15 * * 1-5"'); + expect(yaml).toContain("TZ='America/Los_Angeles'"); // guard gates on local wall-clock + expect(yaml).toContain("08:00"); // intended local time }); - it("handles half-hour offset timezones", () => { - // 09:30 IST (UTC+5:30) -> 04:00 UTC - const def = { ...base, tz: "Asia/Kolkata", schedule: "30 9 * * *" }; - const { yaml } = buildWorkflowYaml(def, { now: WINTER }); + it("emits a SINGLE cron and no guard for a non-DST timezone", () => { + // 09:30 IST (UTC+5:30, no DST) → 04:00 UTC year-round + const { yaml } = buildWorkflowYaml({ ...base, tz: "Asia/Kolkata", schedule: "30 9 * * *" }, { now: NOW }); expect(yaml).toContain('cron: "0 4 * * *"'); + expect(yaml).not.toContain('cron: "0 5'); // only one schedule entry + expect(yaml).not.toContain("date +%H:%M"); // no guard needed }); - it("passes the schedule through unchanged and warns when no tz is set", () => { - const def = { ...base, tz: undefined }; - const { yaml, warnings } = buildWorkflowYaml(def, { now: WINTER }); + it("passes the schedule through and warns when no tz is set", () => { + const { yaml, warnings } = buildWorkflowYaml({ ...base, tz: undefined }, { now: NOW }); expect(yaml).toContain('cron: "0 8 * * 1-5"'); expect(warnings.join(" ")).toMatch(/UTC/); }); - it("warns when the UTC conversion crosses midnight against a restricted weekday set", () => { - // 20:00 PST -> 04:00 next-day UTC; weekday set 1-5 would actually shift by a day. - const def = { ...base, schedule: "0 20 * * 1-5" }; - const { warnings } = buildWorkflowYaml(def, { now: WINTER }); - expect(warnings.join(" ").toLowerCase()).toMatch(/day|weekday/); + it("correctly SHIFTS the day-of-week when the UTC conversion crosses midnight (no false warning)", () => { + // 20:00 LA Mon-Fri → next-day UTC; weekday set must shift 1-5 → 2-6 + const { yaml, warnings } = buildWorkflowYaml({ ...base, schedule: "0 20 * * 1-5" }, { now: NOW }); + expect(yaml).toContain('cron: "0 4 * * 2-6"'); // winter + expect(yaml).toContain('cron: "0 3 * * 2-6"'); // summer + expect(warnings.join(" ").toLowerCase()).not.toContain("day-of-week"); // it was fixed, not warned + }); + + it("warns (and does not shift) when a restricted day-of-MONTH crosses midnight", () => { + const { warnings } = buildWorkflowYaml({ ...base, schedule: "0 20 1 * *" }, { now: NOW }); + expect(warnings.join(" ").toLowerCase()).toMatch(/day-of-month/); }); }); -describe("board scaffold-workflow", () => { +describe("cronToUtc (single offset helper)", () => { + it("returns the input unchanged with a warning when no tz", () => { + const r = cronToUtc("0 8 * * 1-5", undefined, NOW); + expect(r.cron).toBe("0 8 * * 1-5"); + expect(r.warnings.length).toBeGreaterThan(0); + }); +}); + +describe("board scaffold-workflow command", () => { let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "tc-wf-")); }); afterEach(() => rmSync(cwd, { recursive: true, force: true })); @@ -65,7 +96,7 @@ describe("board scaffold-workflow", () => { mkdirSync(join(cwd, ".termchart", "boards"), { recursive: true }); writeFileSync(join(cwd, ".termchart", "boards", `${def.id}.yaml`), stringifyYaml(def)); }; - const silence = () => vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const silence = () => { vi.spyOn(process.stdout, "write").mockImplementation(() => true); vi.spyOn(process.stderr, "write").mockImplementation(() => true); }; it("writes .github/workflows/termchart-.yml and exits 0", async () => { writeDef(base); @@ -74,11 +105,11 @@ describe("board scaffold-workflow", () => { expect(code).toBe(0); const file = join(cwd, ".github", "workflows", "termchart-daily.yml"); expect(existsSync(file)).toBe(true); - expect(readFileSync(file, "utf8")).toMatch(/termchart run daily/); + expect(readFileSync(file, "utf8")).toMatch(/run daily/); }); it("returns 1 for an unknown id", async () => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); + silence(); expect(await board(["scaffold-workflow", "nope"], { cwd })).toBe(1); }); }); diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts index 1f017035..81cdc011 100644 --- a/packages/core/src/board.ts +++ b/packages/core/src/board.ts @@ -15,6 +15,7 @@ export interface BoardLifecycle { enabled?: boolean; until?: string | null; // ISO-8601 instant after which scheduling stops maxRuns?: number | null; // stop after N successful runs + runs?: number; // persisted counter of successful runs (written back by `termchart run`) } export interface BoardDef { @@ -92,6 +93,10 @@ export function validateBoardDef(obj: unknown): string | null { if (typeof lc.until !== "string" || Number.isNaN(Date.parse(lc.until))) return "lifecycle.until must be an ISO-8601 date string"; } + if (lc.runs !== undefined) { + if (typeof lc.runs !== "number" || !Number.isInteger(lc.runs) || lc.runs < 0) + return "lifecycle.runs must be a non-negative integer"; + } } return null; diff --git a/packages/core/test/validate-board.test.ts b/packages/core/test/validate-board.test.ts index 41f77fca..5af2f19d 100644 --- a/packages/core/test/validate-board.test.ts +++ b/packages/core/test/validate-board.test.ts @@ -67,4 +67,11 @@ describe("validateBoardDef", () => { expect(validateBoardDef({ ...valid(), lifecycle: { until: "not-a-date" } })).toMatch(/until/); expect(validateBoardDef({ ...valid(), lifecycle: { until: "2026-07-01T00:00:00Z", maxRuns: 5 } })).toBeNull(); }); + + it("accepts a non-negative integer run counter and rejects a bad one", () => { + expect(validateBoardDef({ ...valid(), lifecycle: { runs: 0 } })).toBeNull(); + expect(validateBoardDef({ ...valid(), lifecycle: { runs: 3 } })).toBeNull(); + expect(validateBoardDef({ ...valid(), lifecycle: { runs: -1 } })).toMatch(/runs/); + expect(validateBoardDef({ ...valid(), lifecycle: { runs: 1.5 } })).toMatch(/runs/); + }); }); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index c3e1b078..1e0341d5 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "termchart", "displayName": "termchart", "description": "A live canvas your AI draws on. Instead of walls of text, your agent pushes rich, native visuals to a browser tab / iPad / second screen in real time — React Flow graphs (architecture, sequence, call-graph, ER/class, state machine, PR-review, journeys, recursion trees), Vega-Lite charts (incl. scientific + Big-O), Mantine UI dashboards & product comparisons, deep-code walkthroughs, markdown, and split panes — with animated edges and live status overlays (toasts, progress bars, loaders). Deterministic Mermaid→ASCII/Unicode is the terminal fallback. Ships persona recipes, a showcase gallery, fullscreen panes, and a collapsible sidebar. Adds /termchart commands + skills, backed by the termchart CLI.", - "version": "0.13.0", + "version": "0.14.0", "author": { "name": "Ivan Cheung" }, diff --git a/plugin/commands/schedule-board.md b/plugin/commands/schedule-board.md index c52678ad..3a79bc64 100644 --- a/plugin/commands/schedule-board.md +++ b/plugin/commands/schedule-board.md @@ -25,10 +25,15 @@ Do this: 2. **Write the definition:** ``` termchart board create --id --schedule "" --project --agent \ - --prompt "" [--tz ] [--host ] [--description ""] + --prompt "" [--tz ] [--host ] [--description ""] \ + [--until ] [--max-runs ] ``` - Make the `--prompt` self-sufficient (what data, what diagram, grouped/sorted how). For a job - tracker, end it with "if the job has finished, unschedule this board." + Make the `--prompt` self-sufficient (what data, what diagram type — see the `diagram-recipes` + skill for payload shapes — grouped/sorted how). For a **job tracker**, use a frequent cron and + bound it: `--until`/`--max-runs` are enforced by `termchart run` (note: `maxRuns` needs the YAML + counter to persist, so it works for `claude-session`/`cron` but not on ephemeral GitHub runners — + use `--until` there). The assembled prompt already tells the agent to unschedule a bounded or + frequent board when its work is done. 3. **Verify it runs** before scheduling — `termchart run ` once and confirm the board appears on the viewer (this is the real test that the prompt + push work). @@ -37,9 +42,10 @@ Do this: - **`claude-session`:** get the prompt with `termchart board prompt `, then register a **`CronCreate`** with that prompt and the board's cron. It fires in-session; the agent gathers + pushes. (Ephemeral — dies with the session; ~7-day cap.) - - **`github-actions`:** `termchart board scaffold-workflow `, then tell the human to set repo - secrets `ANTHROPIC_API_KEY`, `TERMCHART_VIEWER_URL`, `TERMCHART_VIEWER_TOKEN`. Note the UTC/DST - caveat printed by the scaffolder. + - **`github-actions`:** `termchart board scaffold-workflow ` (turnkey: it installs the agent + CLI, sets `permissions:`/`GH_TOKEN`, pins the termchart version, and handles DST via two seasonal + crons + a local-time guard). Then tell the human to set repo secrets `ANTHROPIC_API_KEY`, + `TERMCHART_VIEWER_URL`, `TERMCHART_VIEWER_TOKEN`. Surface any warnings the scaffolder prints. - **`cron`:** give the human a crontab line running `termchart run ` from the repo root with the viewer env set. diff --git a/plugin/skills/scheduled-boards/SKILL.md b/plugin/skills/scheduled-boards/SKILL.md index b8f282b1..ea2bb66f 100644 --- a/plugin/skills/scheduled-boards/SKILL.md +++ b/plugin/skills/scheduled-boards/SKILL.md @@ -1,16 +1,16 @@ --- name: scheduled-boards -description: Use when the human wants a termchart board that refreshes itself on a schedule — "a daily morning board with my tasks", "a board that tracks this running job and updates every few minutes", "auto-update this dashboard each hour", "schedule this view". Explains how to define a board (committed YAML), pick a scheduler host (Claude Code session cron now; GitHub Actions / system cron via the run primitive), and wire it up. Pairs with /termchart:schedule-board. +description: Use when the human wants a termchart board that refreshes itself on a schedule — "a daily morning board with my tasks", "a board that tracks this running job and updates every few minutes", "auto-update this dashboard each hour", "schedule this view". Explains how to define a board (committed YAML), pick a scheduler host (Claude Code session cron now; GitHub Actions / system cron via the run primitive), wire it up, and stop it. Pairs with /termchart:schedule-board. --- # Scheduled boards — boards that refresh themselves A **scheduled board** is just a normal termchart board (a `project/agent` scope on the viewer) that an **agent re-refreshes on a schedule**. On each tick, an agent is handed a saved prompt; it -gathers the data, builds the diagram, and `termchart push`es it to the board's scope — exactly the -normal push path. Nothing new runs inside the viewer. +gathers the data, builds the diagram, and `termchart push`es it to the board's scope — the normal +push path. Nothing new runs inside the viewer. -Two shapes this covers, both with one mechanism: +Two shapes, one mechanism: - **Daily morning board** — "today's tasks, calendar, open PRs", rebuilt at 8am on weekdays. - **Job tracker** — "track this running job's metrics", refreshed every couple of minutes, then @@ -18,13 +18,12 @@ Two shapes this covers, both with one mechanism: ## The model: definition + run primitive + a scheduler -termchart owns two portable pieces; the scheduler is pluggable on top. - 1. **Definition** — a committed YAML file at `.termchart/boards/.yaml` (prompt, cron, target, - lifecycle). It's diffable, reviewable, and a GitHub Actions runner gets it on checkout. -2. **Run primitive** — `termchart run ` loads the definition, assembles the agent prompt, and - runs the board's `agentCommand` with that prompt **on stdin**. The agent pushes the board. Every - host ultimately calls this same primitive, so behaviour is identical everywhere. + lifecycle). Diffable, reviewable, and a GitHub Actions runner gets it on checkout. +2. **Run primitive** — `termchart run ` loads the definition, enforces lifecycle, assembles the + agent prompt, and runs the board's `agentCommand` with that prompt **on stdin**. The agent pushes + the board. Every host calls this same primitive, so behaviour is identical everywhere. It exits 4 + if no viewer is configured (`TERMCHART_VIEWER_URL`/`TOKEN`). ``` .termchart/boards/.yaml ──► termchart run ──► agent gathers data + termchart push ──► viewer @@ -49,39 +48,57 @@ host: claude-session # claude-session | github-actions | cron lifecycle: enabled: true # set false to pause until: null # ISO-8601 instant to stop after (optional) - maxRuns: null # stop after N runs (optional) + maxRuns: null # stop after N successful runs (optional) + runs: 0 # managed by `termchart run` — don't edit ``` -The `prompt` is self-contained: `termchart board prompt ` appends the exact push target and -command, so even a context-free headless agent knows where to send the board. +`board create` always writes a `lifecycle` block (so you have a key to flip to pause). The `prompt` +is self-contained: `termchart board prompt ` appends the exact push target + command, so even a +context-free headless agent knows where to send the board. + +### The diagram payload + +The `prompt` should say which diagram **type** to build and push. For the payload shapes (Mantine +`component` trees, `vegalite` specs, `flow` graphs, etc.) read the **`diagram-recipes`** skill and +its "Choose the diagram" router — e.g. a tasks/PRs board is a `component` `Stack` of `Card`/`Table` +nodes. A `component` node always needs a `"type"` field; don't invent an ad-hoc shape. ## Pick a scheduler host | Host | When | How it fires | |---|---|---| | **`claude-session`** (v1 first-class) | "watch this job while I work"; short-lived; data is local | A Claude Code **session cron** (`CronCreate`) fires the assembled prompt **in-session**. Dies when the session ends. | -| **`github-actions`** | durable, unattended (daily board); data is cloud-reachable (repo, APIs, the viewer) | A generated workflow runs `termchart run ` on a UTC cron. | +| **`github-actions`** | durable, unattended (daily board); data is cloud-reachable (repo, APIs, the viewer) | A generated workflow runs `termchart run ` on a UTC cron. Turnkey (see below). | | **`cron`** | durable on a machine you keep running; local data | A crontab line runs `termchart run `. | **Data-reachability rule:** a GitHub Actions runner is a clean cloud box — it can reach the repo, HTTP APIs, and the viewer, but **not your laptop's files**. Boards whose data is local-only must use -`claude-session` or a local `cron` host. +`claude-session` or a local `cron` host. Likewise, a board with a **local** `agentCommand` (an +absolute path script) can't run on a cloud runner — use the default `claude -p` or a command the +runner installs. -## Wiring it up +## Lifecycle & stopping a board -### Claude Code session cron (v1) +Three ways a board stops; know which applies: -After writing the definition: +1. **`enabled: false`** — `run` skips it (exit 0). Manual pause/resume. +2. **`until` / `maxRuns`** — **`termchart run` enforces these**: it skips once past `until`, and + counts successful runs in `lifecycle.runs` and stops at `maxRuns`. ⚠️ `maxRuns` relies on the + counter persisting in the committed YAML — it works for `claude-session`/`cron` hosts. On + **GitHub Actions the runner is ephemeral**, so the counter doesn't persist across runs; use + `until` (stateless — just a timestamp comparison) or self-unschedule instead. +3. **Agent self-unschedule** — for bounded boards and frequent (sub-daily) trackers, the assembled + prompt instructs the agent: when the tracked work is done, `CronDelete` the session cron / disable + the workflow. This is how a job tracker stops itself early. -1. Get the prompt the cron should fire: `termchart board prompt `. -2. Register it with the harness `CronCreate` tool — cron = the board's `schedule`, prompt = that - output. It fires in-session and the agent gathers + pushes. -3. **Job trackers self-terminate:** the assembled prompt already instructs the agent to - `CronDelete` the job when the tracked work is done or `lifecycle` limits are reached. Keep the - cadence sane (every 2–5 min for a job tracker, not every few seconds). +## Wiring it up + +### Claude Code session cron (v1) -Session crons are ephemeral (they die with the session, and the harness expires them after ~7 days). -For a board that must fire unattended every morning, prefer GitHub Actions. +After writing the definition: `termchart board prompt ` → register it with the harness +`CronCreate` (cron = the board's `schedule`, prompt = that output). It fires in-session; the agent +gathers + pushes. Session crons are ephemeral (die with the session; ~7-day cap) — for an unattended +daily board, prefer GitHub Actions. ### GitHub Actions (turnkey) @@ -89,24 +106,32 @@ For a board that must fire unattended every morning, prefer GitHub Actions. termchart board scaffold-workflow ``` -Writes `.github/workflows/termchart-.yml` running `termchart run ` on the schedule -(converted to UTC). Then set repo secrets: `ANTHROPIC_API_KEY`, `TERMCHART_VIEWER_URL`, -`TERMCHART_VIEWER_TOKEN`. **Caveat:** GitHub cron is UTC and the conversion is a fixed offset — DST -can shift the wall-clock time; the generated file annotates the source schedule. +Writes `.github/workflows/termchart-.yml` that: +- runs a **version-pinned** `npx -y @ivanmkc/termchart@ run `, +- installs the agent CLI (for the default `claude -p`; a custom `agentCommand` gets a TODO note), +- sets `permissions:` + `GH_TOKEN` so `gh` can read PRs/issues, plus `timeout-minutes` and a + `concurrency` guard, +- handles **DST** by emitting two seasonal UTC crons + a local-time guard step (GitHub cron is + UTC-only), so the board fires at the intended local time year-round. + +Then set repo secrets: `ANTHROPIC_API_KEY` (for `claude`), `TERMCHART_VIEWER_URL`, +`TERMCHART_VIEWER_TOKEN`. ### System cron (compatible) -Add a crontab line that runs `termchart run ` from the repo root, with the viewer env set. Same -primitive, same prompt. +A crontab line that runs `termchart run ` from the repo root, with the viewer env set. ## CLI reference ``` -termchart board create --id --schedule "" --project

--agent --prompt "" [--tz ] [--host ] [--agent-command ""] [--description ""] -termchart board list # id · schedule · target · host -termchart board show [--json] # parsed definition +termchart board create --id --schedule "" --project

--agent --prompt "" + [--tz ] [--host ] [--agent-command ""] [--description ""] + [--enabled ] [--until ] [--max-runs ] [--force] +termchart board list [--json] # id · schedule · target · host (warns about invalid files) +termchart board show [--json] # parsed definition + effective defaults termchart board prompt # the assembled agent prompt (feed this to a scheduler) termchart board scaffold-workflow # generate a GitHub Actions workflow termchart board delete -termchart run # refresh now: assemble prompt + run agent → push +termchart run # refresh now: enforce lifecycle, assemble prompt, run agent → push ``` +Every subcommand supports `--help`. From f077f4b9c5d8fc89acb03d354bb35e62622704bb Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 22:26:52 +0000 Subject: [PATCH 4/9] =?UTF-8?q?fix(scheduled-boards):=20code-review=20fixe?= =?UTF-8?q?s=20=E2=80=94=20DST=20guard,=20dow=20wrap,=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Senior-review pass on the branch found 2 critical defects in the GH Actions scaffolding plus hardening gaps; all fixed with tests. Critical: - DST guard was exact-minute wall-clock equality — GitHub's routinely-late cron starts (3-20+ min) would skip nearly every run, and workflow_dispatch was swallowed too. Now keyed on WHICH cron fired (github.event.schedule via an EVENT_SCHEDULE env) vs the zone's CURRENT UTC offset (date +%z): delay-immune, fail-open, dispatch always runs. Behavioral test executes the generated shell. - shiftDow emitted an invalid descending range when a day-of-week shift wrapped past Saturday ("4-6"+1 -> "5-0", which GitHub rejects — silent dead schedule). Wrapping ranges now split ("5-6,0"). Hardening: - Board ids are validated (BOARD_ID_RE, exported from core) before any path join — no traversal via `board delete ../../x` etc. - agentCommand containing quotes is rejected at validation (whitespace-split, no-shell contract documented in the skill); filename stem must match id (loadBoard error + scanBoards issue); saveBoard writes tmp+rename (atomic). - Cron field values validated in core (charset, per-field bounds, step>0, numeric-only) so typos fail at create, not on GitHub. - scaffold-workflow warns when lifecycle.maxRuns can't persist on an ephemeral runner (suggests until) and when the board's host isn't github-actions; the bounded-lifecycle prompt clause no longer overclaims enforcement. - --enabled accepts only true|false; removed unused cronToUtc export. Also: regenerate package-lock for 0.7.0; spec truth-ups (drop the never-built {prompt} placeholder, record run-enforced lifecycle, note next-run deferral); sessionCronId write-back instruction in the schedule-board command. Tests: core 13, cli 234, viewer 492 — all green; live run->push->viewer loop re-verified against the remote viewer. --- .../2026-06-24-scheduled-boards-design.md | 17 +++-- package-lock.json | 2 +- packages/cli/src/board-prompt.ts | 6 +- packages/cli/src/board-store.ts | 24 +++++- packages/cli/src/board.ts | 17 ++++- packages/cli/src/scaffold-workflow.ts | 67 ++++++++++------- packages/cli/test/board.test.ts | 24 ++++++ packages/cli/test/scaffold-workflow.test.ts | 74 ++++++++++++++++--- packages/core/src/board.ts | 43 ++++++++++- packages/core/test/validate-board.test.ts | 20 +++++ plugin/commands/schedule-board.md | 4 +- plugin/skills/scheduled-boards/SKILL.md | 4 +- 12 files changed, 242 insertions(+), 60 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md b/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md index 1d35235a..6da5dd63 100644 --- a/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md +++ b/docs/superpowers/specs/2026-06-24-scheduled-boards-design.md @@ -114,7 +114,7 @@ Mirrors the existing `termchart template ` sub-dispatch pattern. |---|---| | `termchart board create` | Scaffold a `.termchart/boards/.yaml` from flags (`--id --schedule --project --agent --prompt --host --tz`). No LLM. | | `termchart board list` | List defined boards: id · schedule · target · host · enabled. `--json`. | -| `termchart board show ` | Print the parsed/validated definition + computed next-run. `--json`. | +| `termchart board show ` | Print the parsed/validated definition + effective defaults. `--json`. (Computed next-run display deferred — needs a cron evaluator; not in v1.) | | `termchart board delete ` | Remove the file. | | `termchart board prompt ` | Print the fully-assembled agent prompt (the canonical text every host hands to the agent). | | `termchart run ` | **Host-agnostic run primitive.** Load + validate def, assemble prompt, execute `agentCommand` with the prompt on **stdin**, return its exit code. The agent does the push. | @@ -124,10 +124,10 @@ Mirrors the existing `termchart template ` sub-dispatch pattern. `termchart run` delivers the assembled prompt to `agentCommand` via **stdin**, not as an argv string. This avoids the `E2BIG` argv-overflow class of failure and all shell-quoting -hazards. `agentCommand` is tokenised (not run through a shell) and the prompt is piped in; -`claude -p` and `agy --print` both accept a prompt on stdin. A `{prompt}` placeholder is -*also* supported for commands that need it inline, but stdin is the default and documented -path. +hazards. `agentCommand` is tokenised on whitespace (not run through a shell; quotes are rejected +at validation) and the prompt is piped in; `claude -p` and `agy --print` both accept a prompt on +stdin. Stdin is the only delivery path — an inline `{prompt}` placeholder was considered and +dropped (YAGNI; it reintroduces the quoting/E2BIG hazards). ### Assembled prompt @@ -148,8 +148,11 @@ The `/termchart:schedule-board` skill, after authoring the definition, registers `CronCreate` whose prompt is the output of `termchart board prompt `. It fires **in-session** (no subprocess) — ideal for "watch this running job while I work." The skill records the cron id in the board file (`sessionCronId`, written back) so it can be updated -or `CronDelete`d. Session crons inherit the harness's 7-day auto-expiry; `lifecycle.until` -/`maxRuns` are enforced by the assembled prompt instructing the agent to self-unschedule. +or `CronDelete`d. Session crons inherit the harness's 7-day auto-expiry. `lifecycle.until`/ +`maxRuns` are enforced deterministically by `termchart run` (skip past `until`; a persisted +`lifecycle.runs` counter stops at `maxRuns` — counter-based stops need the YAML to persist, so on +ephemeral GitHub runners use `until`), with the assembled prompt additionally instructing the +agent to self-unschedule when the tracked work completes. ### v1 turnkey — GitHub Actions diff --git a/package-lock.json b/package-lock.json index c3d240fc..1b23084e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3435,7 +3435,7 @@ }, "packages/cli": { "name": "@ivanmkc/termchart", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "yaml": "^2.9.0" diff --git a/packages/cli/src/board-prompt.ts b/packages/cli/src/board-prompt.ts index 4d001b5d..798da226 100644 --- a/packages/cli/src/board-prompt.ts +++ b/packages/cli/src/board-prompt.ts @@ -30,9 +30,9 @@ export function assembleBoardPrompt(def: BoardDef): string { lc!.maxRuns != null ? `after ${lc!.maxRuns} run(s)` : null, ].filter(Boolean).join(" or "); parts.push( - `Lifecycle: this board is bounded — \`termchart run\` stops it ${bound}. If the work it tracks ` + - `finishes earlier, unschedule it now — CronDelete the job in a Claude Code session, or disable ` + - `the GitHub Actions workflow.`, + `Lifecycle: this board is meant to stop ${bound}. If the work it tracks finishes earlier (or ` + + `that limit is reached), unschedule it — CronDelete the job in a Claude Code session, or ` + + `disable the GitHub Actions workflow.`, ); } else if (frequent) { parts.push( diff --git a/packages/cli/src/board-store.ts b/packages/cli/src/board-store.ts index 6871bbcc..73d4ab48 100644 --- a/packages/cli/src/board-store.ts +++ b/packages/cli/src/board-store.ts @@ -2,10 +2,10 @@ // `/.termchart/boards/.yaml`. Pure fs + YAML; the structural rules live in // @ivanmkc/termchart-core (validateBoardDef). Shared by the `board` command and `run`. -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { validateBoardDef, type BoardDef } from "@ivanmkc/termchart-core"; +import { validateBoardDef, BOARD_ID_RE, type BoardDef } from "@ivanmkc/termchart-core"; export const BOARDS_SUBDIR = join(".termchart", "boards"); @@ -13,7 +13,14 @@ export function boardsDir(root: string): string { return join(root, BOARDS_SUBDIR); } +// The id comes straight from CLI argv and is joined into a path — reject anything that isn't a +// plain slug so `../../x` can never read/delete/execute a YAML outside the boards dir. +function assertValidId(id: string): void { + if (!BOARD_ID_RE.test(id)) throw new Error(`invalid board id "${id}" (must match ${BOARD_ID_RE})`); +} + export function boardPath(root: string, id: string): string { + assertValidId(id); return join(boardsDir(root), `${id}.yaml`); } @@ -29,6 +36,8 @@ export function loadBoard(root: string, id: string): BoardDef { } const err = validateBoardDef(def); if (err) throw new Error(`invalid board ${id}: ${err}`); + if ((def as BoardDef).id !== id) + throw new Error(`invalid board ${id}: file ${id}.yaml declares id "${(def as BoardDef).id}" — the filename stem and id must match (rename one)`); return def as BoardDef; } @@ -51,7 +60,10 @@ export function scanBoards(root: string): { boards: BoardDef[]; issues: BoardIss try { const def = parseYaml(readFileSync(join(dir, name), "utf8")) as unknown; const err = validateBoardDef(def); + const stem = name.replace(/\.ya?ml$/, ""); if (err) issues.push({ file: name, error: err }); + else if ((def as BoardDef).id !== stem) + issues.push({ file: name, error: `declares id "${(def as BoardDef).id}" but the filename stem is "${stem}" — they must match` }); else boards.push(def as BoardDef); } catch (e) { issues.push({ file: name, error: (e as Error).message }); @@ -74,10 +86,14 @@ export function saveBoard(root: string, def: BoardDef): void { const err = validateBoardDef(def); if (err) throw new Error(err); mkdirSync(boardsDir(root), { recursive: true }); - writeFileSync(boardPath(root, def.id), stringifyYaml(def)); + // tmp + rename so a crash mid-write can't truncate the definition + const file = boardPath(root, def.id); + const tmp = `${file}.tmp`; + writeFileSync(tmp, stringifyYaml(def)); + renameSync(tmp, file); } -/** Delete a board file. Returns false if it didn't exist. */ +/** Delete a board file. Returns false if it didn't exist. Throws on a malformed id. */ export function deleteBoard(root: string, id: string): boolean { const file = boardPath(root, id); if (!existsSync(file)) return false; diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts index d22aede8..8395a660 100644 --- a/packages/cli/src/board.ts +++ b/packages/cli/src/board.ts @@ -106,8 +106,12 @@ function create(argv: string[], root: string): number { if (a.host) def.host = a.host as BoardHost; if (a.agentCommand) def.agentCommand = a.agentCommand; + if (a.enabled !== undefined && a.enabled !== "true" && a.enabled !== "false") { + process.stderr.write(`--enabled must be "true" or "false" (got "${a.enabled}")\n`); + return 3; + } // Always write a visible lifecycle block so it can be edited (e.g. to pause) without guessing keys. - const lifecycle: BoardLifecycle = { enabled: a.enabled ? a.enabled !== "false" : true }; + const lifecycle: BoardLifecycle = { enabled: a.enabled !== "false" }; if (a.until) lifecycle.until = a.until; if (a.maxRuns !== undefined) { const n = Number(a.maxRuns); @@ -205,9 +209,14 @@ function del(argv: string[], root: string): number { if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.delete); return 0; } const id = argv.find((x) => !x.startsWith("-")); if (!id) { process.stderr.write("delete needs a board id\n"); return 3; } - if (!deleteBoard(root, id)) { - process.stderr.write(`no such board: ${id} (expected ${root}/.termchart/boards/${id}.yaml)\n`); - return 1; + try { + if (!deleteBoard(root, id)) { + process.stderr.write(`no such board: ${id} (expected ${root}/.termchart/boards/${id}.yaml)\n`); + return 1; + } + } catch (e) { + process.stderr.write(`${(e as Error).message}\n`); + return 3; } process.stdout.write(`deleted board ${id}\n`); return 0; diff --git a/packages/cli/src/scaffold-workflow.ts b/packages/cli/src/scaffold-workflow.ts index 418d625c..c19d05f6 100644 --- a/packages/cli/src/scaffold-workflow.ts +++ b/packages/cli/src/scaffold-workflow.ts @@ -39,7 +39,14 @@ function shiftDow(field: string, delta: number): string | null { for (const tok of field.split(",")) { if (tok === "*") { out.push("*"); continue; } const range = tok.match(/^(\d+)-(\d+)$/); - if (range) { out.push(`${mod7(Number(range[1]) + delta)}-${mod7(Number(range[2]) + delta)}`); continue; } + if (range) { + const s = mod7(Number(range[1]) + delta); + const e = mod7(Number(range[2]) + delta); + // Cron ranges must ascend; a shift across Saturday/Sunday wraps, so split into two tokens. + if (s <= e) out.push(`${s}-${e}`); + else out.push(s === 6 ? "6" : `${s}-6`, e === 0 ? "0" : `0-${e}`); + continue; + } if (/^\d+$/.test(tok)) { out.push(String(mod7(Number(tok) + delta))); continue; } return null; // names (MON) or steps (*/2) — bail, let the caller warn } @@ -72,54 +79,55 @@ function convertForOffset(fields: string[], offsetMin: number): Converted { return { cron: `${norm % 60} ${Math.floor(norm / 60)} ${outDom} ${mon} ${outDow}`, convertible: true, warnings }; } -/** Single-offset conversion at instant `at` — kept as a small, testable helper. */ -export function cronToUtc(schedule: string, tz: string | undefined, at: Date): { cron: string; warnings: string[] } { - if (!tz) return { cron: schedule.trim(), warnings: ["No tz set — GitHub Actions cron is UTC; the schedule is used as-is."] }; - const r = convertForOffset(schedule.trim().split(/\s+/), tzOffsetMinutes(tz, at)); - if (!r.convertible) return { cron: schedule.trim(), warnings: [`Could not convert "${schedule}" from ${tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`] }; - return { cron: r.cron, warnings: r.warnings }; +/** Format an offset in minutes as `date +%z` prints it: -0800, +0530. */ +function offsetStr(min: number): string { + const sign = min < 0 ? "-" : "+"; + const abs = Math.abs(min); + return `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`; } -interface Plan { crons: string[]; guard: boolean; localTime: string | null; warnings: string[] } +interface SeasonalCron { cron: string; offset: string } +interface Plan { entries: SeasonalCron[]; guard: boolean; warnings: string[] } /** Compute the UTC cron(s) + whether a DST guard is needed. */ function planSchedule(def: BoardDef, at: Date): Plan { const fields = def.schedule.trim().split(/\s+/); - if (!def.tz) return { crons: [def.schedule.trim()], guard: false, localTime: null, warnings: ["No tz set — GitHub Actions cron is UTC; the schedule is used as-is."] }; + if (!def.tz) return { entries: [{ cron: def.schedule.trim(), offset: "+0000" }], guard: false, warnings: ["No tz set — GitHub Actions cron is UTC; the schedule is used as-is."] }; const year = at.getUTCFullYear(); const offWinter = tzOffsetMinutes(def.tz, new Date(Date.UTC(year, 0, 15, 12))); const offSummer = tzOffsetMinutes(def.tz, new Date(Date.UTC(year, 6, 15, 12))); const offsets = offWinter === offSummer ? [offWinter] : [offWinter, offSummer]; - const crons: string[] = []; + const entries: SeasonalCron[] = []; const warnings = new Set(); for (const off of offsets) { const r = convertForOffset(fields, off); if (!r.convertible) { warnings.add(`Could not convert "${def.schedule}" from ${def.tz} to UTC (minute/hour not single values); GitHub cron is UTC — adjust manually.`); - return { crons: [def.schedule.trim()], guard: false, localTime: null, warnings: [...warnings] }; + return { entries: [{ cron: def.schedule.trim(), offset: offsetStr(off) }], guard: false, warnings: [...warnings] }; } r.warnings.forEach((w) => warnings.add(w)); - if (!crons.includes(r.cron)) crons.push(r.cron); + if (!entries.some((e) => e.cron === r.cron)) entries.push({ cron: r.cron, offset: offsetStr(off) }); } - // A guard is only meaningful (and possible) when there are two seasonal crons and a fixed local time. - const numeric = /^\d+$/.test(fields[0]) && /^\d+$/.test(fields[1]); - const guard = crons.length > 1 && numeric; - const localTime = numeric ? `${pad(Number(fields[1]))}:${pad(Number(fields[0]))}` : null; - return { crons, guard, localTime, warnings: [...warnings] }; + return { entries, guard: entries.length > 1, warnings: [...warnings] }; } /** Build the workflow YAML for a board. Returns the file text and any scheduling caveats. */ export function buildWorkflowYaml(def: BoardDef, opts: BuildOpts = {}): { yaml: string; warnings: string[] } { const at = opts.now ?? new Date(); - const { crons, guard, localTime, warnings } = planSchedule(def, at); + const { entries, guard, warnings } = planSchedule(def, at); + + if (def.lifecycle?.maxRuns != null) + warnings.push("lifecycle.maxRuns relies on the runs counter persisting in the YAML, which an ephemeral GitHub runner discards — use lifecycle.until (stateless) to bound this board on Actions."); + if (def.host && def.host !== "github-actions") + warnings.push(`this board's host is "${def.host}" — scaffolding a GitHub Actions workflow anyway; update host: github-actions if this is now the real scheduler.`); const agentCommand = def.agentCommand ?? "claude -p"; const isClaude = /^claude(\s|$)/.test(agentCommand); - const scheduleLines = crons - .map((c, i) => ` - cron: "${c}"${i === 0 && def.tz ? ` # ${def.schedule} ${def.tz}` : ""}`) + const scheduleLines = entries + .map((e, i) => ` - cron: "${e.cron}"${i === 0 && def.tz ? ` # ${def.schedule} ${def.tz}` : ""}`) .join("\n"); const installStep = isClaude @@ -128,15 +136,24 @@ export function buildWorkflowYaml(def: BoardDef, opts: BuildOpts = {}): { yaml: : ` # NOTE: ensure your agent command "${agentCommand}" is installed and authenticated on the runner, # e.g. add an install step here and the matching secret(s) to the env block below.`; - const guardPrefix = guard && localTime - ? ` # DST-safe: two seasonal crons fire; proceed only at the intended local time. - if [ "$(TZ='${def.tz}' date +%H:%M)" != "${localTime}" ]; then - echo "Local time $(TZ='${def.tz}' date +%H:%M) != ${localTime}; skipping (off-season cron)."; exit 0 - fi + // DST guard: two seasonal crons both fire year-round; skip ONLY the one that doesn't match the + // zone's current UTC offset. Keyed on which cron fired (github.event.schedule) — immune to GitHub's + // routinely-late cron starts — and fail-open: workflow_dispatch (empty) and anything unexpected runs. + // Every (fired cron, offset) pair where the offset belongs to the OTHER season → skip. + const offSeasonPatterns = entries + .flatMap((e) => entries.filter((o) => o !== e).map((o) => `"${e.cron}|${o.offset}"`)) + .join(" | "); + const guardPrefix = guard + ? ` # DST-safe: skip the off-season duplicate cron; manual dispatch always runs. + OFF="$(TZ='${def.tz}' date +%z)" + case "\${EVENT_SCHEDULE:-}|$OFF" in + ${offSeasonPatterns}) echo "off-season cron ($EVENT_SCHEDULE at UTC offset $OFF) — skipping"; exit 0 ;; + esac ` : ""; const secretLines = [ + guard ? " EVENT_SCHEDULE: ${{ github.event.schedule }}" : null, isClaude ? " ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}" : null, " TERMCHART_VIEWER_URL: ${{ secrets.TERMCHART_VIEWER_URL }}", " TERMCHART_VIEWER_TOKEN: ${{ secrets.TERMCHART_VIEWER_TOKEN }}", diff --git a/packages/cli/test/board.test.ts b/packages/cli/test/board.test.ts index dd59aafb..ab5d9321 100644 --- a/packages/cli/test/board.test.ts +++ b/packages/cli/test/board.test.ts @@ -188,3 +188,27 @@ describe("board help + errors", () => { expect(errs.join("")).toContain("bad"); }); }); + +describe("board-store hardening", () => { + it("rejects a path-traversal id on show/delete/run-style loads (exit 1/3, no fs escape)", async () => { + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + expect(await board(["show", "../../evil"], { cwd })).not.toBe(0); + expect(await board(["delete", "../../evil"], { cwd })).not.toBe(0); + expect(errs.join("")).toMatch(/id/i); + }); + + it("flags a file whose stem does not match its id", async () => { + mkdirSync(boardsDir(), { recursive: true }); + // valid def, wrong filename + writeFileSync(join(boardsDir(), "renamed.yaml"), 'id: original\nschedule: "0 8 * * *"\ntarget:\n project: p\n agent: a\nprompt: go\n'); + const errs: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + const code = await board(["list"], { cwd }); + expect(code).toBe(0); + expect(errs.join("")).toMatch(/renamed/); + // and loading by filename stem also errors + expect(await board(["show", "renamed"], { cwd })).toBe(1); + }); +}); diff --git a/packages/cli/test/scaffold-workflow.test.ts b/packages/cli/test/scaffold-workflow.test.ts index 033044f5..77057329 100644 --- a/packages/cli/test/scaffold-workflow.test.ts +++ b/packages/cli/test/scaffold-workflow.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify as stringifyYaml } from "yaml"; import type { BoardDef } from "@ivanmkc/termchart-core"; -import { buildWorkflowYaml, cronToUtc } from "../src/scaffold-workflow.js"; +import { buildWorkflowYaml } from "../src/scaffold-workflow.js"; import { VERSION } from "../src/version.js"; import { board } from "../src/board.js"; @@ -40,16 +40,28 @@ describe("buildWorkflowYaml — structure & provisioning", () => { expect(yaml).not.toContain("ANTHROPIC_API_KEY"); expect(yaml.toLowerCase()).toContain("agy"); // a note about the custom agent }); + + it("warns when the board relies on maxRuns (not persistable on an ephemeral runner)", () => { + const { warnings } = buildWorkflowYaml({ ...base, lifecycle: { maxRuns: 5 } }, { now: NOW }); + expect(warnings.join(" ")).toMatch(/maxRuns/); + expect(warnings.join(" ")).toMatch(/until/); + }); + + it("warns when the board's host is not github-actions", () => { + const { warnings } = buildWorkflowYaml({ ...base, host: "claude-session" }, { now: NOW }); + expect(warnings.join(" ")).toMatch(/claude-session/); + }); }); describe("buildWorkflowYaml — DST-robust scheduling", () => { - it("emits TWO seasonal UTC crons + a local-time guard for a DST timezone", () => { - // 08:00 LA → 16:00 UTC in winter (PST -8), 15:00 UTC in summer (PDT -7) + it("emits TWO seasonal UTC crons + an offset-based guard for a DST timezone", () => { + // 08:00 LA → 16:00 UTC in winter (PST -0800), 15:00 UTC in summer (PDT -0700) const { yaml } = buildWorkflowYaml(base, { now: NOW }); expect(yaml).toContain('cron: "0 16 * * 1-5"'); expect(yaml).toContain('cron: "0 15 * * 1-5"'); - expect(yaml).toContain("TZ='America/Los_Angeles'"); // guard gates on local wall-clock - expect(yaml).toContain("08:00"); // intended local time + expect(yaml).toContain("github.event.schedule"); // guard keys on WHICH cron fired… + expect(yaml).toContain("date +%z"); // …vs the zone's CURRENT UTC offset (delay-immune) + expect(yaml).not.toContain("date +%H:%M"); // NOT exact wall-clock equality (GH starts late) }); it("emits a SINGLE cron and no guard for a non-DST timezone", () => { @@ -57,7 +69,7 @@ describe("buildWorkflowYaml — DST-robust scheduling", () => { const { yaml } = buildWorkflowYaml({ ...base, tz: "Asia/Kolkata", schedule: "30 9 * * *" }, { now: NOW }); expect(yaml).toContain('cron: "0 4 * * *"'); expect(yaml).not.toContain('cron: "0 5'); // only one schedule entry - expect(yaml).not.toContain("date +%H:%M"); // no guard needed + expect(yaml).not.toContain("EVENT_SCHEDULE"); // no guard needed }); it("passes the schedule through and warns when no tz is set", () => { @@ -74,17 +86,57 @@ describe("buildWorkflowYaml — DST-robust scheduling", () => { expect(warnings.join(" ").toLowerCase()).not.toContain("day-of-week"); // it was fixed, not warned }); + it("splits a day-of-week range that wraps past Saturday (never emits an invalid 5-0 range)", () => { + // 20:00 LA Thu-Sat → next-day UTC Fri-Sun; 4-6 + 1 wraps → must be "5-6,0", not "5-0" + const { yaml } = buildWorkflowYaml({ ...base, schedule: "0 20 * * 4-6" }, { now: NOW }); + expect(yaml).toContain("5-6,0"); + expect(yaml).not.toMatch(/\d-0[ "]/); // no descending range anywhere + }); + + it("wraps a single Saturday day-of-week to Sunday", () => { + const { yaml } = buildWorkflowYaml({ ...base, schedule: "0 20 * * 6" }, { now: NOW }); + expect(yaml).toMatch(/cron: "0 [34] \* \* 0"/); + }); + it("warns (and does not shift) when a restricted day-of-MONTH crosses midnight", () => { const { warnings } = buildWorkflowYaml({ ...base, schedule: "0 20 1 * *" }, { now: NOW }); expect(warnings.join(" ").toLowerCase()).toMatch(/day-of-month/); }); }); -describe("cronToUtc (single offset helper)", () => { - it("returns the input unchanged with a warning when no tz", () => { - const r = cronToUtc("0 8 * * 1-5", undefined, NOW); - expect(r.cron).toBe("0 8 * * 1-5"); - expect(r.warnings.length).toBeGreaterThan(0); +describe("buildWorkflowYaml — guard behavior (executes the generated shell)", () => { + // Extract the "Refresh board" run script from the YAML, stub the npx line, and actually run it. + function extractScript(yaml: string): string { + const m = yaml.match(/- name: Refresh board\n\s+run: \|\n([\s\S]*?)\n\s+env:/); + if (!m) throw new Error("run block not found"); + return m[1].split("\n").map((l) => l.replace(/^ {10}/, "")).join("\n") + .replace(/^\s*npx .*$/m, "echo RAN"); + } + // The guard uses the REAL current offset of the tz, so pick season-matching crons dynamically. + function currentOffsetStr(tz: string): string { + const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" }); + const part = dtf.formatToParts(new Date()).find((p) => p.type === "timeZoneName")!.value; // GMT-07:00 + const m = part.match(/GMT([+-])(\d{2}):(\d{2})/); + return m ? `${m[1]}${m[2]}${m[3]}` : "+0000"; + } + const runScript = (script: string, eventSchedule: string): string => { + const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); + return execFileSync("bash", ["-c", script], { + encoding: "utf8", + env: { ...process.env, EVENT_SCHEDULE: eventSchedule }, + }); + }; + + it("runs for the in-season cron even when started late, skips the off-season cron, and always runs on dispatch", () => { + const { yaml } = buildWorkflowYaml(base, { now: NOW }); + const script = extractScript(yaml); + const winter = "0 16 * * 1-5"; // PST -0800 + const summer = "0 15 * * 1-5"; // PDT -0700 + const inSeason = currentOffsetStr("America/Los_Angeles") === "-0800" ? winter : summer; + const offSeason = inSeason === winter ? summer : winter; + expect(runScript(script, inSeason)).toContain("RAN"); // delayed start is fine — no wall-clock check + expect(runScript(script, offSeason)).not.toContain("RAN"); // off-season duplicate skipped + expect(runScript(script, "")).toContain("RAN"); // workflow_dispatch always refreshes }); }); diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts index 81cdc011..3206e146 100644 --- a/packages/core/src/board.ts +++ b/packages/core/src/board.ts @@ -31,7 +31,34 @@ export interface BoardDef { sessionCronId?: string; // written back when registered with a Claude Code session cron } -const ID_RE = /^[a-z0-9][a-z0-9-]*$/; +export const BOARD_ID_RE = /^[a-z0-9][a-z0-9-]*$/; +const ID_RE = BOARD_ID_RE; + +// Numeric bounds per cron field (dow allows 0-7: both 0 and 7 mean Sunday). +const CRON_FIELDS: readonly { name: string; min: number; max: number }[] = [ + { name: "minute", min: 0, max: 59 }, + { name: "hour", min: 0, max: 23 }, + { name: "day-of-month", min: 1, max: 31 }, + { name: "month", min: 1, max: 12 }, + { name: "day-of-week", min: 0, max: 7 }, +]; + +/** Validate one cron field: comma list of `*` | N | N-M, optionally with a /step. Numeric only. */ +function cronFieldError(value: string, spec: { name: string; min: number; max: number }): string | null { + for (const token of value.split(",")) { + const m = token.match(/^(\*|\d+|\d+-\d+)(?:\/(\d+))?$/); + if (!m) return `schedule ${spec.name} field has an unsupported token "${token}" (numeric fields only: * N N-M, optional /step)`; + if (m[2] !== undefined && Number(m[2]) < 1) return `schedule ${spec.name} field has a zero step in "${token}"`; + if (m[1] !== "*") { + const nums = m[1].split("-").map(Number); + for (const n of nums) { + if (n < spec.min || n > spec.max) + return `schedule ${spec.name} field value ${n} is out of range (${spec.min}-${spec.max})`; + } + } + } + return null; +} function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -59,6 +86,11 @@ export function validateBoardDef(obj: unknown): string | null { if (typeof obj.schedule !== "string" || obj.schedule.trim().split(/\s+/).length !== 5) return `schedule must be a 5-field cron string (got ${JSON.stringify(obj.schedule)})`; + const cronParts = obj.schedule.trim().split(/\s+/); + for (let i = 0; i < 5; i++) { + const e = cronFieldError(cronParts[i], CRON_FIELDS[i]); + if (e) return e; + } if (obj.tz !== undefined) { if (typeof obj.tz !== "string" || !validTimeZone(obj.tz)) @@ -74,8 +106,13 @@ export function validateBoardDef(obj: unknown): string | null { if (typeof obj.prompt !== "string" || obj.prompt.trim().length === 0) return "prompt must be a non-empty string"; - if (obj.agentCommand !== undefined && (typeof obj.agentCommand !== "string" || obj.agentCommand.trim().length === 0)) - return "agentCommand, when set, must be a non-empty string"; + if (obj.agentCommand !== undefined) { + if (typeof obj.agentCommand !== "string" || obj.agentCommand.trim().length === 0) + return "agentCommand, when set, must be a non-empty string"; + // The runner splits agentCommand on whitespace (no shell), so quotes would be passed literally. + if (/["']/.test(obj.agentCommand)) + return "agentCommand must not contain quotes — it is split on whitespace without a shell, so quoted arguments are not supported (wrap complex commands in a script)"; + } if (obj.host !== undefined && !BOARD_HOSTS.includes(obj.host as BoardHost)) return `host must be one of ${BOARD_HOSTS.join(", ")} (got ${JSON.stringify(obj.host)})`; diff --git a/packages/core/test/validate-board.test.ts b/packages/core/test/validate-board.test.ts index 5af2f19d..664bd9e2 100644 --- a/packages/core/test/validate-board.test.ts +++ b/packages/core/test/validate-board.test.ts @@ -41,6 +41,26 @@ describe("validateBoardDef", () => { expect(validateBoardDef({ ...valid(), schedule: "" })).toMatch(/schedule|cron/i); }); + it("validates cron field values (charset, bounds, steps)", () => { + // valid shapes pass + for (const ok of ["*/2 * * * *", "0 8 1 12 0-7", "5,35 9-17 * * 1-5", "0 8 */2 * *"]) + expect(validateBoardDef({ ...valid(), schedule: ok })).toBeNull(); + // out-of-range + garbage rejected, pointing at the field + expect(validateBoardDef({ ...valid(), schedule: "60 8 * * *" })).toMatch(/minute/i); + expect(validateBoardDef({ ...valid(), schedule: "0 25 * * *" })).toMatch(/hour/i); + expect(validateBoardDef({ ...valid(), schedule: "0 8 32 * *" })).toMatch(/day/i); + expect(validateBoardDef({ ...valid(), schedule: "0 8 * 13 *" })).toMatch(/month/i); + expect(validateBoardDef({ ...valid(), schedule: "0 8 * * 8" })).toMatch(/week/i); + expect(validateBoardDef({ ...valid(), schedule: '0 8 * * "' })).toMatch(/schedule|cron|week/i); + expect(validateBoardDef({ ...valid(), schedule: "0 8 * * MON" })).toMatch(/numeric|week/i); + expect(validateBoardDef({ ...valid(), schedule: "*/0 * * * *" })).toMatch(/step|minute/i); + }); + + it("rejects an agentCommand containing quotes (whitespace-split contract)", () => { + expect(validateBoardDef({ ...valid(), agentCommand: 'claude -p --flag "two words"' })).toMatch(/quote/i); + expect(validateBoardDef({ ...valid(), agentCommand: "sh -c 'echo hi'" })).toMatch(/quote/i); + }); + it("rejects an unknown timezone but accepts a valid IANA one", () => { expect(validateBoardDef({ ...valid(), tz: "Mars/Phobos" })).toMatch(/tz|timezone/i); expect(validateBoardDef({ ...valid(), tz: "UTC" })).toBeNull(); diff --git a/plugin/commands/schedule-board.md b/plugin/commands/schedule-board.md index 3a79bc64..e47cc2b0 100644 --- a/plugin/commands/schedule-board.md +++ b/plugin/commands/schedule-board.md @@ -41,7 +41,9 @@ Do this: 4. **Schedule it for the chosen host:** - **`claude-session`:** get the prompt with `termchart board prompt `, then register a **`CronCreate`** with that prompt and the board's cron. It fires in-session; the agent gathers - + pushes. (Ephemeral — dies with the session; ~7-day cap.) + + pushes. (Ephemeral — dies with the session; ~7-day cap.) Record the returned cron job id in + the board file (`sessionCronId: `) so a later session can update or `CronDelete` it without + hunting. - **`github-actions`:** `termchart board scaffold-workflow ` (turnkey: it installs the agent CLI, sets `permissions:`/`GH_TOKEN`, pins the termchart version, and handles DST via two seasonal crons + a local-time guard). Then tell the human to set repo secrets `ANTHROPIC_API_KEY`, diff --git a/plugin/skills/scheduled-boards/SKILL.md b/plugin/skills/scheduled-boards/SKILL.md index ea2bb66f..d7f5dc2d 100644 --- a/plugin/skills/scheduled-boards/SKILL.md +++ b/plugin/skills/scheduled-boards/SKILL.md @@ -43,7 +43,9 @@ target: prompt: | # the saved intent handed to the agent each run Gather my open tasks, today's calendar, and assigned PRs. Build a component board grouped by priority. -agentCommand: "claude -p" # optional; agent-agnostic (claude -p | agy --print | …) +agentCommand: "claude -p" # optional; agent-agnostic (claude -p | agy --print | …). + # Split on whitespace, no shell — quotes are rejected; wrap + # anything complex in a script and point agentCommand at it. host: claude-session # claude-session | github-actions | cron lifecycle: enabled: true # set false to pause From 1d4c7d17809a6821b15d8dbfa0a6be064ecbf0a3 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 23:03:12 +0000 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20verify=20skill=20=E2=80=94=20runtim?= =?UTF-8?q?e-verification=20recipe=20for=20CLI=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/verify/SKILL.md | 64 ++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .claude/skills/verify/SKILL.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 00000000..9d5c8cbb --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,64 @@ +--- +name: verify +description: How to runtime-verify termchart CLI changes (esp. scheduled boards) — build, drive the real binary against the live viewer, execute generated workflow guards. Use when verifying a termchart diff at its surface instead of re-running CI. +--- + +# Verifying termchart CLI changes at the surface + +## Handle + +```bash +cd packages/cli && npm run build # tsc + esbuild → dist/cli.js (single bundle) +node packages/cli/dist/cli.js --version # sanity: version matches src/version.ts +``` + +Drive `node /packages/cli/dist/cli.js` as `termchart` — never `import ./src/...`. +The **built bundle** is the surface: esbuild CJS→ESM shims have broken it before while +unit tests stayed green (yaml dep dynamic-require). + +## Live viewer + +`TERMCHART_VIEWER_URL`/`TERMCHART_VIEWER_TOKEN` live in `~/.profile`. The Bash tool shell +is initialized from the profile, so they are often ALREADY SET — to test the "no viewer" +path use `env -u TERMCHART_VIEWER_URL -u TERMCHART_VIEWER_TOKEN …`, don't assume unset. +Verify pushes landed with `termchart list --project

` and round-trip with `pull`. +Always `termchart clear --project

--agent ` when done; use a unique throwaway scope. + +## Scheduled boards flow + +Work in `mktemp -d` (definitions are cwd-relative: `.termchart/boards/.yaml`). + +```bash +termchart board create --id x --schedule "0 8 * * 1-5" --project

--agent \ + --prompt "…" [--tz Zone] [--max-runs N] [--agent-command ] +termchart run x # spawns agentCommand, prompt on stdin +``` + +- For a deterministic run→push→viewer proof, point `--agent-command` at a stub script that + `cat`s stdin then calls the real binary's `push`. The default `claude -p` can hang for + minutes silently (no run timeout) — don't use it in probes. +- lifecycle checks: `--max-runs 2` → run 3× (third skips, `runs:` persisted in YAML); + `--until ` and `--enabled false` → skip lines, exit 0. + +## Generated GH workflow + +`termchart board scaffold-workflow ` → `.github/workflows/termchart-.yml`. +Execute the DST guard locally (don't just read it): + +```bash +sed -n '/run: |/,/env:/p' .github/workflows/termchart-.yml \ + | sed '1d;$d;s/^ //' | sed 's/^npx .*/echo RAN/' > guard.sh +EVENT_SCHEDULE="" bash guard.sh # → RAN +EVENT_SCHEDULE="" bash guard.sh # → skip line +EVENT_SCHEDULE="" bash guard.sh # dispatch → RAN +``` + +In-season = the cron whose offset matches `TZ= date +%z` today. + +## Gotchas + +- System awk is mawk: `{10}` regex intervals silently don't match — use sed. +- Piping the CLI into `head` can produce bogus exit codes (SIGPIPE) — redirect to a file. +- The full vitest suite is load-flaky (viewer-stub `fetch failed`) under a parallel + process; that's contention, not a regression — but /verify shouldn't run tests anyway. +- `board create --force` REPLACES the definition; fields not re-passed (e.g. --tz) are lost. From 2dc6469a4bfd6bd1194ca5621fcdbe6130df3740 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 23:09:08 +0000 Subject: [PATCH 6/9] =?UTF-8?q?refactor(scheduled-boards):=20coding-standa?= =?UTF-8?q?rds=20audit=20=E2=80=94=20single-source,=20predicate=20narrowin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied the transferable /dev-coding-standards rules to the branch: - Single source of truth: "claude -p" default was duplicated in run.ts, board.ts (show), and scaffold-workflow.ts — now one DEFAULT_AGENT_COMMAND exported from core alongside the BoardDef domain. - Correct-by-construction: added isBoardDef() type predicate in core; the six `as BoardDef` casts in board-store.ts are gone — narrowing is structural. - Version dual-source (version.ts vs package.json is forced by the standalone bundle): added a tripwire test asserting they match, since scaffolded workflows pin @ivanmkc/termchart@VERSION. - Dead code: removed unused BoardDeps.env field and the unconsumed BOARDS_SUBDIR export. - No function-scoped imports: hoisted a require() in scaffold-workflow.test.ts to a top-level import. Behavior unchanged: core 14, cli 235 green (viewer-stub files re-run serially under load-114 box contention); built binary spot-checked. --- packages/cli/src/board-store.ts | 22 ++++++++++----------- packages/cli/src/board.ts | 5 ++--- packages/cli/src/cli.ts | 2 +- packages/cli/src/run.ts | 2 +- packages/cli/src/scaffold-workflow.ts | 4 ++-- packages/cli/test/scaffold-workflow.test.ts | 7 +++---- packages/cli/test/version.test.ts | 14 +++++++++++++ packages/core/src/board.ts | 8 ++++++++ packages/core/test/validate-board.test.ts | 8 +++++++- 9 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 packages/cli/test/version.test.ts diff --git a/packages/cli/src/board-store.ts b/packages/cli/src/board-store.ts index 73d4ab48..d6a86343 100644 --- a/packages/cli/src/board-store.ts +++ b/packages/cli/src/board-store.ts @@ -5,9 +5,9 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { validateBoardDef, BOARD_ID_RE, type BoardDef } from "@ivanmkc/termchart-core"; +import { validateBoardDef, isBoardDef, BOARD_ID_RE, type BoardDef } from "@ivanmkc/termchart-core"; -export const BOARDS_SUBDIR = join(".termchart", "boards"); +const BOARDS_SUBDIR = join(".termchart", "boards"); export function boardsDir(root: string): string { return join(root, BOARDS_SUBDIR); @@ -34,11 +34,10 @@ export function loadBoard(root: string, id: string): BoardDef { } catch (e) { throw new Error(`invalid board ${id}: could not parse ${file} — ${(e as Error).message}`); } - const err = validateBoardDef(def); - if (err) throw new Error(`invalid board ${id}: ${err}`); - if ((def as BoardDef).id !== id) - throw new Error(`invalid board ${id}: file ${id}.yaml declares id "${(def as BoardDef).id}" — the filename stem and id must match (rename one)`); - return def as BoardDef; + if (!isBoardDef(def)) throw new Error(`invalid board ${id}: ${validateBoardDef(def)}`); + if (def.id !== id) + throw new Error(`invalid board ${id}: file ${id}.yaml declares id "${def.id}" — the filename stem and id must match (rename one)`); + return def; } export interface BoardIssue { @@ -59,12 +58,11 @@ export function scanBoards(root: string): { boards: BoardDef[]; issues: BoardIss if (!name.endsWith(".yaml") && !name.endsWith(".yml")) continue; try { const def = parseYaml(readFileSync(join(dir, name), "utf8")) as unknown; - const err = validateBoardDef(def); const stem = name.replace(/\.ya?ml$/, ""); - if (err) issues.push({ file: name, error: err }); - else if ((def as BoardDef).id !== stem) - issues.push({ file: name, error: `declares id "${(def as BoardDef).id}" but the filename stem is "${stem}" — they must match` }); - else boards.push(def as BoardDef); + if (!isBoardDef(def)) issues.push({ file: name, error: validateBoardDef(def) ?? "invalid" }); + else if (def.id !== stem) + issues.push({ file: name, error: `declares id "${def.id}" but the filename stem is "${stem}" — they must match` }); + else boards.push(def); } catch (e) { issues.push({ file: name, error: (e as Error).message }); } diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts index 8395a660..8c1aa407 100644 --- a/packages/cli/src/board.ts +++ b/packages/cli/src/board.ts @@ -2,14 +2,13 @@ // definitions stored as committed `.termchart/boards/.yaml` files. Definitions are portable: a // GitHub Actions runner gets them on checkout, and `termchart run ` reads them anywhere. -import { validateBoardDef, type BoardDef, type BoardHost, type BoardLifecycle } from "@ivanmkc/termchart-core"; +import { validateBoardDef, DEFAULT_AGENT_COMMAND, type BoardDef, type BoardHost, type BoardLifecycle } from "@ivanmkc/termchart-core"; import { boardExists, deleteBoard, loadBoard, saveBoard, scanBoards } from "./board-store.js"; import { assembleBoardPrompt } from "./board-prompt.js"; import { writeWorkflow } from "./scaffold-workflow.js"; export interface BoardDeps { cwd?: string; - env?: Record; } const SUBCOMMANDS = ["create", "list", "show", "delete", "prompt", "scaffold-workflow"]; @@ -166,7 +165,7 @@ function show(argv: string[], root: string): number { process.stdout.write(` schedule: ${def.schedule}${def.tz ? ` (${def.tz})` : ""}\n`); process.stdout.write(` target: ${def.target.project}/${def.target.agent}\n`); process.stdout.write(` host: ${def.host ?? "claude-session"}\n`); - process.stdout.write(` agentCommand: ${def.agentCommand ?? "claude -p"}\n`); + process.stdout.write(` agentCommand: ${def.agentCommand ?? DEFAULT_AGENT_COMMAND}\n`); if (def.lifecycle) process.stdout.write(` lifecycle: ${JSON.stringify(def.lifecycle)}\n`); process.stdout.write(` prompt: ${def.prompt}\n`); return 0; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 58f49c81..d3873b75 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -386,7 +386,7 @@ if (isEntryPoint()) { .catch((e) => { process.stderr.write(`template failed: ${e?.message ?? e}\n`); process.exit(1); }); } else if (argv[0] === "board") { import("./board.js") - .then(({ board }) => board(argv.slice(1), { env: process.env }).then((code) => process.exit(code))) + .then(({ board }) => board(argv.slice(1), {}).then((code) => process.exit(code))) .catch((e) => { process.stderr.write(`board failed: ${e?.message ?? e}\n`); process.exit(1); }); } else if (argv[0] === "run") { import("./run.js") diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index ac7e0016..22537e84 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -4,6 +4,7 @@ // session cron, a GitHub Actions workflow, a system crontab — ultimately calls this same command. import { spawn as nodeSpawn } from "node:child_process"; +import { DEFAULT_AGENT_COMMAND } from "@ivanmkc/termchart-core"; import { loadBoard, saveBoard } from "./board-store.js"; import { assembleBoardPrompt } from "./board-prompt.js"; import { EXIT_NO_VIEWER, missingConfigMessage } from "./viewer-detect.js"; @@ -23,7 +24,6 @@ export interface RunDeps { now?: Date; // injectable clock for lifecycle.until (tests) } -const DEFAULT_AGENT_COMMAND = "claude -p"; const USAGE = "usage: termchart run \n Refresh a scheduled board now: assemble its prompt, run its agent, which pushes the board.\n"; // Deliver the prompt on stdin (never as an argv string) to dodge E2BIG and shell-quoting hazards; diff --git a/packages/cli/src/scaffold-workflow.ts b/packages/cli/src/scaffold-workflow.ts index c19d05f6..246cab55 100644 --- a/packages/cli/src/scaffold-workflow.ts +++ b/packages/cli/src/scaffold-workflow.ts @@ -10,7 +10,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { BoardDef } from "@ivanmkc/termchart-core"; +import { DEFAULT_AGENT_COMMAND, type BoardDef } from "@ivanmkc/termchart-core"; import { VERSION } from "./version.js"; export interface BuildOpts { @@ -123,7 +123,7 @@ export function buildWorkflowYaml(def: BoardDef, opts: BuildOpts = {}): { yaml: if (def.host && def.host !== "github-actions") warnings.push(`this board's host is "${def.host}" — scaffolding a GitHub Actions workflow anyway; update host: github-actions if this is now the real scheduler.`); - const agentCommand = def.agentCommand ?? "claude -p"; + const agentCommand = def.agentCommand ?? DEFAULT_AGENT_COMMAND; const isClaude = /^claude(\s|$)/.test(agentCommand); const scheduleLines = entries diff --git a/packages/cli/test/scaffold-workflow.test.ts b/packages/cli/test/scaffold-workflow.test.ts index 77057329..dd9b70e7 100644 --- a/packages/cli/test/scaffold-workflow.test.ts +++ b/packages/cli/test/scaffold-workflow.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -119,13 +120,11 @@ describe("buildWorkflowYaml — guard behavior (executes the generated shell)", const m = part.match(/GMT([+-])(\d{2}):(\d{2})/); return m ? `${m[1]}${m[2]}${m[3]}` : "+0000"; } - const runScript = (script: string, eventSchedule: string): string => { - const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); - return execFileSync("bash", ["-c", script], { + const runScript = (script: string, eventSchedule: string): string => + execFileSync("bash", ["-c", script], { encoding: "utf8", env: { ...process.env, EVENT_SCHEDULE: eventSchedule }, }); - }; it("runs for the in-season cron even when started late, skips the off-season cron, and always runs on dispatch", () => { const { yaml } = buildWorkflowYaml(base, { now: NOW }); diff --git a/packages/cli/test/version.test.ts b/packages/cli/test/version.test.ts new file mode 100644 index 00000000..c3ea99cf --- /dev/null +++ b/packages/cli/test/version.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { VERSION } from "../src/version.js"; + +// The version lives in two places by necessity (package.json for npm, version.ts for the bundle, +// which can't read package.json at runtime once published standalone). This tripwire keeps the +// copies from drifting — scaffolded workflows pin `@ivanmkc/termchart@${VERSION}`, so a stale +// constant would generate workflows pointing at the wrong (or a missing) release. +describe("version single-source tripwire", () => { + it("src/version.ts matches package.json", () => { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }; + expect(VERSION).toBe(pkg.version); + }); +}); diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts index 3206e146..fe94dae9 100644 --- a/packages/core/src/board.ts +++ b/packages/core/src/board.ts @@ -6,6 +6,9 @@ export type BoardHost = "claude-session" | "github-actions" | "cron"; export const BOARD_HOSTS: readonly BoardHost[] = ["claude-session", "github-actions", "cron"]; +/** The agent invoked by `termchart run` when a board doesn't set agentCommand. */ +export const DEFAULT_AGENT_COMMAND = "claude -p"; + export interface BoardTarget { project: string; agent: string; @@ -138,3 +141,8 @@ export function validateBoardDef(obj: unknown): string | null { return null; } + +/** Type predicate: narrows to BoardDef when the definition validates cleanly. */ +export function isBoardDef(obj: unknown): obj is BoardDef { + return validateBoardDef(obj) === null; +} diff --git a/packages/core/test/validate-board.test.ts b/packages/core/test/validate-board.test.ts index 664bd9e2..eef0ad40 100644 --- a/packages/core/test/validate-board.test.ts +++ b/packages/core/test/validate-board.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateBoardDef } from "../src/index.js"; +import { isBoardDef, validateBoardDef } from "../src/index.js"; // A minimal, fully-valid board definition. Each test mutates a clone to isolate one rule. const valid = () => ({ @@ -56,6 +56,12 @@ describe("validateBoardDef", () => { expect(validateBoardDef({ ...valid(), schedule: "*/0 * * * *" })).toMatch(/step|minute/i); }); + it("isBoardDef narrows exactly when validation passes", () => { + expect(isBoardDef(valid())).toBe(true); + expect(isBoardDef({ ...valid(), schedule: "bad" })).toBe(false); + expect(isBoardDef(null)).toBe(false); + }); + it("rejects an agentCommand containing quotes (whitespace-split contract)", () => { expect(validateBoardDef({ ...valid(), agentCommand: 'claude -p --flag "two words"' })).toMatch(/quote/i); expect(validateBoardDef({ ...valid(), agentCommand: "sh -c 'echo hi'" })).toMatch(/quote/i); From 7c52bf8d32aad8e0d5636fd1bb452c9f57f82467 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 23:21:50 +0000 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20verify=20skill=20=E2=80=94=20defaul?= =?UTF-8?q?t-agent=20resolution=20probe=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/verify/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 9d5c8cbb..275191fb 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -37,6 +37,11 @@ termchart run x # spawns agentCommand, prompt on stdin - For a deterministic run→push→viewer proof, point `--agent-command` at a stub script that `cat`s stdin then calls the real binary's `push`. The default `claude -p` can hang for minutes silently (no run timeout) — don't use it in probes. +- To exercise DEFAULT agent resolution safely (without a hanging real `claude`), run with a + PATH that has node but not claude: `mkdir nodeonly && ln -s "$(which node)" nodeonly/node; + PATH="$PWD/nodeonly:/usr/bin:/bin" node $BIN run ` → expect `cannot launch agent + "claude"` + exit 127. (Plain `PATH=/usr/bin:/bin` loses node itself — fnm installs it + elsewhere.) - lifecycle checks: `--max-runs 2` → run 3× (third skips, `runs:` persisted in YAML); `--until ` and `--enabled false` → skip lines, exit 0. From 8ec5a5448c67f7da609411bc5165c89bf439d6c2 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Fri, 3 Jul 2026 23:24:25 +0000 Subject: [PATCH 8/9] =?UTF-8?q?refactor(scheduled-boards):=20standards=20p?= =?UTF-8?q?ass=202=20=E2=80=94=20remove=20type=20lie=20in=20--max-runs=20p?= =?UTF-8?q?arsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep second audit against /dev-coding-standards. One real violation found: create() smuggled a non-numeric --max-runs value through `as unknown as number` so the core validator would report it — a deliberate type-system lie (hidden from grep by its own trailing comment). Now validated at the flag edge with an error that names --max-runs (test added, RED→GREEN). Also: JSDoc on BOARD_ID_RE. Everything else audited clean: catch-(e as Error) is idiomatic TS, cron "*" literals are domain vocabulary, host comparison is compile-checked via the BoardHost literal type, planSchedule's early returns are graceful degradation (one strategy), spawn injection is DI not patching. --- packages/cli/src/board.ts | 6 +++++- packages/cli/test/board.test.ts | 7 +++++++ packages/core/src/board.ts | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts index 8c1aa407..2829eda7 100644 --- a/packages/cli/src/board.ts +++ b/packages/cli/src/board.ts @@ -114,7 +114,11 @@ function create(argv: string[], root: string): number { if (a.until) lifecycle.until = a.until; if (a.maxRuns !== undefined) { const n = Number(a.maxRuns); - lifecycle.maxRuns = Number.isInteger(n) ? n : (a.maxRuns as unknown as number); // let validation report a bad value + if (!Number.isInteger(n) || n < 1) { + process.stderr.write(`--max-runs must be a positive integer (got "${a.maxRuns}")\n`); + return 3; + } + lifecycle.maxRuns = n; } def.lifecycle = lifecycle; diff --git a/packages/cli/test/board.test.ts b/packages/cli/test/board.test.ts index ab5d9321..e3a41c56 100644 --- a/packages/cli/test/board.test.ts +++ b/packages/cli/test/board.test.ts @@ -126,6 +126,13 @@ describe("board create — lifecycle flags", () => { expect(def.lifecycle).toMatchObject({ enabled: false, until: "2026-12-31T00:00:00Z", maxRuns: 5 }); }); + it("rejects a non-integer --max-runs at the flag, naming it", async () => { + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + expect(await board([...baseArgs, "--max-runs", "abc"], { cwd })).toBe(3); + expect(errs.join("")).toContain("--max-runs"); + }); + it("reports the offending FLAG (not its value) for an unknown flag", async () => { const errs: string[] = []; vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts index fe94dae9..e50b4145 100644 --- a/packages/core/src/board.ts +++ b/packages/core/src/board.ts @@ -34,6 +34,7 @@ export interface BoardDef { sessionCronId?: string; // written back when registered with a Claude Code session cron } +/** Board id shape: a lowercase slug, safe to join into filesystem paths. */ export const BOARD_ID_RE = /^[a-z0-9][a-z0-9-]*$/; const ID_RE = BOARD_ID_RE; From 0ae78412783b9fb21ec65b8e01d6da59dd8cafe3 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Sat, 4 Jul 2026 01:54:03 +0000 Subject: [PATCH 9/9] fix(scheduled-boards): run --timeout (kill hung agents) + --force dropped-field warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two findings left open by runtime verification: - `termchart run` now kills a hung agent: --timeout (default 600), SIGTERM then SIGKILL after 5s, exit 124 (GNU timeout convention). A stuck default `claude -p` previously wedged a session-cron/local-cron scheduler indefinitely (only GH Actions had a timeout-minutes backstop). Verified against the built bundle: `sleep 60` agent killed at 1s → exit 124. - `board create --force` REPLACES the definition; it now warns on stderr with the exact previously-set fields that were dropped because they weren't re-passed (description, tz, host, agentCommand, sessionCronId, lifecycle.until/maxRuns). This bit live testing when a --force re-create silently lost --tz. Docs updated (skill CLI reference, top-level usage, verify-skill gotchas). Tests: run 18, board 24, bundle incl. real-process timeout kill — green. --- .claude/skills/verify/SKILL.md | 8 +++-- packages/cli/src/board.ts | 29 +++++++++++++-- packages/cli/src/cli.ts | 2 +- packages/cli/src/run.ts | 48 ++++++++++++++++++++----- packages/cli/test/board-bundle.test.ts | 12 +++++++ packages/cli/test/board.test.ts | 19 ++++++++++ packages/cli/test/run.test.ts | 30 ++++++++++++++-- plugin/skills/scheduled-boards/SKILL.md | 3 +- 8 files changed, 132 insertions(+), 19 deletions(-) diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 275191fb..9d58708c 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -35,8 +35,9 @@ termchart run x # spawns agentCommand, prompt on stdin ``` - For a deterministic run→push→viewer proof, point `--agent-command` at a stub script that - `cat`s stdin then calls the real binary's `push`. The default `claude -p` can hang for - minutes silently (no run timeout) — don't use it in probes. + `cat`s stdin then calls the real binary's `push`. Don't use the real `claude -p` in probes — + it can sit silently until `run`'s timeout (default 600s; override with `--timeout `, + hung agents are killed → exit 124). - To exercise DEFAULT agent resolution safely (without a hanging real `claude`), run with a PATH that has node but not claude: `mkdir nodeonly && ln -s "$(which node)" nodeonly/node; PATH="$PWD/nodeonly:/usr/bin:/bin" node $BIN run ` → expect `cannot launch agent @@ -66,4 +67,5 @@ In-season = the cron whose offset matches `TZ= date +%z` today. - Piping the CLI into `head` can produce bogus exit codes (SIGPIPE) — redirect to a file. - The full vitest suite is load-flaky (viewer-stub `fetch failed`) under a parallel process; that's contention, not a regression — but /verify shouldn't run tests anyway. -- `board create --force` REPLACES the definition; fields not re-passed (e.g. --tz) are lost. +- `board create --force` REPLACES the definition; fields not re-passed (e.g. --tz) are lost — + a stderr warning now lists exactly which were dropped. diff --git a/packages/cli/src/board.ts b/packages/cli/src/board.ts index 2829eda7..680bc915 100644 --- a/packages/cli/src/board.ts +++ b/packages/cli/src/board.ts @@ -124,15 +124,38 @@ function create(argv: string[], root: string): number { const err = validateBoardDef(def); if (err) { process.stderr.write(`invalid board: ${err}\n`); return 3; } - if (boardExists(root, a.id) && !a.force) { - process.stderr.write(`board "${a.id}" already exists — pass --force to overwrite\n`); - return 3; + if (boardExists(root, a.id)) { + if (!a.force) { + process.stderr.write(`board "${a.id}" already exists — pass --force to overwrite\n`); + return 3; + } + warnDroppedFields(root, a.id, def); } saveBoard(root, def); process.stdout.write(`created board ${a.id} — verify with: termchart run ${a.id}, then schedule via /termchart:schedule-board\n`); return 0; } +// --force REPLACES the definition, so anything set before but not re-passed silently vanishes +// (tz was lost this way in live testing). Compare against the old def and say what was dropped. +function warnDroppedFields(root: string, id: string, next: BoardDef): void { + let prev: BoardDef; + try { + prev = loadBoard(root, id); + } catch { + return; // old file unreadable/invalid — nothing meaningful to diff + } + const dropped: string[] = []; + for (const key of ["description", "tz", "host", "agentCommand", "sessionCronId"] as const) { + if (prev[key] !== undefined && next[key] === undefined) dropped.push(key); + } + for (const key of ["until", "maxRuns"] as const) { + if (prev.lifecycle?.[key] != null && next.lifecycle?.[key] == null) dropped.push(`lifecycle.${key}`); + } + if (dropped.length) + process.stderr.write(`warning: --force replaced board "${id}"; previously-set fields not re-specified were dropped: ${dropped.join(", ")}\n`); +} + function list(argv: string[], root: string): number { if (wantsHelp(argv)) { process.stdout.write(SUB_USAGE.list); return 0; } const json = argv.includes("--json"); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d3873b75..2b721c34 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -42,7 +42,7 @@ Usage: termchart template Reusable diagram templates: save --project --agent --name | list | get | delete termchart board Scheduled boards (auto-refreshing): create | list | show | prompt | scaffold-workflow | delete (try \`board --help\`) create flags: --id --schedule --project --agent --prompt [--tz --host --agent-command --description --enabled --until --max-runs --force] - termchart run Refresh a scheduled board now — enforce lifecycle, assemble its prompt + run its agent, which pushes the board + termchart run Refresh a scheduled board now — enforce lifecycle, assemble its prompt + run its agent, which pushes the board (--timeout , default 600) termchart --version Render flags: diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 22537e84..080f12de 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -15,6 +15,7 @@ export type AgentSpawn = ( args: string[], input: string, env: Record, + timeoutMs: number, ) => Promise; export interface RunDeps { @@ -24,14 +25,29 @@ export interface RunDeps { now?: Date; // injectable clock for lifecycle.until (tests) } -const USAGE = "usage: termchart run \n Refresh a scheduled board now: assemble its prompt, run its agent, which pushes the board.\n"; +const DEFAULT_TIMEOUT_S = 600; +const EXIT_TIMEOUT = 124; // GNU timeout convention +const USAGE = `usage: termchart run [--timeout ] + Refresh a scheduled board now: assemble its prompt, run its agent, which pushes the board. + --timeout: kill the agent if it runs longer than this (default ${DEFAULT_TIMEOUT_S}s; exit ${EXIT_TIMEOUT}). +`; // Deliver the prompt on stdin (never as an argv string) to dodge E2BIG and shell-quoting hazards; -// the agent command is tokenised and spawned without a shell. -const defaultSpawn: AgentSpawn = (cmd, args, input, env) => +// the agent command is tokenised and spawned without a shell. A hung agent (e.g. an +// unauthenticated `claude -p` waiting forever) is killed at the timeout so schedulers never wedge. +const defaultSpawn: AgentSpawn = (cmd, args, input, env, timeoutMs) => new Promise((resolve) => { const child = nodeSpawn(cmd, args, { env, stdio: ["pipe", "inherit", "inherit"] }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + process.stderr.write(`run: agent timed out after ${timeoutMs / 1000}s — killing\n`); + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5000).unref(); + }, timeoutMs); + timer.unref(); child.on("error", (e) => { + clearTimeout(timer); process.stderr.write(`run: cannot launch agent "${cmd}": ${e.message}\n`); resolve(127); }); @@ -40,7 +56,10 @@ const defaultSpawn: AgentSpawn = (cmd, args, input, env) => child.stdin.write(input); child.stdin.end(); } - child.on("close", (code) => resolve(code ?? 0)); + child.on("close", (code) => { + clearTimeout(timer); + resolve(timedOut ? EXIT_TIMEOUT : code ?? 0); + }); }); export async function run(argv: string[], deps: RunDeps): Promise { @@ -48,10 +67,21 @@ export async function run(argv: string[], deps: RunDeps): Promise { process.stdout.write(USAGE); return 0; } - // Only a single positional board id is allowed — reject stray flags so typos aren't silently dropped. - const flags = argv.filter((x) => x.startsWith("-")); - if (flags.length) { process.stderr.write(`Unknown flag: ${flags[0]}\n${USAGE}`); return 3; } - const id = argv.find((x) => !x.startsWith("-")); + // One positional board id + --timeout; any other flag is rejected so typos aren't silently dropped. + let timeoutS = DEFAULT_TIMEOUT_S; + const positionals: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--timeout") { + const v = Number(argv[++i]); + if (!Number.isFinite(v) || v <= 0) { process.stderr.write(`--timeout must be a positive number of seconds (got "${argv[i]}")\n`); return 3; } + timeoutS = v; + } else if (arg.startsWith("-")) { + process.stderr.write(`Unknown flag: ${arg}\n${USAGE}`); + return 3; + } else positionals.push(arg); + } + const id = positionals[0]; if (!id) { process.stderr.write(USAGE); return 3; } // A board exists to push to the viewer — fail fast and clearly if there's nowhere to push. @@ -90,7 +120,7 @@ export async function run(argv: string[], deps: RunDeps): Promise { const spawn = deps.spawn ?? defaultSpawn; process.stdout.write(`running board ${id}: ${cmd} ${args.join(" ")} → ${def.target.project}/${def.target.agent}\n`); - const code = await spawn(cmd, args, prompt, env); + const code = await spawn(cmd, args, prompt, env, timeoutS * 1000); if (code === 0) { // Persist the run counter at the source (write-time) so maxRuns is enforceable across runs. diff --git a/packages/cli/test/board-bundle.test.ts b/packages/cli/test/board-bundle.test.ts index edcc6d2a..01019d55 100644 --- a/packages/cli/test/board-bundle.test.ts +++ b/packages/cli/test/board-bundle.test.ts @@ -39,4 +39,16 @@ maybe("board commands work in the built bundle", () => { expect(out).toContain("Report metrics."); expect(out).toContain("termchart push --project ops --agent j1"); }); + + it("run kills a hung agent at --timeout and exits 124", () => { + run(["board", "create", "--id", "hang", "--schedule", "* * * * *", "--project", "ops", "--agent", "h1", "--prompt", "x", "--agent-command", "sleep 30"]); + try { + run(["run", "hang", "--timeout", "1"]); + throw new Error("expected non-zero exit"); + } catch (err) { + const e = err as { status?: number; stderr?: string }; + expect(e.status).toBe(124); + expect(String(e.stderr)).toMatch(/timed out/i); + } + }, 15_000); }); diff --git a/packages/cli/test/board.test.ts b/packages/cli/test/board.test.ts index e3a41c56..afed0df0 100644 --- a/packages/cli/test/board.test.ts +++ b/packages/cli/test/board.test.ts @@ -53,6 +53,25 @@ describe("board create", () => { expect(await board(createArgs, { cwd })).toBe(3); // exists expect(await board([...createArgs, "--force"], { cwd })).toBe(0); // force ok }); + + it("--force warns about previously-set fields that were not re-specified (they are dropped)", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + await board([...createArgs, "--tz", "America/Los_Angeles", "--description", "morning"], { cwd }); + expect(await board([...createArgs, "--force"], { cwd })).toBe(0); // re-create without tz/description + expect(errs.join("")).toMatch(/tz/); + expect(errs.join("")).toMatch(/description/); + }); + + it("--force stays silent when every previously-set field is re-specified", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((s) => (errs.push(String(s)), true)); + await board([...createArgs, "--tz", "UTC"], { cwd }); + expect(await board([...createArgs, "--tz", "UTC", "--force"], { cwd })).toBe(0); + expect(errs.join("")).toBe(""); + }); }); describe("board list", () => { diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index d978e94c..1acefb26 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -26,8 +26,8 @@ const base: BoardDef = { const VENV = { TERMCHART_VIEWER_URL: "http://x/w/ws1", TERMCHART_VIEWER_TOKEN: "t" }; function recordingSpawn(exitCode = 0) { - const calls: { cmd: string; args: string[]; input: string; env: Record }[] = []; - const spawn: AgentSpawn = async (cmd, args, input, env) => { calls.push({ cmd, args, input, env }); return exitCode; }; + const calls: { cmd: string; args: string[]; input: string; env: Record; timeoutMs: number }[] = []; + const spawn: AgentSpawn = async (cmd, args, input, env, timeoutMs) => { calls.push({ cmd, args, input, env, timeoutMs }); return exitCode; }; return { calls, spawn }; } const silence = () => { vi.spyOn(process.stderr, "write").mockImplementation(() => true); vi.spyOn(process.stdout, "write").mockImplementation(() => true); }; @@ -149,6 +149,32 @@ describe("run", () => { expect(await run([], { cwd, env: VENV, spawn })).toBe(3); }); + // --- timeout --- + it("defaults the agent timeout to 10 minutes", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + silence(); + await run(["daily"], { cwd, env: VENV, spawn }); + expect(calls[0].timeoutMs).toBe(600_000); + }); + + it("honours --timeout ", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + silence(); + expect(await run(["daily", "--timeout", "5"], { cwd, env: VENV, spawn })).toBe(0); + expect(calls[0].timeoutMs).toBe(5_000); + }); + + it("rejects a non-positive or non-numeric --timeout (exit 3, no spawn)", async () => { + writeDef(base); + const { calls, spawn } = recordingSpawn(0); + silence(); + expect(await run(["daily", "--timeout", "abc"], { cwd, env: VENV, spawn })).toBe(3); + expect(await run(["daily", "--timeout", "0"], { cwd, env: VENV, spawn })).toBe(3); + expect(calls).toHaveLength(0); + }); + it("rejects an unknown flag after the id (exit 3, no spawn)", async () => { writeDef(base); const { calls, spawn } = recordingSpawn(0); diff --git a/plugin/skills/scheduled-boards/SKILL.md b/plugin/skills/scheduled-boards/SKILL.md index d7f5dc2d..be073c13 100644 --- a/plugin/skills/scheduled-boards/SKILL.md +++ b/plugin/skills/scheduled-boards/SKILL.md @@ -134,6 +134,7 @@ termchart board show [--json] # parsed definition + effective default termchart board prompt # the assembled agent prompt (feed this to a scheduler) termchart board scaffold-workflow # generate a GitHub Actions workflow termchart board delete -termchart run # refresh now: enforce lifecycle, assemble prompt, run agent → push +termchart run [--timeout ] # refresh now: enforce lifecycle, assemble prompt, run agent → push + # a hung agent is killed at --timeout (default 600s, exit 124) ``` Every subcommand supports `--help`.