antcolony
All repositories: gitoria
16.8 KB
// brief.hl — `brief <project> <n>`: assemble a worker brief MECHANICALLY, modelled on the// architect's hand-written `missions/*.md` (reading order, standard Hybriel block, rules, report).//// Mission 027: + "Conventions for all apps" (templates/conventions.md, as text) after Read first; "Tickets of <project>"// (every other ticket, newest first, ≤ 40) before the Budget; the report part says WHERE the creator tests (live URL).// Parts: [mission 022: "REJECTED by the creator: …" first while a rejection is unanswered] · header (ticket, session, ports) ·// [mission 026: "Previous attempt" when the ticket had an earlier colony session — previous.hl] · Read first (the ticket, the concept, the project's// README/STATUS, the antcolony README) · Where (code folder, live/deploy note) · Hybriel block// (only when the project depends on hybriel) · Known open tickets of every project it depends on ·// Relations (mission 022: parent / children / blocked by / blocks) · Rules · Report (the JSON schema of docs/scheduler-agent.md §4) · the ticket itself with its// history (Markdown read view when tickets serves `Accept: text/markdown`, else rendered here// from the JSON).import { readFile, writeFile, mkDir } from 'hl:fs'import { env } from 'hl:proc'import { now, timestamp } from 'hl:time'import { randomBytes } from 'hl:crypto'import { loadRegistry } from './registry.hl'import { getJson, getRaw, ticketPath, pageUrl, baseUrl, isCreatorEvent } from './tickets.hl'import { NL, fromCwd, option, positionals, sortByNumber } from './util.hl'import { relationsOf, rejectionOf, rejectedLines, relationLines, questionWhy, isCreatorQuestion } from './relations.hl'import { setting, isWholeNumber } from './claude.hl'import { previousLines } from './previous.hl'import { decisionLines, layoutsConventions, creatorNames } from './librarian.hl'static CLOSED = ['confirmed', 'rejected']// `A-B` with 1 <= A <= B <= 65535 → { from, to } or nullstatic portRange = (s) => {let d = s.indexOf('-')if (d <= 0) { return null }let a = toNumber(s.slice(0, d))let b = toNumber(s.slice(d + 1))if (a == null || b == null || a < 1 || b > 65535 || a > b) { return null }return { from = a to = b }}// the ticket + history as Markdown, rendered from the JSON (fallback while tickets has no read view)static renderTicket = (data) => {let t = data.ticketlet out = []out.push('# ' + t.project + ' ' + t.ref + ': ' + t.subject)out.push('')out.push('state: **' + t.state + '** · opened ' + t.created + ' · updated ' + t.updated + ' · ' + pageUrl(t))out.push('')out.push(t.summary == '' ? '_(no summary)_' : t.summary)out.push('')out.push('## History')for (e of data.events) {let what = e.labelif (e.kind == 'state') { what = what + ' ' + e.from + ' → ' + e.to }out.push('')out.push('### ' + e.seq + ' · ' + e.when + ' · ' + e.author + ' ' + what)if (e.text != null && e.text != '') {out.push('')out.push(e.text)}}return out.join(NL)}// → { text, how } : the Markdown read view if tickets serves it, else the rendering abovestatic ticketMarkdown = (project, number, data) => {let r = getRaw(ticketPath(project, number), 'text/markdown')let ct = r == null ? null : r.headers['content-type']if (r != null && r.status == 200 && ct != null && ct.startsWith('text/markdown')) { return { text = r.text().trim() how = 'Markdown read view' } }return { text = renderTicket(data) how = 'rendered from JSON: tickets served no Markdown read view' }}// → lines: the not-closed tickets of one projectstatic depTickets = (project) => {let r = getJson('/api/projects/' + project + '/tickets')if (r.status == 404) { return ['- _(no tickets in project ' + project + ')_'] }if (r.status != 200) { return ['- _(could not read: ' + r.status + ')_'] }let out = []for (t of r.json.tickets) {if (!CLOSED.includes(t.state)) { out.push('- ' + t.project + ' ' + t.ref + ' [' + t.state + '] ' + t.subject + ' — ' + pageUrl(t)) }}if (out.length == 0) { out.push('- _(none open)_') }return out}// mission 027 ("before asking, check … the project's open / confirmed tickets"): every other ticket of the project// (all states: answered questions and confirmed work hold decisions), newest first, at most 40 → linesstatic PROJECT_TICKETS_MAX = 40// antcolony#22: an answered question shows the creator's answer — the creator's comments (and state notes) on it, quoted,// each cut at 600 characters, the last 3 → lines (empty when the creator wrote nothing / the ticket cannot be read)static ANSWER_MAX = 600static answerLines = (t) => {let d = getJson(ticketPath(t.project, t.number))if (d.status != 200 || d.json == null || d.json.events == null) { return [] }let names = creatorNames()let texts = []for (e of d.json.events) {if ((e.kind == 'comment' || e.kind == 'state') && e.text != null && e.text.trim() != '' && isCreatorEvent(t.project, e, names)) { texts.push(e.text.trim()) }}let out = []let i = texts.length > 3 ? texts.length - 3 : 0while (i < texts.length) {let x = texts[i]if (x.length > ANSWER_MAX) { x = x.slice(0, ANSWER_MAX) + ' …' }let first = truefor (l of x.split(NL)) {out.push(' ' + (first ? '- Answer of the creator: ' : ' ') + l)first = false}i = i + 1}return out}static projectTickets = (project, number) => {let r = getJson('/api/projects/' + project + '/tickets')if (r.status != 200) { return ['- _(could not read: ' + r.status + ')_'] }let rows = []for (t of r.json.tickets) { if (('' + t.number) != ('' + number)) { rows.push(t) } }// newest first (the API's order is not relied on)sortByNumber(rows, (t) => { return 0 - toNumber('' + t.number) })let out = []let i = 0while (i < rows.length && out.length < PROJECT_TICKETS_MAX) {let t = rows[i]out.push('- ' + t.project + ' ' + t.ref + ' [' + t.state + '] ' + t.subject + ' — ' + pageUrl(t))// antcolony#22: an answered / confirmed question carries the creator's answerif ((t.state == 'answered' || t.state == 'confirmed') && isCreatorQuestion(t)) {for (l of answerLines(t)) { out.push(l) }}i = i + 1}if (rows.length > PROJECT_TICKETS_MAX) { out.push('- _(and ' + (rows.length - PROJECT_TICKETS_MAX) + ' older ones: GET ' + baseUrl() + '/api/projects/' + project + '/tickets)_') }if (out.length == 0) { out.push('- _(no other tickets)_') }return out}static fill = (text, vars) => {let s = textfor (k of vars.keys()) { s = s.replaceAll('{{' + k + '}}', vars[k]) }return s}// the default session id: s-<UTC yyyymmddThhmm>-<6 hex>static newSession = () => { return 's-' + timestamp(now()).slice(0, 16).replaceAll(':', '').replaceAll('-', '') + '-' + randomBytes(3, 'hex') }static PORTS_MARKER = '@AGENT@'static PORT_FROM_MARKER = '@PORT_FROM@'static PORT_TO_MARKER = '@PORT_TO@'// the port range from --ports / COLONY_PORTS / 8700-8749 → { from, to, text } or { error }static portsOf = (argv) => {let portsText = option(argv, '--ports')if (portsText == null) { portsText = env('COLONY_PORTS') }if (portsText == null || portsText == '') { portsText = '8700-8749' }// mission 035 (antcolony#14): a brief built by the scheduler for an AGENT on another host — the agent owns the port pool,// so the range is a marker the agent fills in (work --brief-file)if (portsText == PORTS_MARKER) { return { from = PORT_FROM_MARKER to = PORT_TO_MARKER text = PORTS_MARKER } }let ports = portRange(portsText)if (ports == null) { return { error = '--ports must be A-B (1 ≤ A ≤ B ≤ 65535), got ' + portsText } }return ports}// mission 025: the step budget → the step by which the report must be written: N − min(10, N/3 rounded down), at least 1static reportStep = (n) => {let r = (n - (n % 3)) / 3if (r > 10) { r = 10 }let s = n - rreturn s < 1 ? 1 : s}// mission 025 (ticket antcolony#1 — the first live worker ran out of steps before its report): the brief tells the// worker its budget (the same --max-turns / --timeout `work` passes to Claude) and to leave room for the reportstatic budgetLines = (argv) => {let turnsText = setting(argv, '--max-turns', 'COLONY_MAX_TURNS', '40')let timeText = setting(argv, '--timeout', 'COLONY_WORK_TIMEOUT', '3600')let n = isWholeNumber(turnsText) ? toNumber(turnsText) : 40let secs = isWholeNumber(timeText) ? toNumber(timeText) : 3600let mins = (secs - (secs % 60)) / 60let o = []o.push('## Budget — leave room for the report')o.push('- **Steps: at most ' + n + '** (every tool call is a step; Claude stops the session at step ' + n + '). **Write your report before step ' + reportStep(n) + '.** A session cut off before its report is lost work: nobody sees what you did.')o.push('- Time: at most ' + (mins < 1 ? secs + ' seconds' : mins + ' minutes') + ', then the session is killed.')o.push('- Running short: stop, start nothing new, and report what is done, what is verified and what is still open (e.g. "not copied back yet") — an honest partial report is worth more than one more step.')return o}// assemble the brief (no file written) → { text, ref, session, ports, how, meta, ticket, reg }// or { error, refused } (refused = a rule said no, not a failure)static buildBrief = (project, number, argv) => {let reg = loadRegistry()for (e of reg.errors) { console.log('registry: ' + e) }let m = reg.projects[project]if (m == null) { return { refused = true error = 'REFUSED — project ' + project + ' has no metadata (' + reg.dir + '/' + project + '.json) → question for the creator (`next --post` files it)' } }if (!m.hasConcept) { return { refused = true error = 'REFUSED — project ' + project + ' has no concept → question for the creator (`next --post` files it). No brief without a written concept.' } }let ports = portsOf(argv)if (ports.error != null) { return { error = ports.error } }let got = getJson(ticketPath(project, number))if (got.status != 200) { return { error = 'ticket ' + project + '#' + number + ' → ' + got.status + ' ' + (got.text == null ? '' : got.text) } }let data = got.jsonlet t = data.ticket// mission 030: a question ticket is never work (no brief, so `work` / `cycle` / `run` cannot start on it either)let qwhy = questionWhy(t)if (qwhy != null) { return { refused = true error = 'REFUSED — ' + project + t.ref + ' "' + t.subject + '" is ' + qwhy + ' (state ' + t.state + '); never work — no brief, no worker' } }let session = option(argv, '--session')if (session == null || session == '') { session = newSession() }let ref = project + t.reflet md = ticketMarkdown(project, number, data)// mission 022: relations (children looked up for their `source`: creator questions vs. work) + the rejectionlet lookup = (id) => {let r = getJson('/api/tickets/' + id)return r.status == 200 ? r.json.ticket : null}let rel = relationsOf(t, lookup)let rj = rejectionOf(data.events)let o = []// "Rejections are work": a rejection no colony report answered yet is the FIRST thing of the briefif (rj.pending) { for (l of rejectedLines(rj, ref)) { o.push(l) } }o.push('# Brief — ' + ref + ': ' + t.subject)o.push('')o.push('Generated by antcolony-scheduler v0 at ' + timestamp(now()) + ' (UTC). Ticket: ' + pageUrl(t) + ' · session `' + session + '`.')o.push('')o.push("One-shot worker on ONE ticket. You do NOT deploy and do NOT touch Byrodin's services; only the creator confirms — you can at most set `awaiting creator` (the scheduler does it from your report).")o.push('')// mission 026: a ticket that had an earlier colony session → what it did, what is open, its work copy, its reportlet prev = previousLines(data.events, m.dev == null ? '' : m.dev.folder)if (prev.length > 0) {for (l of prev) { o.push(l) }o.push('')}o.push('## Read first — in this order')o.push('1. The ticket with its history — section "Ticket" at the end of this brief (live: `GET ' + baseUrl() + ticketPath(project, number) + '`, send a `user-agent` header, `Accept: text/markdown` for the read view).')o.push('2. **Concept: `' + m.concept + "`** — source of truth; don't add what it doesn't describe; where it is silent, make the smallest choice and list it under `decided`.")if (m.dev != null && m.dev.folder != null) {o.push('3. `' + m.dev.host + ':' + m.dev.folder + '/README.md` and `STATUS.md` (and any `docs/` they link).')} else {o.push("3. The project's README.md / STATUS.md.")}// mission 027: the conventions reach the worker AS TEXT (workers on Loreana cannot read Byrodin — the first gitoria// worker tried ssh and was refused); the antcolony README stays named as the sourceo.push('4. "Conventions for all apps" below (from `byrodin:/CONTAINERS/projects/antcolony/README.md`, as text — you may not be able to reach Byrodin).')// mission 029 (antcolony#21): the creator's decisions (librarian) — global + this project's, after the conventionso.push('5. "Decisions of the creator" below — collected from the creator\'s own words; they override anything else.')// mission 029: every brief for an app with a UI (registry `"ui": true`) names the creator's markup/CSS rulesif (m.hasUi) { o.push('6. **UI rules: `' + layoutsConventions() + '`** — the creator\'s markup/CSS rules (WorldAPI layout conventions); every page and style you write follows them.') }o.push('')o.push(readFile('./templates/conventions.md').trim())o.push('')for (l of decisionLines(project, reg, t.subject + NL + (t.summary == null ? '' : t.summary))) { o.push(l) }o.push('')o.push('## Where')if (m.dev != null) { o.push('- Development: `' + m.dev.host + ':' + m.dev.folder + '`') }if (m.code != null) { o.push('- Code: `' + m.code.host + ':' + m.code.folder + '`') }if (m.live != null) { o.push('- Live (never touch): `' + m.live.host + ':' + m.live.folder + '`' + (m.live.url != null ? ' — ' + m.live.url : '') + ' — the creator tests THERE after the deploy: write your `test` steps for it') }if (m.deploy != null) { o.push('- Deploy: ' + m.deploy + ' — not your job') }o.push('- **Ports for everything you start: ' + ports.from + '–' + ports.to + ' only** (other workers run tests on the same machine; `work` / `run` also put the range into your environment: `COLONY_PORTS`, `COLONY_PORT_FROM`, `COLONY_PORT_TO`).')o.push('')for (l of relationLines(rel)) { o.push(l) }o.push('')if (m.deps.includes('hybriel')) {o.push(readFile('./templates/hybriel-block.md').trim())o.push('In the JSON report below, a Hybriel finding is an `issues` entry with `"project": "hybriel"`.')o.push('')}if (m.deps.length > 0) {o.push('## Known open tickets of the projects ' + project + " depends on (check them before reporting an issue; don't re-report)")for (d of m.deps) {o.push('')o.push('### ' + d)for (l of depTickets(d)) { o.push(l) }}o.push('')}o.push('## Tickets of ' + project + ' — decisions may already be there (check before you ask a question)')for (l of projectTickets(project, number)) { o.push(l) }o.push('')for (l of budgetLines(argv)) { o.push(l) }o.push('')o.push(readFile('./templates/rules.md').trim())o.push('')// mission 027: the test steps are for the creator on the live site (registry `live.url`) — or, without one, the real resultlet where = m.live != null && m.live.url != null ? 'on the live site ' + m.live.url + ' (after the deploy — you do not deploy; describe what the creator clicks there)' : 'on the real result (this project has no live site: name what the creator opens, e.g. the document in the project folder — never a work copy)'o.push(fill(readFile('./templates/report.md').trim(), { TICKET = ref SESSION = session PROJECTS = reg.names.join(', ') WHERE = where }))o.push('')o.push('## Ticket (' + md.how + ')')o.push('')o.push(md.text)o.push('')return { text = o.join(NL) ref = ref session = session ports = ports how = md.how meta = m ticket = t reg = reg rel = rel rejection = rj events = data.events }}static runBrief = (argv) => {let pos = positionals(argv)if (pos.length != 3) {console.log('usage: brief <project> <number> [--out FILE] [--ports A-B] [--session ID]')return false}let b = buildBrief(pos[1], pos[2], argv)if (b.error != null) {console.log('brief: ' + b.error)return false}if (b.ticket.state != 'open') { console.log('brief: note — ' + b.ref + ' is "' + b.ticket.state + '", not open (the brief is written anyway)') }if (!b.meta.takesWorkers) { console.log('brief: note — project ' + b.meta.name + ' takes no workers ("workers": false; `work` refuses it — the brief is written anyway)') }let out = option(argv, '--out')if (out == null || out == '') {mkDir('./briefs', 448)out = './briefs/' + pos[1] + '-' + pos[2] + '.md'} else {out = fromCwd(out)}writeFile(out, b.text, 420)console.log('brief: ' + b.ref + ' → ' + (out.startsWith('./briefs/') ? out.slice(2) + ' (beside scheduler.hl)' : out) + ' (session ' + b.session + ', ports ' + b.ports.from + '-' + b.ports.to + ', ticket via ' + b.how + ')')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