antcolony
All repositories: gitoria
49.2 KB
// librarian.hl — `librarian`: the creator's decisions in one place per project (mission 029, ticket antcolony#21).//// Why: a worker sees its own ticket, the concept and the TITLES of other tickets — the creator's answers and decisions// made in comments never reach it, so workers ask about things long decided (gitoria#3). Sources (creator, 2026-09-24:// "the librarian should be enough. i have to adopt to the workflow"): TICKETS only, plus once the architect's seed of// decisions made in chat (`--seed`). The librarian reads every text// the CREATOR wrote in tickets (comments, state-change texts, ticket texts, edits — incl. answers on question tickets),// asks ONE one-shot Claude step per new text (`--json-schema templates/librarian.schema.json`) whether it is a decision,// for which project (or `all`), under which topic, what it means and which earlier entries it corrects, and keeps:// - `<dev folder>/DECISIONS.md` per project (current state only, by topic, contents at the top, newest first per topic)// + `DECISIONS-archive.md` beside it (superseded entries, kept with quote + link);// dev folder not on this host → the code folder; neither → `librarian/projects/<name>/DECISIONS.md`;// mission 030: a project with `"workers": false` (hybriel) ALWAYS → `librarian/projects/<name>/` (never into its repo);// - `templates/decisions-global.md` (+ `decisions-global-archive.md`) for `all`.// Every entry = date · the creator's EXACT words (taken from the ticket by the code — the model may only pick a// verbatim excerpt, checked character for character) · where (ticket link / chat seed) · ONE line "means" marked as the// librarian's (or, for the chat seed, the architect's) reading. Never a reading without the quote.// Data: `librarian/decisions.json` (the entries — the .md files are rendered from it, never edited by hand),// `librarian/state.json` (cursor = newest ticket `updatedMs` read, floor = --since, done = event id → outcome, tries),// `librarian/calls/<event>-{prompt.md,result.json,stderr.log}` (every model call, for the audit). READS tickets only.//// ./colony librarian dry run: lists the new creator texts; no model call, nothing written// ./colony librarian --post-files classify every new text (one model call each) + write the files// [--since DATE|ms] [--max N] [--model M] [--retry-refused]// ./colony librarian --post-files --seed FILE [--seed-source TEXT] import decisions made in CHAT (architect's seed file)// ./colony librarian --render re-render every file from decisions.json (no tickets, no model)// ORDER (the creator: a worker must know decisions from OTHER tickets written seconds earlier): `run` awaits one// `librarianPass` at the start of EVERY iteration before it picks / starts anything, and starts nothing in an iteration// that left a creator text unfiled (daemon.hl); `brief`, `work`, `cycle` by hand await one pass before the brief is built// (`librarianFirst`, scheduler.hl). One GET /api/tickets + the updated tickets; no model call unless a creator text is// new; files are rewritten only when something changed.import { env } from 'hl:proc'import { readFile, writeFile, exists, mkDir, remove } from 'hl:fs'import { now, timestamp } from 'hl:time'import { sha256 } from 'hl:crypto'import { fetch } from 'hl:fetch'import { jsonErrorAt } from './jsoncheck.hl'import { loadRegistry } from './registry.hl'import { getJson, baseUrl, isCreatorEvent } from './tickets.hl'import { runClaude, readClaude, usageLine, setting, isWholeNumber, round4, rateLimitOf } from './claude.hl'import { NL, readJson, fromCwd, isList, isObject, isString, option, sortByNumber, sortStrings, fenceFor } from './util.hl'import { isoToMs } from './quota.hl'static DEFAULT_CREATORS = 'Caramboleyo,creator'static LOCK_STALE_MS = 900000static FULL_LINES_DEFAULT = 150static LAYOUTS_DEFAULT = '/media/STORAGE/projects/antcolony-docs/docs/layouts-conventions.md'static GENERATED = '<!-- generated by the antcolony librarian (antcolony-scheduler: ./colony librarian) from librarian/decisions.json — do not edit by hand, it is rewritten -->'// ---- settings --------------------------------------------------------------------------------------------------static homeDir = () => {let h = env('COLONY_HOME')return h == null || h == '' ? null : h}// the librarian's own folder (state, entries, calls): COLONY_LIBRARIAN_DIR, else <scheduler>/librarianstatic libDir = () => {let d = env('COLONY_LIBRARIAN_DIR')if (d != null && d != '') { return fromCwd(d) }let h = homeDir()return h == null ? './librarian' : h + '/librarian'}// the decisions for ALL projects: COLONY_DECISIONS_GLOBAL, else <scheduler>/templates/decisions-global.mdstatic globalFile = () => {let g = env('COLONY_DECISIONS_GLOBAL')if (g != null && g != '') { return fromCwd(g) }let h = homeDir()return h == null ? './templates/decisions-global.md' : h + '/templates/decisions-global.md'}// DECISIONS.md → DECISIONS-archive.md, decisions-global.md → decisions-global-archive.mdstatic archiveOf = (path) => { return path.endsWith('.md') ? path.slice(0, path.length - 3) + '-archive.md' : path + '-archive' }static layoutsConventions = () => {let v = env('COLONY_LAYOUTS_CONVENTIONS')return v == null || v == '' ? LAYOUTS_DEFAULT : v}// the creator's display names in tickets (the API shows names, not user ids — see README "Librarian")static creatorNames = () => {let v = env('COLONY_CREATOR_NAMES')if (v == null || v.trim() == '') { v = DEFAULT_CREATORS }let out = []for (p of v.split(',')) { if (p.trim() != '') { out.push(p.trim()) } }return out}static thisHost = () => {let h = env('COLONY_HOST')if (h == null || h == '') { h = exists('/etc/hostname') ? readFile('/etc/hostname').trim() : '' }return h}// → { dir, model, bin, timeout, maxTurns, max } or { error }. `pre` = option prefix ('' for `librarian`, '--librarian-'// style names are used by `run`: --librarian-model, --librarian-claude, --librarian-max)static librarianSettings = (argv, inRun) => {let model = setting(argv, inRun ? '--librarian-model' : '--model', 'COLONY_LIBRARIAN_MODEL', 'sonnet')let fallbackBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')let bin = setting(argv, '--librarian-claude', 'COLONY_LIBRARIAN_CLAUDE', fallbackBin)let timeoutText = setting(argv, '--librarian-timeout', 'COLONY_LIBRARIAN_TIMEOUT', '300')let turnsText = setting(argv, '--librarian-max-turns', 'COLONY_LIBRARIAN_MAX_TURNS', '4')let maxText = setting(argv, inRun ? '--librarian-max' : '--max', 'COLONY_LIBRARIAN_MAX', '0')if (!isWholeNumber(timeoutText) || timeoutText == '0') { return { error = 'the librarian timeout must be whole seconds ≥ 1, got ' + timeoutText } }if (!isWholeNumber(turnsText) || turnsText == '0') { return { error = 'the librarian max turns must be a whole number ≥ 1, got ' + turnsText } }if (!isWholeNumber(maxText)) { return { error = (inRun ? '--librarian-max' : '--max') + ' must be a whole number (0 = no limit), got ' + maxText } }// antcolony#20: `local` (or `local:<alias>`) = a small model on a local llama-server (OpenAI-compatible endpoint) answers// first; an unreachable server or an answer the checks refuse goes on to Claude (`--librarian-fallback`, default sonnet, `off` = no fallback)let localUrl = setting(argv, '--librarian-local-url', 'COLONY_LOCAL_URL', 'http://127.0.0.1:8080/v1')let fallback = setting(argv, '--librarian-fallback', 'COLONY_LIBRARIAN_FALLBACK', 'sonnet')let localTimeout = setting(argv, '--librarian-local-timeout', 'COLONY_LOCAL_TIMEOUT', '120')if (!isWholeNumber(localTimeout) || localTimeout == '0') { return { error = 'the local model timeout must be whole seconds ≥ 1, got ' + localTimeout } }return { dir = libDir() model = model bin = bin timeout = timeoutText maxTurns = turnsText max = toNumber(maxText) localUrl = localUrl fallback = fallback localTimeout = toNumber(localTimeout) }}// ---- small helpers ---------------------------------------------------------------------------------------------static oneLine = (s) => {let t = ('' + s).replaceAll('\r', ' ').replaceAll(NL, ' ')while (t.indexOf(' ') >= 0) { t = t.replaceAll(' ', ' ') }return t.trim()}static clip = (s, n) => {let t = oneLine(s)return t.length <= n ? t : t.slice(0, n - 1) + '…'}static money = (n) => { return n == null ? '$?' : '$' + round4(n) }// `--since`: epoch ms, `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS]` (UTC) → ms or nullstatic sinceMs = (s) => {if (s == null || s == '') { return null }if (isWholeNumber(s)) { return toNumber(s) }let t = sif (t.length == 10) { t = t + 'T00:00:00Z' }else if (t.length == 16) { t = t + ':00Z' }else if (t.length == 19) { t = t + 'Z' }return isoToMs(t)}// ---- where the files live --------------------------------------------------------------------------------------// → { path, where } — the project's DECISIONS.md: dev folder on THIS host, else its code folder on this host, else// the librarian's own folder (a project registered on another host, or one without a registry file)static projectFile = (name, reg) => {if (name == 'all') { return { path = globalFile() where = 'all projects' } }let host = thisHost().toLowerCase()let m = reg == null ? null : reg.projects[name]// mission 030: a project with `"workers": false` (hybriel: its own process in its own repo) never gets a file from usif (m != null && !m.takesWorkers) { return { path = libDir() + '/projects/' + name + '/DECISIONS.md' where = 'librarian folder (the project takes no workers: "workers": false — nothing is written into its repo)' } }if (m != null) {if (m.dev != null && m.dev.host != null && m.dev.folder != null && m.dev.host.toLowerCase() == host && exists(m.dev.folder)) { return { path = m.dev.folder + '/DECISIONS.md' where = 'dev folder' } }if (m.code != null && m.code.host != null && m.code.folder != null && m.code.host.toLowerCase() == host && exists(m.code.folder)) { return { path = m.code.folder + '/DECISIONS.md' where = 'code folder (the dev folder is on ' + m.dev.host + ')' } }}return { path = libDir() + '/projects/' + name + '/DECISIONS.md' where = 'librarian folder (' + (m == null ? 'no registry file' : 'no dev or code folder on this host') + ')' }}// ---- store + state ---------------------------------------------------------------------------------------------static loadStore = (dir) => {let f = dir + '/decisions.json'if (!exists(f)) { return { entries = [] } }let r = readJson(f)if (r.error != null) { return { error = r.error } }if (!isObject(r.value) || !isList(r.value.entries)) { return { error = f + ': no entries list' } }return r.value}static saveStore = (dir, store) => {writeFile(dir + '/decisions.json', JSON.stringify(store), 420)return null}static loadState = (dir) => {let f = dir + '/state.json'if (!exists(f)) { return { cursorMs = 0 floorMs = 0 done = {} tries = {} passes = 0 } }let r = readJson(f)if (r.error != null) { return { error = r.error } }let s = r.valueif (s.done == null) { s.done = {} }if (s.tries == null) { s.tries = {} }if (s.cursorMs == null) { s.cursorMs = 0 }if (s.floorMs == null) { s.floorMs = 0 }if (s.passes == null) { s.passes = 0 }return s}static saveState = (dir, st) => {writeFile(dir + '/state.json', JSON.stringify(st), 420)return null}// ---- the creator's texts ---------------------------------------------------------------------------------------// the creator's own words in one event → list of strings (empty = nothing to read: a bare state change, a link)static quotesOf = (e, t) => {let out = []if (e.kind == 'created') {if (t.subject != null && t.subject.trim() != '') { out.push(t.subject.trim()) }if (t.summary != null && t.summary.trim() != '') { out.push(t.summary.trim()) }} else if (e.kind == 'comment' || e.kind == 'state') {if (e.text != null && e.text.trim() != '') { out.push(e.text.trim()) }} else if (e.kind == 'edit') {if (e.subjectChanged == true && e.newSubject != null && e.newSubject.trim() != '') { out.push(e.newSubject.trim()) }if (e.summaryChanged == true && e.newSummary != null && e.newSummary.trim() != '') { out.push(e.newSummary.trim()) }}return out}static whatOf = (e) => {if (e.kind == 'created') { return 'ticket text (opened the ticket)' }if (e.kind == 'state') { return 'state change ' + e.from + ' → ' + e.to }if (e.kind == 'edit') { return 'edit of the ticket' }return 'comment'}static textOf = (e, t, evs, i, quotes) => {let before = []let j = i - 8if (j < 0) { j = 0 }while (j < i) {let b = evs[j]let bt = b.text == null ? '' : b.textif (b.kind == 'edit') { bt = 'edited: ' + (b.newSubject == null ? '' : b.newSubject) }before.push({ author = b.author kind = b.kind to = b.to == null ? '' : b.to when = b.when text = clip(bt, 600) })j = j + 1}let refText = t.project + '#' + t.numberlet urlText = baseUrl() + t.hreflet subj = t.subject == null ? '' : t.subjectlet summ = t.summary == null ? '' : t.summarylet whenText = e.when == null ? '' : e.whenreturn { key = e.id kind = e.kind what = whatOf(e) author = e.author when = whenText date = whenText.slice(0, 10) createdMs = e.createdMs quotes = quotes project = t.project number = t.number ref = refText url = urlText subject = subj summary = summ state = t.state isQuestion = subj.startsWith('Question') before = before }}// every new creator text → { texts (oldest first), cursorMs (newest updatedMs seen), read, total } or { error }static gatherTexts = (names, floorMs, cursorMs, done) => {let all = getJson('/api/tickets')if (all.status != 200) { return { error = 'GET /api/tickets → ' + all.status + (all.text == null ? '' : ' ' + clip(all.text, 120)) } }let texts = []let maxUpd = cursorMslet read = 0let lowest = cursorMs > floorMs ? cursorMs : floorMsfor (t of all.json.tickets) {let upd = t.updatedMs == null ? 0 : t.updatedMsif (upd > maxUpd) { maxUpd = upd }if (upd >= lowest) {let d = getJson(t.apiHref)if (d.status != 200) { return { error = 'GET ' + t.apiHref + ' → ' + d.status } }read = read + 1let evs = d.json.eventslet tk = d.json.ticketlet i = 0while (i < evs.length) {let e = evs[i]let ms = e.createdMs == null ? 0 : e.createdMsif (isCreatorEvent(t.project, e, names) && done[e.id] == null && ms >= floorMs) {let q = quotesOf(e, tk)if (q.length > 0) { texts.push(textOf(e, tk, evs, i, q)) }}i = i + 1}}}sortByNumber(texts, (x) => { return x.createdMs })return { texts = texts cursorMs = maxUpd read = read total = all.json.tickets.length }}// ---- topics ----------------------------------------------------------------------------------------------------static normTopic = (s) => {let t = oneLine(s).toLowerCase()let p = t.indexOf(' (')if (p > 0) { t = t.slice(0, p) }return t.trim()}static topicsOf = (entries, project) => {let out = []for (e of entries) { if (e.status == 'current' && e.project == project && !out.includes(e.topic)) { out.push(e.topic) } }sortStrings(out)return out}// ---- the model call --------------------------------------------------------------------------------------------static promptOf = (x, allowed, entries) => {let topics = {}for (p of allowed) {let ts = topicsOf(entries, p)if (ts.length > 0) { topics[p] = ts }}let cur = []for (e of entries) {if (e.status == 'current') { cur.push({ id = e.id project = e.project topic = e.topic quote = clip(e.quotes.join(' · '), 300) means = e.means }) }}let ct = { quotes = x.quotes what = x.what author = x.author when = x.when }let input = { creatorText = ct }input.ticket = { ref = x.ref project = x.project subject = x.subject summary = clip(x.summary, 2000) state = x.state isQuestion = x.isQuestion url = x.url }input.historyBefore = x.beforeinput.allowedProjects = allowedinput.existingTopics = topicsinput.currentEntries = curlet js = JSON.stringify(input)let f = fenceFor(js)return readFile('./templates/librarian.md').trim() + NL + NL + '## Input (JSON)' + NL + NL + f + 'json' + NL + js + NL + f + NL}// antcolony#20: a small local model answers the same question (same prompt, the schema enforced by the server) →// { answer } or { reason }; the call's files stay in <dir>/calls/ like Claude'sstatic localAnswer = (c, x, allowed, entries) => {let base = c.dir + '/calls/' + x.key + '-local'let prompt = promptOf(x, allowed, entries)writeFile(base + '-prompt.md', prompt, 384)let alias = c.model.indexOf(':') > 0 ? c.model.slice(c.model.indexOf(':') + 1) : 'local'let schema = JSON.parse(readFile('./templates/librarian.schema.json'))let body = { model = alias temperature = 0 max_tokens = 700 messages = [{ role = 'user' content = prompt }] response_format = { type = 'json_schema' json_schema = { name = 'librarian' strict = true schema = schema } } }let res = fetch(c.localUrl + '/chat/completions', { method = 'POST' headers = { 'user-agent' = 'antcolony-scheduler' } json = body timeoutMs = c.localTimeout * 1000 })if (res == null) { return { reason = 'the local model server ' + c.localUrl + ' did not answer' } }let text = res.text()writeFile(base + '-result.json', text == null ? '' : text, 384)if (res.status != 200) { return { reason = 'the local model server answered ' + res.status } }if (jsonErrorAt(text) >= 0) { return { reason = 'the local model server answer is not JSON' } }let v = JSON.parse(text)if (!isObject(v) || !isList(v.choices) || v.choices.length == 0 || !isObject(v.choices[0].message) || !isString(v.choices[0].message.content)) { return { reason = 'the local model answer has no message' } }let content = v.choices[0].message.contentif (jsonErrorAt(content) >= 0) { return { reason = 'the local model answer is cut off or not JSON (finish: ' + v.choices[0].finish_reason + ')' } }let a = JSON.parse(content)if (!isObject(a)) { return { reason = 'the local model answer is not a JSON object' } }return { answer = a }}// one text → done({ answer } | { reason }), with cost; the files of the call stay in <dir>/calls/static classify = (c, x, allowed, entries, done) => {let calls = c.dir + '/calls'if (c.model == 'local' || c.model.startsWith('local:')) {mkDir(calls, 448)let la = localAnswer(c, x, allowed, entries)let why = la.reasonif (la.answer != null) {let chk = checkAnswer(la.answer, x, allowed, entries)if (chk.problems == null) {console.log(' LOCAL MODEL (' + c.model + ') answered')done({ answer = la.answer cost = 0 })return null}why = 'the answer failed the checks: ' + chk.problems.join('; ')}if (c.fallback == 'off' || c.fallback == '') {done({ reason = 'local model: ' + why })return null}console.log(' LOCAL MODEL could not answer (' + why + ') → Claude ' + c.fallback)let c2 = cc2.model = c.fallbackreturn classify(c2, x, allowed, entries, done)}mkDir(calls, 448)let base = calls + '/' + x.keylet inFile = base + '-prompt.md'let outFile = base + '-result.json'let errFile = base + '-stderr.log'writeFile(inFile, promptOf(x, allowed, entries), 384)let args = ['-p' '--output-format' 'json' '--json-schema' readFile('./templates/librarian.schema.json') '--model' c.model '--max-turns' c.maxTurns '--permission-mode' 'default' '--permission-prompts' 'none' '--strict-mcp-config' '--tools' '' '--no-session-persistence']let p = runClaude({ label = 'librarian' folder = c.dir inFile = inFile outFile = outFile errFile = errFile bin = c.bin args = args timeout = c.timeout env = null }, (r) => {let rc = readClaude(outFile, r.ev.exit, r.timedOut, c.timeout, 'librarian')let cost = nullif (rc.res != null) {cost = rc.res.total_cost_usdconsole.log(' ' + usageLine('LIBRARIAN USAGE', rc.res))}// mission 034 (antcolony#25): the call ran into the Claude usage limit (live: subtype "success", is_error, 429,// empty answer) → reported as LIMITED, not as "no answer" (the pass stops; `run` pauses until the reset)if (rc.value != null) {let rl = rateLimitOf(rc.value, rc.res)if (rl.limited) {done({ limited = true resetMs = rl.resetMs limitText = rl.text cost = cost })return null}}if (rc.reason != null) {done({ reason = rc.reason cost = cost })return null}if (rc.res.structured_output == null) {done({ reason = 'no structured answer (Claude: ' + rc.res.subtype + (rc.res.is_error ? ', error' : '') + ', ' + rc.res.num_turns + ' turns, exit ' + r.ev.exit + ')' cost = cost })return null}done({ answer = rc.res.structured_output cost = cost })return null})return p}// the code decides what is stored: → { problems } or { decision: false, why } or { decision: true, project, topic,// quotes, quoteNote, means, supersedes, dropped, why }static KEYS = ['decision' 'project' 'topic' 'quote' 'means' 'supersedes' 'why']static checkAnswer = (a, x, allowed, entries) => {let problems = []if (!isObject(a)) { return { problems = ['the answer is not an object'] } }for (k of a.keys()) { if (!KEYS.includes(k)) { problems.push("unknown key '" + k + "'") } }for (k of KEYS) { if (a[k] == null) { problems.push("'" + k + "' is missing") } }if (problems.length > 0) { return { problems = problems } }if (hlTypeName(a.decision) != 'Boolean') { problems.push("'decision' must be true or false") }if (!isString(a.why)) { problems.push("'why' must be text") }if (problems.length > 0) { return { problems = problems } }let why = oneLine(a.why)if (a.decision == false) { return { decision = false why = why } }if (!isString(a.project) || !allowed.includes(a.project)) { problems.push("'project' must be one of " + allowed.join(', ') + ", got '" + a.project + "'") }let topic = isString(a.topic) ? normTopic(a.topic) : ''if (topic == '' || topic.length > 40) { problems.push("'topic' must be 1–40 characters, got '" + a.topic + "'") }let means = isString(a.means) ? oneLine(a.means) : ''if (means == '') { problems.push("'means' must not be empty for a decision") }if (!isString(a.quote)) { problems.push("'quote' must be text") }if (!isList(a.supersedes)) { problems.push("'supersedes' must be a list") }if (problems.length > 0) { return { problems = problems } }// the quote: the whole text, or a VERBATIM excerpt of it (checked here — the model never writes the creator's words)let quotes = x.quoteslet note = ''let ex = a.quote.trim()if (ex != '') {let hit = falsefor (q of x.quotes) { if (q.includes(ex)) { hit = true } }if (hit) {let whole = falsefor (q of x.quotes) { if (q == ex) { whole = true } }if (!whole) { quotes = [ex] }} else { note = 'the excerpt the model gave is not verbatim — the whole text is stored' }}let sup = []let dropped = []for (id of a.supersedes) {let found = falsefor (e of entries) { if (e.id == id && e.status == 'current') { found = true } }if (found && !sup.includes(id)) { sup.push(id) } else { dropped.push('' + id) }}// the reading is ONE line of at most 300 characters (first live pass: 5 of 155 answers were longer — clipped, not refused)if (means.length > 300) {means = means.slice(0, 299) + '…'note = note == '' ? 'the reading was longer than 300 characters — clipped' : note + '; the reading was clipped to 300 characters'}return { decision = true project = a.project topic = topic quotes = quotes quoteNote = note means = means supersedes = sup dropped = dropped why = why }}// ---- rendering -------------------------------------------------------------------------------------------------static slug = (s) => {let out = ''let low = s.toLowerCase()let i = 0while (i < low.length) {let c = low.charCodeAt(i)let ch = low.slice(i, i + 1)if ((c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c == 45 || c == 95) { out = out + ch }else if (c == 32) { out = out + '-' }i = i + 1}return out}static linkOf = (e) => {if (e.source.kind == 'seed') { return 'source: ' + e.source.text }let subj = clip(e.source.subject, 70).replaceAll('[', '(').replaceAll(']', ')')return '[' + e.source.ref.replaceAll('#', ' #') + ' — ' + subj + '](' + e.source.url + ')'}static readingLabel = (e) => {if (e.means == null || e.means == '') { return 'means: — (no reading recorded; the quote is all there is)' }let who = e.readingBy == 'architect' ? "the architect's reading" : "the librarian's reading"return 'means (' + who + ', not the creator\'s words): ' + e.means}static entryLines = (e, byId) => {let o = []let multi = falsefor (q of e.quotes) { if (q.indexOf(NL) >= 0) { multi = true } }let ctx = e.context != null && e.context != '' ? e.context + ' ' : ''if (!multi) {let qs = []for (q of e.quotes) { qs.push('**"' + q + '"**') }o.push('- ' + e.date + ' · ' + ctx + qs.join(' · ') + ' · ' + linkOf(e))} else {o.push('- ' + e.date + ' · ' + ctx + linkOf(e) + ' · the creator wrote:')let first = truefor (q of e.quotes) {if (!first) { o.push(' >') }for (l of q.split(NL)) { o.push(' > ' + l.replaceAll('\r', '')) }first = false}}o.push(' - ' + readingLabel(e))if (e.status == 'superseded') {let by = byId[e.supersededBy]let byText = by == null ? e.supersededBy : e.supersededBy + ' — **"' + clip(by.quotes.join(' · '), 120) + '"** · ' + linkOf(by)o.push(' - superseded on ' + e.supersededOn + ' by ' + byText)}return o}// the text of one DECISIONS file (current or archive) for `name` from `entries` (already filtered)static renderFile = (name, entries, archive, byId) => {let o = []let label = name == 'all' ? 'all projects' : nameo.push('# Decisions — ' + label + (archive ? ' (archive: superseded)' : ''))o.push('')o.push(GENERATED)o.push('')if (archive) {o.push('Superseded decisions of the creator for **' + label + '** — kept with the quote and where it was said. The current ones are in `' + (name == 'all' ? 'decisions-global.md' : 'DECISIONS.md') + '` beside this file. Nothing here is valid any more.')} else {o.push('The creator\'s decisions for **' + label + '** — current state only (superseded ones: `' + (name == 'all' ? 'decisions-global-archive.md' : 'DECISIONS-archive.md') + '` beside this file).')o.push('**A decision here overrides anything else; ask only if it is not decided here.**')o.push('Each entry: date · the creator\'s exact words in quotes · where it was said · "means" = a READING (the librarian\'s or the architect\'s), not the creator\'s words — when in doubt, the quote counts.')}o.push('')if (entries.length == 0) {o.push(archive ? '_(nothing superseded yet)_' : '_(no decisions recorded yet)_')o.push('')return o.join(NL)}let topics = []for (e of entries) { if (!topics.includes(e.topic)) { topics.push(e.topic) } }sortStrings(topics)o.push('## Contents')for (tp of topics) {let n = 0for (e of entries) { if (e.topic == tp) { n = n + 1 } }o.push('- [' + tp + '](#' + slug(tp) + ') — ' + n)}for (tp of topics) {o.push('')o.push('## ' + tp)o.push('')let list = []for (e of entries) { if (e.topic == tp) { list.push(e) } }sortByNumber(list, (e) => { return 0 - e.ms })for (e of list) { for (l of entryLines(e, byId)) { o.push(l) } }}o.push('')return o.join(NL)}// write every file whose text changed → list of 'path (n current, k superseded)' lines writtenstatic renderAll = (store, reg) => {let byId = {}for (e of store.entries) { byId[e.id] = e }let names = ['all']for (n of reg.names) { names.push(n) }for (e of store.entries) { if (!names.includes(e.project)) { names.push(e.project) } }let written = []for (name of names) {let cur = []let old = []for (e of store.entries) {if (e.project == name) { if (e.status == 'current') { cur.push(e) } else { old.push(e) } }}let pf = projectFile(name, reg)let slash = pf.path.lastIndexOf('/')if (slash > 0) { mkDir(pf.path.slice(0, slash), 493) }let text = renderFile(name, cur, false, byId)if (!exists(pf.path) || readFile(pf.path) != text) {writeFile(pf.path, text, 420)written.push(pf.path + ' (' + cur.length + ' current · ' + pf.where + ')')}let ap = archiveOf(pf.path)if (old.length > 0 || exists(ap)) {let at = renderFile(name, old, true, byId)if (!exists(ap) || readFile(ap) != at) {writeFile(ap, at, 420)written.push(ap + ' (' + old.length + ' superseded)')}}}return written}// ---- the brief's part (brief.hl) -------------------------------------------------------------------------------static STOP = ['that' 'this' 'with' 'from' 'have' 'should' 'would' 'will' 'into' 'when' 'what' 'which' 'there' 'their' 'then' 'than' 'also' 'only' 'make' 'made' 'like' 'just' 'some' 'each' 'every' 'more' 'most' 'other' 'about' 'after' 'before' 'worker' 'workers' 'creator' 'colony' 'must' 'need' 'needs' 'does' 'done' 'them' 'they' 'your' 'here' 'where' 'were' 'been' 'being' 'because' 'while' 'within' 'without' 'over' 'under' 'again' 'same' 'such' 'very' 'work' 'working' 'question' 'ticket' 'tickets' 'project' 'projects' 'reading' 'librarian' 'means' 'decision' 'decisions' 'should' 'these' 'those' 'could' 'into' 'onto' 'upon' 'still' 'already' 'later' 'first' 'next' 'used' 'uses' 'using']// the words of a text that can match: lower case, letters/digits, ≥ 4 characters, no stop words → stems (first 5)static stemsOf = (text) => {let out = []let low = ('' + text).toLowerCase()let w = ''let i = 0while (i <= low.length) {let c = i < low.length ? low.charCodeAt(i) : 32if ((c >= 97 && c <= 122) || (c >= 48 && c <= 57)) { w = w + low.slice(i, i + 1) }else {if (w.length >= 4 && !STOP.includes(w)) {let st = w.length > 5 ? w.slice(0, 5) : wif (!out.includes(st)) { out.push(st) }}w = ''}i = i + 1}return out}static demote = (line) => { return line.startsWith('#') ? '##' + line : line }// the lines of one decisions file for the brief: the whole file while it is short, else its contents + the topic// sections whose name or reading lines share a word with the ticket (subject + summary)static fileLines = (label, pf, ticketText, full) => {let o = []if (!exists(pf.path)) {o.push('_(no decisions recorded yet for ' + label + ')_')return o}let lines = readFile(pf.path).split(NL)let body = []for (l of lines) { if (!l.startsWith('# ') && !l.startsWith('<!-- generated')) { body.push(l) } }while (body.length > 0 && body[0].trim() == '') { body = body.slice(1) }if (lines.length <= full) {for (l of body) { o.push(demote(l)) }return o}// long file: split into the head (before the first topic) and the topic sectionslet head = []let sections = []let cur = nullfor (l of body) {if (l.startsWith('## ') && l != '## Contents') {if (cur != null) { sections.push(cur) }cur = { name = l.slice(3).trim() lines = [l] }} else if (cur != null) { cur.lines.push(l) } else { head.push(l) }}if (cur != null) { sections.push(cur) }let want = stemsOf(ticketText)let picked = []let names = []for (s of sections) {let hay = s.namefor (l of s.lines) { if (l.indexOf('means (') >= 0) { hay = hay + ' ' + l.slice(l.indexOf('means (')) } }let has = stemsOf(hay)let hit = falsefor (st of want) { if (has.includes(st)) { hit = true } }if (hit) {picked.push(s)names.push(s.name)}}for (l of head) { o.push(demote(l)) }o.push('')o.push('**This file is long (' + lines.length + ' lines): only the topics that share a word with this ticket are carried here (' + (names.length == 0 ? 'none matched' : names.join(', ')) + '; ' + picked.length + ' of ' + sections.length + ' topics). Search the whole file `' + pf.path + '` before you ask anything.**')for (s of picked) {o.push('')for (l of s.lines) { o.push(demote(l)) }}return o}// the brief's section "Decisions of the creator" (after the conventions): global + the project's file, with pathsstatic decisionLines = (project, reg, ticketText) => {let fullText = env('COLONY_DECISIONS_FULL_LINES')let full = fullText != null && isWholeNumber(fullText) ? toNumber(fullText) : FULL_LINES_DEFAULTlet host = thisHost()let g = projectFile('all', reg)let p = projectFile(project, reg)let o = []o.push('## Decisions of the creator — a decision here overrides anything else; ask only if it is not decided here')o.push('Collected by the librarian from the creator\'s own words (tickets; for all projects also the architect\'s chat seed). Each entry quotes the creator exactly; the "means" line is only a reading — when in doubt, the quote counts. Superseded decisions are in the `-archive.md` file beside each file.')o.push('- All projects: `' + host + ':' + g.path + '`')o.push('- ' + project + ': `' + host + ':' + p.path + '`')o.push('- **Search both files (e.g. grep your topic) before you ask a question.** A question about something decided there is refused.')o.push('')o.push('### For all projects')o.push('')for (l of fileLines('all projects', g, ticketText, full)) { o.push(l) }o.push('')o.push('### For ' + project)o.push('')for (l of fileLines(project, p, ticketText, full)) { o.push(l) }return o}// ---- seed (decisions made in chat) -----------------------------------------------------------------------------// the architect's seed file: `## Topic` headings, `- "quote" · "quote" · reading: …` bullets (continuation lines// indented). A bullet without a quoted part is not an entry (printed as skipped). → { items, skipped }static parseSeed = (text) => {let items = []let skipped = []let topic = 'general'let cur = nulllet flush = () => {if (cur == null) { return null }let item = curcur = nulllet r = item.indexOf('reading:')let head = r >= 0 ? item.slice(0, r) : itemlet means = r >= 0 ? oneLine(item.slice(r + 8)) : ''let quotes = []let ctx = ''let i = head.indexOf('"')if (i > 0) { ctx = head.slice(0, i).trim() }while (i >= 0) {let j = head.indexOf('"', i + 1)if (j < 0) { i = -1 } else {let q = head.slice(i + 1, j)if (q.trim() != '') { quotes.push(q) }i = head.indexOf('"', j + 1)}}if (quotes.length == 0) { skipped.push(topic + ': ' + clip(item, 100)) }else { items.push({ topic = topic context = ctx quotes = quotes means = means }) }return null}for (l of text.split(NL)) {if (l.startsWith('## ')) {flush()topic = normTopic(l.slice(3))} else if (l.startsWith('- ')) {flush()cur = l.slice(2).trim()} else if (cur != null && l.startsWith(' ') && l.trim() != '') {cur = cur + ' ' + l.trim()} else { flush() }}flush()return { items = items skipped = skipped }}static importSeed = (c, file, sourceText) => {let p = fromCwd(file)if (!exists(p)) { return { error = 'no such seed file: ' + p } }let store = loadStore(c.dir)if (store.error != null) { return { error = store.error } }let st = loadState(c.dir)if (st.error != null) { return { error = st.error } }let ps = parseSeed(readFile(p))let added = 0let have = 0let base = isoToMs('2026-09-24T00:00:00Z')let dateText = '2026-09-23/24'let src = sourceTextfor (it of ps.items) {let h = sha256(it.quotes.join(NL)).slice(0, 16)let key = 'seed:' + hif (st.done[key] != null) { have = have + 1 } else {let eid = 'g-' + h.slice(0, 12)let entry = { id = eid project = 'all' topic = it.topic quotes = it.quotes context = it.context means = it.means readingBy = 'architect' date = dateText ms = base source = { kind = 'seed' text = src file = p } added = now() status = 'current' }store.entries.push(entry)let d = st.doned[key] = 'decision ' + eidst.done = dadded = added + 1console.log('LIBRARIAN SEED ' + eid + ' · all · ' + it.topic + ' · "' + clip(it.quotes.join(' · '), 100) + '"')}}for (s of ps.skipped) { console.log('librarian: seed item without a quote — skipped (no reading without the quote): ' + s) }saveStore(c.dir, store)saveState(c.dir, st)return { added = added have = have skipped = ps.skipped.length store = store }}// ---- the pass --------------------------------------------------------------------------------------------------static lockFile = (dir) => { return dir + '/lock' }static takeLock = (dir) => {let f = lockFile(dir)if (exists(f)) {let r = readJson(f)let ms = r.error == null && r.value.ms != null ? r.value.ms : 0if (now() - ms < LOCK_STALE_MS) { return { error = 'another librarian pass holds ' + f + ' since ' + timestamp(ms).slice(0, 19) + 'Z (stale after 15 min)' } }}writeFile(f, JSON.stringify({ ms = now() }), 384)return { ok = true }}static dropLock = (dir) => {remove(lockFile(dir))return null}// o = { argv, post (write), auto (called by `run`: quiet when nothing is new, files only when something changed),// stop () → true when the run wants to stop }. done(summary) is called once:// summary = { newTexts, decisions, not, refused, failed, cost, written, error, left } — mission 034: a call that ran into the// Claude usage limit stops the pass → summary.limited = true, resetMs (null = unknown), limitText (no try counted)static librarianPass = (o, done) => {let argv = o.argvlet c = librarianSettings(argv, o.auto)if (c.error != null) {console.log('librarian: REFUSED — ' + c.error)done({ error = c.error })return false}if (homeDir() == null) {console.log('librarian: REFUSED — COLONY_HOME is not set (run through ./colony, which sets it)')done({ error = 'no COLONY_HOME' })return false}mkDir(c.dir, 448)let reg = loadRegistry()for (e of reg.errors) { console.log('registry: ' + e) }let allowed = []for (n of reg.names) { allowed.push(n) }// --render: files from the store, nothing elseif (argv.includes('--render')) {let store = loadStore(c.dir)if (store.error != null) {console.log('librarian: ' + store.error)done({ error = store.error })return false}let w = renderAll(store, reg)for (l of w) { console.log('LIBRARIAN FILE ' + l) }console.log('LIBRARIAN RENDER — ' + w.length + ' file(s) written, the others unchanged')done({ written = w })return true}// --seed FILE: decisions made in chat (the architect's seed) — no model calllet seed = option(argv, '--seed')if (seed != null) {if (!o.post) {console.log('librarian: REFUSED — --seed writes: add --post-files')done({ error = 'seed without --post-files' })return false}let lk = takeLock(c.dir)if (lk.error != null) {console.log('librarian: BUSY — ' + lk.error)done({ error = lk.error })return false}let srcText = option(argv, '--seed-source')if (srcText == null || srcText == '') { srcText = 'architect session 2026-09-23/24, chat' }let si = importSeed(c, seed, srcText)if (si.error != null) {dropLock(c.dir)console.log('librarian: ' + si.error)done({ error = si.error })return false}let w = renderAll(si.store, reg)for (l of w) { console.log('LIBRARIAN FILE ' + l) }dropLock(c.dir)console.log('LIBRARIAN SEED — ' + si.added + ' new entr' + (si.added == 1 ? 'y' : 'ies') + ', ' + si.have + ' already there (HAVE), ' + si.skipped + ' item(s) without a quote skipped · ' + w.length + ' file(s) written')done({ decisions = si.added written = w })return true}let st = loadState(c.dir)if (st.error != null) {console.log('librarian: ' + st.error)done({ error = st.error })return false}let sinceText = option(argv, '--since')let floor = st.floorMsif (sinceText != null) {let s = sinceMs(sinceText)if (s == null) {console.log('librarian: REFUSED — --since must be epoch ms, YYYY-MM-DD or YYYY-MM-DDTHH:MM (UTC), got ' + sinceText)done({ error = 'bad --since' })return false}floor = s}// --redo EVENT[,EVENT…] (ticket event ids): classify these texts again — their outcome is forgotten and an entry the// librarian made from them is removed (its own reading was wrong; not a correction by the creator, so no archive)let redo = option(argv, '--redo')if (redo != null) {if (!o.post) {console.log('librarian: REFUSED — --redo writes: add --post-files')done({ error = 'redo without --post-files' })return false}let rstore = loadStore(c.dir)if (rstore.error != null) {console.log('librarian: ' + rstore.error)done({ error = rstore.error })return false}let keys = []for (k of redo.split(',')) { if (k.trim() != '') { keys.push(k.trim()) } }let keepDone = {}for (k of st.done.keys()) { if (!keys.includes(k)) { keepDone[k] = st.done[k] } }let keepTries = {}for (k of st.tries.keys()) { if (!keys.includes(k)) { keepTries[k] = st.tries[k] } }let kept = []for (e of rstore.entries) {let srcKey = e.source == null || e.source.kind != 'ticket' ? '' : e.source.eventif (keys.includes(srcKey)) { console.log('librarian: --redo removes ' + e.id + ' ("' + clip(e.quotes.join(' · '), 80) + '")') } else { kept.push(e) }}rstore.entries = keptsaveStore(c.dir, rstore)st.done = keepDonest.tries = keepTriesst.cursorMs = 0console.log('librarian: --redo ' + keys.length + ' text(s): ' + keys.join(', '))}if (argv.includes('--retry-refused')) {let keep = {}for (k of st.done.keys()) { if (!st.done[k].startsWith('refused')) { keep[k] = st.done[k] } }st.done = keepst.tries = {}st.cursorMs = 0}let names = creatorNames()let g = gatherTexts(names, floor, sinceText != null ? 0 : st.cursorMs, st.done)if (g.error != null) {console.log('librarian: ERROR — ' + g.error + ' (nothing written, the next pass tries again)')done({ error = g.error })return false}let texts = g.textsif (!o.auto || texts.length > 0) { console.log('librarian: ' + texts.length + ' new creator text(s) (creator = ' + names.join(' / ') + ') · read ' + g.read + ' of ' + g.total + ' ticket(s) updated since ' + (floor > st.cursorMs && sinceText != null ? timestamp(floor).slice(0, 19) + 'Z (--since)' : (st.cursorMs == 0 ? 'the beginning' : timestamp(st.cursorMs).slice(0, 19) + 'Z')) + ' · ' + baseUrl()) }if (!o.post) {for (x of texts) { console.log('LIBRARIAN TEXT ' + x.ref + ' · ' + x.what + ' · ' + x.when + ' · "' + clip(x.quotes.join(' · '), 100) + '"') }console.log('LIBRARIAN DRY RUN — ' + texts.length + ' new creator text(s); no model call, nothing written (--post-files classifies them and writes the files)')done({ newTexts = texts.length })return true}if (texts.length == 0) {st.cursorMs = g.cursorMsst.floorMs = floorst.passes = st.passes + 1st.lastPass = timestamp(now())saveState(c.dir, st)let w = []if (!o.auto) {let store0 = loadStore(c.dir)if (store0.error == null) { w = renderAll(store0, reg) }for (l of w) { console.log('LIBRARIAN FILE ' + l) }console.log('LIBRARIAN: nothing new — no model call · ' + w.length + ' file(s) written, the others unchanged')}done({ newTexts = 0 written = w cost = 0 left = 0 })return true}let lk = takeLock(c.dir)if (lk.error != null) {console.log('librarian: BUSY — ' + lk.error)done({ error = lk.error })return false}let store = loadStore(c.dir)if (store.error != null) {dropLock(c.dir)console.log('librarian: ' + store.error)done({ error = store.error })return false}let sum = { newTexts = texts.length decisions = 0 not = 0 refused = 0 failed = 0 cost = 0 calls = 0 written = [] superseded = 0 left = texts.length }let changed = falselet idx = 0let limit = c.max > 0 && c.max < texts.length ? c.max : texts.lengthlet finish = (why) => {// the cursor moves only when every text of this pass got an outcome (a capped / stopped pass reads them again)let left = 0for (x of texts) { if (st.done[x.key] == null) { left = left + 1 } }if (left == 0) { st.cursorMs = g.cursorMs }sum.left = leftst.floorMs = floorst.passes = st.passes + 1st.lastPass = timestamp(now())saveState(c.dir, st)if (changed || !o.auto) {sum.written = renderAll(store, reg)for (l of sum.written) { console.log('LIBRARIAN FILE ' + l) }}dropLock(c.dir)console.log('LIBRARIAN: ' + sum.calls + ' of ' + texts.length + ' new text(s) classified' + (why == null ? '' : ' (' + why + ')') + ' · ' + sum.decisions + ' decision(s), ' + sum.superseded + ' superseded, ' + sum.not + ' not a decision, ' + sum.refused + ' refused, ' + sum.failed + ' without an answer · ' + sum.written.length + ' file(s) written')console.log('LIBRARIAN COST: ' + money(sum.cost) + ' for ' + sum.calls + ' model call(s) (' + c.model + ')')done(sum)return null}let step = nullstep = () => {if (idx >= limit) {finish(limit < texts.length ? 'limit ' + limit + ' per pass — the rest next pass' : null)return null}if (o.stop != null && o.stop()) {finish('stop requested — the rest next pass')return null}let x = texts[idx]idx = idx + 1let allowedHere = []for (n of allowed) { allowedHere.push(n) }if (!allowedHere.includes(x.project)) { allowedHere.push(x.project) }allowedHere.push('all')console.log('LIBRARIAN TEXT ' + x.ref + ' · ' + x.what + ' · ' + x.author + ' · ' + x.when + ' · "' + clip(x.quotes.join(' · '), 100) + '"')classify(c, x, allowedHere, store.entries, (r) => {sum.calls = sum.calls + 1if (r.cost != null) { sum.cost = sum.cost + r.cost }// mission 034: the usage limit — no try counted, the text stays unfiled, the pass stops here (no further call)if (r.limited == true) {sum.limited = truesum.resetMs = r.resetMssum.limitText = r.limitTextconsole.log(' → USAGE LIMIT — ' + r.limitText + ' · resets ' + (r.resetMs == null ? 'at an unknown time' : timestamp(r.resetMs).slice(0, 19) + 'Z') + ' → the pass stops, the text stays unfiled (no try counted)')finish('Claude usage limit reached — the rest after the reset')return null}let tries = st.tries[x.key] == null ? 1 : st.tries[x.key] + 1let tr = st.triestr[x.key] = triesst.tries = trlet dn = st.doneif (r.reason != null) {sum.failed = sum.failed + 1if (tries >= 3) { dn[x.key] = 'failed 3 times: ' + clip(r.reason, 200) }console.log(' → NO ANSWER (' + tries + ' of 3 tries) — ' + r.reason)} else {let a = checkAnswer(r.answer, x, allowedHere, store.entries)if (a.problems != null) {sum.refused = sum.refused + 1if (tries >= 2) { dn[x.key] = 'refused twice: ' + clip(a.problems.join('; '), 200) }console.log(' → REFUSED (' + tries + ' of 2 tries) — ' + a.problems.join('; '))} else if (a.decision == false) {sum.not = sum.not + 1dn[x.key] = 'not a decision: ' + clip(a.why, 200)console.log(' → not a decision — ' + a.why)} else {let eid = 'd-' + x.keylet src = { kind = 'ticket' ref = x.ref url = x.url subject = x.subject what = x.what author = x.author event = x.key }let entry = { id = eid project = a.project topic = a.topic quotes = a.quotes context = '' means = a.means readingBy = 'librarian' date = x.date ms = x.createdMs source = src added = now() status = 'current' why = a.why }// corrections: the entries it supersedes move to the archive (kept, marked)let es = []for (e of store.entries) {if (a.supersedes.includes(e.id)) {let ee = eee.status = 'superseded'ee.supersededBy = eidee.supersededOn = x.datees.push(ee)sum.superseded = sum.superseded + 1console.log(' → supersedes ' + e.id + ' ("' + clip(e.quotes.join(' · '), 80) + '")')} else { es.push(e) }}es.push(entry)store.entries = eschanged = truesum.decisions = sum.decisions + 1dn[x.key] = 'decision ' + eidconsole.log(' → DECISION ' + eid + ' · ' + a.project + ' · ' + a.topic + ' · quote "' + clip(a.quotes.join(' · '), 80) + '" · means: ' + a.means)if (a.quoteNote != '') { console.log(' → note: ' + a.quoteNote) }if (a.dropped.length > 0) { console.log(' → note: unknown / not current ids in supersedes dropped: ' + a.dropped.join(', ')) }saveStore(c.dir, store)}}st.done = dnsaveState(c.dir, st)step()return null})return null}step()return true}// ORDER for the commands that build a brief by hand (`brief`, `work`, `cycle` — scheduler.hl): one pass FIRST, awaited,// so a decision the creator wrote in any ticket a moment ago is in DECISIONS.md before the brief is assembled. Skipped// for `--dry-run` (no model call there) and with `--librarian off` / COLONY_LIBRARIAN=off. A failed / busy pass is said// and the command goes on with the files as they are. `then()` runs once.static librarianFirst = (argv, what, then) => {let off = setting(argv, '--librarian', 'COLONY_LIBRARIAN', 'on') == 'off'if (off || argv.includes('--dry-run')) {then()return null}librarianPass({ argv = argv post = true auto = true stop = null }, (s) => {if (s != null && s.limited == true) { console.log('librarian: the Claude usage limit is reached (resets ' + (s.resetMs == null ? 'at an unknown time' : timestamp(s.resetMs).slice(0, 19) + 'Z') + ') — ' + s.left + ' creator text(s) not filed; ' + what + ' goes on with the files as they are') }else if (s != null && s.error != null) { console.log('librarian: ' + what + ' goes on WITHOUT a finished librarian pass (' + s.error + ') — the decisions files are as they were') }else if (s != null && s.left != null && s.left > 0) { console.log('librarian: ' + s.left + ' creator text(s) not filed yet — ' + what + ' goes on with the files as they are') }then()return null})return null}// `librarian …` by handstatic runLibrarian = (argv) => {let post = argv.includes('--post-files')librarianPass({ argv = argv post = post auto = false stop = null }, (s) => { return null })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