diff --git a/foreman/.claude-plugin/marketplace.json b/foreman/.claude-plugin/marketplace.json new file mode 100644 index 0000000..77de609 --- /dev/null +++ b/foreman/.claude-plugin/marketplace.json @@ -0,0 +1,8 @@ +{ + "name": "mega-foreman", + "owner": { "name": "davidels" }, + "description": "Foreman, the Mega Infrastructure site agent.", + "plugins": [ + { "name": "foreman", "source": "./mod", "description": "Scheduled MiniMax jobs with a live board and reports." } + ] +} diff --git a/foreman/.gitignore b/foreman/.gitignore new file mode 100644 index 0000000..faffbea --- /dev/null +++ b/foreman/.gitignore @@ -0,0 +1,4 @@ +# Foreman's run output stays on this machine +runs/ +state/ +mod/.claude-plugin/types/ diff --git a/foreman/bin/runner.mjs b/foreman/bin/runner.mjs new file mode 100644 index 0000000..cc320b7 --- /dev/null +++ b/foreman/bin/runner.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// Foreman's runner: runs scheduled skills with MiniMax Code (mcode) and writes reports. +// No Claude involved. macOS launchd calls `tick` every minute; the pane calls `run `. +// +// node runner.mjs tick run every job that is due now +// node runner.mjs run run one job now (manual) +// node runner.mjs install install the launchd scheduler (every minute) +// node runner.mjs uninstall remove it +// node runner.mjs status print scheduler + jobs status + +import { spawn, execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { graceMs, lastSlot, parseSchedule } from '../mod/hooks/schedule.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') // foreman/ +const PROJECT = path.resolve(ROOT, '..') +const JOBS = path.join(ROOT, 'jobs.json') +const STATE = path.join(ROOT, 'state') +const RUNS = path.join(ROOT, 'runs') +const LABEL = 'com.megainfra.foreman' +// launchd won't load or log from an external drive here (home is on one), so the +// agent file lives on the internal disk and the scheduler log in /tmp. +const PLIST_DIR = '/Users/Shared/foreman' +const PLIST = path.join(PLIST_DIR, `${LABEL}.plist`) +const OLD_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) +const SCHED_LOG = '/tmp/foreman-scheduler.log' +const DEFAULT_MCODE = path.join(os.homedir(), '.minimax-code', 'bin', 'mcode') +const HISTORY_MAX = 40 + +fs.mkdirSync(STATE, { recursive: true }) +fs.mkdirSync(RUNS, { recursive: true }) + +const readJson = (file, fallback) => { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return fallback + } +} +const writeJson = (file, value) => { + const tmp = `${file}.${process.pid}.tmp` + fs.writeFileSync(tmp, JSON.stringify(value, null, 2)) + fs.renameSync(tmp, file) +} +const loadConfig = () => readJson(JOBS, { paused: false, jobs: [] }) +const stateFile = id => path.join(STATE, `${id}.json`) +const loadState = id => readJson(stateFile(id), { id, status: 'idle', history: [] }) +const pidAlive = pid => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} +const log = (...a) => console.log(new Date().toISOString(), ...a) + +function skillText(job) { + const p = path.isAbsolute(job.skill) ? job.skill : path.join(ROOT, job.skill) + const raw = fs.readFileSync(p, 'utf8') + return raw.replace(/^---\n[\s\S]*?\n---\n/, '').trim() +} + +function buildPrompt(job, startedAt) { + return `${skillText(job)} + +--- +You are Foreman, the site agent for Mega Infrastructure, running the job "${job.name}" on a schedule. +Run time: ${startedAt.toString()} +Project folder: ${PROJECT} + +Report contract (required): +- Your FINAL message is saved as this run's report and shown on Foreman's board. +- Its first line must be exactly \`VERDICT: CLEAR\` when nothing needs a person, or \`VERDICT: ATTENTION ( items)\` when something does. +- Then markdown sections: "## Summary" (2-4 lines), "## Needs attention" (a table: reference | problem | suggested action; leave out when CLEAR), "## What I checked". +- Be specific: document numbers, suppliers, amounts, dates. No guesses presented as facts.` +} + +function describeInput(input) { + if (!input || typeof input !== 'object') return '' + for (const k of ['path', 'file_path', 'command', 'query', 'url', 'pattern']) { + if (typeof input[k] === 'string') return input[k].replace(/\s+/g, ' ').slice(0, 90) + } + return JSON.stringify(input).slice(0, 90) +} + +async function runJob(job, trigger, slot) { + const st = loadState(job.id) + if (st.status === 'running' && st.pid && pidAlive(st.pid)) { + log(`skip ${job.id}: already running (pid ${st.pid})`) + return + } + const startedAt = new Date() + const stamp = startedAt.toISOString().replace(/[:T]/g, '-').slice(0, 16) + const runDir = path.join(RUNS, job.id) + fs.mkdirSync(runDir, { recursive: true }) + const reportRel = path.join('runs', job.id, `${stamp}.md`) + const run = { + runId: `${job.id}-${stamp}`, + trigger, + slot: slot ? slot.toISOString() : undefined, + startedAt: startedAt.toISOString(), + toolCount: 0, + activity: [], + } + const save = extra => { + const cur = loadState(job.id) + writeJson(stateFile(job.id), { ...cur, ...extra, id: job.id }) + } + save({ status: 'running', pid: process.pid, current: run, ...(slot ? { lastSlot: slot.toISOString() } : {}) }) + log(`start ${job.id} (${trigger})`) + + let prompt + try { + prompt = buildPrompt(job, startedAt) + } catch (err) { + return finish({ status: 'failed', error: `Could not read the skill file: ${err.message}` }) + } + + const mcode = job.mcode || loadConfig().mcode || DEFAULT_MCODE + const args = [ + 'exec', '--cwd', PROJECT, '--input', '-', '--output-format', 'stream-json', + '--permission', job.permission === 'full' ? 'full' : 'smart', + '--timeout', `${Math.max(1, Math.min(120, Number(job.timeoutMin) || 20))}m`, + ] + const child = spawn(mcode, args, { cwd: PROJECT, stdio: ['pipe', 'pipe', 'pipe'] }) + child.stdin.end(prompt) + + let output = '' + let error = '' + let stderrTail = '' + let tokensIn = 0 + let tokensOut = 0 + let model = '' + let lastSave = 0 + const activity = (text, kind) => { + run.activity.push({ at: new Date().toISOString(), kind, text: text.slice(0, 160) }) + run.activity = run.activity.slice(-30) + if (Date.now() - lastSave > 1000) { + lastSave = Date.now() + save({ current: run }) + } + } + + let buf = '' + child.stdout.on('data', chunk => { + buf += chunk + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim() + buf = buf.slice(nl + 1) + if (!line) continue + let ev + try { + ev = JSON.parse(line) + } catch { + continue + } + const item = ev.item + if (ev.type === 'item.completed' && item?.type === 'tool_call') { + run.toolCount++ + const c = item.toolCall ?? {} + activity(`${c.name ?? 'tool'} ${describeInput(c.input)}`.trim(), c.status === 2 ? 'tool' : 'tool-fail') + } else if (ev.type === 'item.completed' && item?.type === 'agent_message') { + const text = String(item.content ?? '').replace(/\s+/g, ' ').trim() + if (text) activity(text, 'say') + } else if (ev.type === 'turn.completed') { + tokensIn += Number(ev.usage?.inputTokens ?? 0) + tokensOut += Number(ev.usage?.outputTokens ?? 0) + if (ev.model?.modelId) model = ev.model.modelId + } else if (ev.type === 'exec.completed') { + output = String(ev.result?.output ?? '') + if (ev.result?.status !== 'succeeded') error = `MiniMax ended with status ${ev.result?.status}${ev.result?.error ? `: ${JSON.stringify(ev.result.error)}` : ''}` + } + } + }) + child.stderr.on('data', chunk => { + stderrTail = (stderrTail + chunk).slice(-1500) + }) + + const code = await new Promise(resolve => { + child.on('error', err => { + error = `Could not start MiniMax (${mcode}): ${err.message}` + resolve(-1) + }) + child.on('close', resolve) + }) + if (!output && !error) error = `MiniMax exited (code ${code}) without a report. ${stderrTail.trim()}`.trim() + return finish({ status: error ? 'failed' : undefined, error }) + + function finish({ status, error: err }) { + const endedAt = new Date() + const verdictLine = /^\s*VERDICT:\s*(CLEAR|ATTENTION)(?:\s*\((\d+)[^)]*\))?/im.exec(output) + const verdict = verdictLine ? verdictLine[1].toUpperCase() : null + const finalStatus = status ?? (verdict === 'CLEAR' ? 'clear' : 'attention') + const attentionCount = verdictLine?.[2] ? Number(verdictLine[2]) : verdict === 'ATTENTION' ? null : 0 + const summaryMatch = /##\s*Summary\s*\n([\s\S]*?)(?:\n##|$)/i.exec(output) + const summary = (summaryMatch?.[1] ?? output.split('\n').slice(1, 4).join(' ')) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 300) + const header = [ + `# ${job.name}`, + ``, + `- Run: ${startedAt.toString()} (${trigger})`, + `- Duration: ${Math.round((endedAt - startedAt) / 1000)}s · ${run.toolCount} tool calls · model ${model || 'unknown'} · tokens ${tokensIn}/${tokensOut}`, + `- Status: ${finalStatus}${verdict ? '' : output ? ' (no VERDICT line found, treated as needing attention)' : ''}`, + ``, + `---`, + ``, + ].join('\n') + fs.writeFileSync(path.join(ROOT, reportRel), header + (err ? `**Run failed:** ${err}\n\n` : '') + (output || '')) + const entry = { + runId: run.runId, + trigger, + slot: run.slot, + startedAt: run.startedAt, + endedAt: endedAt.toISOString(), + status: finalStatus, + attentionCount, + summary: err ? err.slice(0, 300) : summary, + report: reportRel, + toolCount: run.toolCount, + tokensIn, + tokensOut, + model, + } + const cur = loadState(job.id) + writeJson(stateFile(job.id), { + ...cur, + id: job.id, + status: finalStatus, + pid: undefined, + current: undefined, + lastRun: entry, + history: [entry, ...(cur.history ?? [])].slice(0, HISTORY_MAX), + }) + log(`end ${job.id}: ${finalStatus}`) + } +} + +async function tick() { + const cfg = loadConfig() + writeJson(path.join(STATE, 'scheduler.json'), { lastTick: new Date().toISOString(), pid: process.pid, paused: !!cfg.paused }) + if (cfg.paused) return + const now = new Date() + const due = [] + for (const job of cfg.jobs ?? []) { + if (!job.enabled) continue + const s = parseSchedule(job.schedule) + const slot = lastSlot(s, now) + if (!slot || now - slot > graceMs(s)) continue + const st = loadState(job.id) + if (st.lastSlot && new Date(st.lastSlot) >= slot) continue + due.push({ job, slot }) + } + // One at a time keeps MiniMax usage predictable. + for (const { job, slot } of due) await runJob(job, 'schedule', slot) +} + +function install() { + const node = process.execPath + const runner = fileURLToPath(import.meta.url) + const plist = ` + + + + Label${LABEL} + ProgramArguments + ${node}${runner}tick + StartInterval60 + RunAtLoad + StandardOutPath${SCHED_LOG} + StandardErrorPath${SCHED_LOG} + EnvironmentVariables + + PATH${path.dirname(node)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + HOME${os.homedir()} + + + +` + const cfg = loadConfig() + writeJson(JOBS, { ...cfg, node }) + fs.mkdirSync(PLIST_DIR, { recursive: true }) + fs.rmSync(OLD_PLIST, { force: true }) + fs.writeFileSync(PLIST, plist) + const uid = process.getuid() + try { + execFileSync('launchctl', ['bootout', `gui/${uid}/${LABEL}`], { stdio: 'ignore' }) + } catch {} + execFileSync('launchctl', ['bootstrap', `gui/${uid}`, PLIST]) + console.log(`Scheduler installed: ${PLIST} (runs "tick" every minute, log ${SCHED_LOG}).`) + console.log(`To keep it after a restart: sudo cp ${PLIST} /Library/LaunchAgents/`) +} + +function uninstall() { + try { + execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }) + } catch {} + fs.rmSync(PLIST, { force: true }) + fs.rmSync(OLD_PLIST, { force: true }) + console.log(`Scheduler removed. If you copied it to /Library/LaunchAgents, also run: sudo rm /Library/LaunchAgents/${LABEL}.plist`) +} + +function status() { + const cfg = loadConfig() + let loaded = false + try { + execFileSync('launchctl', ['print', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }) + loaded = true + } catch {} + console.log(`Scheduler: ${loaded ? 'installed' : 'NOT installed'}${cfg.paused ? ' (paused)' : ''}`) + console.log('Last tick:', readJson(path.join(STATE, 'scheduler.json'), {}).lastTick ?? 'never') + for (const job of cfg.jobs ?? []) { + const st = loadState(job.id) + console.log(`- ${job.id} [${job.enabled ? 'on' : 'off'}] "${job.schedule}" → ${st.status}${st.lastRun ? ` (last ${st.lastRun.endedAt})` : ''}`) + } +} + +const [cmd, arg] = process.argv.slice(2) +if (cmd === 'tick') await tick() +else if (cmd === 'run') { + const job = (loadConfig().jobs ?? []).find(j => j.id === arg) + if (!job) { + console.error(`No job "${arg}" in ${JOBS}`) + process.exit(1) + } + await runJob(job, 'manual') +} else if (cmd === 'install') install() +else if (cmd === 'uninstall') uninstall() +else if (cmd === 'status') status() +else { + console.log('Usage: node runner.mjs tick | run | install | uninstall | status') + process.exit(cmd ? 1 : 0) +} diff --git a/foreman/jobs.json b/foreman/jobs.json new file mode 100644 index 0000000..9588065 --- /dev/null +++ b/foreman/jobs.json @@ -0,0 +1,5 @@ +{ + "paused": true, + "jobs": [], + "node": "/Volumes/KIOXIA/.hermes/node/bin/node" +} diff --git a/foreman/mod/.claude-plugin/plugin.json b/foreman/mod/.claude-plugin/plugin.json new file mode 100644 index 0000000..092c326 --- /dev/null +++ b/foreman/mod/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "foreman", + "version": "0.2.1", + "description": "Foreman, Mega Infrastructure's site agent: schedule skills to run with MiniMax, watch them on a board, read the reports.", + "author": { "name": "davidels" }, + "types": "./types/index.d.ts" +} diff --git a/foreman/mod/hooks/hooks.json b/foreman/mod/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/foreman/mod/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/foreman/mod/hooks/mascot.ts b/foreman/mod/hooks/mascot.ts new file mode 100644 index 0000000..5ffa943 --- /dev/null +++ b/foreman/mod/hooks/mascot.ts @@ -0,0 +1,142 @@ +export type Mood = 'idle' | 'running' | 'clear' | 'attention' | 'failed' + +export type Theme = { + name: string + bg: string // pane background + panel: string // card background + chip: string // chip / column background + line: string // borders + text: string // main text + sub: string // secondary text + muted: string // hints + accent: string // headings, primary + good: string // clear / done + warn: string // needs attention + bad: string // failed + run: string // running + /** Dark themes only: the light chip buttons and fields sit on, since the app draws their text dark. */ + control?: string +} + +// Bright by default; Dracula and Monokai for the evenings. +export const THEMES = { + hivis: { name: 'Hi-Vis', bg: '#FFFCF2', panel: '#FFFFFF', chip: '#FFF3C4', line: '#EED68A', text: '#23201A', sub: '#4D473A', muted: '#8A8270', accent: '#D99100', good: '#2E9E5B', warn: '#E8590C', bad: '#D6336C', run: '#1C7ED6' }, + sky: { name: 'Sky', bg: '#F3F8FF', panel: '#FFFFFF', chip: '#E3EEFF', line: '#BCD3F5', text: '#142338', sub: '#3A4E6B', muted: '#7A8BA3', accent: '#2F80ED', good: '#12A37A', warn: '#F76707', bad: '#E03131', run: '#7048E8' }, + mint: { name: 'Mint', bg: '#F1FBF6', panel: '#FFFFFF', chip: '#DDF5E8', line: '#A8DFC1', text: '#12261C', sub: '#345244', muted: '#6F8F80', accent: '#0CA678', good: '#2B8A3E', warn: '#F08C00', bad: '#E8590C', run: '#1C7ED6' }, + sunrise: { name: 'Sunrise', bg: '#FFF6F2', panel: '#FFFFFF', chip: '#FFE6DC', line: '#F7C2AE', text: '#2B1A14', sub: '#5A3A2E', muted: '#9A7A6E', accent: '#FF6B4A', good: '#2F9E44', warn: '#F59F00', bad: '#D6336C', run: '#4263EB' }, + grape: { name: 'Grape', bg: '#F8F5FF', panel: '#FFFFFF', chip: '#EDE6FF', line: '#CFC0F5', text: '#1F1636', sub: '#463A66', muted: '#8A80A6', accent: '#7950F2', good: '#12A37A', warn: '#FD7E14', bad: '#FA5252', run: '#339AF0' }, + dracula: { name: 'Dracula', bg: '#282A36', panel: '#2F3242', chip: '#383A4C', line: '#6272A4', text: '#F8F8F2', sub: '#E9E9F0', muted: '#A4ACD6', accent: '#BD93F9', good: '#50FA7B', warn: '#FFB86C', bad: '#FF5555', run: '#8BE9FD', control: '#ECE4FF' }, + monokai: { name: 'Monokai', bg: '#272822', panel: '#2F302A', chip: '#3E3D32', line: '#75715E', text: '#F8F8F2', sub: '#E6E6DC', muted: '#B5AF96', accent: '#E6DB74', good: '#A6E22E', warn: '#FD971F', bad: '#F92672', run: '#66D9EF', control: '#F5F1D3' }, +} satisfies Record + +export type ThemeId = keyof typeof THEMES +export const THEME_ORDER = Object.keys(THEMES) as ThemeId[] +export const isThemeId = (v: unknown): v is ThemeId => typeof v === 'string' && v in THEMES + +export const MOOD_LABEL: Record = { + idle: 'On site, waiting for the next job', + running: 'Out checking paperwork', + clear: 'All clear on the last run', + attention: 'Found things that need a look', + failed: 'A job hit a problem', +} + +// Terminal fallback: the block-glyph critter in a hard hat. +export const ASCII: Record = { + idle: ' ▄██▄\n▐▛█M█▜▌ z', + running: ' ▄██▄\n▐▛█M█▜▌ ~', + clear: ' ▄██▄\n▐▛█M█▜▌ ✓', + attention: ' ▄██▄\n▐▛█M█▜▌ !', + failed: ' ▄██▄\n▐▛█M█▜▌ ✗', +} + +const CRAB = { body: '#3F7FB8', light: '#7DB1DE', dark: '#2A5A86', eye: '#F7F3EA', ink: '#1E1A16' } +const HAT = { main: '#FFC21A', shade: '#E0A100', shine: '#FFE27A' } +const VEST = { main: '#FF7A1A', strip: '#E8EEF2' } + +const px = (x: number, y: number, w: number, h: number, fill: string, extra = '') => + `` + +const bob = (dy: number, dur: string, begin = '0s') => + `` + +const M_ROWS = ['X...X', 'XX.XX', 'X.X.X', 'X...X'] +const monogram = (x0: number, y0: number, fill: string) => + M_ROWS.flatMap((row, r) => [...row].map((c, col) => (c === 'X' ? px(x0 + col * 0.5, y0 + r * 0.5, 0.5, 0.5, fill) : ''))).join('') + +/** Foreman: the blue pixel critter in a yellow hard hat and hi-vis vest. */ +export function foremanSvg(mood: Mood, t: Theme): string { + const running = mood === 'running' + const idle = mood === 'idle' + const happy = mood === 'clear' + const alert = mood === 'attention' + const sad = mood === 'failed' + const body = CRAB.body + const eye = CRAB.eye + + const eyes = idle + ? px(4, 3.5, 1, 0.5, CRAB.ink) + px(11, 3.5, 1, 0.5, CRAB.ink) + : happy + ? px(4, 2.5, 1, 0.5, eye) + px(3.5, 3, 0.5, 0.5, eye) + px(5, 3, 0.5, 0.5, eye) + + px(11, 2.5, 1, 0.5, eye) + px(10.5, 3, 0.5, 0.5, eye) + px(12, 3, 0.5, 0.5, eye) + : sad + ? px(4, 2.5, 1, 1.5, eye) + px(11, 2.5, 1, 1.5, eye) + px(3.5, 2, 1.5, 0.5, CRAB.dark) + px(11, 2, 1.5, 0.5, CRAB.dark) + : [4, 11] + .map(x => + running + ? ` + + ` + : px(x, alert ? 1.5 : 2, 1, alert ? 2.5 : 2, eye), + ) + .join('') + + const legsA = px(3, 8, 1, 2, body) + px(10, 8, 1, 2, body) + const legsB = px(5, 8, 1, 2, body) + px(12, 8, 1, 2, body) + + // Hard hat: dome, ridge, shine and a wide brim over the top of the body. + const hat = + px(4, -3, 8, 0.5, HAT.main) + px(3, -2.5, 10, 2, HAT.main) + px(7.5, -3.5, 1, 3, HAT.shade) + + px(4, -2.5, 1.5, 0.5, HAT.shine) + px(1, -0.5, 14, 0.75, HAT.main) + px(1, 0.25, 14, 0.25, HAT.shade) + + // Hi-vis vest straps with reflective strips. + const vest = + px(2, 4.5, 2.5, 3.5, VEST.main) + px(11.5, 4.5, 2.5, 3.5, VEST.main) + + px(2, 6, 2.5, 0.5, VEST.strip) + px(11.5, 6, 2.5, 0.5, VEST.strip) + + const extras = idle + ? `${px(16, -2, 1, 0.25, t.muted)}${px(16.75, -1.75, 0.25, 0.5, t.muted)}${px(16, -1.25, 1, 0.25, t.muted)} + ` + : running + ? [0, 0.8, 1.6] + .map( + (d, i) => ` + + `, + ) + .join('') + : happy + ? `${px(16.5, -3, 0.5, 1.5, t.good)}${px(16, -2.5, 1.5, 0.5, t.good)} + + ${px(-0.5, -2.5, 0.5, 1, HAT.main)}${px(-0.75, -2.25, 1, 0.5, HAT.main)} + ` + : alert + ? `${px(16.25, -3.5, 1, 2.5, t.warn)}${px(16.25, -0.5, 1, 1, t.warn)} + ` + : px(5.25, 4.5, 0.5, 0.75, CRAB.light) + px(12.25, 4.5, 0.5, 0.75, CRAB.light) + + return ` + ${px(1, 10.4, 14, 0.4, t.muted, ' opacity="0.25"')} + ${running ? bob(-0.25, '0.8s') : ''} + ${running ? bob(-0.5, '0.4s') : ''}${legsA} + ${running ? bob(-0.5, '0.4s', '0.2s') : ''}${legsB} + ${running ? bob(-0.5, '0.6s') : ''}${px(0, 4, 2, 2, body)}${px(14, 4, 2, 2, body)} + ${px(2, 0, 12, 8, body)} + ${vest} + ${eyes} + ${monogram(6.75, 5, HAT.main)} + ${hat} + + ${extras} +` +} diff --git a/foreman/mod/hooks/register.tsx b/foreman/mod/hooks/register.tsx new file mode 100644 index 0000000..10bdeda --- /dev/null +++ b/foreman/mod/hooks/register.tsx @@ -0,0 +1,604 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, Register, RenderChildren } from 'claude-code' + +import type { Job, JobState, RunEntry, SkillInfo, Snapshot, UiState } from '../types' +import { ASCII, MOOD_LABEL, THEMES, THEME_ORDER, foremanSvg, isThemeId } from './mascot' +import type { Mood, Theme } from './mascot' +import { describeSchedule, nextSlot, parseSchedule } from './schedule.mjs' + +const PROJECT = '/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE' +const ROOT = `${PROJECT}/foreman` +const JOBS = `${ROOT}/jobs.json` +const RUNNER = `${ROOT}/bin/runner.mjs` +const PANE = 'foreman' +const PANE_TITLE = '👷 Foreman' +const POLL_MS = 2000 +const THEME_KEY = 'theme' + +const EMPTY: Snapshot = { jobs: [], paused: false, states: {}, acks: [], skills: [] } +const snapAtom = atom({ plugin: 'foreman', key: 'snap' } as const, EMPTY) +const uiAtom = atom({ plugin: 'foreman', key: 'ui' } as const, { view: 'board' } as UiState) +const themeAtom = atom({ plugin: 'foreman', key: 'theme' } as const, 'hivis') + +type Dollar = EngineInterface + +// ---------- reading Foreman's folder ---------- + +const parseJson = (text: string | undefined, fallback: T): T => { + try { + return text ? (JSON.parse(text) as T) : fallback + } catch { + return fallback + } +} + +async function readText($: Dollar, path: string): Promise { + try { + const got: unknown = await $.fs.read(path) + return typeof got === 'string' ? got : undefined + } catch { + return undefined + } +} + +function frontmatter(text: string): Record { + const m = /^---\n([\s\S]*?)\n---/.exec(text) + const out: Record = {} + for (const line of m?.[1]?.split('\n') ?? []) { + const kv = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line) + if (kv?.[1]) out[kv[1]] = (kv[2] ?? '').trim() + } + return out +} + +async function listSkills($: Dollar, dir: string, source: SkillInfo['source']): Promise { + const entries = await $.fs.list(dir).catch(() => []) + const out: SkillInfo[] = [] + for (const entry of entries) { + if (entry.kind !== 'dir' || entry.name.startsWith('_') || entry.name.startsWith('.')) continue + const path = `${dir}/${entry.name}/SKILL.md` + const text = await readText($, path) + if (text === undefined) continue + const fm = frontmatter(text) + out.push({ path, folder: entry.name, name: fm.name || entry.name, description: fm.description || '', source }) + } + return out +} + +let lastKey = '' + +async function refresh($: Dollar): Promise { + const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {}) + const jobs = Array.isArray(cfg.jobs) ? cfg.jobs : [] + const states: Record = {} + for (const job of jobs) { + const st = parseJson(await readText($, `${ROOT}/state/${job.id}.json`), undefined) + if (st) states[job.id] = st + } + const acks = parseJson(await readText($, `${ROOT}/state/acks.json`), []) + const sched = parseJson<{ lastTick?: string }>(await readText($, `${ROOT}/state/scheduler.json`), {}) + const skills = [ + ...(await listSkills($, `${ROOT}/skills`, 'foreman')), + ...(await listSkills($, `${PROJECT}/.claude/skills`, 'project')), + ] + const snap: Snapshot = { + jobs, + paused: cfg.paused === true, + ...(cfg.node ? { node: cfg.node } : {}), + states, + acks: Array.isArray(acks) ? acks : [], + skills, + ...(sched.lastTick ? { lastTick: sched.lastTick } : {}), + } + const key = JSON.stringify(snap) + if (key === lastKey) return + lastKey = key + await update($, snapAtom, () => snap) +} + +async function saveJobs($: Dollar, change: (cfg: { paused: boolean; node?: string; jobs: Job[] }) => void): Promise { + const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {}) + const next = { ...cfg, paused: cfg.paused === true, jobs: Array.isArray(cfg.jobs) ? cfg.jobs : [] } + change(next) + await $.fs.write(JOBS, `${JSON.stringify(next, null, 2)}\n`) + await refresh($) +} + +async function setUi($: Dollar, patch: Partial): Promise { + await update($, uiAtom, ui => ({ ...ui, message: undefined, confirmRemove: undefined, ...patch })) +} + +const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/ + +async function runNow($: Dollar, job: Job): Promise { + if (!SAFE_ID.test(job.id)) return void $.ui.toast(`👷 Can't run "${job.id}": bad job id`) + const snap = await read($, snapAtom) + if (snap.states[job.id]?.status === 'running') return void $.ui.toast(`👷 ${job.name} is already running`) + const node = snap.node ?? 'node' + const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'` + const cmd = `nohup ${quote(node)} ${quote(RUNNER)} run ${job.id} >> ${quote(`${ROOT}/state/manual.log`)} 2>&1 &` + await $.process.run(['/bin/sh', '-c', cmd], { cwd: ROOT, timeoutMs: 10_000 }) + $.ui.toast(`👷 Foreman is starting "${job.name}"`) + $.clock.after(800, () => void refresh($)) +} + +async function openReport($: Dollar, job: Job | undefined, entry: RunEntry): Promise { + if (!entry.report) return + const path = `${ROOT}/${entry.report}` + const text = (await readText($, path)) ?? '(The report file is missing.)' + await setUi($, { + view: 'report', + report: { title: job?.name ?? entry.runId, path, text, runId: entry.runId, ...(job ? { jobId: job.id } : {}) }, + }) +} + +async function acknowledge($: Dollar, runId: string): Promise { + const acks = parseJson(await readText($, `${ROOT}/state/acks.json`), []) + if (!acks.includes(runId)) acks.push(runId) + await $.fs.write(`${ROOT}/state/acks.json`, JSON.stringify(acks.slice(-500))) + await refresh($) +} + +async function askClaude($: Dollar, job: Job | undefined, entry: RunEntry): Promise { + const when = new Date(entry.startedAt).toLocaleString() + await $.prompt.submit({ + asUser: true, + text: + `Foreman flagged items in "${job?.name ?? entry.runId}" (run of ${when}). Read the report at ${ROOT}/${entry.report}. ` + + 'For each flagged item, check it against the app (use the MegaDash tools, megadash-direct) and tell me which ones can go to ready-to-post and which need me. ' + + "Don't change anything in the app yet.", + }) +} + +// ---------- small helpers for drawing ---------- + +const pad2 = (n: number) => String(n).padStart(2, '0') +const clock = (d: Date) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +function whenLabel(d: Date, now: Date): string { + const days = Math.round((new Date(d).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / 86_400_000) + if (days === 0) return `today ${clock(d)}` + if (days === 1) return `tomorrow ${clock(d)}` + if (days === -1) return `yesterday ${clock(d)}` + return `${d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} ${clock(d)}` +} +function ago(iso: string | undefined, now: Date): string { + if (!iso) return '—' + const s = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1000)) + return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d` +} +const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t) +const slug = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'job' + +function mood(snap: Snapshot, nowMs: number): Mood { + const states = Object.values(snap.states) + if (states.some(s => s.status === 'running')) return 'running' + const open = states.flatMap(s => s.history ?? []).filter(r => r.status !== 'clear' && !snap.acks.includes(r.runId)) + if (open.some(r => r.status === 'failed')) return 'failed' + if (open.length) return 'attention' + const last = states.map(s => s.lastRun).filter((r): r is RunEntry => !!r).sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] + return last && nowMs - new Date(last.startedAt).getTime() < 12 * 3600_000 ? 'clear' : 'idle' +} + +// ---------- the module ---------- + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ name: 'foreman', description: 'Open Foreman, the Mega Infrastructure site agent' }) + const saved = await $.store.get(THEME_KEY).catch(() => undefined) + if (isThemeId(saved)) await update($, themeAtom, () => saved) + await refresh($).catch(() => undefined) + $.clock.every(POLL_MS, () => void refresh($).catch(() => undefined)) + return next(e) + }) + + on('command.run', { command: 'foreman' }, async $ => { + await refresh($).catch(() => undefined) + await $.ui.open({ id: PANE, title: PANE_TITLE }) + return { text: '👷 Foreman is on site.' } + }) + + on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { + const ui = $.ui.resolve(e) + const { Box, Text, Button } = ui + const Input = 'Input' in ui ? ui.Input : undefined + const Select = 'Select' in ui ? ui.Select : undefined + const snap = await read($, snapAtom) + const view = await read($, uiAtom) + const themeId = await read($, themeAtom) + const T: Theme = isThemeId(themeId) ? THEMES[themeId] : THEMES.hivis + const now = new Date(await $.clock.now()) + const cols = e.props.bodyColumns ?? 80 + const isWide = cols >= 100 + const svg = 'Svg' in ui ? ui.Svg : undefined + const jobById = (id: string) => snap.jobs.find(j => j.id === id) + const m = mood(snap, now.getTime()) + const tickAge = snap.lastTick ? now.getTime() - new Date(snap.lastTick).getTime() : Infinity + const schedulerOk = tickAge < 3 * 60_000 + + const section = (title: string, color: string, body: RenderChildren, right?: RenderChildren) => ( + + + {title} + {right ?? null} + + {body} + + ) + // Buttons, fields and selects take no text colour: the app draws them in its own + // (dark text on a light app theme). Dark themes seat them on light chips to stay readable. + const ctl = (child: RenderChildren) => + T.control ? {child} : child + const controlRow = () => ({ + flexDirection: 'row' as const, + gap: 1, + flexWrap: 'wrap' as const, + ...(T.control ? { backgroundColor: T.control, paddingX: 1 } : {}), + }) + const pill = (text: string, color: string) => {`● ${text}`} + + // ----- header ----- + const header = ( + + {svg + ? h(svg, { source: foremanSvg(m, T), alt: `Foreman: ${MOOD_LABEL[m]}`, width: 132, height: 100, isInteractive: true }) + : {ASCII[m]}} + + Foreman + Mega Infrastructure · site agent · runs on MiniMax + {MOOD_LABEL[m]} + + {snap.paused + ? pill('Schedule paused', T.warn) + : schedulerOk + ? pill(`Scheduler on · ${ago(snap.lastTick, now)} ago`, T.good) + : pill('Scheduler not running', T.bad)} + + + + ) + + // ----- toolbar ----- + const toolbar = ( + + + + + + + ) + + const message = view.message ? {view.message} : null + + // ----- board ----- + const boardView = () => { + type Card = { key: string; title: string; lines: Array<[string, string]>; color: string; buttons: RenderChildren } + const upNext: Card[] = [] + const running: Card[] = [] + const done: Card[] = [] + const attention: Card[] = [] + const startOfToday = new Date(now).setHours(0, 0, 0, 0) + + for (const job of snap.jobs) { + const st = snap.states[job.id] + if (st?.status === 'running' && st.current) { + const last = st.current.activity.at(-1) + running.push({ + key: `run-${job.id}`, + title: job.name, + color: T.run, + lines: [ + [`${st.current.trigger === 'manual' ? 'Manual' : 'Scheduled'} · ${ago(st.current.startedAt, now)} · ${st.current.toolCount} tools`, T.sub], + ...(last ? ([[clip(last.text, 70), T.muted]] as Array<[string, string]>) : []), + ], + buttons: null, + }) + } + const sched = parseSchedule(job.schedule) + const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null + if (nxt && nxt.getTime() - now.getTime() < 36 * 3600_000) { + upNext.push({ + key: `next-${job.id}`, + title: job.name, + color: T.accent, + lines: [[whenLabel(nxt, now), T.sub]], + buttons: ctl(), + }) + } + for (const r of st?.history ?? []) { + const isOpen = r.status !== 'clear' && !snap.acks.includes(r.runId) + const started = new Date(r.startedAt) + const label = `${whenLabel(started, now)} · ${r.trigger}` + if (isOpen) { + attention.push({ + key: `att-${r.runId}`, + title: job.name, + color: r.status === 'failed' ? T.bad : T.warn, + lines: [ + [`${r.status === 'failed' ? 'Failed' : `${r.attentionCount ?? '?'} to check`} · ${label}`, r.status === 'failed' ? T.bad : T.warn], + ...(r.summary ? ([[clip(r.summary, 110), T.sub]] as Array<[string, string]>) : []), + ], + buttons: ( + + {r.report ? : null} + {r.report && r.status === 'attention' ? : null} + + + ), + }) + } else if (started.getTime() >= startOfToday) { + done.push({ + key: `done-${r.runId}`, + title: job.name, + color: T.good, + lines: [[`${r.status === 'clear' ? 'All clear' : 'Reviewed'} · ${label}`, T.good]], + buttons: r.report ? ctl() : null, + }) + } + } + } + upNext.sort((a, b) => a.lines[0]![0].localeCompare(b.lines[0]![0])) + + const column = (title: string, color: string, cards: Card[], empty: string) => ( + + {`${title} · ${cards.length}`} + {cards.length === 0 ? {empty} : null} + {cards.slice(0, 6).map(c => ( + + {c.title} + {c.lines.map(([t, col]) => {t})} + {c.buttons} + + ))} + {cards.length > 6 ? {`+${cards.length - 6} more`} : null} + + ) + + const board = ( + + {column('UP NEXT', T.accent, upNext, snap.paused ? 'Schedule paused' : 'Nothing scheduled')} + {column('RUNNING', T.run, running, 'Nobody out')} + {column('DONE TODAY', T.good, done, 'No runs yet today')} + {column('NEEDS ATTENTION', T.warn, attention, 'All clear 👍')} + + ) + + const jobRows = snap.jobs.map(job => { + const sched = parseSchedule(job.schedule) + const st = snap.states[job.id] + const last = st?.lastRun + const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null + return ( + + + {ctl()} + {job.name} + {st?.status === 'running' ? pill('running', T.run) : null} + + {`🗓 ${describeSchedule(sched)}${nxt ? ` · next ${whenLabel(nxt, now)}` : ''}`} + {last ? ( + + {`Last: ${last.status === 'clear' ? 'all clear' : last.status === 'failed' ? 'failed' : `${last.attentionCount ?? '?'} to check`} · ${whenLabel(new Date(last.startedAt), now)}`} + + ) : Never run yet} + + + + {last?.report ? : null} + + + ) + }) + + return ( + + {board} + {section(`JOBS · ${snap.jobs.length}`, T.accent, + + {snap.jobs.length === 0 ? No jobs yet. Open Skills and add one to the board. : jobRows} + , + ctl())} + + ) + } + + // ----- job editor ----- + const jobView = () => { + const job = view.jobId ? jobById(view.jobId) : undefined + if (!job) return That job is gone. + const sched = parseSchedule(job.schedule) + const st = snap.states[job.id] + const patch = (fn: (j: Job) => void) => saveJobs($, cfg => { + const j = cfg.jobs.find(x => x.id === job.id) + if (j) fn(j) + }) + return ( + + {section(`EDIT · ${job.name}`, T.accent, + + {`Skill: ${job.skill}`} + {Input ? ctl( + void (v.trim() ? patch(j => void (j.name = v.trim())) : undefined)} /> + ) : {`Name: ${job.name}`}} + {Input ? ctl( + { + const parsed = parseSchedule(v) + if (parsed.kind === 'error') return void setUi($, { view: 'job', jobId: job.id, message: parsed.error }) + await patch(j => void (j.schedule = v.trim() || 'manual')) + $.ui.toast(`👷 ${describeSchedule(parsed)}`) + }} /> + ) : {`When: ${job.schedule}`}} + {`→ ${describeSchedule(sched)}`} + Examples: mon-fri 08:00 11:00 14:00 · daily 07:30 · every 2h mon-fri 07:00-17:00 · manual + {Select ? ctl( + { + const n = Math.round(Number(v)) + if (!Number.isFinite(n) || n < 1 || n > 120) return void setUi($, { view: 'job', jobId: job.id, message: 'Time limit is 1 to 120 minutes.' }) + void patch(j => void (j.timeoutMin = n)) + }} /> + ) : null} + + + + {view.confirmRemove === job.id ? ( + + ) : ( + + )} + + + )} + {section('RECENT RUNS', T.accent, + + {(st?.history ?? []).length === 0 ? No runs yet. : null} + {(st?.history ?? []).slice(0, 8).map(r => ( + + + {r.status === 'clear' ? '✓' : r.status === 'failed' ? '✗' : '!'} + + {`${whenLabel(new Date(r.startedAt), now)} · ${r.trigger}`} + {r.report ? ctl() : null} + + ))} + )} + + ) + } + + // ----- skills ----- + const skillsView = () => { + const onBoard = new Set(snap.jobs.map(j => j.skill)) + const relOrAbs = (p: string) => (p.startsWith(`${ROOT}/`) ? p.slice(ROOT.length + 1) : p) + const addJob = async (skillPath: string, name: string) => { + let added: Job | undefined + await saveJobs($, cfg => { + let id = slug(name) + for (let i = 2; cfg.jobs.some(j => j.id === id); i++) id = `${slug(name)}-${i}` + added = { id, name, skill: relOrAbs(skillPath), schedule: 'mon-fri 08:00', enabled: false, permission: 'smart', timeoutMin: 20 } + cfg.jobs.push(added) + }) + if (added) { + $.ui.toast(`👷 "${name}" is on the board (off until you turn it on)`) + await setUi($, { view: 'job', jobId: added.id }) + } + } + return ( + + {section('SKILLS', T.accent, + + A skill is one job's instructions. Add it to the board, then set when it runs. + {snap.skills.length === 0 ? No skills found in foreman/skills yet. : null} + {snap.skills.map(s => { + const added = onBoard.has(relOrAbs(s.path)) + return ( + + + {s.name} + {s.source === 'project' ? '(project skill)' : ''} + + {s.description ? {s.description} : null} + {added + ? ✓ On the board + : ctl()} + + ) + })} + )} + {section('LOAD A SKILL FILE', T.accent, + + Any SKILL.md (or .md) on this Mac. New skills: copy foreman/skills/_template and fill it in. + {Input ? ctl( + { + const p = v.trim() + const text = p.endsWith('.md') ? await readText($, p) : undefined + if (text === undefined) return void setUi($, { view: 'skills', message: `No readable .md file at ${p}` }) + const fm = frontmatter(text) + await addJob(p, fm.name || p.split('/').slice(-2, -1)[0] || 'job') + }} /> + ) : (Loading by path needs the desktop or terminal app.)} + )} + + ) + } + + // ----- report ----- + const reportView = () => { + const r = view.report + if (!r) return No report open. + const job = r.jobId ? jobById(r.jobId) : undefined + const entry = job ? snap.states[job.id]?.history?.find(x => x.runId === r.runId) : undefined + const lines = r.text.split('\n') + const out: RenderChildren[] = [] + for (const raw of lines.slice(0, 220)) { + const line = raw.replace(/\*\*|`/g, '') + if (/^\s*\|?\s*:?-{2,}/.test(line)) continue + if (/^#{1,6}\s/.test(line)) out.push({line.replace(/^#+\s*/, '')}) + else if (/^\s*VERDICT:/i.test(line)) out.push({line.trim()}) + else if (line.trim().startsWith('|')) out.push({`• ${line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()).join(' · ')}`}) + else if (line.trim() === '---') out.push({'─'.repeat(Math.max(10, Math.min(60, cols - 8)))}) + else out.push({line || ' '}) + } + if (lines.length > 220) out.push({`… ${lines.length - 220} more lines in ${r.path}`}) + return ( + + + + {entry && entry.status === 'attention' && !snap.acks.includes(entry.runId) + ? + : null} + {entry && entry.status !== 'clear' && !snap.acks.includes(entry.runId) + ? + : null} + + + {section(`REPORT · ${r.title}`, T.accent, {out})} + + ) + } + + const body = + view.view === 'job' ? jobView() : view.view === 'skills' ? skillsView() : view.view === 'report' ? reportView() : boardView() + + return ( + + {header} + {toolbar} + {message} + {!schedulerOk && !snap.paused ? ( + + {`The Mac scheduler hasn't checked in${snap.lastTick ? ` for ${ago(snap.lastTick, now)}` : ''}. Scheduled jobs won't run until it does (run: node foreman/bin/runner.mjs install).`} + + ) : null} + {body} + + ) + }) +} diff --git a/foreman/mod/hooks/schedule.d.mts b/foreman/mod/hooks/schedule.d.mts new file mode 100644 index 0000000..0805963 --- /dev/null +++ b/foreman/mod/hooks/schedule.d.mts @@ -0,0 +1,12 @@ +export type Schedule = + | { kind: 'manual' } + | { kind: 'error'; error: string } + | { kind: 'times'; days: number[]; times: number[] } + | { kind: 'every'; days: number[]; everyMin: number; window: [number, number] | null } + +export function parseSchedule(text: string): Schedule +export function lastSlot(s: Schedule, now?: Date): Date | null +export function nextSlot(s: Schedule, now?: Date): Date | null +export function slotsBetween(s: Schedule, from: Date, to: Date): Date[] +export function describeSchedule(s: Schedule): string +export function graceMs(s: Schedule): number diff --git a/foreman/mod/hooks/schedule.mjs b/foreman/mod/hooks/schedule.mjs new file mode 100644 index 0000000..65615f6 --- /dev/null +++ b/foreman/mod/hooks/schedule.mjs @@ -0,0 +1,166 @@ +// Foreman's schedule language, shared by the pane (mod) and the runner (bin/runner.mjs). +// +// manual only when you press Run now +// mon-fri 08:00 11:00 14:00 set times on set days +// daily 07:30 every day +// mon,wed,fri 09:00 a list of days +// every 2h every two hours, all day +// every 30m mon-fri 07:00-17:00 every 30 minutes inside a window +// +// Times are the Mac's local time. + +const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] +const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6] +const MIN_EVERY = 5 + +const dayIndex = name => DAY_NAMES.indexOf(name.slice(0, 3)) + +function parseTime(token) { + let m = /^(\d{1,2}):(\d{2})$/.exec(token) + if (m) { + const h = Number(m[1]) + const min = Number(m[2]) + return h < 24 && min < 60 ? h * 60 + min : null + } + m = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/.exec(token) + if (m) { + let h = Number(m[1]) % 12 + if (m[3] === 'pm') h += 12 + const min = Number(m[2] ?? 0) + return min < 60 ? h * 60 + min : null + } + return null +} + +function parseDays(token) { + if (token === 'daily' || token === 'everyday' || token === 'every-day') return ALL_DAYS + if (token === 'weekdays') return [1, 2, 3, 4, 5] + if (token === 'weekends' || token === 'weekend') return [0, 6] + const range = /^([a-z]{3,9})-([a-z]{3,9})$/.exec(token) + if (range) { + const a = dayIndex(range[1]) + const b = dayIndex(range[2]) + if (a < 0 || b < 0) return null + const out = [] + for (let d = a; ; d = (d + 1) % 7) { + out.push(d) + if (d === b) break + } + return out + } + const list = token.split(',').filter(Boolean).map(dayIndex) + return list.length && list.every(d => d >= 0) ? list : null +} + +/** @returns {import('./schedule.d.mts').Schedule} */ +export function parseSchedule(text) { + const t = String(text ?? '').trim().toLowerCase() + if (t === '' || t === 'manual' || t === 'off') return { kind: 'manual' } + const tokens = t.split(/\s+/) + let days = null + const times = [] + let everyMin = null + let window = null + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i] + if (tok === 'at' || tok === 'and') continue + if (tok === 'every') { + const amount = tokens[++i] ?? '' + const m = /^(\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)$/.exec(amount) + if (!m) return { kind: 'error', error: `After "every" put an amount like 2h or 30m (got "${amount}").` } + everyMin = Number(m[1]) * (m[2].startsWith('h') ? 60 : 1) + if (everyMin < MIN_EVERY) return { kind: 'error', error: `The shortest interval is every ${MIN_EVERY}m.` } + continue + } + const win = /^(\S+)-(\d{1,2}:\d{2}(?:am|pm)?)$/.exec(tok) + if (win && parseTime(win[1]) !== null && parseTime(win[2]) !== null) { + window = [parseTime(win[1]), parseTime(win[2])] + continue + } + const time = parseTime(tok) + if (time !== null) { + times.push(time) + continue + } + const d = parseDays(tok) + if (d) { + days = [...new Set([...(days ?? []), ...d])].sort() + continue + } + return { kind: 'error', error: `Didn't understand "${tok}". Try "mon-fri 08:00 14:00" or "every 2h".` } + } + const useDays = days ?? ALL_DAYS + if (everyMin !== null) return { kind: 'every', days: useDays, everyMin, window } + if (times.length) return { kind: 'times', days: useDays, times: [...new Set(times)].sort((a, b) => a - b) } + return { kind: 'error', error: 'Add a time (08:00), an interval (every 2h), or "manual".' } +} + +function slotsOn(s, dayStart) { + if (s.kind !== 'times' && s.kind !== 'every') return [] + if (!s.days.includes(dayStart.getDay())) return [] + const mins = + s.kind === 'times' + ? s.times + : (() => { + const [from, to] = s.window ?? [0, 24 * 60 - 1] + const out = [] + for (let m = from; m <= to; m += s.everyMin) out.push(m) + return out + })() + return mins.map(m => new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), Math.floor(m / 60), m % 60)) +} + +const startOfDay = (d, offset) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset) + +/** The latest scheduled moment at or before `now`, or null. */ +export function lastSlot(s, now = new Date()) { + for (let back = 0; back <= 7; back++) { + const slots = slotsOn(s, startOfDay(now, -back)).filter(x => x <= now) + if (slots.length) return slots[slots.length - 1] + } + return null +} + +/** The next scheduled moment after `now`, or null. */ +export function nextSlot(s, now = new Date()) { + for (let ahead = 0; ahead <= 7; ahead++) { + const slot = slotsOn(s, startOfDay(now, ahead)).find(x => x > now) + if (slot) return slot + } + return null +} + +/** Every slot between from and to (inclusive), oldest first. */ +export function slotsBetween(s, from, to) { + const out = [] + for (let d = startOfDay(from, 0); d <= to; d = startOfDay(d, 1)) { + for (const x of slotsOn(s, d)) if (x >= from && x <= to) out.push(x) + } + return out +} + +const hhmm = m => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}` + +function daysLabel(days) { + const key = [...days].sort().join(',') + if (key === '0,1,2,3,4,5,6') return 'every day' + if (key === '1,2,3,4,5') return 'Mon–Fri' + if (key === '0,6') return 'weekends' + return days.map(d => DAY_LABEL[d]).join(', ') +} + +/** A short human description. */ +export function describeSchedule(s) { + if (s.kind === 'manual') return 'Manual only' + if (s.kind === 'error') return `⚠ ${s.error}` + if (s.kind === 'times') return `${daysLabel(s.days)} at ${s.times.map(hhmm).join(', ')}` + const every = s.everyMin % 60 === 0 ? `${s.everyMin / 60}h` : `${s.everyMin}m` + return `Every ${every}, ${daysLabel(s.days)}${s.window ? `, ${hhmm(s.window[0])}–${hhmm(s.window[1])}` : ''}` +} + +/** How late a missed slot may still run (the Mac was asleep, say). */ +export function graceMs(s) { + const twoHours = 2 * 60 * 60 * 1000 + return s.kind === 'every' ? Math.min(twoHours, s.everyMin * 60 * 1000) : twoHours +} diff --git a/foreman/mod/tests/foreman.test.tsx b/foreman/mod/tests/foreman.test.tsx new file mode 100644 index 0000000..ac42c71 --- /dev/null +++ b/foreman/mod/tests/foreman.test.tsx @@ -0,0 +1,155 @@ +import { expect, mock, test } from 'claude-code/testing' +import type { On } from 'claude-code' + +const ROOT = '/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/foreman' +const SURFACES = ['terminal', 'desktop'] as const +const PANE = { + component: 'Pane', + requestId: 'foreman', + // scroll and view are engine-filled; the pane never reads them. + props: { title: '👷 Foreman', isFocused: false, bodyColumns: 120, placement: 'dock' } as never, +} as const + +// An in-memory copy of Foreman's folder, answering the plugin's $.fs calls. +function fakeDisk(on: On, files: Record) { + const disk = new Map(Object.entries(files)) + const runs: string[][] = [] + on('fs.read', (_$, e) => (disk.has(e.path) ? { value: disk.get(e.path)! } : { deny: 'ENOENT' })) + on('fs.write', (_$, e) => { + disk.set(e.path, e.text) + return { value: undefined } + }) + on('fs.list', (_$, e) => { + const prefix = `${e.path}/` + const names = new Set() + for (const p of disk.keys()) if (p.startsWith(prefix)) names.add(p.slice(prefix.length).split('/')[0]!) + if (names.size === 0) return { deny: 'ENOENT' } + return { value: [...names].map(name => ({ name, kind: disk.has(prefix + name) ? ('file' as const) : ('dir' as const), size: 0, mtimeMs: 0, isLink: false })) } + }) + on('process.run', (_$, e) => { + runs.push([...e.argv]) + return { value: { exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } } + }) + on('ui.open', () => ({ value: { isPlaced: true as const } })) + return { disk, runs } +} + +const jobsJson = (jobs: unknown[]) => JSON.stringify({ paused: false, node: '/usr/local/bin/node', jobs }) +const NOW = new Date(2026, 9, 9, 10, 30).getTime() // Fri 9 Oct 2026 10:30 + +test('an empty Foreman draws his guide on every surface', async ($, on) => { + mock.clock(on, { now: NOW }) + mock.store(on) + fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) }) + await $.command.run({ command: 'foreman', args: '' } as never) + for (const surface of SURFACES) { + const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE }) + expect(await ui.find({ type: 'Text', text: /^Foreman$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /No jobs yet/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /Scheduler not running/ })).toBeDefined() + await ui.unmount() + } +}) + +test('the board shows what is next, what needs attention, and Reviewed clears it', async ($, on) => { + mock.clock(on, { now: NOW }) + mock.store(on) + const run = { + runId: 'notes-2026-10-09-08-00', trigger: 'schedule', startedAt: new Date(2026, 9, 9, 8, 0).toISOString(), + status: 'attention', attentionCount: 2, summary: 'Two delivery notes have no purchase order.', report: 'runs/notes/x.md', + } + const { disk } = fakeDisk(on, { + [`${ROOT}/jobs.json`]: jobsJson([{ id: 'notes', name: 'Delivery notes check', skill: 'skills/notes/SKILL.md', schedule: 'mon-fri 08:00 11:00 14:00', enabled: true }]), + [`${ROOT}/state/notes.json`]: JSON.stringify({ id: 'notes', status: 'attention', lastRun: run, history: [run] }), + [`${ROOT}/state/scheduler.json`]: JSON.stringify({ lastTick: new Date(NOW - 30_000).toISOString() }), + [`${ROOT}/runs/notes/x.md`]: '# Delivery notes check\n\nVERDICT: ATTENTION (2 items)\n\n## Summary\nTwo notes lack a PO.', + [`${ROOT}/skills/notes/SKILL.md`]: '---\nname: notes\ndescription: Checks delivery notes\n---\nbody', + }) + await $.command.run({ command: 'foreman', args: '' } as never) + for (const surface of SURFACES) { + const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE }) + expect(await ui.find({ type: 'Text', text: /^UP NEXT · 1$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /today 11:00/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^NEEDS ATTENTION · 1$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /Scheduler on/ })).toBeDefined() + await ui.unmount() + } + const ui = await $.ui.mount({ plugin: 'foreman', surface: 'desktop', ...PANE }) + await ui.press({ key: `rep-${run.runId}` }) + expect(await ui.find({ type: 'Text', text: /VERDICT: ATTENTION/ })).toBeDefined() + await ui.press({ key: 'rack' }) + expect(JSON.parse(disk.get(`${ROOT}/state/acks.json`) ?? '[]')).toContain(run.runId) + await ui.press({ key: 'rback' }) + expect(await ui.find({ type: 'Text', text: /^NEEDS ATTENTION · 0$/ })).toBeDefined() + await ui.unmount() +}) + +test('a skill goes on the board, its schedule is edited, and Run now starts the runner', async ($, on) => { + mock.clock(on, { now: NOW }) + mock.store(on) + const { disk, runs } = fakeDisk(on, { + [`${ROOT}/jobs.json`]: jobsJson([]), + [`${ROOT}/skills/notes/SKILL.md`]: '---\nname: Delivery notes check\ndescription: Checks delivery notes\n---\nbody', + }) + await $.command.run({ command: 'foreman', args: '' } as never) + const ui = await $.ui.mount({ plugin: 'foreman', surface: 'desktop', ...PANE }) + await ui.press({ key: 'nav-skills' }) + await ui.press({ key: `addskill-${ROOT}/skills/notes/SKILL.md` }) + const jobs = () => JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs as Array> + expect(jobs()[0]).toMatchObject({ id: 'delivery-notes-check', skill: 'skills/notes/SKILL.md', enabled: false }) + + await ui.input({ key: 'sched-delivery-notes-check', text: 'banana 8:00' }) + expect(await ui.find({ type: 'Text', text: /Didn't understand "banana"/ })).toBeDefined() + expect(jobs()[0]?.schedule).toBe('mon-fri 08:00') + + await ui.input({ key: 'sched-delivery-notes-check', text: 'every 2h mon-fri 07:00-17:00' }) + expect(jobs()[0]?.schedule).toBe('every 2h mon-fri 07:00-17:00') + expect(await ui.find({ type: 'Text', text: /Every 2h, Mon–Fri, 07:00–17:00/ })).toBeDefined() + + await ui.press({ key: 'etoggle-delivery-notes-check' }) + expect(jobs()[0]?.enabled).toBe(true) + + await ui.press({ key: 'erun-delivery-notes-check' }) + expect(runs.at(-1)?.join(' ')).toContain('runner.mjs') + expect(runs.at(-1)?.join(' ')).toContain('run delivery-notes-check') + await ui.unmount() +}) + +test('the theme button cycles through the bright themes', async ($, on) => { + mock.clock(on, { now: NOW }) + mock.store(on) + fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) }) + for (const surface of SURFACES) { + const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE }) + const before = (await ui.find({ key: 'theme' }))?.text ?? '' + await ui.press({ key: 'theme' }) + expect((await ui.find({ key: 'theme' }))?.text).not.toBe(before) + await ui.unmount() + } +}) + +test('dark themes (Dracula, Monokai) still draw every control', async ($, on) => { + mock.clock(on, { now: NOW }) + mock.store(on) + fakeDisk(on, { + [`${ROOT}/jobs.json`]: jobsJson([{ id: 'notes', name: 'Delivery notes check', skill: 'skills/notes/SKILL.md', schedule: 'mon-fri 08:00', enabled: true }]), + }) + await $.command.run({ command: 'foreman', args: '' } as never) + for (const surface of SURFACES) { + const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE }) + const seen: string[] = [] + for (let i = 0; i < 8; i++) { + const label = (await ui.find({ key: 'theme' }))?.text ?? '' + seen.push(label) + if (/Dracula|Monokai/.test(label)) { + expect(await ui.find({ key: 'run-notes' })).toBeDefined() + expect(await ui.find({ key: 'toggle-notes' })).toBeDefined() + expect(await ui.find({ key: 'nav-skills' })).toBeDefined() + } + await ui.press({ key: 'theme' }) + } + expect(seen.some(l => l.includes('Dracula'))).toBe(true) + expect(seen.some(l => l.includes('Monokai'))).toBe(true) + await ui.unmount() + } +}) diff --git a/foreman/mod/types/index.d.ts b/foreman/mod/types/index.d.ts new file mode 100644 index 0000000..6fad592 --- /dev/null +++ b/foreman/mod/types/index.d.ts @@ -0,0 +1,64 @@ +export type Job = { + id: string + name: string + skill: string + schedule: string + enabled: boolean + permission?: 'smart' | 'full' + timeoutMin?: number +} + +export type RunEntry = { + runId: string + trigger: 'schedule' | 'manual' + slot?: string + startedAt: string + endedAt?: string + status: 'clear' | 'attention' | 'failed' + attentionCount?: number | null + summary?: string + report?: string + toolCount?: number + tokensIn?: number + tokensOut?: number + model?: string +} + +export type Activity = { at: string; kind: string; text: string } + +export type JobState = { + id: string + status: 'idle' | 'running' | 'clear' | 'attention' | 'failed' + pid?: number + current?: { runId: string; trigger: string; startedAt: string; toolCount: number; activity: Activity[] } + lastRun?: RunEntry + history?: RunEntry[] +} + +export type SkillInfo = { path: string; folder: string; name: string; description: string; source: 'foreman' | 'project' } + +export type Snapshot = { + jobs: Job[] + paused: boolean + node?: string + states: Record + acks: string[] + skills: SkillInfo[] + lastTick?: string +} + +export type View = 'board' | 'job' | 'skills' | 'report' + +export type UiState = { + view: View + jobId?: string + report?: { title: string; path: string; text: string; runId?: string; jobId?: string } + confirmRemove?: string + message?: string +} + +declare module 'claude-code' { + interface PluginState { + foreman: { snap: Snapshot; ui: UiState; theme: string } + } +} diff --git a/foreman/skills/_template/SKILL.md b/foreman/skills/_template/SKILL.md new file mode 100644 index 0000000..9c94d15 --- /dev/null +++ b/foreman/skills/_template/SKILL.md @@ -0,0 +1,40 @@ +--- +name: +description: +--- + + + +# + +## Goal + + +## Where to look +- +- +- + +## How to check each item +1. +2. +3. + +## Rules +- Treat as fine (no attention needed): +- Flag for attention: +- Never: + +## Domain notes + + +## Examples +- +- diff --git a/foreman/skills/raw-docs-inventory/SKILL.md b/foreman/skills/raw-docs-inventory/SKILL.md new file mode 100644 index 0000000..59cf542 --- /dev/null +++ b/foreman/skills/raw-docs-inventory/SKILL.md @@ -0,0 +1,25 @@ +--- +name: raw-docs-inventory +description: Demo job. Lists the documents in RAW_DOCS and flags anything that looks wrong. Read-only. +--- + +# RAW_DOCS inventory (demo) + +## Goal +Give a quick picture of what is waiting in the project's `RAW_DOCS` folder, so nothing sits there unnoticed. + +## Where to look +- The folder `RAW_DOCS` in the project folder (list it with your file tools; do not open other folders). + +## How to check each item +1. List every file in `RAW_DOCS` with its type (pdf, xls, xlsx, jpeg, ...) and size. +2. From the file name, note which site and which month it seems to belong to, when the name says so. + +## Rules +- Fine: normal reports and scans with a recognisable site or month in the name. +- Flag for attention: empty files (0 bytes), files whose name gives no site or month, and obvious duplicates (same site and month twice). +- Never: open, change, move, rename or delete any file content. Only list and report. + +## Domain notes +- Site names that appear in file names include The Hills, Autumns Gate, Cosmo City, Pitsana Mills, West Park, Castle Gate. +- "MatrixTReport" files are casual-worker hours reports. diff --git a/tsconfig.json b/tsconfig.json index 7bbb10f..9046f44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "foreman" ] }