antcolony
All repositories: gitoria
8.4 KB
// status.hl — the project status and the daily summary, built from the tickets (ticket antcolony#19; concept README// "STATUS is generated from tickets, not written by models", docs/scheduler-agent.md §2 timer "daily digest"). Code only, no model.// ./colony status [--write] → per project: how many tickets in which state + what waits for the creator;// --write also writes status/STATUS.md (COLONY_STATUS_DIR)// ./colony digest [--post] → the daily summary: what waits for the creator. Dry run prints it; --post files it as// ONE ticket in the inbox project (source `colony:digest:<UTC day>` = once per day, also// after a restart) and writes status/STATUS.md. `run` does it once a day after// COLONY_DIGEST_HOUR (UTC, default 7; `off` = never).// What waits for the creator = tickets in `awaiting creator` (built work to check, or a question to answer). The digest// tickets themselves are left out (source `colony:digest:`). Names are the scheduler's vocabulary (tickets.hl reads the// new server names as the old ones).import { writeFile, mkDir, exists } from 'hl:fs'import { timestamp, now } from 'hl:time'import { allTickets, readToken, fileQuestionIn, inboxProject, pageUrl, setting } from './tickets.hl'import { isWholeNumber } from './claude.hl'import { isCreatorQuestion } from './relations.hl'import { NL, fromCwd, sortStrings } from './util.hl'static DIGEST_SOURCE = 'colony:digest:'static STATES = ['open', 'in progress', 'awaiting creator', 'on hold', 'rejected', 'answered', 'confirmed']static SHOWN = 8static isDigest = (t) => { return t.source != null && t.source.startsWith(DIGEST_SOURCE) }static dayOf = (ms) => { return timestamp(ms).slice(0, 10) }static hourOf = (ms) => { return toNumber(timestamp(ms).slice(11, 13)) }static statusDir = () => { return fromCwd(setting('COLONY_STATUS_DIR', 'status')) }// tickets by project (digest tickets left out) → { names: sorted project names, by: { project: { state: [rows] } } }static group = (rows) => {let by = {}let names = []for (t of rows) {if (!isDigest(t)) {if (by[t.project] == null) { by[t.project] = {} names.push(t.project) }if (by[t.project][t.state] == null) { by[t.project][t.state] = [] }by[t.project][t.state].push(t)}}return { names = sortStrings(names) by = by }}static bullet = (t) => { return '- ' + t.project + t.ref + ' — ' + t.subject + ' — ' + pageUrl(t) }// the tickets waiting for the creator, newest first is not needed: oldest first (they wait longest)static waiting = (g) => {let questions = []let built = []for (p of g.names) {let l = g.by[p]['awaiting creator']if (l != null) {for (t of l) { if (isCreatorQuestion(t)) { questions.push(t) } else { built.push(t) } }}}return { questions = questions built = built }}static listLines = (title, rows) => {let out = []if (rows.length == 0) { return out }out.push(title + ' (' + rows.length + ')')let n = 0for (t of rows) {if (n < SHOWN) { out.push(bullet(t)) }n = n + 1}if (rows.length > SHOWN) { out.push('- … and ' + (rows.length - SHOWN) + ' more') }return out}// `alpha 3, beta 1` — how many of the rows belong to which projectstatic perProject = (rows) => {let n = {}let names = []for (t of rows) {if (n[t.project] == null) { n[t.project] = 0 names.push(t.project) }n[t.project] = n[t.project] + 1}let parts = []for (p of sortStrings(names)) { parts.push(p + ' ' + n[p]) }return parts.join(', ')}// the daily summary → { text, count } (count = how many tickets wait for the creator)static digestText = (rows, ms) => {let g = group(rows)let w = waiting(g)let count = w.questions.length + w.built.lengthlet out = []if (count == 0) {out.push('Nothing waits for you today.')} else {out.push(count + ' ' + (count == 1 ? 'ticket waits' : 'tickets wait') + ' for you.')out.push('')for (l of listLines('Questions to answer', w.questions)) { out.push(l) }if (w.questions.length > 0 && w.built.length > 0) { out.push('') }for (l of listLines('Built, to check', w.built)) { out.push(l) }if (w.built.length > SHOWN) { out.push('By project: ' + perProject(w.built)) }}let working = 0let queued = 0for (p of g.names) {let a = g.by[p]['in progress']let o = g.by[p]['open']if (a != null) { working = working + a.length }if (o != null) { queued = queued + o.length }}out.push('')out.push('The colony is working on ' + working + ' and has ' + queued + ' more in the queue.')return { text = out.join(NL) count = count }}// the status of every project → Markdown (generated, never edited by hand)static statusText = (rows, ms) => {let g = group(rows)let out = []out.push('# Status')out.push('')out.push('Generated from the tickets at ' + timestamp(ms).slice(0, 19) + 'Z — do not edit by hand.')out.push('')out.push('| project | open | in progress | awaiting creator | on hold | rejected | confirmed |')out.push('|---|---|---|---|---|---|---|')for (p of g.names) {let cells = []for (s of ['open', 'in progress', 'awaiting creator', 'on hold', 'rejected', 'confirmed']) {let l = g.by[p][s]let n = l == null ? 0 : l.lengthif (s == 'awaiting creator' && g.by[p]['answered'] != null) { n = n + g.by[p]['answered'].length }cells.push('' + n)}out.push('| ' + p + ' | ' + cells.join(' | ') + ' |')}out.push('')out.push('## What waits for the creator')out.push('')out.push(digestText(rows, ms).text)for (p of g.names) {let a = g.by[p]['in progress']if (a != null) {out.push('')for (l of listLines('Being worked on in ' + p, a)) { out.push(l) }}}return out.join(NL) + NL}static writeStatusFile = (rows, ms) => {let dir = statusDir()if (!exists(dir)) { mkDir(dir, 493) }writeFile(dir + '/STATUS.md', statusText(rows, ms), 420)return dir + '/STATUS.md'}// file the summary of `day` in the inbox (idempotent by source) → { ticket, created, text, count } or { error }static postDigest = (rows, ms, token) => {let day = dayOf(ms)let d = digestText(rows, ms)let subject = 'Daily summary ' + day + (d.count == 0 ? ' — nothing waits for you' : ' — ' + d.count + (d.count == 1 ? ' ticket waits' : ' tickets wait') + ' for you')let r = fileQuestionIn(token, inboxProject(), DIGEST_SOURCE + day, subject, d.text + NL + NL + 'colony-digest: ' + day, null)if (r.error != null) { return r }return { ticket = r.ticket created = r.created text = d.text count = d.count }}// the daemon: once a day after the digest hour. `st` = { day } remembers the last day done in this process.// Returns a log line or null.static digestTick = (st, ms) => {let h = setting('COLONY_DIGEST_HOUR', '7')if (h == 'off') { return null }if (!isWholeNumber(h) || toNumber(h) > 23) { return null }let hour = toNumber(h)if (st.day == dayOf(ms) || hourOf(ms) < hour) { return null }let a = allTickets()if (a.error != null) { return 'digest: ' + a.error }let tk = readToken()if (tk.error != null) { return 'digest: ' + tk.error }let r = postDigest(a.tickets, ms, tk.token)if (r.error != null) { return 'digest: ' + r.error }writeStatusFile(a.tickets, ms)st.day = dayOf(ms)return 'DIGEST ' + dayOf(ms) + ' — ' + r.count + ' waiting · ' + (r.created ? 'filed ' : 'already filed ') + r.ticket.project + r.ticket.ref}static runStatus = (argv) => {let a = allTickets()if (a.error != null) { console.log('status: ' + a.error) return false }let ms = now()console.log(statusText(a.tickets, ms))if (argv.includes('--write')) { console.log('written: ' + writeStatusFile(a.tickets, ms)) }return true}static runDigest = (argv) => {let a = allTickets()if (a.error != null) { console.log('digest: ' + a.error) return false }let ms = now()if (!argv.includes('--post')) {console.log('Daily summary ' + dayOf(ms) + ' (dry run; --post files it in ' + inboxProject() + ')')console.log('')console.log(digestText(a.tickets, ms).text)return true}let tk = readToken()if (tk.error != null) { console.log('digest: ' + tk.error) return false }let r = postDigest(a.tickets, ms, tk.token)if (r.error != null) { console.log('digest: ' + r.error) return false }console.log(r.text)console.log('')console.log((r.created ? 'filed ' : 'already filed today: ') + r.ticket.project + r.ticket.ref + ' ' + pageUrl(r.ticket))console.log('written: ' + writeStatusFile(a.tickets, ms))return true}
Branches
- mainmain branch
Latest commits
- 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre