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:
DEls 2026-10-09 12:05:50 +02:00
parent 3cd4101ca6
commit e97bf93c7d
15 changed files with 1575 additions and 1 deletions

View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
{
"paused": true,
"jobs": [],
"node": "/Volumes/KIOXIA/.hermes/node/bin/node"
}

View 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"
}

View file

@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }

142
foreman/mod/hooks/mascot.ts Normal file
View 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>`
}

View 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>
)
})
}

View 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

View 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
}

View 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
View 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 }
}
}

View 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>

View 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.

View file

@ -36,6 +36,7 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"foreman"
]
}