diff --git a/agents/sage/work/vikunja-setup/2026-10-12_close-verdict.json b/agents/sage/work/vikunja-setup/2026-10-12_close-verdict.json new file mode 100644 index 000000000..01535f24a --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-12_close-verdict.json @@ -0,0 +1,12 @@ +{ + "at": "2026-10-12T18:17:41.624Z", + "project": 19, + "title": "Decide whether task.close checks the cited review verdict (decision 113)", + "problems": [], + "create": 201, + "id": 218, + "index": 69, + "inverse": "mark done: POST /api/v1/tasks/218 done true; delete is Jason's or ops-01's", + "readBack": 200, + "createdBy": "bot-mosaic-stack-sage" +} diff --git a/agents/sage/work/vikunja-setup/2026-10-12_m1-brief-comment.json b/agents/sage/work/vikunja-setup/2026-10-12_m1-brief-comment.json new file mode 100644 index 000000000..2e04d69e2 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-12_m1-brief-comment.json @@ -0,0 +1,11 @@ +{ + "at": "2026-10-12T18:16:37.475Z", + "task": 154, + "problems": [], + "title": "M1 brief and queue row: chief-of-staff role (decision 104)", + "status": 201, + "id": 31, + "readBack": 200, + "found": true, + "author": "bot-mosaic-stack-sage" +} diff --git a/agents/sage/work/vikunja-setup/comment-m1-brief.mjs b/agents/sage/work/vikunja-setup/comment-m1-brief.mjs new file mode 100644 index 000000000..d6bcf4cf2 --- /dev/null +++ b/agents/sage/work/vikunja-setup/comment-m1-brief.mjs @@ -0,0 +1,42 @@ +// Sage, 2026-10-12: Mos sage-0089. Comments on #154 (the M1 brief) that the brief and queue row 84 exist. +// Sage's own key (bot user 13). The sync key reads the task title first and the comment back. No state +// change to the task. Prints no secret. +// Usage: node comment-m1-brief.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2); +if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only"); +if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing"); +const TASK = 154; +const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13)."; +const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); +const p = (...xs) => xs.map((x) => `

${esc(x)}

`).join("") + `

${esc(SIG)}

`; +const TEXT = "Done: the M1 brief is docs/plans/2026-10-12_m1-chief-of-staff-role.md (commit ff61135e), on BRIEF-TEMPLATE.md from M1-draft.md and lead decision 104. Queue row 84, Gitea #1578, briefed at queue rev 671 (95cb51c3). Owner Rocko after row 77; reviewers Darkwing and Filbert; gate Sage. The instance name stays with Jason (#150), so the row documents the entry with a placeholder."; +const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim(); +async function api(method, path, body, auth) { + const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" }); + const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {} + return { status: r.status, json }; +} +const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); +const rec = { at: new Date().toISOString(), task: TASK, problems: [] }; +const save = () => { + const out = JSON.stringify(rec, null, 1) + "\n"; + if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written"); + writeFileSync(OUT, out); +}; +const task = await api("GET", `/api/v2/tasks/${TASK}`, undefined, sync); +rec.title = task.json?.title ?? null; +if (task.status !== 200 || task.json?.project_id !== 19) throw new Error(`task ${TASK}: ${task.status}, project ${task.json?.project_id}`); +writeFileSync(OUT, "", { flag: "wx" }); +const r = await api("PUT", `/api/v1/tasks/${TASK}/comments`, { comment: p(TEXT) }, key); +Object.assign(rec, { status: r.status, id: r.json?.id ?? null }); +if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`comment: ${r.status} ${r.json?.message ?? ""}`); +else { + const back = await api("GET", `/api/v2/tasks/${TASK}/comments?per_page=50`, undefined, sync); + const found = list(back.json).find((c) => c.id === r.json.id); + Object.assign(rec, { readBack: back.status, found: Boolean(found), author: found?.author?.username ?? null }); + if (back.status !== 200 || !found) rec.problems.push(`read-back: ${back.status}`); +} +save(); +process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); +if (rec.problems.length) process.exit(2); diff --git a/agents/sage/work/vikunja-setup/file-close-verdict.mjs b/agents/sage/work/vikunja-setup/file-close-verdict.mjs new file mode 100644 index 000000000..f8cbc5219 --- /dev/null +++ b/agents/sage/work/vikunja-setup/file-close-verdict.mjs @@ -0,0 +1,57 @@ +// Sage, 2026-10-12: lead decision 113. Files one task in project 19: task.close records the cited review +// verdict without checking it. Sage's own key (bot user 13). Refuses if the title exists. The sync key +// reads it back. No labels, no assignees, no bucket moves. Rollback is marking it done; deletion is +// Jason's or ops-01's. Prints no secret. +// Usage: node file-close-verdict.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2); +if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only"); +if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing"); +const PROJECT = 19; +const TITLE = "Decide whether task.close checks the cited review verdict (decision 113)"; +const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13)."; +const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); +const p = (...xs) => xs.map((x) => `

