chore: add foreman scheduler mod; keep it out of the app type-check
foreman/ is a Claude Code mod (job runner + hooks) that imports the claude-code runtime types, so tsc over the app failed on it. Excluding it from tsconfig keeps the Next build green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3cd4101ca6
commit
e97bf93c7d
15 changed files with 1575 additions and 1 deletions
8
foreman/.claude-plugin/marketplace.json
Normal file
8
foreman/.claude-plugin/marketplace.json
Normal file
|
|
@ -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." }
|
||||
]
|
||||
}
|
||||
4
foreman/.gitignore
vendored
Normal file
4
foreman/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Foreman's run output stays on this machine
|
||||
runs/
|
||||
state/
|
||||
mod/.claude-plugin/types/
|
||||
340
foreman/bin/runner.mjs
Normal file
340
foreman/bin/runner.mjs
Normal file
|
|
@ -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 <job>`.
|
||||
//
|
||||
// node runner.mjs tick run every job that is due now
|
||||
// node runner.mjs run <jobId> 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 (<n> 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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>${node}</string><string>${runner}</string><string>tick</string></array>
|
||||
<key>StartInterval</key><integer>60</integer>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>${SCHED_LOG}</string>
|
||||
<key>StandardErrorPath</key><string>${SCHED_LOG}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key><string>${path.dirname(node)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key><string>${os.homedir()}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
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 <jobId> | install | uninstall | status')
|
||||
process.exit(cmd ? 1 : 0)
|
||||
}
|
||||
5
foreman/jobs.json
Normal file
5
foreman/jobs.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"paused": true,
|
||||
"jobs": [],
|
||||
"node": "/Volumes/KIOXIA/.hermes/node/bin/node"
|
||||
}
|
||||
7
foreman/mod/.claude-plugin/plugin.json
Normal file
7
foreman/mod/.claude-plugin/plugin.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
1
foreman/mod/hooks/hooks.json
Normal file
1
foreman/mod/hooks/hooks.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "modules": ["./register.tsx"] }
|
||||
142
foreman/mod/hooks/mascot.ts
Normal file
142
foreman/mod/hooks/mascot.ts
Normal file
|
|
@ -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<string, Theme>
|
||||
|
||||
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<Mood, string> = {
|
||||
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<Mood, string> = {
|
||||
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 = '') =>
|
||||
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
|
||||
|
||||
const bob = (dy: number, dur: string, begin = '0s') =>
|
||||
`<animateTransform attributeName="transform" type="translate" values="0 0;0 ${dy};0 0" dur="${dur}" begin="${begin}" repeatCount="indefinite" calcMode="discrete"/>`
|
||||
|
||||
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
|
||||
? `<rect x="${x}" y="2" width="1" height="2" fill="${eye}">
|
||||
<animate attributeName="height" values="2;2;0.3;2" keyTimes="0;0.9;0.95;1" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="2;2;3.7;2" keyTimes="0;0.9;0.95;1" dur="3s" repeatCount="indefinite"/></rect>`
|
||||
: 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
|
||||
? `<g>${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)}
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2.6s" repeatCount="indefinite"/></g>`
|
||||
: running
|
||||
? [0, 0.8, 1.6]
|
||||
.map(
|
||||
(d, i) => `<rect x="${16 + (i % 2) * 0.6}" y="2" width="0.5" height="0.5" fill="${CRAB.light}">
|
||||
<animate attributeName="y" values="2;-2.5" dur="2.4s" begin="${d}s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="1;0" dur="2.4s" begin="${d}s" repeatCount="indefinite"/></rect>`,
|
||||
)
|
||||
.join('')
|
||||
: happy
|
||||
? `<g>${px(16.5, -3, 0.5, 1.5, t.good)}${px(16, -2.5, 1.5, 0.5, t.good)}
|
||||
<animate attributeName="opacity" values="1;0.2;1" dur="1.4s" repeatCount="indefinite"/></g>
|
||||
<g>${px(-0.5, -2.5, 0.5, 1, HAT.main)}${px(-0.75, -2.25, 1, 0.5, HAT.main)}
|
||||
<animate attributeName="opacity" values="0.2;1;0.2" dur="1.4s" repeatCount="indefinite"/></g>`
|
||||
: alert
|
||||
? `<g>${px(16.25, -3.5, 1, 2.5, t.warn)}${px(16.25, -0.5, 1, 1, t.warn)}
|
||||
<animate attributeName="opacity" values="1;0.35;1" dur="1s" repeatCount="indefinite"/></g>`
|
||||
: px(5.25, 4.5, 0.5, 0.75, CRAB.light) + px(12.25, 4.5, 0.5, 0.75, CRAB.light)
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -5 21 16" width="132" height="100" shape-rendering="crispEdges">
|
||||
${px(1, 10.4, 14, 0.4, t.muted, ' opacity="0.25"')}
|
||||
<g>${running ? bob(-0.25, '0.8s') : ''}
|
||||
<g>${running ? bob(-0.5, '0.4s') : ''}${legsA}</g>
|
||||
<g>${running ? bob(-0.5, '0.4s', '0.2s') : ''}${legsB}</g>
|
||||
<g>${running ? bob(-0.5, '0.6s') : ''}${px(0, 4, 2, 2, body)}${px(14, 4, 2, 2, body)}</g>
|
||||
${px(2, 0, 12, 8, body)}
|
||||
${vest}
|
||||
${eyes}
|
||||
${monogram(6.75, 5, HAT.main)}
|
||||
${hat}
|
||||
</g>
|
||||
${extras}
|
||||
</svg>`
|
||||
}
|
||||
604
foreman/mod/hooks/register.tsx
Normal file
604
foreman/mod/hooks/register.tsx
Normal file
|
|
@ -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 = <T,>(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<string | undefined> {
|
||||
try {
|
||||
const got: unknown = await $.fs.read(path)
|
||||
return typeof got === 'string' ? got : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function frontmatter(text: string): Record<string, string> {
|
||||
const m = /^---\n([\s\S]*?)\n---/.exec(text)
|
||||
const out: Record<string, string> = {}
|
||||
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<SkillInfo[]> {
|
||||
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<void> {
|
||||
const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {})
|
||||
const jobs = Array.isArray(cfg.jobs) ? cfg.jobs : []
|
||||
const states: Record<string, JobState> = {}
|
||||
for (const job of jobs) {
|
||||
const st = parseJson<JobState | undefined>(await readText($, `${ROOT}/state/${job.id}.json`), undefined)
|
||||
if (st) states[job.id] = st
|
||||
}
|
||||
const acks = parseJson<string[]>(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<void> {
|
||||
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<UiState>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const acks = parseJson<string[]>(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<void> {
|
||||
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) => (
|
||||
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={T.line} paddingX={1}>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Text color={color} bold>{title}</Text>
|
||||
{right ?? null}
|
||||
</Box>
|
||||
{body}
|
||||
</Box>
|
||||
)
|
||||
// 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 ? <Box backgroundColor={T.control} paddingX={1} flexDirection="column">{child}</Box> : 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 color={color} bold>{`● ${text}`}</Text>
|
||||
|
||||
// ----- header -----
|
||||
const header = (
|
||||
<Box flexDirection="row" gap={2} alignItems="center">
|
||||
{svg
|
||||
? h(svg, { source: foremanSvg(m, T), alt: `Foreman: ${MOOD_LABEL[m]}`, width: 132, height: 100, isInteractive: true })
|
||||
: <Text color={T.run} bold>{ASCII[m]}</Text>}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text color={T.text} bold>Foreman</Text>
|
||||
<Text color={T.muted}>Mega Infrastructure · site agent · runs on MiniMax</Text>
|
||||
<Text color={m === 'attention' ? T.warn : m === 'failed' ? T.bad : m === 'running' ? T.run : T.sub}>{MOOD_LABEL[m]}</Text>
|
||||
<Box flexDirection="row" gap={2} marginTop={1}>
|
||||
{snap.paused
|
||||
? pill('Schedule paused', T.warn)
|
||||
: schedulerOk
|
||||
? pill(`Scheduler on · ${ago(snap.lastTick, now)} ago`, T.good)
|
||||
: pill('Scheduler not running', T.bad)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// ----- toolbar -----
|
||||
const toolbar = (
|
||||
<Box {...controlRow()}>
|
||||
<Button key="nav-board" variant={view.view === 'board' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'board' })}>Board</Button>
|
||||
<Button key="nav-skills" variant={view.view === 'skills' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'skills' })}>Skills</Button>
|
||||
<Button key="pause" onPress={() => void saveJobs($, cfg => void (cfg.paused = !cfg.paused))}>
|
||||
{snap.paused ? '▶ Resume schedule' : '⏸ Pause schedule'}
|
||||
</Button>
|
||||
<Button key="theme" onPress={async () => {
|
||||
const nextId = await update($, themeAtom, cur => {
|
||||
const i = THEME_ORDER.indexOf(isThemeId(cur) ? cur : 'hivis')
|
||||
return THEME_ORDER[(i + 1) % THEME_ORDER.length] ?? 'hivis'
|
||||
})
|
||||
await $.store.set(THEME_KEY, nextId)
|
||||
}}>{`🎨 ${T.name}`}</Button>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const message = view.message ? <Text color={T.warn} bold>{view.message}</Text> : 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(<Button key={`runnow-${job.id}`} plain dimColor={!T.control} onPress={() => void runNow($, job)}>▶ Run now</Button>),
|
||||
})
|
||||
}
|
||||
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: (
|
||||
<Box {...controlRow()}>
|
||||
{r.report ? <Button key={`rep-${r.runId}`} plain onPress={() => void openReport($, job, r)}>📄 Report</Button> : null}
|
||||
{r.report && r.status === 'attention' ? <Button key={`ask-${r.runId}`} plain onPress={() => void askClaude($, job, r)}>✦ Ask Claude</Button> : null}
|
||||
<Button key={`ack-${r.runId}`} plain onPress={() => void acknowledge($, r.runId)}>✓ Reviewed</Button>
|
||||
</Box>
|
||||
),
|
||||
})
|
||||
} 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(<Button key={`rep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
upNext.sort((a, b) => a.lines[0]![0].localeCompare(b.lines[0]![0]))
|
||||
|
||||
const column = (title: string, color: string, cards: Card[], empty: string) => (
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} width={isWide ? '25%' : '100%'} backgroundColor={T.chip} borderStyle="round" borderColor={T.line} paddingX={1}>
|
||||
<Text color={color} bold>{`${title} · ${cards.length}`}</Text>
|
||||
{cards.length === 0 ? <Text color={T.muted}>{empty}</Text> : null}
|
||||
{cards.slice(0, 6).map(c => (
|
||||
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={c.color} paddingX={1}>
|
||||
<Text color={T.text} bold wrap="truncate-end">{c.title}</Text>
|
||||
{c.lines.map(([t, col]) => <Text color={col} wrap="wrap">{t}</Text>)}
|
||||
{c.buttons}
|
||||
</Box>
|
||||
))}
|
||||
{cards.length > 6 ? <Text color={T.muted}>{`+${cards.length - 6} more`}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const board = (
|
||||
<Box flexDirection={isWide ? 'row' : 'column'} gap={1}>
|
||||
{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 👍')}
|
||||
</Box>
|
||||
)
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={job.enabled ? T.accent : T.line} paddingX={1}>
|
||||
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
||||
{ctl(<Button key={`toggle-${job.id}`} plain onPress={() => void saveJobs($, cfg => {
|
||||
const j = cfg.jobs.find(x => x.id === job.id)
|
||||
if (j) j.enabled = !j.enabled
|
||||
})}>{job.enabled ? '🟢 On' : '⚪ Off'}</Button>)}
|
||||
<Text color={T.text} bold>{job.name}</Text>
|
||||
{st?.status === 'running' ? pill('running', T.run) : null}
|
||||
</Box>
|
||||
<Text color={sched.kind === 'error' ? T.bad : T.sub}>{`🗓 ${describeSchedule(sched)}${nxt ? ` · next ${whenLabel(nxt, now)}` : ''}`}</Text>
|
||||
{last ? (
|
||||
<Text color={last.status === 'clear' ? T.good : last.status === 'failed' ? T.bad : T.warn}>
|
||||
{`Last: ${last.status === 'clear' ? 'all clear' : last.status === 'failed' ? 'failed' : `${last.attentionCount ?? '?'} to check`} · ${whenLabel(new Date(last.startedAt), now)}`}
|
||||
</Text>
|
||||
) : <Text color={T.muted}>Never run yet</Text>}
|
||||
<Box {...controlRow()}>
|
||||
<Button key={`run-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
|
||||
<Button key={`edit-${job.id}`} onPress={() => void setUi($, { view: 'job', jobId: job.id })}>✎ Edit</Button>
|
||||
{last?.report ? <Button key={`last-${job.id}`} onPress={() => void openReport($, job, last)}>📄 Last report</Button> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{board}
|
||||
{section(`JOBS · ${snap.jobs.length}`, T.accent,
|
||||
<Box flexDirection="column" gap={0}>
|
||||
{snap.jobs.length === 0 ? <Text color={T.muted}>No jobs yet. Open Skills and add one to the board.</Text> : jobRows}
|
||||
</Box>,
|
||||
ctl(<Button key="add-job" plain onPress={() => void setUi($, { view: 'skills' })}>+ Add from a skill</Button>))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- job editor -----
|
||||
const jobView = () => {
|
||||
const job = view.jobId ? jobById(view.jobId) : undefined
|
||||
if (!job) return <Text color={T.muted}>That job is gone.</Text>
|
||||
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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{section(`EDIT · ${job.name}`, T.accent,
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={T.muted} wrap="truncate-start">{`Skill: ${job.skill}`}</Text>
|
||||
{Input ? ctl(
|
||||
<Input key={`name-${job.id}`} label="Name" value={job.name} submitLabel="Save"
|
||||
onSubmit={v => void (v.trim() ? patch(j => void (j.name = v.trim())) : undefined)} />
|
||||
) : <Text color={T.text}>{`Name: ${job.name}`}</Text>}
|
||||
{Input ? ctl(
|
||||
<Input key={`sched-${job.id}`} label="When" value={job.schedule} submitLabel="Save"
|
||||
placeholder="mon-fri 08:00 11:00 14:00"
|
||||
onSubmit={async v => {
|
||||
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)}`)
|
||||
}} />
|
||||
) : <Text color={T.text}>{`When: ${job.schedule}`}</Text>}
|
||||
<Text color={sched.kind === 'error' ? T.bad : T.good}>{`→ ${describeSchedule(sched)}`}</Text>
|
||||
<Text color={T.muted}>Examples: mon-fri 08:00 11:00 14:00 · daily 07:30 · every 2h mon-fri 07:00-17:00 · manual</Text>
|
||||
{Select ? ctl(
|
||||
<Select key={`perm-${job.id}`} label="Permissions" value={job.permission ?? 'smart'}
|
||||
options={[
|
||||
{ value: 'smart', label: 'Careful (smart): asks MiniMax to stay cautious' },
|
||||
{ value: 'full', label: 'Full access: may change files and run commands' },
|
||||
]}
|
||||
onSelect={v => void patch(j => void (j.permission = v === 'full' ? 'full' : 'smart'))} />
|
||||
) : null}
|
||||
{Input ? ctl(
|
||||
<Input key={`timeout-${job.id}`} label="Time limit (minutes)" value={String(job.timeoutMin ?? 20)} submitLabel="Save"
|
||||
onSubmit={v => {
|
||||
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}
|
||||
<Box {...controlRow()}>
|
||||
<Button key={`etoggle-${job.id}`} variant="primary" onPress={() => void patch(j => void (j.enabled = !j.enabled))}>
|
||||
{job.enabled ? '🟢 On: turn off' : '⚪ Off: turn on'}
|
||||
</Button>
|
||||
<Button key={`erun-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
|
||||
{view.confirmRemove === job.id ? (
|
||||
<Button key={`econfirm-${job.id}`} onPress={async () => {
|
||||
await saveJobs($, cfg => void (cfg.jobs = cfg.jobs.filter(x => x.id !== job.id)))
|
||||
await setUi($, { view: 'board' })
|
||||
}}>Yes, remove it</Button>
|
||||
) : (
|
||||
<Button key={`eremove-${job.id}`} dimColor onPress={() => void setUi($, { view: 'job', jobId: job.id, confirmRemove: job.id })}>Remove job</Button>
|
||||
)}
|
||||
<Button key="eback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
|
||||
</Box>
|
||||
</Box>)}
|
||||
{section('RECENT RUNS', T.accent,
|
||||
<Box flexDirection="column">
|
||||
{(st?.history ?? []).length === 0 ? <Text color={T.muted}>No runs yet.</Text> : null}
|
||||
{(st?.history ?? []).slice(0, 8).map(r => (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={r.status === 'clear' ? T.good : r.status === 'failed' ? T.bad : T.warn} bold>
|
||||
{r.status === 'clear' ? '✓' : r.status === 'failed' ? '✗' : '!'}
|
||||
</Text>
|
||||
<Text color={T.sub}>{`${whenLabel(new Date(r.startedAt), now)} · ${r.trigger}`}</Text>
|
||||
{r.report ? ctl(<Button key={`hrep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- 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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{section('SKILLS', T.accent,
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Text color={T.muted}>A skill is one job's instructions. Add it to the board, then set when it runs.</Text>
|
||||
{snap.skills.length === 0 ? <Text color={T.muted}>No skills found in foreman/skills yet.</Text> : null}
|
||||
{snap.skills.map(s => {
|
||||
const added = onBoard.has(relOrAbs(s.path))
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={added ? T.good : T.line} paddingX={1}>
|
||||
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
||||
<Text color={T.text} bold>{s.name}</Text>
|
||||
<Text color={T.muted}>{s.source === 'project' ? '(project skill)' : ''}</Text>
|
||||
</Box>
|
||||
{s.description ? <Text color={T.sub} wrap="wrap">{s.description}</Text> : null}
|
||||
{added
|
||||
? <Text color={T.good}>✓ On the board</Text>
|
||||
: ctl(<Button key={`addskill-${s.path}`} variant="primary" onPress={() => void addJob(s.path, s.name)}>+ Add to board</Button>)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>)}
|
||||
{section('LOAD A SKILL FILE', T.accent,
|
||||
<Box flexDirection="column">
|
||||
<Text color={T.muted} wrap="wrap">Any SKILL.md (or .md) on this Mac. New skills: copy foreman/skills/_template and fill it in.</Text>
|
||||
{Input ? ctl(
|
||||
<Input key="loadpath" label="Path" placeholder="/Volumes/EXT/.../SKILL.md" submitLabel="Load"
|
||||
onSubmit={async v => {
|
||||
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')
|
||||
}} />
|
||||
) : <Text color={T.muted}>(Loading by path needs the desktop or terminal app.)</Text>}
|
||||
</Box>)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ----- report -----
|
||||
const reportView = () => {
|
||||
const r = view.report
|
||||
if (!r) return <Text color={T.muted}>No report open.</Text>
|
||||
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(<Text color={T.accent} bold>{line.replace(/^#+\s*/, '')}</Text>)
|
||||
else if (/^\s*VERDICT:/i.test(line)) out.push(<Text color={/CLEAR/i.test(line) ? T.good : T.warn} bold>{line.trim()}</Text>)
|
||||
else if (line.trim().startsWith('|')) out.push(<Text color={T.text} wrap="wrap">{`• ${line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()).join(' · ')}`}</Text>)
|
||||
else if (line.trim() === '---') out.push(<Text color={T.line}>{'─'.repeat(Math.max(10, Math.min(60, cols - 8)))}</Text>)
|
||||
else out.push(<Text color={line.startsWith('- ') ? T.sub : T.text} wrap="wrap">{line || ' '}</Text>)
|
||||
}
|
||||
if (lines.length > 220) out.push(<Text color={T.muted}>{`… ${lines.length - 220} more lines in ${r.path}`}</Text>)
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box {...controlRow()}>
|
||||
<Button key="rback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
|
||||
{entry && entry.status === 'attention' && !snap.acks.includes(entry.runId)
|
||||
? <Button key="rask" variant="primary" onPress={() => void askClaude($, job, entry)}>✦ Ask Claude to review</Button>
|
||||
: null}
|
||||
{entry && entry.status !== 'clear' && !snap.acks.includes(entry.runId)
|
||||
? <Button key="rack" onPress={() => void acknowledge($, entry.runId)}>✓ Mark reviewed</Button>
|
||||
: null}
|
||||
<Button key="rcopy" onPress={p => void $.ui.copy({ text: r.path, surface: p.surface })}>Copy path</Button>
|
||||
</Box>
|
||||
{section(`REPORT · ${r.title}`, T.accent, <Box flexDirection="column">{out}</Box>)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const body =
|
||||
view.view === 'job' ? jobView() : view.view === 'skills' ? skillsView() : view.view === 'report' ? reportView() : boardView()
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} backgroundColor={T.bg} padding={1}>
|
||||
{header}
|
||||
{toolbar}
|
||||
{message}
|
||||
{!schedulerOk && !snap.paused ? (
|
||||
<Text color={T.bad} wrap="wrap">
|
||||
{`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).`}
|
||||
</Text>
|
||||
) : null}
|
||||
{body}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
12
foreman/mod/hooks/schedule.d.mts
Normal file
12
foreman/mod/hooks/schedule.d.mts
Normal file
|
|
@ -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
|
||||
166
foreman/mod/hooks/schedule.mjs
Normal file
166
foreman/mod/hooks/schedule.mjs
Normal file
|
|
@ -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
|
||||
}
|
||||
155
foreman/mod/tests/foreman.test.tsx
Normal file
155
foreman/mod/tests/foreman.test.tsx
Normal file
|
|
@ -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<string, string>) {
|
||||
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<string>()
|
||||
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<Record<string, unknown>>
|
||||
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()
|
||||
}
|
||||
})
|
||||
64
foreman/mod/types/index.d.ts
vendored
Normal file
64
foreman/mod/types/index.d.ts
vendored
Normal file
|
|
@ -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<string, JobState>
|
||||
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 }
|
||||
}
|
||||
}
|
||||
40
foreman/skills/_template/SKILL.md
Normal file
40
foreman/skills/_template/SKILL.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: <short-job-name>
|
||||
description: <one line: what this job checks and why>
|
||||
---
|
||||
|
||||
<!--
|
||||
Foreman skill template.
|
||||
Copy this folder to foreman/skills/<your-job-name>/ and fill in every section.
|
||||
Foreman (MiniMax Code) reads this file top to bottom each time the job runs,
|
||||
with no memory of earlier runs, so write it for someone starting cold.
|
||||
Foreman adds its own report rules after this text (VERDICT line, Summary,
|
||||
Needs attention table, What I checked); don't repeat them here.
|
||||
-->
|
||||
|
||||
# <Job title>
|
||||
|
||||
## Goal
|
||||
<What this job is for, in 2-3 sentences. What does "done well" look like?>
|
||||
|
||||
## Where to look
|
||||
- <Tools / MCP servers to use, e.g. "the MegaDash MCP (megadash-direct): delivery_note_queue, get_delivery_note, get_purchase_order">
|
||||
- <Folders or files, if any>
|
||||
- <Which items: e.g. "delivery notes received since the last working day, status = needs_review">
|
||||
|
||||
## How to check each item
|
||||
1. <Step by step: what to open, what to compare with what>
|
||||
2. <e.g. "Match each delivery line to its purchase order line by item code and quantity">
|
||||
3. <e.g. "Check the supplier, site and date are the same on both">
|
||||
|
||||
## Rules
|
||||
- Treat as fine (no attention needed): <e.g. quantity differences under 2%, rounding of prices to the cent>
|
||||
- Flag for attention: <e.g. no matching purchase order, price differs by more than R50, wrong site>
|
||||
- Never: <e.g. change, approve, post or delete anything. This job only reads and reports.>
|
||||
|
||||
## Domain notes
|
||||
<Anything a newcomer would get wrong: supplier nicknames, how sites are named, known quirks of the documents.>
|
||||
|
||||
## Examples
|
||||
- <A real example of something that should be flagged, and why>
|
||||
- <A real example of something that looks odd but is fine>
|
||||
25
foreman/skills/raw-docs-inventory/SKILL.md
Normal file
25
foreman/skills/raw-docs-inventory/SKILL.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"foreman"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue