antcolony
All repositories: gitoria
8.8 KB
// keywords.hl — the creator's short commands in a comment (ticket antcolony#17; concept docs/scheduler-agent.md §2// "Comment keywords (code, no LLM)"): /confirm, /reject <why>, /prio <high|normal|low|-9..9>, /hold.//// A keyword counts only when it is the FIRST word of a comment written by the creator (`creatorNames()`, the same// names the librarian uses). Nothing is stored here: what was handled is in the ticket's history.// * /hold → state `on hold` ("On hold, as you asked.")// * /confirm → state `confirmed`, /reject <why> → state `rejected` (the reason is the state text, so a rework// brief quotes it). The colony's own token may NOT confirm/reject (tickets: creator only) — these// two need the creator's token in COLONY_CREATOR_TOKEN_FILE; without it the colony answers once// in a comment ("use the button") instead of pretending.// * /prio → the last valid /prio decides (read by `next` / `run`, see prioOf); the colony answers once.// A state keyword is stale — ignored — when a state change came after it (not the tickets app's automatic `answered` after// the creator's comment), or the ticket is already in that state.// Every answer comment carries `colony-keyword: <event id> · <keyword>` (idempotence: a keyword with that marker// in the history is done); state keywords rely on the state event that follows them.import { env } from 'hl:proc'import { readFile, exists } from 'hl:fs'import { getJson, postJson, ticketPath, allTickets, readToken, pageUrl, isCreatorEvent } from './tickets.hl'import { creatorNames } from './librarian.hl'import { NL, fromCwd } from './util.hl'static MARK = 'colony-keyword: 'static WORDS = ['/confirm' '/reject' '/prio' '/hold']// the creator's token for /confirm and /reject (optional): { token } or { error }static readCreatorToken = () => {let f = env('COLONY_CREATOR_TOKEN_FILE')if (f == null || f == '') { return { error = 'COLONY_CREATOR_TOKEN_FILE is not set' } }let p = fromCwd(f)if (!exists(p)) { return { error = 'COLONY_CREATOR_TOKEN_FILE: no such file: ' + p } }let t = readFile(p).trim()if (t == '') { return { error = 'COLONY_CREATOR_TOKEN_FILE is empty: ' + p } }return { token = t }}// priority word / number → a whole number in -9..9 or null. higher = sooner; normal = 0static prioValue = (arg) => {let a = arg.trim().toLowerCase()if (a == 'high') { return 1 }if (a == 'normal') { return 0 }if (a == 'low') { return -1 }let neg = a.startsWith('-')let d = neg ? a.slice(1) : aif (d.length != 1 || '0123456789'.indexOf(d) < 0) { return null }let n = '0123456789'.indexOf(d)return neg ? 0 - n : n}// a comment text → { kind: confirm|reject|prio|hold, arg } or null. The first non-empty line must start with the word// (and end or continue with a space); for /reject the reason is the rest of the line plus the following lines.static parseKeyword = (text) => {if (text == null) { return null }let t = text.trim()if (!t.startsWith('/')) { return null }for (w of WORDS) {if (t.startsWith(w)) {let rest = t.slice(w.length)if (rest == '' || rest.startsWith(' ') || rest.startsWith(NL) || rest.startsWith('\t')) {let arg = rest.trim()if (w != '/reject') {let nl = arg.indexOf(NL)if (nl >= 0) { arg = arg.slice(0, nl).trim() }}return { kind = w.slice(1) arg = arg }}}}return null}// the priority of a ticket from its history: the last valid `/prio` by the creator, else 0static prioOf = (events, project) => {let names = creatorNames()let p = 0for (e of events) {if (e.kind == 'comment' && isCreatorEvent(project, e, names)) {let k = parseKeyword(e.text)if (k != null && k.kind == 'prio') {let v = prioValue(k.arg)if (v != null) { p = v }}}}return p}static prioWord = (p) => {if (p == 1) { return 'high' }if (p == 0) { return 'normal' }if (p == -1) { return 'low' }return '' + p}static handledIn = (events, id) => {let m = MARK + idfor (e of events) { if (e.text != null && e.text.indexOf(m) >= 0) { return true } }return false}// → the keywords of one ticket still to act on: [{ event, kind, arg, stale }] (oldest first)static pendingOf = (events, names, project) => {let out = []let i = 0while (i < events.length) {let e = events[i]if (e.kind == 'comment' && isCreatorEvent(project, e, names)) {let k = parseKeyword(e.text)if (k != null && !handledIn(events, e.id)) {let later = falselet j = i + 1while (j < events.length) {// a state change after the keyword makes it stale — except the tickets app's own move of the creator's// answered question to `answered` (the creator's comment triggers it, it is not a decision)let ev = events[j]if (ev.kind == 'state' && !(isCreatorEvent(project, ev, names) && ev.to == 'answered')) { later = true }j = j + 1}out.push({ event = e kind = k.kind arg = k.arg stale = later })}}i = i + 1}return out}static reply = (project, number, text, e, kind, token) => {let r = postJson(ticketPath(project, number) + '/comments', { text = text + NL + NL + MARK + e.id + ' · ' + kind }, token)return r.status == 201 ? 'answered' : 'ANSWER FAILED ' + r.status + ' ' + r.text}// act on one keyword → a line of textstatic act = (t, p, post, tk, ck) => {let e = p.eventlet path = ticketPath(t.project, t.number)if (p.kind == 'prio') {let v = prioValue(p.arg)if (v == null) { return post ? reply(t.project, t.number, 'I did not understand the priority "' + p.arg + '". Write /prio high, /prio normal, /prio low or a number from -9 to 9 (higher = sooner).', e, 'prio-refused', tk.token) : 'would refuse: not a priority' }if (!post) { return 'would set priority ' + prioWord(v) }return 'priority ' + prioWord(v) + ' — ' + reply(t.project, t.number, 'Priority is now ' + prioWord(v) + ' (higher goes first).', e, 'prio', tk.token)}if (p.stale) { return 'ignored (the state changed after it)' }if (p.kind == 'hold') {if (t.state == 'on hold') { return 'ignored (already on hold)' }if (!post) { return 'would put the ticket on hold' }let s = postJson(path + '/state', { state = 'on hold' text = 'On hold, as you asked.' }, tk.token)return s.status == 201 ? 'on hold' : 'FAILED ' + s.status + ' ' + s.text}// confirm / reject: creator onlylet target = p.kind == 'confirm' ? 'confirmed' : 'rejected'if (t.state == target) { return 'ignored (already ' + target + ')' }if (p.kind == 'reject' && p.arg == '') {return post ? reply(t.project, t.number, 'A rejection needs a reason. Write /reject and then what is wrong.', e, 'reject-refused', tk.token) : 'would refuse: /reject without a reason'}if (ck.error != null) {return post ? reply(t.project, t.number, 'I cannot ' + p.kind + ' for you: only you may, and the colony has no permission to do it in your name. Please use the button on this page.', e, p.kind + '-refused', tk.token) : 'would refuse: no creator token (' + ck.error + ')'}if (!post) { return 'would set ' + target }let s = postJson(path + '/state', { state = target text = p.kind == 'confirm' ? 'Confirmed.' : p.arg }, ck.token)return s.status == 201 ? target : 'FAILED ' + s.status + ' ' + s.text}// one pass over every ticket. opts = { post, seen } — `seen` (an object, optional) remembers each ticket's updatedMs so an// unchanged ticket is not read again (the daemon keeps one). → { acted, error }static keywordPass = (post, seen) => {let all = allTickets()if (all.error != null) { return { error = all.error acted = 0 } }let names = creatorNames()let tk = post ? readToken() : { token = '' }if (tk.error != null) { return { error = tk.error acted = 0 } }let ck = readCreatorToken()let acted = 0for (t of all.tickets) {let upd = t.updatedMs == null ? 0 : t.updatedMsif (seen == null || seen[t.id] != upd) {let d = getJson(t.apiHref)if (d.status == 200) {let pend = pendingOf(d.json.events, names, t.project)let done = truefor (p of pend) {let line = act(t, p, post, tk, ck)console.log('KEYWORD ' + t.project + t.ref + ' /' + p.kind + (p.arg == '' ? '' : ' ' + p.arg.slice(0, 40)) + ' — ' + line + ' ' + pageUrl(t))if (line.indexOf('ignored') != 0) { acted = acted + 1 }if (line.indexOf('FAILED') >= 0) { done = false }if (!post) { done = false }}// remember the ticket unless an action failed (a written answer changes updatedMs → read once more, finds it handled)if (seen != null && done) { seen[t.id] = upd }}}}return { acted = acted error = null }}static runKeywords = (argv) => {let post = argv.includes('--post')let r = keywordPass(post, null)if (r.error != null) {console.log('keywords: ' + r.error)return false}console.log('keywords: ' + r.acted + ' keyword(s) ' + (post ? 'handled' : 'pending (dry run; --post acts)'))return true}
Branches
- mainmain branch
Latest commits
- 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