${esc(x)}

`).join("") + `

${esc(SIG)}

`; +const DESCRIPTION = p( + "Darkwing, on queue row 61 (Gitea #1550): task.close takes the review verdict as a citation and records it without checking it (packages/tasks/src/verbs.mjs:334). The slice 1 field table said done is written by pm 'after a review verdict'.", + "Lead decision 113 corrects the table to 'pm, citing a review verdict (recorded, not checked)' inside row 61 and leaves the behaviour alone. Open question for a later row: should close verify that the cited verdict exists and approves (for example against the queue review record or the Gitea comment), and refuse otherwise? Sage decides when slice 1 acceptance or a review shows a need.", +); +const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim(); +async function api(method, path, body, auth) { + const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" }); + const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {} + return { status: r.status, json }; +} +const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); +async function existing() { + const out = []; + for (let page = 1; page < 20; page++) { + const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync); + if (r.status !== 200) throw new Error(`list tasks: ${r.status}`); + out.push(...list(r.json)); + if (list(r.json).length < 50) return out; + } + throw new Error("more than 19 pages"); +} +const rec = { at: new Date().toISOString(), project: PROJECT, title: TITLE, problems: [] }; +const save = () => { + const out = JSON.stringify(rec, null, 1) + "\n"; + if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written"); + writeFileSync(OUT, out); +}; +const clash = (await existing()).filter((t) => t.title === TITLE); +if (clash.length) throw new Error(`exists: #${clash[0].id}; refusing`); +writeFileSync(OUT, "", { flag: "wx" }); +const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: TITLE, description: DESCRIPTION }, key); +rec.create = r.status; +if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { + rec.problems.push(`create: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); +} else { + Object.assign(rec, { id: r.json.id, index: r.json.index, inverse: `mark done: POST /api/v1/tasks/${r.json.id} done true; delete is Jason's or ops-01's` }); + const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync); + Object.assign(rec, { readBack: back.status, createdBy: back.json?.created_by?.username }); + if (back.status !== 200 || back.json?.title !== TITLE || back.json?.project_id !== PROJECT || back.json?.description !== DESCRIPTION) rec.problems.push("read-back differs"); +} +save(); +process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); +if (rec.problems.length) process.exit(2); diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index cdbfc629d..0ea565ee6 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -2514,3 +2514,36 @@ which stay with him. Each item names who decided it and what happened. - Mos does the one reinstall after the row lands: `copy`, stop, `install --copy ID` with the uid, start, then status, `/healthz` and the anonymous 302. +113. **Row 61: the slice 1 field table follows the code, edited in the + row (2026-10-12).** Darkwing, on row 61 (#1550), stopped as its + brief says and listed five places where `docs/plans/2026-10-04_slice-1.md`'s + field table and the task verbs disagree, each checked against the + code and a probe on the tasks test world. None changes who may + write a field; each records what the code already does. Decision 90 + set the rule that the prose moves, not the code, and it applies + here. + - Priority: pm, with a resolved decision. `roles/pm.json` lists + `task.priority.change` cross-role, so the broker returns + decision-required (verbs.test.mjs:170 asserts it). + - Due date and priority: `task.create` sets both at create + (verbs.mjs:176-181), so it joins that row's actions. + - Title and description afterwards: any role through + `task.scope.change` with a resolved decision. The arbiter decides + for a role that lists it cross-role (pm, cto and coder today) and + Jason for any other, since an unlisted action is gated. Decision + 90's wording named only the first path. + - Percent, state and comments: the assigned role, and a role + without `task.update.assigned` (the researcher today) needs + Jason's decision even when assigned. + - Done: pm, citing a review verdict. `task.close` records the + verdict without checking it (verbs.mjs:334); the table says so. + Whether close should check it is Vikunja #218, not row 61. + - Darkwing makes the edit inside row 61, so Dewey and Filbert see it + beside the code-match test. Row 61's files owned gain the field + table section of `docs/plans/2026-10-04_slice-1.md`, and only + that section. The edit adds a line citing this decision, as + decision 90's did. + - The table ships in two layers, as Darkwing proposed: the + verb-level writer `packages/tasks` enforces (pm by definition, the + assigned role, or any role), and whether a decision is needed, + which the test reads from `roles/*.json` instead of copying.