gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc613d26bc613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mrec613d26b/lib/brief.hl

16.8 KB

  1. // brief.hl — `brief <project> <n>`: assemble a worker brief MECHANICALLY, modelled on the
  2. // architect's hand-written `missions/*.md` (reading order, standard Hybriel block, rules, report).
  3. //
  4. // Mission 027: + "Conventions for all apps" (templates/conventions.md, as text) after Read first; "Tickets of <project>"
  5. // (every other ticket, newest first, ≤ 40) before the Budget; the report part says WHERE the creator tests (live URL).
  6. // Parts: [mission 022: "REJECTED by the creator: …" first while a rejection is unanswered] · header (ticket, session, ports) ·
  7. // [mission 026: "Previous attempt" when the ticket had an earlier colony session — previous.hl] · Read first (the ticket, the concept, the project's
  8. // README/STATUS, the antcolony README) · Where (code folder, live/deploy note) · Hybriel block
  9. // (only when the project depends on hybriel) · Known open tickets of every project it depends on ·
  10. // Relations (mission 022: parent / children / blocked by / blocks) · Rules · Report (the JSON schema of docs/scheduler-agent.md §4) · the ticket itself with its
  11. // history (Markdown read view when tickets serves `Accept: text/markdown`, else rendered here
  12. // from the JSON).
  13. import { readFile, writeFile, mkDir } from 'hl:fs'
  14. import { env } from 'hl:proc'
  15. import { now, timestamp } from 'hl:time'
  16. import { randomBytes } from 'hl:crypto'
  17. import { loadRegistry } from './registry.hl'
  18. import { getJson, getRaw, ticketPath, pageUrl, baseUrl, isCreatorEvent } from './tickets.hl'
  19. import { NL, fromCwd, option, positionals, sortByNumber } from './util.hl'
  20. import { relationsOf, rejectionOf, rejectedLines, relationLines, questionWhy, isCreatorQuestion } from './relations.hl'
  21. import { setting, isWholeNumber } from './claude.hl'
  22. import { previousLines } from './previous.hl'
  23. import { decisionLines, layoutsConventions, creatorNames } from './librarian.hl'
  24. static CLOSED = ['confirmed', 'rejected']
  25. // `A-B` with 1 <= A <= B <= 65535 → { from, to } or null
  26. static portRange = (s) => {
  27. let d = s.indexOf('-')
  28. if (d <= 0) { return null }
  29. let a = toNumber(s.slice(0, d))
  30. let b = toNumber(s.slice(d + 1))
  31. if (a == null || b == null || a < 1 || b > 65535 || a > b) { return null }
  32. return { from = a to = b }
  33. }
  34. // the ticket + history as Markdown, rendered from the JSON (fallback while tickets has no read view)
  35. static renderTicket = (data) => {
  36. let t = data.ticket
  37. let out = []
  38. out.push('# ' + t.project + ' ' + t.ref + ': ' + t.subject)
  39. out.push('')
  40. out.push('state: **' + t.state + '** · opened ' + t.created + ' · updated ' + t.updated + ' · ' + pageUrl(t))
  41. out.push('')
  42. out.push(t.summary == '' ? '_(no summary)_' : t.summary)
  43. out.push('')
  44. out.push('## History')
  45. for (e of data.events) {
  46. let what = e.label
  47. if (e.kind == 'state') { what = what + ' ' + e.from + ' → ' + e.to }
  48. out.push('')
  49. out.push('### ' + e.seq + ' · ' + e.when + ' · ' + e.author + ' ' + what)
  50. if (e.text != null && e.text != '') {
  51. out.push('')
  52. out.push(e.text)
  53. }
  54. }
  55. return out.join(NL)
  56. }
  57. // → { text, how } : the Markdown read view if tickets serves it, else the rendering above
  58. static ticketMarkdown = (project, number, data) => {
  59. let r = getRaw(ticketPath(project, number), 'text/markdown')
  60. let ct = r == null ? null : r.headers['content-type']
  61. if (r != null && r.status == 200 && ct != null && ct.startsWith('text/markdown')) { return { text = r.text().trim() how = 'Markdown read view' } }
  62. return { text = renderTicket(data) how = 'rendered from JSON: tickets served no Markdown read view' }
  63. }
  64. // → lines: the not-closed tickets of one project
  65. static depTickets = (project) => {
  66. let r = getJson('/api/projects/' + project + '/tickets')
  67. if (r.status == 404) { return ['- _(no tickets in project ' + project + ')_'] }
  68. if (r.status != 200) { return ['- _(could not read: ' + r.status + ')_'] }
  69. let out = []
  70. for (t of r.json.tickets) {
  71. if (!CLOSED.includes(t.state)) { out.push('- ' + t.project + ' ' + t.ref + ' [' + t.state + '] ' + t.subject + ' — ' + pageUrl(t)) }
  72. }
  73. if (out.length == 0) { out.push('- _(none open)_') }
  74. return out
  75. }
  76. // mission 027 ("before asking, check … the project's open / confirmed tickets"): every other ticket of the project
  77. // (all states: answered questions and confirmed work hold decisions), newest first, at most 40 → lines
  78. static PROJECT_TICKETS_MAX = 40
  79. // antcolony#22: an answered question shows the creator's answer — the creator's comments (and state notes) on it, quoted,
  80. // each cut at 600 characters, the last 3 → lines (empty when the creator wrote nothing / the ticket cannot be read)
  81. static ANSWER_MAX = 600
  82. static answerLines = (t) => {
  83. let d = getJson(ticketPath(t.project, t.number))
  84. if (d.status != 200 || d.json == null || d.json.events == null) { return [] }
  85. let names = creatorNames()
  86. let texts = []
  87. for (e of d.json.events) {
  88. if ((e.kind == 'comment' || e.kind == 'state') && e.text != null && e.text.trim() != '' && isCreatorEvent(t.project, e, names)) { texts.push(e.text.trim()) }
  89. }
  90. let out = []
  91. let i = texts.length > 3 ? texts.length - 3 : 0
  92. while (i < texts.length) {
  93. let x = texts[i]
  94. if (x.length > ANSWER_MAX) { x = x.slice(0, ANSWER_MAX) + ' …' }
  95. let first = true
  96. for (l of x.split(NL)) {
  97. out.push(' ' + (first ? '- Answer of the creator: ' : ' ') + l)
  98. first = false
  99. }
  100. i = i + 1
  101. }
  102. return out
  103. }
  104. static projectTickets = (project, number) => {
  105. let r = getJson('/api/projects/' + project + '/tickets')
  106. if (r.status != 200) { return ['- _(could not read: ' + r.status + ')_'] }
  107. let rows = []
  108. for (t of r.json.tickets) { if (('' + t.number) != ('' + number)) { rows.push(t) } }
  109. // newest first (the API's order is not relied on)
  110. sortByNumber(rows, (t) => { return 0 - toNumber('' + t.number) })
  111. let out = []
  112. let i = 0
  113. while (i < rows.length && out.length < PROJECT_TICKETS_MAX) {
  114. let t = rows[i]
  115. out.push('- ' + t.project + ' ' + t.ref + ' [' + t.state + '] ' + t.subject + ' — ' + pageUrl(t))
  116. // antcolony#22: an answered / confirmed question carries the creator's answer
  117. if ((t.state == 'answered' || t.state == 'confirmed') && isCreatorQuestion(t)) {
  118. for (l of answerLines(t)) { out.push(l) }
  119. }
  120. i = i + 1
  121. }
  122. if (rows.length > PROJECT_TICKETS_MAX) { out.push('- _(and ' + (rows.length - PROJECT_TICKETS_MAX) + ' older ones: GET ' + baseUrl() + '/api/projects/' + project + '/tickets)_') }
  123. if (out.length == 0) { out.push('- _(no other tickets)_') }
  124. return out
  125. }
  126. static fill = (text, vars) => {
  127. let s = text
  128. for (k of vars.keys()) { s = s.replaceAll('{{' + k + '}}', vars[k]) }
  129. return s
  130. }
  131. // the default session id: s-<UTC yyyymmddThhmm>-<6 hex>
  132. static newSession = () => { return 's-' + timestamp(now()).slice(0, 16).replaceAll(':', '').replaceAll('-', '') + '-' + randomBytes(3, 'hex') }
  133. static PORTS_MARKER = '@AGENT@'
  134. static PORT_FROM_MARKER = '@PORT_FROM@'
  135. static PORT_TO_MARKER = '@PORT_TO@'
  136. // the port range from --ports / COLONY_PORTS / 8700-8749 → { from, to, text } or { error }
  137. static portsOf = (argv) => {
  138. let portsText = option(argv, '--ports')
  139. if (portsText == null) { portsText = env('COLONY_PORTS') }
  140. if (portsText == null || portsText == '') { portsText = '8700-8749' }
  141. // mission 035 (antcolony#14): a brief built by the scheduler for an AGENT on another host — the agent owns the port pool,
  142. // so the range is a marker the agent fills in (work --brief-file)
  143. if (portsText == PORTS_MARKER) { return { from = PORT_FROM_MARKER to = PORT_TO_MARKER text = PORTS_MARKER } }
  144. let ports = portRange(portsText)
  145. if (ports == null) { return { error = '--ports must be A-B (1 ≤ A ≤ B ≤ 65535), got ' + portsText } }
  146. return ports
  147. }
  148. // mission 025: the step budget → the step by which the report must be written: N − min(10, N/3 rounded down), at least 1
  149. static reportStep = (n) => {
  150. let r = (n - (n % 3)) / 3
  151. if (r > 10) { r = 10 }
  152. let s = n - r
  153. return s < 1 ? 1 : s
  154. }
  155. // mission 025 (ticket antcolony#1 — the first live worker ran out of steps before its report): the brief tells the
  156. // worker its budget (the same --max-turns / --timeout `work` passes to Claude) and to leave room for the report
  157. static budgetLines = (argv) => {
  158. let turnsText = setting(argv, '--max-turns', 'COLONY_MAX_TURNS', '40')
  159. let timeText = setting(argv, '--timeout', 'COLONY_WORK_TIMEOUT', '3600')
  160. let n = isWholeNumber(turnsText) ? toNumber(turnsText) : 40
  161. let secs = isWholeNumber(timeText) ? toNumber(timeText) : 3600
  162. let mins = (secs - (secs % 60)) / 60
  163. let o = []
  164. o.push('## Budget — leave room for the report')
  165. 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.')
  166. o.push('- Time: at most ' + (mins < 1 ? secs + ' seconds' : mins + ' minutes') + ', then the session is killed.')
  167. 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.')
  168. return o
  169. }
  170. // assemble the brief (no file written) → { text, ref, session, ports, how, meta, ticket, reg }
  171. // or { error, refused } (refused = a rule said no, not a failure)
  172. static buildBrief = (project, number, argv) => {
  173. let reg = loadRegistry()
  174. for (e of reg.errors) { console.log('registry: ' + e) }
  175. let m = reg.projects[project]
  176. 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)' } }
  177. 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.' } }
  178. let ports = portsOf(argv)
  179. if (ports.error != null) { return { error = ports.error } }
  180. let got = getJson(ticketPath(project, number))
  181. if (got.status != 200) { return { error = 'ticket ' + project + '#' + number + ' → ' + got.status + ' ' + (got.text == null ? '' : got.text) } }
  182. let data = got.json
  183. let t = data.ticket
  184. // mission 030: a question ticket is never work (no brief, so `work` / `cycle` / `run` cannot start on it either)
  185. let qwhy = questionWhy(t)
  186. if (qwhy != null) { return { refused = true error = 'REFUSED — ' + project + t.ref + ' "' + t.subject + '" is ' + qwhy + ' (state ' + t.state + '); never work — no brief, no worker' } }
  187. let session = option(argv, '--session')
  188. if (session == null || session == '') { session = newSession() }
  189. let ref = project + t.ref
  190. let md = ticketMarkdown(project, number, data)
  191. // mission 022: relations (children looked up for their `source`: creator questions vs. work) + the rejection
  192. let lookup = (id) => {
  193. let r = getJson('/api/tickets/' + id)
  194. return r.status == 200 ? r.json.ticket : null
  195. }
  196. let rel = relationsOf(t, lookup)
  197. let rj = rejectionOf(data.events)
  198. let o = []
  199. // "Rejections are work": a rejection no colony report answered yet is the FIRST thing of the brief
  200. if (rj.pending) { for (l of rejectedLines(rj, ref)) { o.push(l) } }
  201. o.push('# Brief — ' + ref + ': ' + t.subject)
  202. o.push('')
  203. o.push('Generated by antcolony-scheduler v0 at ' + timestamp(now()) + ' (UTC). Ticket: ' + pageUrl(t) + ' · session `' + session + '`.')
  204. o.push('')
  205. 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).")
  206. o.push('')
  207. // mission 026: a ticket that had an earlier colony session → what it did, what is open, its work copy, its report
  208. let prev = previousLines(data.events, m.dev == null ? '' : m.dev.folder)
  209. if (prev.length > 0) {
  210. for (l of prev) { o.push(l) }
  211. o.push('')
  212. }
  213. o.push('## Read first — in this order')
  214. 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).')
  215. 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`.")
  216. if (m.dev != null && m.dev.folder != null) {
  217. o.push('3. `' + m.dev.host + ':' + m.dev.folder + '/README.md` and `STATUS.md` (and any `docs/` they link).')
  218. } else {
  219. o.push("3. The project's README.md / STATUS.md.")
  220. }
  221. // mission 027: the conventions reach the worker AS TEXT (workers on Loreana cannot read Byrodin — the first gitoria
  222. // worker tried ssh and was refused); the antcolony README stays named as the source
  223. o.push('4. "Conventions for all apps" below (from `byrodin:/CONTAINERS/projects/antcolony/README.md`, as text — you may not be able to reach Byrodin).')
  224. // mission 029 (antcolony#21): the creator's decisions (librarian) — global + this project's, after the conventions
  225. o.push('5. "Decisions of the creator" below — collected from the creator\'s own words; they override anything else.')
  226. // mission 029: every brief for an app with a UI (registry `"ui": true`) names the creator's markup/CSS rules
  227. if (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.') }
  228. o.push('')
  229. o.push(readFile('./templates/conventions.md').trim())
  230. o.push('')
  231. for (l of decisionLines(project, reg, t.subject + NL + (t.summary == null ? '' : t.summary))) { o.push(l) }
  232. o.push('')
  233. o.push('## Where')
  234. if (m.dev != null) { o.push('- Development: `' + m.dev.host + ':' + m.dev.folder + '`') }
  235. if (m.code != null) { o.push('- Code: `' + m.code.host + ':' + m.code.folder + '`') }
  236. 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') }
  237. if (m.deploy != null) { o.push('- Deploy: ' + m.deploy + ' — not your job') }
  238. 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`).')
  239. o.push('')
  240. for (l of relationLines(rel)) { o.push(l) }
  241. o.push('')
  242. if (m.deps.includes('hybriel')) {
  243. o.push(readFile('./templates/hybriel-block.md').trim())
  244. o.push('In the JSON report below, a Hybriel finding is an `issues` entry with `"project": "hybriel"`.')
  245. o.push('')
  246. }
  247. if (m.deps.length > 0) {
  248. o.push('## Known open tickets of the projects ' + project + " depends on (check them before reporting an issue; don't re-report)")
  249. for (d of m.deps) {
  250. o.push('')
  251. o.push('### ' + d)
  252. for (l of depTickets(d)) { o.push(l) }
  253. }
  254. o.push('')
  255. }
  256. o.push('## Tickets of ' + project + ' — decisions may already be there (check before you ask a question)')
  257. for (l of projectTickets(project, number)) { o.push(l) }
  258. o.push('')
  259. for (l of budgetLines(argv)) { o.push(l) }
  260. o.push('')
  261. o.push(readFile('./templates/rules.md').trim())
  262. o.push('')
  263. // mission 027: the test steps are for the creator on the live site (registry `live.url`) — or, without one, the real result
  264. let 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)'
  265. o.push(fill(readFile('./templates/report.md').trim(), { TICKET = ref SESSION = session PROJECTS = reg.names.join(', ') WHERE = where }))
  266. o.push('')
  267. o.push('## Ticket (' + md.how + ')')
  268. o.push('')
  269. o.push(md.text)
  270. o.push('')
  271. 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 }
  272. }
  273. static runBrief = (argv) => {
  274. let pos = positionals(argv)
  275. if (pos.length != 3) {
  276. console.log('usage: brief <project> <number> [--out FILE] [--ports A-B] [--session ID]')
  277. return false
  278. }
  279. let b = buildBrief(pos[1], pos[2], argv)
  280. if (b.error != null) {
  281. console.log('brief: ' + b.error)
  282. return false
  283. }
  284. if (b.ticket.state != 'open') { console.log('brief: note — ' + b.ref + ' is "' + b.ticket.state + '", not open (the brief is written anyway)') }
  285. 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)') }
  286. let out = option(argv, '--out')
  287. if (out == null || out == '') {
  288. mkDir('./briefs', 448)
  289. out = './briefs/' + pos[1] + '-' + pos[2] + '.md'
  290. } else {
  291. out = fromCwd(out)
  292. }
  293. writeFile(out, b.text, 420)
  294. 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 + ')')
  295. return true
  296. }

Branches

Latest commits

  • 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