diff --git a/agents/sage/work/vikunja-setup/2026-10-11_remint.json b/agents/sage/work/vikunja-setup/2026-10-11_remint.json new file mode 100644 index 000000000..fc4e26cd7 --- /dev/null +++ b/agents/sage/work/vikunja-setup/2026-10-11_remint.json @@ -0,0 +1,145 @@ +{ + "at": "2026-10-11T18:11:17.758Z", + "origin": "https://tasks.woltje.com", + "by": "svc-mosaic-stack", + "keys": { + "pm": { + "id": 8, + "title": "mosaic-stack-pm-2026-10-11-q61a", + "expires": "2027-01-07T00:00:00Z", + "before": { + "labels": [ + "read_all" + ], + "projects": [ + "read_all", + "read_one", + "update", + "views_buckets_tasks" + ], + "tasks": [ + "create", + "read_one", + "update" + ], + "tasks_assignees": [ + "create", + "delete" + ], + "tasks_comments": [ + "create" + ], + "tasks_labels": [ + "create", + "delete" + ], + "tasks_relations": [ + "create", + "delete" + ] + }, + "permissions": { + "labels": [ + "read_all" + ], + "projects": [ + "create", + "read_all", + "read_one", + "update", + "views_buckets", + "views_buckets_post", + "views_buckets_put", + "views_buckets_tasks" + ], + "projects_views": [ + "read_all", + "update" + ], + "tasks": [ + "create", + "read_one", + "update" + ], + "tasks_assignees": [ + "create", + "delete" + ], + "tasks_comments": [ + "create" + ], + "tasks_labels": [ + "create", + "delete" + ], + "tasks_relations": [ + "create", + "delete" + ] + }, + "control": [ + 404, + 4002 + ], + "projectsList": 200, + "fileMode": "600", + "revoked": 7, + "oldKeyAfterRevoke": 401, + "keysNow": [ + 8 + ] + }, + "sync": { + "id": 9, + "title": "mosaic-stack-sync-2026-10-11-q61a", + "expires": "2027-01-07T00:00:00Z", + "before": { + "projects": [ + "read_one", + "views_buckets", + "views_buckets_tasks_get" + ], + "projects_views": [ + "read_all" + ], + "tasks": [ + "read_all", + "read_one" + ], + "tasks_comments": [ + "read_all" + ] + }, + "permissions": { + "projects": [ + "read_all", + "read_one", + "views_buckets", + "views_buckets_tasks_get" + ], + "projects_views": [ + "read_all" + ], + "tasks": [ + "read_all", + "read_one" + ], + "tasks_comments": [ + "read_all" + ] + }, + "control": [ + 404, + 4002 + ], + "projectsList": 200, + "fileMode": "600", + "revoked": 6, + "oldKeyAfterRevoke": 401, + "keysNow": [ + 9 + ] + } + }, + "problems": [] +} diff --git a/agents/sage/work/vikunja-setup/remint.mjs b/agents/sage/work/vikunja-setup/remint.mjs new file mode 100644 index 000000000..1df6b0b6a --- /dev/null +++ b/agents/sage/work/vikunja-setup/remint.mjs @@ -0,0 +1,87 @@ +// Sage, 2026-10-11: Q61a (Jason, relayed by Mos: "full agentic access", no task or project delete, +// no share route). svc-mosaic-stack re-mints the pm and sync keys with added scopes: the pm gets +// projects create plus bucket and view writes so the agents install #19's buckets, and the sync bot +// gets projects read_all to find child projects. Each new key goes to a .new file (wx, 0600), is +// checked with reads only, renamed over the old file, and the old key is revoked. No delete or write +// is tried as a test; the bus startup probes run on the next start. Refuses if a bot's keys aren't +// exactly the expected old key, or if a scope set holds a forbidden group or permission. +// Usage: node remint.mjs https://tasks.woltje.com SVC_PASSWORD_FILE TOKEN_DIR OUT_JSON +import { readFileSync, writeFileSync, existsSync, renameSync, statSync } from "node:fs"; +const [ORIGIN, PW, DIR, 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 to rerun"); +const BASE = ORIGIN + "/api/v2"; +const EXP = "2027-01-07T00:00:00Z", MISSING = 2147483647; +const KEYS = { + pm: { owner: 6, old: 7, add: { projects: ["create", "views_buckets", "views_buckets_put", "views_buckets_post"], projects_views: ["read_all", "update"] } }, + sync: { owner: 10, old: 6, add: { projects: ["read_all"] } }, +}; +const FORBIDDEN_GROUPS = ["projects_shares", "projects_teams", "projects_users", "projects_webhooks", "teams", "users", "tokens"]; +// Delete on tasks and projects (and their views and buckets) is Jason's alone. Unassign, unlink and +// unlabel are deletes of a join row, not of a task, and the pm already holds them. +const forbidden = (g, p) => FORBIDDEN_GROUPS.includes(g) || ((g === "tasks" || g.startsWith("projects")) && /delete|duplicate/.test(p)); +async function api(method, path, body, auth) { + const headers = { "Content-Type": "application/json" }; + if (auth) headers.Authorization = `Bearer ${auth}`; + const r = await fetch(BASE + path, { method, headers, 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 must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; }; +const items = (j, l) => { if (!Array.isArray(j?.items)) throw new Error(`${l}: no items array`); return j.items; }; +const norm = (p) => Object.fromEntries(Object.entries(p).map(([g, l]) => [g, [...new Set(l)].sort()]).sort(([a], [b]) => a.localeCompare(b))); + +const password = readFileSync(PW, "utf8").trim(); +const tok = must(await api("POST", "/login", { username: "svc-mosaic-stack", password }), "login svc").token; +if (!tok) throw new Error("login svc: no token field"); +const secrets = [password, tok]; +const keysOf = (owner) => api("GET", `/tokens?owner_id=${owner}&per_page=50`, undefined, tok).then((r) => items(must(r, `keys ${owner}`), `keys ${owner}`)); + +// Preconditions for both keys before any write. +const plan = {}; +for (const [role, k] of Object.entries(KEYS)) { + const file = `${DIR}/${role}-vikunja.token`; + if (existsSync(`${file}.new`)) throw new Error(`${role}-vikunja.token.new exists; refusing`); + const l = await keysOf(k.owner); + if (l.map((x) => x.id).join() !== String(k.old)) throw new Error(`${role} bot keys are not exactly [${k.old}]; refusing`); + const perms = structuredClone(l[0].permissions); + for (const [g, ps] of Object.entries(k.add)) perms[g] = [...(perms[g] ?? []), ...ps]; + for (const [g, ps] of Object.entries(perms)) for (const p of ps) if (forbidden(g, p)) throw new Error(`${role}: forbidden scope ${g}.${p}; refusing`); + const oldValue = readFileSync(file, "utf8").trim(); + secrets.push(oldValue); + plan[role] = { file, oldValue, before: norm(l[0].permissions), perms: norm(perms) }; +} + +const rec = { at: new Date().toISOString(), origin: ORIGIN, by: "svc-mosaic-stack", keys: {}, problems: [] }; +for (const [role, k] of Object.entries(KEYS)) { + const { file, oldValue, before, perms } = plan[role]; + const title = `mosaic-stack-${role}-${new Date().toISOString().slice(0, 10)}-q61a`; + const m = await api("POST", "/tokens", { title, owner_id: k.owner, expires_at: EXP, permissions: perms }, tok); + if (m.status !== 201 || typeof m.json?.token !== "string") throw new Error(`mint ${role}: ${m.status} ${m.json?.code ?? ""} ${m.json?.message ?? ""}`); + const value = m.json.token; + secrets.push(value); + writeFileSync(`${file}.new`, value, { flag: "wx", mode: 0o600 }); + const ctl = await api("GET", `/tasks/${MISSING}`, undefined, value); + const list = await api("GET", "/projects?per_page=1", undefined, value); + const r = { id: m.json.id, title, expires: m.json.expires_at, before, permissions: norm(m.json.permissions ?? {}), control: [ctl.status, ctl.json?.code], projectsList: list.status }; + rec.keys[role] = r; + if (ctl.status !== 404 || ctl.json?.code !== 4002 || list.status !== 200) throw new Error(`new ${role} key check failed: ${JSON.stringify(r)}; .new kept, old key ${k.old} untouched`); + if (JSON.stringify(r.permissions) !== JSON.stringify(perms)) rec.problems.push(`${role} permissions differ from the request`); + renameSync(`${file}.new`, file); + r.fileMode = (statSync(file).mode & 0o777).toString(8); + if (readFileSync(file, "utf8").trim() !== value) rec.problems.push(`${role} file does not hold the new key`); + must(await api("DELETE", `/tokens/${k.old}`, undefined, tok), `revoke old ${role} key`); + r.revoked = k.old; + r.oldKeyAfterRevoke = (await api("GET", `/tasks/${MISSING}`, undefined, oldValue)).status; + r.keysNow = (await keysOf(k.owner)).map((x) => x.id); + if (r.expires !== EXP) rec.problems.push(`${role} expiry differs`); + if (r.oldKeyAfterRevoke !== 401) rec.problems.push(`old ${role} key still answers`); + if (r.keysNow.join() !== String(r.id)) rec.problems.push(`${role} bot keys are not exactly the new one`); + if (r.fileMode !== "600") rec.problems.push(`${role} file mode not 600`); +} + +const out = JSON.stringify(rec, null, 1) + "\n"; +if (secrets.some((s) => out.includes(s))) throw new Error("secret in the record; not written"); +writeFileSync(OUT, out, { flag: "wx" }); +process.stdout.write(out); +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 dcceaf9a4..72eee7493 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -1811,3 +1811,33 @@ which stay with him. Each item names who decided it and what happened. - Open: projects.create for the pm (asked of Jason via Mos), and Q61 (which project the bus watches). Project 32 stays deleted. Jason removed it as a duplicate of #19, and recreate.mjs is gone. +86. **The agents bind the bus to #19 and follow nested projects (2026-10-11, + Q61a).** Jason, relayed by Mos: "just share them. Get it done. You + keep wanting me to do all the manual steps." The agents do every + step, and Jason does none of them. This replaces the holds in 85. + - ops-01 inserts the team shares on #3, #4 and #33 (team 1 at admin, + team 2 at read). Access cascades to their descendants. + - Keys re-minted with "full agentic access" and still no task or + project delete, no duplicate and no share, team, user or webhook + route (Q60). The pm is key 8: key 7's scopes plus projects create, + views_buckets, views_buckets_put and views_buckets_post, and + projects_views read_all and update. The sync bot is key 9: key 6's + scopes plus projects read_all. Keys 7 and 6 are revoked and answer + 401. Evidence: `agents/sage/work/vikunja-setup/2026-10-11_remint.json`. + - The bus binds to #19 "Mosaic Stack". Its kanban gets the five + buckets by renaming where the mapping is obvious (To-Do to todo, + Doing to in-progress, Done to done) and adding in-review and + blocked. Jason's tasks stay where they are. A before-state file + comes first. + - Child boards are watch-only. The agents never rename or add + buckets on them. + - Rows A and B (`docs/plans/2026-10-11_multi-project-sync.md`): + read-only nested sync now, and verbs across the tree after row A + is done. Mos ruled that task.create takes an optional project, + which must be an installed project in the tree. + - Correction to 85: no adapter archive guard is needed now. The + adapter has no project verb, so the pm's projects.update can't + reach a project through the bus. The row that adds a project verb + adds the guard. + - Projects.create is used once now, for "SetSpark" and "Launchpad" + under #33, with no tasks. diff --git a/docs/plans/2026-10-11_multi-project-sync.md b/docs/plans/2026-10-11_multi-project-sync.md new file mode 100644 index 000000000..9301de2e7 --- /dev/null +++ b/docs/plans/2026-10-11_multi-project-sync.md @@ -0,0 +1,151 @@ +# Multi-project sync: rows A and B (2026-10-11) + +Status: written by Sage, lead. It follows `docs/plans/BRIEF-TEMPLATE.md`. +Jason answered Q61a, relayed by Mos: agents see and work his nested +projects ("just share them. Get it done."). Mos ruled the shape: row A +now, row B after row A is done, and task.create takes an optional +project. Lead decision 86 records it. + +Three constraints hold for both rows. A change that breaks one of them +is out of scope, whatever else it fixes. + +- A task's ledger ref stays `vikunja:/`. The ledger + checks that form on `task_snapshots`, `decisions` and the `events` + subject (`packages/bus/schema.sql`), and `task_snapshots` is + append-only. Neither row changes `packages/bus/`. Never key a task by + its id alone. +- `tracker.descendants` defaults to false. With it false, the adapter + behaves exactly as v0 does, and every v0 test passes unchanged. +- The adapter never writes to a board. A child project is installed + only if its one manual kanban view already has the five buckets + (`todo`, `in-progress`, `in-review`, `blocked`, `done`), done and + default wired, as the root's install check requires. Any other child + is watch-only. No bucket, view or project write ships in either row. + +## Multi-project sync: the adapter reads a project tree (row A) + +### Problem + +The adapter watches one project. `settings()` takes one +`tracker.project` (`packages/tasks/src/adapter.mjs:21`), startup checks +that project alone (`startup.mjs:37`), and sync reads its board and task +list only (`sync.mjs:21`, `sync.mjs:45`). A task in any other project is +a shape error in the walk (`sync.mjs:59`), and a move to another project +is a tombstone (`sync.mjs:123`). Jason's projects nest. The bus binds +to #19 "Mosaic Stack", and he wants agents to follow work into its +child projects. + +### Owner and reviewer + +- Owner: Darkwing. +- Reviewers: Dewey and Filbert. + +### Files owned + +- `packages/business/src/vars.mjs` and `packages/business/tests/vars.test.mjs` +- `packages/tasks/src/adapter.mjs`, `startup.mjs`, `sync.mjs`, + `fake.mjs`, `vikunja.mjs`, `index.mjs` +- `packages/tasks/tests/` (all files and fixtures) +- `packages/tasks/README.md`, `packages/tasks/live/README.md` +- `agents/darkwing/work/queue-/` + +`verbs.mjs` is read-only in this row. Verbs keep refusing a task outside +the root with `task-project`, as they do now. + +### What ships + +- `tracker.descendants`, a project-layer boolean var, default false. + `tracker.project` stays the root. +- With descendants on, the tree is the root plus every descendant the + sync bot can see through `GET /projects` (key 9 has `projects + read_all`), followed by `parent_project_id`. Discovery runs at startup + and at each reconcile, not each tick. Archived projects are not in the + tree. Record your rule for a project that leaves the tree (archived, + moved out, unshared or deleted) in the README and test it. Its tasks + become `task.missing` with a reason. The ledger is never rewritten. +- Install state per project. The root's install check refuses the + business, exactly as today. A child that passes the same check is + installed. A child that fails it is watch-only and never refuses the + business. Sync records title, done, assignees and comments for a + watch-only child, and no bucket state. +- Sync covers each project in the tree. A task moved between two + projects in the tree gets the `moved` tombstone naming the new project + and a snapshot under its new ref. A move out of the tree stays the + tombstone it is today. +- Poll cost. Probe whether v2.7.0 serves a cross-project task read to the + sync key's scopes. If it does, use one read per tick for the tree. If + not, read per project and state the per-tick call count in the README. + The probe results go in `agents/darkwing/work/queue-/probes.md`. +- `fake.mjs` models `parent_project_id`, the project list route, + archived projects and access inherited from an ancestor's share. +- Tests: descendants off matches v0; a three-level tree; a watch-only + child keeps the business ready; a root without buckets refuses; moves + root to child, child to child and child to outside; a new child appears + at reconcile; a child leaves the tree. +- Gate suites: `node --test 'packages/tasks/tests/*.test.mjs'`, + `node --test 'packages/business/tests/*.test.mjs'`, + `node --test 'packages/bus/tests/*.test.mjs'`, and every + `scripts/test-*.sh`. + +### Out of scope + +Verbs on child projects (row B). Any change to `packages/bus/`. Any write +to a Vikunja board or project. An archive guard: the adapter has no +project verb, so the pm's `projects.update` can't reach a project through +the bus. A row that adds a project verb adds the guard. + +### Gate + +Dewey and Filbert approve on the issue, naming the candidate manifest. +The suites above pass on Sage's gate rerun. Sage then runs one live read +against tasks.woltje.com with descendants on, root #19, read-only, and +attaches the tick log. + +## Multi-project verbs: task verbs across the tree (row B) + +### Problem + +After row A, the bus sees child projects but can't act in them. +`target()` and `relation()` refuse a ref from another project +(`verbs.mjs:74`, `verbs.mjs:79`), `read()` refuses a task that isn't in +the root (`verbs.mjs:88`), and `task.create` always posts to the root +(`verbs.mjs:355`) with no project argument. + +### Owner and reviewer + +- Owner: Darkwing, after row A is done. +- Reviewers: Dewey and Filbert. + +### Files owned + +- `packages/tasks/src/verbs.mjs`, `fake.mjs` +- `packages/tasks/tests/verbs.test.mjs` and fixtures +- `packages/tasks/README.md` +- `packages/harness/src/tools.mjs` (the `task_create` tool schema gets + the optional project) and its test +- `agents/darkwing/work/queue-/` + +### What ships + +- Mos's ruling: `task.create` takes an optional `project`. It must be an + installed project in the tree; a watch-only or unknown project refuses + with a new code the README names. Without it, the task goes to the + root, as today. +- `target()`, `relation()` and `read()` accept a task in any installed + project of the tree. A bucket move uses that project's own view and + buckets, not the root's. +- A verb on a task in a watch-only project refuses with the same new + code. +- Tests for each verb across two installed projects, a watch-only refusal + and a relation between projects. +- Gate suites as row A. + +### Out of scope + +Project verbs (create, rename, archive, move). They need their own +brief, an archive guard and a ruling. + +### Gate + +Dewey and Filbert approve on the issue, naming the candidate manifest, +and the suites pass on Sage's gate rerun.