vikunja: Q61a #19 install, bus on #19, SetSpark #35 and Launchpad #36; shares read (sage)

install-19.mjs renames 57 done, 159 todo, 161 in-progress, 162 blocked,
adds 248 in-review and moves the default to 159; no task moved. Writes go
to /api/v1 (v2.7.0 405s bucket writes under /api/v2). create-33.mjs adds
#35 and #36 under #33. Records hold no secret; shares-read titles removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 13:22:50 -05:00
co-authored by Claude Opus 5.5
parent 5628885aa5
commit c8a7389615
10 changed files with 1246 additions and 0 deletions
@@ -0,0 +1,373 @@
{
"at": "2026-10-11T18:15:27.432Z",
"project": {
"id": 19,
"title": "Mosaic Stack",
"parent": 33,
"archived": false,
"done_bucket_id": null,
"default_bucket_id": null
},
"views": [
{
"id": 73,
"title": "List",
"kind": "list",
"position": 100,
"mode": "none",
"done_bucket_id": 0,
"default_bucket_id": 0,
"filter": {
"s": "",
"sort_by": null,
"order_by": null,
"filter": "done = false",
"filter_include_nulls": false
}
},
{
"id": 74,
"title": "Gantt",
"kind": "gantt",
"position": 200,
"mode": "none",
"done_bucket_id": 0,
"default_bucket_id": 0,
"filter": null
},
{
"id": 75,
"title": "Table",
"kind": "table",
"position": 300,
"mode": "none",
"done_bucket_id": 0,
"default_bucket_id": 0,
"filter": null
},
{
"id": 76,
"title": "Kanban",
"kind": "kanban",
"position": 400,
"mode": "manual",
"done_bucket_id": 57,
"default_bucket_id": 55,
"filter": null,
"buckets": [
{
"id": 55,
"title": "To-Do",
"position": 100,
"limit": 0,
"count": 0,
"tasks": []
},
{
"id": 56,
"title": "Doing",
"position": 200,
"limit": 0,
"count": 0,
"tasks": []
},
{
"id": 57,
"title": "Done",
"position": 300,
"limit": 0,
"count": 0,
"tasks": [
97,
96,
95,
93,
92,
91,
89,
88,
84,
83,
81,
80
]
},
{
"id": 158,
"title": "Backlog",
"position": 10354688,
"limit": 0,
"count": 0,
"tasks": [
87,
85,
82,
77,
76,
75,
74,
73,
71,
69
]
},
{
"id": 159,
"title": "To Do",
"position": 10420224,
"limit": 0,
"count": 0,
"tasks": [
90,
86,
72
]
},
{
"id": 160,
"title": "Pending",
"position": 10485760,
"limit": 0,
"count": 0,
"tasks": [
2
]
},
{
"id": 161,
"title": "In Progress",
"position": 10551296,
"limit": 0,
"count": 0,
"tasks": [
79,
78,
70
]
},
{
"id": 162,
"title": "Blocked",
"position": 10616832,
"limit": 0,
"count": 0,
"tasks": [
94
]
}
]
}
],
"tasks": [
{
"id": 2,
"title": "Revisit Agent Harness Strategy Planning Session",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 69,
"title": "Fix Mosaic Stack Chat UI — CSS Variables + LLM Backend",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 70,
"title": "v0.0.15 Full Dashboard Styling + Platform Features Orchestration",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 71,
"title": "Implement Agent Harness Wrapper (Claude Code + OpenCode)",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 72,
"title": "Implement Non-AI Coordinator Phase 0 (Foundation)",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 73,
"title": "Update Woodpecker Secrets for Gitea Packages (Mosaic Stack)",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 74,
"title": "Migrate Harbor to Gitea Packages (Homelab)",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 75,
"title": "Mosaic Stack Go-Live: Deploy and pass smoke checks",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 76,
"title": "Mosaic Stack Go-Live: One full agent cycle visible",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 77,
"title": "Mosaic Stack Go-Live: Task and status ingestion working",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 78,
"title": "Mosaic Stack Go-Live: Core dashboard operational",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 79,
"title": "Mosaic Stack: Go-Live MVP to Unblock Projects",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 80,
"title": "Mosaic Stack: Ongoing authentication flow issue",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 81,
"title": "Agent Skills Fleet — mosaic/agent-skills repo (94 skills)",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 82,
"title": "Mosaic Stack: Auto-generate Synapse homeserver.yaml in deployment script",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 83,
"title": "Research OpenClaw and Claude Cowork for Mosaic Stack",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 84,
"title": "TTS/STT Platform Research for Mosaic Stack",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 85,
"title": "Mosaic Stack: UI Reference — OpenClaw Mission Control Patterns",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 86,
"title": "Mosaic Stack: Node 20 → 24 LTS Migration (EOL April 2026)",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 87,
"title": "Mosaic Stack: Migrate to Split Woodpecker Pipelines",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 88,
"title": "Mosaic Stack: API & Coordinator Container Issues",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 89,
"title": "Mosaic Stack: Woodpecker Pipeline link-packages Fix",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 90,
"title": "Evaluate OpenCode as multi-provider orchestrator harness",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 91,
"title": "M9-CredentialSecurity (0.0.9) - Credential Security Hardening",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 92,
"title": "Mosaic Stack: M4-LLM Branch Merge (Neglected Branch)",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 93,
"title": "CRITICAL: Mosaic Stack Agent Orchestration Platform Decision",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 94,
"title": "Mosaic Stack PoC - Homelab Deployment",
"done": false,
"bucket_id": 0,
"project_id": 19
},
{
"id": 95,
"title": "Test quality-rails on uConnect, Syncagent, Inventory Stickers",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 96,
"title": "Create quality-rails repository for portable enforcement",
"done": true,
"bucket_id": 0,
"project_id": 19
},
{
"id": 97,
"title": "OpenClaw (formerly MoltBot/ClawdBot) - MAJOR Issues Investigation",
"done": true,
"bucket_id": 0,
"project_id": 19
}
]
}
@@ -0,0 +1,29 @@
{
"at": "2026-10-11T18:21:56.761Z",
"parent": 33,
"created": [
{
"title": "SetSpark",
"id": 35,
"parent": 33,
"inverse": "archive: POST /api/v1/projects/35 is_archived true (pm); delete is Jason's or ops-01's",
"readBack": 200,
"title_back": "SetSpark",
"parent_back": 33,
"archived": false,
"tasks": 0
},
{
"title": "Launchpad",
"id": 36,
"parent": 33,
"inverse": "archive: POST /api/v1/projects/36 is_archived true (pm); delete is Jason's or ops-01's",
"readBack": 200,
"title_back": "Launchpad",
"parent_back": 33,
"archived": false,
"tasks": 0
}
],
"problems": []
}
@@ -0,0 +1,17 @@
{
"at": "2026-10-11T18:18:25.463Z",
"project": 19,
"view": 76,
"writes": [
{
"op": "rename bucket",
"id": 57,
"from": "Done",
"to": "done",
"inverse": "POST /projects/19/views/76/buckets/57 title \"Done\""
}
],
"problems": [
"stopped: rename 57: 405 Method Not Allowed"
]
}
@@ -0,0 +1,137 @@
{
"at": "2026-10-11T18:19:01.514Z",
"project": 19,
"view": 76,
"writes": [
{
"op": "rename bucket",
"id": 57,
"from": "Done",
"to": "done",
"inverse": "POST /api/v1/projects/19/views/76/buckets/57 title \"Done\"",
"result": {
"title": "done",
"position": 300,
"limit": 0
}
},
{
"op": "rename bucket",
"id": 159,
"from": "To Do",
"to": "todo",
"inverse": "POST /api/v1/projects/19/views/76/buckets/159 title \"To Do\"",
"result": {
"title": "todo",
"position": 10420224,
"limit": 0
}
},
{
"op": "rename bucket",
"id": 161,
"from": "In Progress",
"to": "in-progress",
"inverse": "POST /api/v1/projects/19/views/76/buckets/161 title \"In Progress\"",
"result": {
"title": "in-progress",
"position": 10551296,
"limit": 0
}
},
{
"op": "rename bucket",
"id": 162,
"from": "Blocked",
"to": "blocked",
"inverse": "POST /api/v1/projects/19/views/76/buckets/162 title \"Blocked\"",
"result": {
"title": "blocked",
"position": 10616832,
"limit": 0
}
},
{
"op": "add bucket",
"title": "in-review",
"position": 10584064,
"id": 248,
"inverse": "Jason deletes bucket 248 (no bot holds bucket delete)"
},
{
"op": "view default bucket",
"from": 55,
"to": 159,
"inverse": "POST /api/v1/projects/19/views/76 default_bucket_id 55",
"result": {
"default": 159,
"done": 57,
"mode": "manual",
"filter": null
}
}
],
"problems": [],
"after": {
"done": 57,
"default": 159,
"mode": "manual",
"filter": null,
"buckets": [
{
"id": 55,
"title": "To-Do",
"position": 100,
"tasks": 0
},
{
"id": 56,
"title": "Doing",
"position": 200,
"tasks": 0
},
{
"id": 57,
"title": "done",
"position": 300,
"tasks": 12
},
{
"id": 158,
"title": "Backlog",
"position": 10354688,
"tasks": 10
},
{
"id": 159,
"title": "todo",
"position": 10420224,
"tasks": 3
},
{
"id": 160,
"title": "Pending",
"position": 10485760,
"tasks": 1
},
{
"id": 161,
"title": "in-progress",
"position": 10551296,
"tasks": 3
},
{
"id": 248,
"title": "in-review",
"position": 10584064,
"tasks": 0
},
{
"id": 162,
"title": "blocked",
"position": 10616832,
"tasks": 1
}
]
}
}
@@ -0,0 +1,420 @@
{
"at": "2026-10-11T18:14:52.608Z",
"seen": {
"pm": {
"status": 200,
"projects": [
{
"id": 3,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 4,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 6,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 7,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 8,
"parent": 34,
"archived": false,
"max": null
},
{
"id": 11,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 12,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 13,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 14,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 15,
"parent": 4,
"archived": true,
"max": null
},
{
"id": 17,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 18,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 20,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 21,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 22,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 23,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 24,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 27,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 28,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 29,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 33,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 5,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 26,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 34,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 9,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 10,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 16,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 19,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 25,
"parent": 33,
"archived": false,
"max": null
}
]
},
"cto": {
"status": 401,
"projects": []
},
"coder": {
"status": 401,
"projects": []
},
"reviewer": {
"status": 401,
"projects": []
},
"sync": {
"status": 200,
"projects": [
{
"id": 3,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 4,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 6,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 7,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 8,
"parent": 34,
"archived": false,
"max": null
},
{
"id": 11,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 12,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 13,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 14,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 15,
"parent": 4,
"archived": true,
"max": null
},
{
"id": 17,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 18,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 20,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 21,
"parent": 19,
"archived": false,
"max": null
},
{
"id": 22,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 23,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 24,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 27,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 28,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 29,
"parent": 4,
"archived": false,
"max": null
},
{
"id": 33,
"parent": 0,
"archived": false,
"max": null
},
{
"id": 5,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 26,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 34,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 9,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 10,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 16,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 19,
"parent": 33,
"archived": false,
"max": null
},
{
"id": 25,
"parent": 33,
"archived": false,
"max": null
}
]
}
},
"problems": [
"cto sees a different project set than sync",
"coder sees a different project set than sync",
"reviewer sees a different project set than sync"
],
"descendants": {
"3": [],
"4": [
6,
7,
11,
12,
13,
14,
15,
17,
22,
23,
24,
27,
28,
29
],
"33": [
8,
18,
20,
21,
5,
26,
34,
9,
10,
16,
19,
25
]
},
"childOf33": {
"id": 8,
"project": 200,
"tasks": 200,
"firstPage": 1
},
"note": "Project titles removed from the repo copy (Jason's personal projects); ids, parents and archived flags kept."
}
@@ -0,0 +1,55 @@
// Sage, 2026-10-11: read-only, Q61a. With the sync key, saves project #19's state before the agents
// install the five buckets: the project, its views, each kanban view's buckets, the tasks in each
// bucket, and every task's done flag. GETs only; refuses to overwrite OUT; prints no secret.
// Usage: node before-19.mjs https://tasks.woltje.com SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, 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 BASE = ORIGIN + "/api/v2", P = 19;
const key = readFileSync(KEY, "utf8").trim();
const get = async (path) => {
const r = await fetch(BASE + path, { headers: { Authorization: `Bearer ${key}` }, redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
if (r.status !== 200) throw new Error(`GET ${path}: ${r.status} ${json?.code ?? ""} ${json?.message ?? ""}`);
return json;
};
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function all(path) {
const out = [];
for (let page = 1; page < 100; page++) {
const items = list(await get(`${path}${path.includes("?") ? "&" : "?"}per_page=50&page=${page}`));
out.push(...items);
if (items.length < 50) return out;
}
throw new Error(`${path}: more than 99 pages`);
}
const p = await get(`/projects/${P}`);
const rec = {
at: new Date().toISOString(),
project: { id: p.id, title: p.title, parent: p.parent_project_id, archived: p.is_archived, done_bucket_id: p.done_bucket_id ?? null, default_bucket_id: p.default_bucket_id ?? null },
views: [], tasks: [],
};
for (const v of await all(`/projects/${P}/views`)) {
const view = { id: v.id, title: v.title, kind: v.view_kind, position: v.position, mode: v.bucket_configuration_mode, done_bucket_id: v.done_bucket_id, default_bucket_id: v.default_bucket_id, filter: v.filter ?? null };
if (v.view_kind === "kanban") {
view.buckets = (await all(`/projects/${P}/views/${v.id}/buckets`)).map((b) => ({ id: b.id, title: b.title, position: b.position, limit: b.limit, count: b.count, tasks: [] }));
// The whole board, done tasks included, as the adapter reads it (sync.mjs board()).
const byId = new Map(view.buckets.map((b) => [b.id, b]));
for (let page = 1; page < 100; page++) {
let more = false;
for (const b of list(await get(`/projects/${P}/views/${v.id}/buckets/tasks?per_page=50&page=${page}`))) {
const tasks = b.tasks ?? [];
byId.get(b.id)?.tasks.push(...tasks.map((t) => t.id));
if (Number.isSafeInteger(b.count) ? b.count > page * 50 : tasks.length >= 50) more = true;
}
if (!more) break;
}
}
rec.views.push(view);
}
rec.tasks = (await all(`/projects/${P}/tasks`)).map((t) => ({ id: t.id, title: t.title, done: t.done, bucket_id: t.bucket_id ?? null, project_id: t.project_id }));
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key)) throw new Error("secret in output; not written");
writeFileSync(OUT, out, { flag: "wx" });
process.stdout.write(out);
@@ -0,0 +1,58 @@
// Sage, 2026-10-11: Q61a, the first real work. The pm key creates "SetSpark" and "Launchpad" under
// #33 "Business", with no tasks. Refuses if #33 already has a child by either title. The sync key
// reads each new project back. Rollback is an archive with the pm key; deletion is Jason's or
// ops-01's. Prints no secret.
// Usage: node create-33.mjs https://tasks.woltje.com PM_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");
// v2.7.0 serves reads under /api/v2; project create is PUT /api/v1/projects (GET /api/v2/routes).
const PARENT = 33, TITLES = ["SetSpark", "Launchpad"];
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 projects() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects?per_page=50&page=${page}&is_archived=true`, undefined, sync);
if (r.status !== 200) throw new Error(`list projects: ${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(), parent: PARENT, created: [], 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 before = await projects();
if (!before.some((p) => p.id === PARENT)) throw new Error(`#${PARENT} not visible`);
const clash = before.filter((p) => TITLES.includes(p.title));
if (clash.length) throw new Error(`a project titled ${clash.map((p) => `${p.title} (#${p.id}, parent ${p.parent_project_id})`).join(", ")} exists; refusing`);
writeFileSync(OUT, "", { flag: "wx" });
for (const title of TITLES) {
rec.created.push({ title });
save();
const r = await api("PUT", "/api/v1/projects", { title, parent_project_id: PARENT }, key);
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) {
rec.problems.push(`create ${title}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`);
break;
}
Object.assign(rec.created.at(-1), { id: r.json.id, parent: r.json.parent_project_id, inverse: `archive: POST /api/v1/projects/${r.json.id} is_archived true (pm); delete is Jason's or ops-01's` });
const back = await api("GET", `/api/v2/projects/${r.json.id}`, undefined, sync);
const tasks = await api("GET", `/api/v2/projects/${r.json.id}/tasks?per_page=1`, undefined, sync);
Object.assign(rec.created.at(-1), { readBack: back.status, title_back: back.json?.title, parent_back: back.json?.parent_project_id, archived: back.json?.is_archived, tasks: tasks.status === 200 ? list(tasks.json).length : `status ${tasks.status}` });
if (back.status !== 200 || back.json?.title !== title || back.json?.parent_project_id !== PARENT || back.json?.is_archived) rec.problems.push(`${title} read-back differs`);
if (tasks.status !== 200 || list(tasks.json).length !== 0) rec.problems.push(`${title} task read not empty or failed`);
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,96 @@
// Sage, 2026-10-11: Q61a. The pm key installs the adapter's five buckets on #19's kanban (view 76) by
// renaming the populated buckets whose meaning is plain, adding in-review, and moving the default to
// todo. Every task stays in its bucket; nothing is deleted. The board must still match the
// before-state file, or the script refuses before any write. Each write is logged with its inverse
// as it happens, so a partial run leaves a rollback list. The sync key does every read, as the
// adapter's install check does; the pm key only writes. Prints no secret.
// Usage: node install-19.mjs https://tasks.woltje.com PM_TOKEN_FILE SYNC_TOKEN_FILE BEFORE_JSON OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, BEFORE, 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");
// v2.7.0 serves the reads under /api/v2 and the bucket and view writes under /api/v1 (GET /api/v2/routes).
const BASE = ORIGIN + "/api/v2", WRITE = ORIGIN + "/api/v1", P = 19, V = 76;
const RENAME = { 159: ["To Do", "todo"], 161: ["In Progress", "in-progress"], 162: ["Blocked", "blocked"], 57: ["Done", "done"] };
const ADD = { title: "in-review", after: 161, before: 162 };
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
const before = JSON.parse(readFileSync(BEFORE, "utf8"));
async function api(method, path, body, auth = key) {
const r = await fetch((method === "GET" ? BASE : WRITE) + 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 must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; };
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function board() {
const view = list(must(await api("GET", `/projects/${P}/views?per_page=50`, undefined, sync), "views")).find((v) => v.id === V);
if (!view) throw new Error("view 76 not listed");
const buckets = list(must(await api("GET", `/projects/${P}/views/${V}/buckets?per_page=50`, undefined, sync), "buckets"));
const tasks = Object.fromEntries(buckets.map((b) => [b.id, []]));
for (let page = 1; page < 100; page++) {
let more = false;
for (const b of list(must(await api("GET", `/projects/${P}/views/${V}/buckets/tasks?per_page=50&page=${page}`, undefined, sync), "board"))) {
(tasks[b.id] ??= []).push(...(b.tasks ?? []).map((t) => t.id));
if (Number.isSafeInteger(b.count) ? b.count > page * 50 : (b.tasks ?? []).length >= 50) more = true;
}
if (!more) break;
}
for (const id of Object.keys(tasks)) tasks[id].sort((a, b) => a - b);
return { view, buckets, tasks };
}
const rec = { at: new Date().toISOString(), project: P, view: V, writes: [], 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);
};
// Precondition: the board is the before-state board, bucket for bucket and task for task.
const kb = before.views.find((v) => v.id === V);
const now = await board();
const was = Object.fromEntries(kb.buckets.map((b) => [b.id, { title: b.title, tasks: [...b.tasks].sort((a, c) => a - c) }]));
const is = Object.fromEntries(now.buckets.map((b) => [b.id, { title: b.title, tasks: now.tasks[b.id] ?? [] }]));
if (JSON.stringify(was) !== JSON.stringify(is)) throw new Error("board differs from the before-state; refusing");
if (now.view.done_bucket_id !== 57 || now.view.default_bucket_id !== 55 || now.view.bucket_configuration_mode !== "manual") throw new Error("view 76 differs from the before-state; refusing");
writeFileSync(OUT, "", { flag: "wx" });
try {
for (const [id, [from, to]] of Object.entries(RENAME)) {
const b = now.buckets.find((x) => x.id === Number(id));
if (b.title !== from) throw new Error(`bucket ${id} is ${JSON.stringify(b.title)}, not ${JSON.stringify(from)}`);
rec.writes.push({ op: "rename bucket", id: b.id, from, to, inverse: `POST /api/v1/projects/${P}/views/${V}/buckets/${b.id} title ${JSON.stringify(from)}` });
save();
const r = must(await api("POST", `/projects/${P}/views/${V}/buckets/${b.id}`, { ...b, title: to }), `rename ${id}`);
rec.writes.at(-1).result = { title: r.title, position: r.position, limit: r.limit };
if (r.title !== to || r.position !== b.position || r.limit !== b.limit) rec.problems.push(`bucket ${id} came back ${JSON.stringify(rec.writes.at(-1).result)}`);
}
const pos = (id) => now.buckets.find((x) => x.id === id).position;
const position = (pos(ADD.after) + pos(ADD.before)) / 2;
rec.writes.push({ op: "add bucket", title: ADD.title, position });
save();
const added = must(await api("PUT", `/projects/${P}/views/${V}/buckets`, { title: ADD.title, position, project_view_id: V }), "add in-review");
Object.assign(rec.writes.at(-1), { id: added.id, inverse: `Jason deletes bucket ${added.id} (no bot holds bucket delete)` });
const view = now.view;
rec.writes.push({ op: "view default bucket", from: view.default_bucket_id, to: 159, inverse: `POST /api/v1/projects/${P}/views/${V} default_bucket_id ${view.default_bucket_id}` });
save();
const v = must(await api("POST", `/projects/${P}/views/${V}`, { ...view, default_bucket_id: 159 }), "view default");
rec.writes.at(-1).result = { default: v.default_bucket_id, done: v.done_bucket_id, mode: v.bucket_configuration_mode, filter: v.filter ?? null };
} catch (e) {
rec.problems.push(`stopped: ${e.message}`);
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
process.exit(1);
}
// Read back: the adapter's install rule, and every task still in the bucket it started in.
const after = await board();
const titles = ["todo", "in-progress", "in-review", "blocked", "done"];
const ids = Object.fromEntries(titles.map((t) => [t, after.buckets.filter((b) => b.title === t).map((b) => b.id)]));
rec.after = { done: after.view.done_bucket_id, default: after.view.default_bucket_id, mode: after.view.bucket_configuration_mode, filter: after.view.filter ?? null, buckets: after.buckets.map((b) => ({ id: b.id, title: b.title, position: b.position, tasks: (after.tasks[b.id] ?? []).length })) };
for (const t of titles) if (ids[t].length !== 1) rec.problems.push(`${t} occurs ${ids[t].length} times`);
if (after.view.done_bucket_id !== ids.done[0] || after.view.default_bucket_id !== ids.todo[0]) rec.problems.push("done or default not wired to done and todo");
if (JSON.stringify(after.view.filter ?? null) !== JSON.stringify(now.view.filter ?? null)) rec.problems.push("view filter changed");
for (const [id, w] of Object.entries(was)) if (JSON.stringify(after.tasks[id] ?? []) !== JSON.stringify(w.tasks)) rec.problems.push(`bucket ${id} tasks changed`);
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,49 @@
// Sage, 2026-10-11: read-only, Q61a step 4. After ops-01's team shares on #3, #4 and #33, each bot
// key lists the projects it sees (id, parent, archived, max_permission). The sync key then reads one
// child of #33 and its first task page. GETs only; prints no secret.
// Usage: node shares-read.mjs https://tasks.woltje.com TOKEN_DIR
import { readFileSync } from "node:fs";
const [ORIGIN, DIR] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
const BASE = ORIGIN + "/api/v2";
const ROLES = ["pm", "cto", "coder", "reviewer", "sync"];
const keys = Object.fromEntries(ROLES.map((r) => [r, readFileSync(`${DIR}/${r}-vikunja.token`, "utf8").trim()]));
const get = async (path, auth) => {
const r = await fetch(BASE + path, { headers: { Authorization: `Bearer ${auth}` }, redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
};
async function projects(auth) {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await get(`/projects?per_page=50&page=${page}&is_archived=true`, auth);
if (r.status !== 200) return { status: r.status, items: out };
const items = r.json?.items ?? (Array.isArray(r.json) ? r.json : []);
out.push(...items);
if (items.length < 50) break;
}
return { status: 200, items: out };
}
const rec = { at: new Date().toISOString(), seen: {}, problems: [] };
for (const role of ROLES) {
const l = await projects(keys[role]);
rec.seen[role] = { status: l.status, projects: l.items.map((p) => ({ id: p.id, title: p.title, parent: p.parent_project_id, archived: p.is_archived, max: p.max_permission ?? p.max_right ?? null })) };
}
const sync = rec.seen.sync.projects;
const byId = new Map(sync.map((p) => [p.id, p]));
const under = (p, root) => { for (let x = p, n = 0; x && n < 50; x = byId.get(x.parent), n++) if (x.id === root) return true; return false; };
for (const root of [3, 4, 33]) if (!byId.has(root)) rec.problems.push(`sync does not see #${root}`);
rec.descendants = Object.fromEntries([3, 4, 33].map((root) => [root, sync.filter((p) => p.id !== root && under(p, root)).map((p) => p.id)]));
for (const role of ROLES.filter((r) => r !== "sync")) {
const ids = rec.seen[role].projects.map((p) => p.id).sort((a, b) => a - b).join();
if (ids !== sync.map((p) => p.id).sort((a, b) => a - b).join()) rec.problems.push(`${role} sees a different project set than sync`);
}
const child = rec.descendants[33]?.[0];
if (child) {
const p = await get(`/projects/${child}`, keys.sync);
const t = await get(`/projects/${child}/tasks?per_page=1`, keys.sync);
rec.childOf33 = { id: child, project: p.status, title: p.json?.title, tasks: t.status, firstPage: (t.json?.items ?? (Array.isArray(t.json) ? t.json : [])).length };
} else rec.problems.push("no child of #33 visible");
const out = JSON.stringify(rec, null, 1) + "\n";
if (Object.values(keys).some((s) => out.includes(s))) throw new Error("secret in output; not printed");
process.stdout.write(out);