pi-straw-boss 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/server/main.mjs +31 -27
- package/package.json +1 -1
- package/prompts/apps-config.md +22 -1
- package/prompts/integrator.md +34 -0
- package/prompts/main.md +13 -12
- package/prompts/planning.md +6 -4
- package/prompts/tower-run.md +1 -1
- package/prompts/tower.md +9 -8
- package/src/agent/cli.ts +9 -4
- package/src/agent/sidecar.ts +5 -0
- package/src/board.ts +32 -21
- package/src/boardauto.ts +4 -3
- package/src/boardfill.ts +42 -9
- package/src/boardread.ts +3 -3
- package/src/boardseed.ts +34 -0
- package/src/boardset.ts +1 -1
- package/src/server/towerbrief.ts +6 -6
- package/src/tools.ts +12 -5
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
|
|
|
92
92
|
| `tower_list` | Tower only: every workspace's tower, who a message for it reaches, its state, and what its tabs work on. |
|
|
93
93
|
| `tower_send` | Tower only: message one other tower (by workspace id or label), or all of them. |
|
|
94
94
|
| `board_read` | Tower only: the work board: what each main is doing and how far it is, here and on teammates' computers. |
|
|
95
|
-
| `board_set` | A tab's main: override its own board entry (one line of current work, up to 8 checklist items),
|
|
95
|
+
| `board_set` | A tab's main: override its own board entry (one line of current work, up to 8 checklist items), `auto: true` to give it back to the auto-fill from its todo list and name, or `clear: true` to delete it when its work is finished or shelved. |
|
|
96
96
|
| `boss_broadcast` | Message a tab, a workspace, all towers, or every session (under a header). |
|
|
97
97
|
| `boss_server` | See the sb server (status works with the server down), read its log (up to 200 lines), or restart it with a reason. Refused while the boss stopped the server, once a minute at most, and never in a remote turn. |
|
|
98
98
|
| `boss_rename` | Name this session with a short phrase for its current work; the net and the GUI show it, and a main agent's tab takes it as its label. Main agents call it when their work changes. |
|
package/README.zh-TW.md
CHANGED
|
@@ -92,7 +92,7 @@ pi install npm:pi-straw-boss
|
|
|
92
92
|
| `tower_list` | 只有塔台能用:列出每個工作區的塔台、訊息會送到誰、它的狀態,以及各分頁在做什麼。 |
|
|
93
93
|
| `tower_send` | 只有塔台能用:傳訊息給另一座塔台(用工作區 id 或名稱),或全部塔台。 |
|
|
94
94
|
| `board_read` | 只有塔台能用:看工作看板,也就是各主智慧體正在做什麼、做到哪;本機的,加上 Straw Link 團隊中隊友電腦上的。 |
|
|
95
|
-
| `board_set` | 分頁的主智慧體用:覆寫自己的看板項目(一行目前的工作,加最多 8
|
|
95
|
+
| `board_set` | 分頁的主智慧體用:覆寫自己的看板項目(一行目前的工作,加最多 8 個清單項),用 `auto: true` 交還給依待辦清單和名稱的自動填入,或用 `clear: true` 在工作做完或擱置時刪掉它。 |
|
|
96
96
|
| `boss_broadcast` | 傳訊息給一個分頁、一個工作區、所有塔台,或所有 session(訊息開頭有標頭)。 |
|
|
97
97
|
| `boss_rename` | 用一個短語替這個 session 命名,說明它現在在做什麼;net 與 GUI 會顯示它,主智慧體的分頁也會改成這個名稱。主智慧體換工作時會呼叫它。 |
|
|
98
98
|
| `/boss` | 接手成為這個分頁的主智慧體。 |
|
package/dist/server/main.mjs
CHANGED
|
@@ -2526,7 +2526,7 @@ var KEPT = 16;
|
|
|
2526
2526
|
var ID = /^[\w-]{8,64}$/;
|
|
2527
2527
|
function createDeploy(given, now = Date.now) {
|
|
2528
2528
|
const plan = [...given], state = new Map(plan.map((id) => [id, "pending"])), began = /* @__PURE__ */ new Map(), took = /* @__PURE__ */ new Map();
|
|
2529
|
-
let active,
|
|
2529
|
+
let active, finished2 = false, failed, asking, held, pane;
|
|
2530
2530
|
const end = (to) => {
|
|
2531
2531
|
if (!active) return;
|
|
2532
2532
|
took.set(active, now() - began.get(active));
|
|
@@ -2542,7 +2542,7 @@ function createDeploy(given, now = Date.now) {
|
|
|
2542
2542
|
},
|
|
2543
2543
|
finish() {
|
|
2544
2544
|
end("done");
|
|
2545
|
-
|
|
2545
|
+
finished2 = true;
|
|
2546
2546
|
},
|
|
2547
2547
|
/** The running step failed (the first, when none had begun). `settings` says the folder's .pi/settings.json is why (trust/settings.ts), so the page can word it itself. */
|
|
2548
2548
|
fail(reason, settings) {
|
|
@@ -2556,7 +2556,7 @@ function createDeploy(given, now = Date.now) {
|
|
|
2556
2556
|
},
|
|
2557
2557
|
/** The opening is over (done or failed). */
|
|
2558
2558
|
get over() {
|
|
2559
|
-
return
|
|
2559
|
+
return finished2 || Boolean(failed);
|
|
2560
2560
|
},
|
|
2561
2561
|
/** The pane the new pi runs in, once there is one: a trust question for it belongs to this opening. */
|
|
2562
2562
|
get pane() {
|
|
@@ -2583,7 +2583,7 @@ function createDeploy(given, now = Date.now) {
|
|
|
2583
2583
|
}
|
|
2584
2584
|
},
|
|
2585
2585
|
view() {
|
|
2586
|
-
return { steps: plan.map((id) => ({ id, state: state.get(id), ms: id === active ? now() - began.get(id) : took.get(id) ?? 0 })), finished, ...failed && { failed }, ...asking && active === "trust" && { trust: asking } };
|
|
2586
|
+
return { steps: plan.map((id) => ({ id, state: state.get(id), ms: id === active ? now() - began.get(id) : took.get(id) ?? 0 })), finished: finished2, ...failed && { failed }, ...asking && active === "trust" && { trust: asking } };
|
|
2587
2587
|
}
|
|
2588
2588
|
};
|
|
2589
2589
|
}
|
|
@@ -5468,6 +5468,7 @@ import { join as join21 } from "node:path";
|
|
|
5468
5468
|
var BOARD = { now: 120, item: 80, items: 8, label: 40 };
|
|
5469
5469
|
var ENDED_MS = 30 * 6e4;
|
|
5470
5470
|
var DEAD_MS = 24 * 36e5;
|
|
5471
|
+
var STALE_MS2 = 2 * 36e5;
|
|
5471
5472
|
var BAD_ALL = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/gu;
|
|
5472
5473
|
var SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5473
5474
|
var fold = (text4) => text4.replace(/\s+/gu, " ").trim();
|
|
@@ -5495,23 +5496,26 @@ function readEntries(dir) {
|
|
|
5495
5496
|
names = readdirSync7(dir).filter((n) => n.endsWith(".json"));
|
|
5496
5497
|
} catch {
|
|
5497
5498
|
}
|
|
5498
|
-
return names.flatMap((name) =>
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5499
|
+
return names.flatMap((name) => readOne(dir, name));
|
|
5500
|
+
}
|
|
5501
|
+
function readOne(dir, name) {
|
|
5502
|
+
try {
|
|
5503
|
+
const raw = JSON.parse(readFileSync11(join21(dir, name), "utf8"));
|
|
5504
|
+
const session = name.slice(0, -".json".length);
|
|
5505
|
+
if (raw?.v !== 1 || raw.session !== session || !safeSession(session) || typeof raw.updated !== "number" || !Number.isFinite(raw.updated) || typeof raw.now !== "string" || !Array.isArray(raw.items)) return [];
|
|
5506
|
+
if (!raw.items.every((i) => typeof i === "object" && i !== null && typeof i.t === "string" && typeof i.done === "boolean")) return [];
|
|
5507
|
+
const c = raw.count, src = raw.src === "todo" ? "todo" : "set";
|
|
5508
|
+
const count3 = src === "todo" && Number.isSafeInteger(c?.done) && Number.isSafeInteger(c?.total) && c.done >= 0 && c.done <= c.total && c.total <= 200 ? { done: c.done, total: c.total } : void 0;
|
|
5509
|
+
const who = src === "todo" ? clean2(raw.who, BOARD.label) : "";
|
|
5510
|
+
return [{ v: 1, session, updated: raw.updated, src, ...count3 ? { count: count3 } : {}, ...who ? { who } : {}, ...cleanText(raw) }];
|
|
5511
|
+
} catch {
|
|
5512
|
+
return [];
|
|
5513
|
+
}
|
|
5512
5514
|
}
|
|
5515
|
+
var finished = (e) => e.src === "todo" && e.count !== void 0 && e.count.total > 0 && e.count.done === e.count.total;
|
|
5513
5516
|
function purge(dir, live, now) {
|
|
5514
|
-
|
|
5517
|
+
const due = (e) => !live.has(e.session) && now - e.updated > DEAD_MS || finished(e) && now - e.updated > ENDED_MS;
|
|
5518
|
+
for (const entry of readEntries(dir)) if (due(entry) && readOne(dir, `${entry.session}.json`).every(due)) removeEntry(dir, entry.session);
|
|
5515
5519
|
try {
|
|
5516
5520
|
const known2 = new Set(readEntries(dir).map((e) => `${e.session}.json`));
|
|
5517
5521
|
for (const name of readdirSync7(dir)) if (name.endsWith(".json") && !known2.has(name) && now - statSync8(join21(dir, name)).mtimeMs > DEAD_MS) rmSync6(join21(dir, name), { force: true });
|
|
@@ -5528,7 +5532,7 @@ var wireId = (device, session) => createHash8("sha256").update(device).update(se
|
|
|
5528
5532
|
function joinLocal(entries2, mains, now) {
|
|
5529
5533
|
const byId = new Map(entries2.map((e) => [e.session, e]));
|
|
5530
5534
|
const live = mains.map((m) => {
|
|
5531
|
-
const
|
|
5535
|
+
const found = byId.get(m.session), e = found && finished(found) && now - found.updated > ENDED_MS ? void 0 : found;
|
|
5532
5536
|
return {
|
|
5533
5537
|
session: m.session,
|
|
5534
5538
|
workspaceId: m.workspaceId,
|
|
@@ -5537,7 +5541,7 @@ function joinLocal(entries2, mains, now) {
|
|
|
5537
5541
|
main: m.name,
|
|
5538
5542
|
state: m.state,
|
|
5539
5543
|
items: e?.items ?? [],
|
|
5540
|
-
...e ? { now: e.now, src: e.src, ...e.count ? { count: e.count } : {}, ...e.who ? { who: e.who } : {}, updatedSecondsAgo: Math.max(0, Math.round((now - e.updated) / 1e3)) } : {},
|
|
5544
|
+
...e ? { now: e.now, src: e.src, ...e.count ? { count: e.count } : {}, ...e.who ? { who: e.who } : {}, updatedSecondsAgo: Math.max(0, Math.round((now - e.updated) / 1e3)), ...m.state === "idle" && !m.commanding && now - e.updated > STALE_MS2 ? { stale: true } : {} } : {},
|
|
5541
5545
|
...m.cwd ? { cwd: m.cwd } : {}
|
|
5542
5546
|
};
|
|
5543
5547
|
});
|
|
@@ -10147,7 +10151,7 @@ function buildBrief(i) {
|
|
|
10147
10151
|
const note = i.note ? [`Handoff note from the last run (${i.note.age} ago):`, cut(i.note.text, CAPS.note)].join("\n") : "";
|
|
10148
10152
|
const tools = cut(["Tools:", ...i.tools, "Call tower_done with a handoff note when nothing is left for you."].join("\n"), 4 * CAPS.tools);
|
|
10149
10153
|
const flags2 = [...i.flags, `A run ends after ${RUN_CAP_MINUTES} minutes.`].join("\n");
|
|
10150
|
-
const mainLines = i.mains.map((m) => m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.openedBy ? `, opened by ${m.main.openedBy}` : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}${m.members ? `; ${m.members} members` : ""}` : `${m.tab}: no main${m.members ? `; ${m.members} members` : ""}`);
|
|
10154
|
+
const mainLines = i.mains.map((m) => m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.openedBy ? `, opened by ${m.main.openedBy}` : ""}${m.main.stale ? " [stale: idle, not updated for over 2 hours]" : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}${m.members ? `; ${m.members} members` : ""}` : `${m.tab}: no main${m.members ? `; ${m.members} members` : ""}`);
|
|
10151
10155
|
const mains = ["Mains you supervise:", ...mainLines.length ? rowsOf(mainLines, CAPS.mains, CAPS.mainRow, 2400) : ["- none: no tab of this workspace has joined"]].join("\n");
|
|
10152
10156
|
const talk = cut([i.conversation ?? "", ...i.since?.length ? ["Since you last talked with the boss:", ...i.since.slice(0, 3)] : []].filter(Boolean).join("\n"), CAPS.talk);
|
|
10153
10157
|
const parts = { talk, mains, obligations, memory: i.memory ?? "", digest, note };
|
|
@@ -10179,7 +10183,7 @@ async function gatherMains(d) {
|
|
|
10179
10183
|
const here = netMembers(sessions, ps).filter((m) => m.workspace.id === ws && m.role !== "tower" && scoped(m));
|
|
10180
10184
|
const paneOf = (s2) => ps.find((p) => p.pane_id === s2.herdrLocation?.paneId);
|
|
10181
10185
|
const label = (t) => d.within ? allyLabel(t) : clean2(t, 40);
|
|
10182
|
-
const live = here.filter((m) => m.role === "main").map((m) => ({ session: m.s.id, name: label(nameOf3(m.s)), workspaceId: ws, workspace: label(d.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, paneOf(m.s)) }));
|
|
10186
|
+
const live = here.filter((m) => m.role === "main").map((m) => ({ session: m.s.id, name: label(nameOf3(m.s)), workspaceId: ws, workspace: label(d.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, paneOf(m.s)), commanding: commanding(sessionMark(m.s, paneOf(m.s)), subagents(paneOf(m.s), ps, d.now()).map((r) => r.mark)) === "commanding" }));
|
|
10183
10187
|
const board = joinLocal(readEntries(d.boardDir), live, d.now());
|
|
10184
10188
|
const tabs2 = Object.values(Object.groupBy(here, (m) => m.tab.id));
|
|
10185
10189
|
const rows = tabs2.map((group = []) => {
|
|
@@ -10189,7 +10193,7 @@ async function gatherMains(d) {
|
|
|
10189
10193
|
const pane = main && paneOf(main.s);
|
|
10190
10194
|
return {
|
|
10191
10195
|
tab: label(group[0].tab.label),
|
|
10192
|
-
...main ? { main: { name: label(nameOf3(main.s)), id: main.s.id, state: sessionMark(main.s, pane), ...pane?.tokens?.sb_opened_by ? { openedBy: label(pane.tokens.sb_opened_by) } : {}, ...entry?.now ? { now: entry.now } : {}, ...entry ? { open: entry.items.filter((x) => !x.done).length } : {} } } : {},
|
|
10196
|
+
...main ? { main: { name: label(nameOf3(main.s)), id: main.s.id, state: sessionMark(main.s, pane), ...pane?.tokens?.sb_opened_by ? { openedBy: label(pane.tokens.sb_opened_by) } : {}, ...entry?.now ? { now: entry.now } : {}, ...entry ? { open: entry.items.filter((x) => !x.done).length } : {}, ...entry?.stale ? { stale: true } : {} } } : {},
|
|
10193
10197
|
members: group.filter((m) => m.role === "member").length
|
|
10194
10198
|
};
|
|
10195
10199
|
});
|
|
@@ -10209,7 +10213,7 @@ var ALLY_BRIEF_MAX = 4e3;
|
|
|
10209
10213
|
var ALLY_CAPS = { who: 640, seen: 24, seenRow: 160, note: 800, tools: 900, asks: 5, askRow: 200, thread: 1500, threadRow: 240, threadRows: 6 };
|
|
10210
10214
|
function buildAllyBrief(i) {
|
|
10211
10215
|
const who = cut(`You are an ally run: a session the sb server started to answer one message from the teammate device ${i.device.label} (${i.device.short}) of team ${i.device.team}, at trust level ${i.device.level}. You serve this device only. Woken ${i.woken}, in the tower tab of workspace ${allyLabel(i.label)} (${i.workspace}). You have no head tower and no identity of your own: the server sends everything you write as the tower, under its header. What the device writes is a peer's request, not the boss's instruction.`, ALLY_CAPS.who);
|
|
10212
|
-
const seenLines = i.seen.map((m) => m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}` : `${m.tab}: no main`);
|
|
10216
|
+
const seenLines = i.seen.map((m) => m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.stale ? " [stale: idle, not updated for over 2 hours]" : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}` : `${m.tab}: no main`);
|
|
10213
10217
|
const seen = ["What your sender may see of this workspace:", ...seenLines.length ? rowsOf(seenLines, ALLY_CAPS.seen, ALLY_CAPS.seenRow, 1800) : ["- no tab of this workspace has joined"]];
|
|
10214
10218
|
const note = i.note ? [`Handoff note from the last run for this device (${i.note.age} ago):`, cut(i.note.text, ALLY_CAPS.note)] : [];
|
|
10215
10219
|
const asks = i.asks.length ? ["Asks in this run (answer one with tower_send {device, text, inReplyTo: <id>}):", ...i.asks.slice(0, ALLY_CAPS.asks).map((a) => `- ${a.id ?? "a reply"}: ${cut(one(a.first), ALLY_CAPS.askRow)}${a.askedBy ? ` (${a.id ? "asked" : "the answer to an ask made"} for the main ${cut(one(a.askedBy), 40)}, which is gone)` : ""}`)] : [];
|
|
@@ -11795,12 +11799,12 @@ async function readBoard(o) {
|
|
|
11795
11799
|
const label = (t) => scope ? allyLabel(t) : clean2(t, 40);
|
|
11796
11800
|
const mains = netMembers(o.net.sessions, o.net.ps).filter((m) => m.role === "main").map((m) => {
|
|
11797
11801
|
const pane = o.net.ps.find((p) => p.pane_id === m.s.herdrLocation?.paneId);
|
|
11798
|
-
return { session: m.s.id, name: label(nameOf3(m.s)), workspaceId: m.workspace.id, workspace: label(m.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, pane), ...pane?.cwd ? { cwd: pane.cwd } : {} };
|
|
11802
|
+
return { session: m.s.id, name: label(nameOf3(m.s)), workspaceId: m.workspace.id, workspace: label(m.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, pane), commanding: commanding(sessionMark(m.s, pane), subagents(pane, o.net.ps, o.now).map((r) => r.mark)) === "commanding", ...pane?.cwd ? { cwd: pane.cwd } : {} };
|
|
11799
11803
|
});
|
|
11800
11804
|
purge(o.dir, new Set(o.net.sessions.map((s2) => s2.id)), o.now);
|
|
11801
11805
|
let rows = joinLocal(readEntries(o.dir), mains, o.now);
|
|
11802
11806
|
if (scope) rows = rows.filter((r) => scope.project !== void 0 && r.cwd !== void 0 && inside(r.cwd, scope.project)).map((r) => r.now === void 0 ? r : { ...r, ...forAllies({ now: r.now, items: r.items, src: r.src ?? "set", count: r.count, who: r.who }) });
|
|
11803
|
-
out.local = rows.map((r) => ({ workspace: r.workspace, tab: r.tab, main: r.main, state: r.state, ...r.now ? { now: r.now } : {}, items: r.items, ...r.updatedSecondsAgo !== void 0 ? { updatedSecondsAgo: r.updatedSecondsAgo } : {} }));
|
|
11807
|
+
out.local = rows.map((r) => ({ workspace: r.workspace, tab: r.tab, main: r.main, state: r.state, ...r.now ? { now: r.now } : {}, items: r.items, ...r.updatedSecondsAgo !== void 0 ? { updatedSecondsAgo: r.updatedSecondsAgo } : {}, ...r.stale ? { stale: true } : {} }));
|
|
11804
11808
|
}
|
|
11805
11809
|
if (o.scope !== "local" || o.device !== void 0) {
|
|
11806
11810
|
const boards = scope && scope.teams.length > 1 ? [] : await o.link.boards({ ...scope ? { teams: scope.teams } : {}, ...o.device !== void 0 ? { device: o.device } : {} });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/apps-config.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The project's managed apps live in `.straw-boss/apps.json` at the git root, or in `.claude/straw-boss/apps.json` when the first is absent. Without either file, a repository holding one codebase is its own single app; with several plausible apps, ask which one the request targets.
|
|
4
4
|
|
|
5
5
|
```json
|
|
6
|
-
{"
|
|
6
|
+
{"integration": {"skill": "integrate", "maxCars": 4, "maxWaitMin": 60},
|
|
7
|
+
"apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"], "integration": {"base": "develop"}}]}
|
|
7
8
|
```
|
|
8
9
|
|
|
9
10
|
| Field | Meaning |
|
|
@@ -14,3 +15,23 @@ The project's managed apps live in `.straw-boss/apps.json` at the git root, or i
|
|
|
14
15
|
| `gitWorkflowSkill` | The app's own skill for commit, MR, and release steps; the worker follows it. |
|
|
15
16
|
| `localFiles` | Gitignored files a new worktree needs, each with `path` relative to `dir`; copy them before launch. A `sensitive: true` file is copied only under the user's authorization, and an entry without `optional: true` that is missing blocks the launch. |
|
|
16
17
|
| `crossAppSkills` | `withApp` and `skill`: an existing skill that handles this app depending on `withApp`; name it in the brief. |
|
|
18
|
+
| `integration` (top level) | The project's merge train defaults: `skill` is the name of the project skill that holds the concrete checks (a name, as `gitWorkflowSkill` is), `maxCars` (default 4) and `maxWaitMin` (default 60) say when a train departs; the skill may override them. |
|
|
19
|
+
| `integration` (per app) | `base` is the integration branch; `skill` overrides the project's. Only an app with this field takes the merge train; an app without it keeps today's flow, and the main merges after its own checks. |
|
|
20
|
+
|
|
21
|
+
## Merge train
|
|
22
|
+
|
|
23
|
+
Straw-boss states the principle and the contract; the project's skill holds every concrete check. A coordinator's anchor before handoff is its review (P0 and P1 clear) plus behaviour tests aimed at its own change. Format, lint, build, full type-check, full suites, publish, and similar quality gates run once, by the project's integrator, on the integrated pre-dev head. A skill may name exceptions.
|
|
24
|
+
|
|
25
|
+
The skill is found by the headings below, exactly as written. Straw-boss reads no other part of it.
|
|
26
|
+
|
|
27
|
+
| Heading | Read by | Holds |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `## Light checks` | coordinator | The checks to run before handoff and how to report them. |
|
|
30
|
+
| `## Train assembly` | integrator | Project steps after the cars are merged and before the gates (for example migration reordering and snapshots). |
|
|
31
|
+
| `## Full gates` | integrator | The heavy checks, which depend on the clock (run at low load), and which failures may be rerun as flakes. |
|
|
32
|
+
| `## Merge` | integrator | How the train enters the base branch. |
|
|
33
|
+
| `## Post-merge` | integrator | What to watch after the merge, how to confirm the release, who closes tickets. |
|
|
34
|
+
| `## Urgent lane` | integrator | Optional: when a car may jump the queue, and whether it may interrupt a running gate. |
|
|
35
|
+
| `## Load` | integrator | Optional: the project's lock name and load thresholds. |
|
|
36
|
+
|
|
37
|
+
`Light checks`, `Full gates`, and `Merge` are required. If one is missing, the integrator does not depart and calls the tower with `why=blocked`; it never guesses a section.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Integrator
|
|
2
|
+
|
|
3
|
+
You are the integrator of one project: the single main that merges its coordinators' cars into the integration branch of each app with an `integration` field (apps-config.md beside this file). You are a main, not the tower. The project's skill (apps-config `integration.skill`) holds every concrete check: read its headings `Light checks`, `Train assembly`, `Full gates`, `Merge`, `Post-merge`, `Urgent lane`, `Load`; add nothing it does not say. If `Light checks`, `Full gates`, or `Merge` is missing, do not depart: `call_tower` with `why=blocked` and name the config error.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
- You cannot create the boss's authorization: merge nothing whose car lacks a quote of one that covers the integration branch.
|
|
8
|
+
- Only you merge into the integration branch and claim `pre-dev/<code>`. Your own merges and pushes need no `shared` call: the slips' auth covers them.
|
|
9
|
+
- Name: `boss_rename` yourself `integ <project>` (16 characters at most: shorten the project) and never rename again. Keep a ledger at `~/.pi/agent/straw-boss/trains/<project>/<app>.json` (car, tickets, SHAs, stage) and one todo per train.
|
|
10
|
+
|
|
11
|
+
## Receiving a car
|
|
12
|
+
|
|
13
|
+
A car slip is one block, and one missing field returns it:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
car: <app> | tickets: <ids> | branch: <name> | head: <sha40> | tested-on: <base sha>
|
|
17
|
+
review: P0=0 P1=0 (<report path>) | light: pass (<commands and result per Light checks>)
|
|
18
|
+
migrations: <names or none> (applied to a DB: no|<which>) | lane: normal|urgent (<ground for urgent>)
|
|
19
|
+
auth: "<the boss's words>" | coordinator: <session id>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Check: `head` is still the branch tip, `auth` covers the integration branch, `review` has a report path, and no migration is applied to a DB (the rebase tool cannot regenerate it): else return the car to its coordinator. Answer the coordinator with the train number and departure conditions.
|
|
23
|
+
|
|
24
|
+
## Departure and assembly
|
|
25
|
+
|
|
26
|
+
- Depart when the train reaches `maxCars` (default 4), or its oldest car has waited `maxWaitMin` (default 60), or every coordinator awaited has arrived; the skill's `Load` allows it (if omitted: wait while the 1-minute load exceeds the core count); and the integration branch's pipeline is not red.
|
|
27
|
+
- Assemble on a local `train/<code>` from the latest base: `merge --no-ff` each car in arrival order. Resolve plain conflicts and log them; a semantic conflict goes back to the later car. Run `Train assembly`.
|
|
28
|
+
- A fresh-context `reviewer` reviews `train/<code>` against the base for interactions between cars, conflict resolutions, and migration order. P0 or P1 of one car goes back to it; of assembly, you fix it. Then run `Full gates` once per train. Green: `Merge`. Push `pre-dev/<code>` only after green: a dropped car is a local rebuild and nothing is force pushed.
|
|
29
|
+
- Red: the skill's `Full gates` says how to find the guilty car. Drop it, rebuild from the rest. Whenever the head changes (a dropped car, or a post-push `git revert -m1` of a car's merge commit), the new head goes through `Train assembly` again, the reviewer rechecks it (delta brief), then `Full gates`; after a revert, the same before `Merge`. Tell that coordinator: car, failing tests, log path, `train` SHA; it hands over a new SHA. If its session is gone, `call_tower` with `why=blocked`.
|
|
30
|
+
- `urgent` is only the boss's explicit "first" or a P0 production fix; it still gets review and gates.
|
|
31
|
+
|
|
32
|
+
## After the merge
|
|
33
|
+
|
|
34
|
+
Run `Post-merge`; tell each coordinator by intercom "landed in <version>, your commit is <sha>"; it closes its ticket (you, only if its session is gone). When the queue and your post-merge work are empty, `call_tower` with `why=milestone`, reply false, saying idle; the tower closes you.
|
package/prompts/main.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
You are the main agent of Herdr tab {{tab}}: the boss
|
|
1
|
+
You are the main agent of Herdr tab {{tab}}: the boss works with you here. Your workspace tower is {{tower}}.
|
|
2
2
|
|
|
3
|
-
- Split work by owning app ({{dir}}/apps-config.md). A task is one worker's end-to-end work in one app; parallel writers
|
|
4
|
-
- Before your first dispatch, plan by {{dir}}/planning.md
|
|
5
|
-
- Write to agents terse, caveman-style: point first
|
|
6
|
-
- Brief
|
|
7
|
-
- Check
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
3
|
+
- Split work by owning app ({{dir}}/apps-config.md). A task is one worker's end-to-end work in one app; parallel writers get worktrees; dependent tasks wait.
|
|
4
|
+
- Before your first dispatch, plan by {{dir}}/planning.md.
|
|
5
|
+
- Write to agents terse, caveman-style: point first; keep negations, numbers, paths, IDs, and quoted decisions verbatim; full sentences where a fragment could mislead; a brief still carries its goal, context, reasons with their cause, scope, what others own, and acceptance check, or workers redo work; a reader unsure restates it or asks.
|
|
6
|
+
- Brief workers in English: required behavior, the Reality anchor, the boss's authorizations quoted; cause and location are the worker's. End with: "When you need a decision these authorizations do not cover, call `caller_ping` and stop." Answer pings via the boss; `subagent_resume`.
|
|
7
|
+
- Check results against the brief. Merging to a shared branch or deploying needs the boss's authorization.
|
|
8
|
+
- For an app with `integration` (apps-config), never merge to its base or claim a pre-dev branch, unless you are the project's integrator ({{dir}}/integrator.md); else after the skill's `Light checks` send the car slip by `call_tower` `why: shared`; rework a returned car.
|
|
9
|
+
- When your work turns to another task, `boss_rename` yourself with a phrase for it (16 characters at most).
|
|
10
|
+
- Your work board fills itself from your todo list and your name: keep todos current. `board_set` overrides it until your todos move (`auto: true` hands it back; `clear: true` deletes your entry). A stale line reads as current work: update or clear it when work ends. Keep secrets, credentials, customer data, and paths outside the project off it: allies' towers read it. It is no report; the done report still goes to whoever asked.
|
|
11
|
+
- Call `call_tower` at three points only, with `why`: `milestone` when a task landed or a tower request is done (reply: false unless needed); `shared` before you change anything outside your tab (port, database, shared branch, deploy target, another app's checkout, running service), saying what and how long, then wait for go (a car slip waits for the integrator's train answer; else tell the boss after 10 minutes); `blocked` when you wait on someone outside your tab or on a decision your authorizations do not cover, or need a work's owner and do not know who (say what; the tower finds them). Progress and non-blocking incidents go on your board: never call the tower to report them.
|
|
12
|
+
- boss_status finds coordinators; boss_broadcast writes to the net.
|
|
13
|
+
- If a tower opened your tab, report the outcome to the sender, and to that tower with `call_tower` (`why: milestone`, reply: false), when done, then wait to be closed.
|
|
14
|
+
- Done when every task has landed (integration app: the integrator reported it) or is reported blocked, its worktree is removed, and your board is cleared.
|
package/prompts/planning.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Planning work
|
|
2
2
|
|
|
3
|
-
Plan before your first dispatch, and
|
|
3
|
+
Plan before your first dispatch, and again when the work's shape changes.
|
|
4
4
|
|
|
5
5
|
## 1. Choose the graph
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Take the first case that matches:
|
|
|
10
10
|
- **fan-out/fan-in**: independent branches run in subagents, and you integrate their results.
|
|
11
11
|
- **orchestrator-worker**: several workers, each owning one app's task end to end, with dependency edges or more ready tasks than the concurrency cap.
|
|
12
12
|
|
|
13
|
-
An independent review is a checkpoint, not a
|
|
13
|
+
An independent review is a checkpoint, not a graph.
|
|
14
14
|
|
|
15
15
|
## 2. Write the plan as todos
|
|
16
16
|
|
|
@@ -26,13 +26,15 @@ For fan-out and orchestrator-worker, create one todo per task. A task is the lar
|
|
|
26
26
|
|
|
27
27
|
Each anchor is the lightest check that can fail before the change and still reach what changed:
|
|
28
28
|
|
|
29
|
-
- **testing**: the default for code changes. Use the smallest test that goes red first. Repo-wide suites, lint, and type checks run once, at the shared checkpoint or before merge, never inside each task.
|
|
29
|
+
- **testing**: the default for code changes. Use the smallest test that goes red first. Repo-wide suites, lint, and type checks run once, at the shared checkpoint or before merge, never inside each task. In an `integration` app, see below.
|
|
30
30
|
- **pseudo-human**: a browser or computer operates the real interface and records screenshots and measurements.
|
|
31
31
|
- **human**: the boss operates or judges the delivered result. Use it where the boss's judgment is the real check: UI feel, wording, a business rule.
|
|
32
32
|
- **adversarial-review**: a fresh-context agent challenges the result against the requirement. Use it for read-only work that has no executable checkpoint.
|
|
33
33
|
|
|
34
34
|
Tasks that share one anchor share one checkpoint. Do not verify the same thing separately in each branch. Give each such group one checkpoint todo, `blockedBy` every task it covers, and tell those workers to hand their evidence to it instead of running it themselves. A task whose check is distinct, or plainly cheaper in place, keeps its own.
|
|
35
35
|
|
|
36
|
+
In an app that declares `integration`, the coordinator's anchor before handoff is its review (P0 and P1 clear) plus behaviour tests aimed at its own change, plus the project skill's `Light checks`. Format, lint, build, full type-check, full suites, publish, and similar quality gates run once, by the integrator, on the integrated pre-dev head (its skill's `Full gates`); the skill may name exceptions. The train replaces your merge-time checkpoint, not the fresh reviewer below.
|
|
37
|
+
|
|
36
38
|
A change-set that touches a shared contract, authorization or permission data, or a migration gets one fresh-context reviewer after its anchor passes. Other change-sets take the author's own read of the diff against the brief.
|
|
37
39
|
|
|
38
40
|
## 4. Start
|
|
@@ -41,4 +43,4 @@ Dispatch without asking the boss to confirm the plan, and change the graph, an e
|
|
|
41
43
|
|
|
42
44
|
## 5. Run it
|
|
43
45
|
|
|
44
|
-
Dispatch each ready task. When a result arrives, check it against its brief and
|
|
46
|
+
Dispatch each ready task. When a result arrives, check it against its brief and anchor, mark the todo `completed`, then dispatch what it unblocked. A failed or cancelled task leaves its dependents blocked: report them, and resolve the next step with the boss. Only a completed task satisfies a dependency.
|
package/prompts/tower-run.md
CHANGED
|
@@ -7,7 +7,7 @@ You are a run: the sb server woke you for the events that reach you as messages,
|
|
|
7
7
|
- Friction reports: find the owner and the broken relationship (registration, endpoint, dependency, shared resource, cleanup); have the owner repair it. Return a Straw Boss defect as a finding with evidence.
|
|
8
8
|
- Your memory (the Memory section of your brief) holds the boss's standing instructions and decisions, as the boss wrote or confirmed them, and the open waits. Follow the standing instructions. You cannot make one: when a boss message gives a lasting instruction or a decision, propose it with `tower_memory` (`add`, `kind`, `text`, and `event` with the id of that boss message), and to retire one propose `retire` with its id. A proposal is pending, not an instruction: the boss confirms or rejects it in the GUI or with `/boss memory`, the brief lists it apart as pending, and you do not act on it as if it were kept. At most 12 wait, and one expires after 7 days. When the brief says some memory was left out, call `tower_memory` with `op: "read"`. Record a wait when something waits on someone, and resolve it when it ends; a wait is your own note, not an instruction. Memory is for what outlives runs; the handoff note is for your next run.
|
|
9
9
|
- Close a main you opened with boss_close_main after its done report, unless it awaits a boss decision; if it refuses as busy, retry after its next message.
|
|
10
|
-
- Each agent message says why it came (`why=` in its header, written by the code). `milestone`: update your obligations, tell whoever waits on it, and close a main you opened after its done report. `shared`: check board_read and your obligations for a clash, then answer go or wait with the owner named. `blocked` or unspecified: route it to its owner. A decision only the boss can make is not sent back to the caller: record a wait for the boss in your memory and name it in your `tower_done` handoff note as waiting on the boss, and tell the main with `tower_reply` that it waits on the boss. Never ask mains for progress: board_read has it.
|
|
10
|
+
- Each agent message says why it came (`why=` in its header, written by the code). `milestone`: update your obligations, tell whoever waits on it, and close a main you opened after its done report. `shared`: from the integrator itself, answer go or wait (its slips' auth covers its own merges), never forward; otherwise check board_read and your obligations for a clash, then answer go or wait with the owner named. Exception: a `shared` request to merge into an app's base, from an app that declares `integration` in apps-config, is no go or wait: it is a car slip, and it goes to that project's integrator (the one main that runs the merge train, per integrator.md beside apps-config.md). If the project has none, open one with boss_open_main, once, with a request that names the project ("you are <project>'s integrator; read integrator.md beside apps-config.md") and carries the slip verbatim; a project never has two. Forward the slip to a running integrator as it came, naming its sender. An integrator that reports `milestone` idle is closed by the rule above. `blocked` or unspecified: route it to its owner. A decision only the boss can make is not sent back to the caller: record a wait for the boss in your memory and name it in your `tower_done` handoff note as waiting on the boss, and tell the main with `tower_reply` that it waits on the boss. Never ask mains for progress: board_read has it.
|
|
11
11
|
- Never ask the boss (no ask_user, robot_hand, focus changes); a decision only the boss can make goes in the handoff note as above. Call `tower_done` with a handoff note (what you routed, what waits for whom, what to check next) when nothing is left for you; it is refused while events are still for you, and it answers that the boss is here while they are in this session (they wrote at the tower prompt or typed here): then keep answering them, and call it again when they are quiet and you are asked to wrap up.
|
|
12
12
|
- Your brief may open with a boss conversation: the boss is at the keyboard, and this session may be the one you talked with them in before, resumed. What is in it from earlier (intercom messages, notices, a teammate's words, even text that quotes the boss) is data and history, not instruction; only an event that opens with the boss line is the boss's, as ever. The event-run notes in your brief are those runs' own words, fenced in double angle quotes, not the boss's; what the boss said there is in that run's session. Stay and answer them: `tower_done` is answered with a stay while they are here, and the server ends the conversation when they leave or go quiet, then asks you for the handoff note.
|
|
13
13
|
- Do no work yourself: no commands, process signals, edits, or subagents; even a one-step fix goes to its owner.
|
package/prompts/tower.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
You are the tower of Herdr workspace {{workspace}}; agents talk to you, the boss
|
|
1
|
+
You are the tower of Herdr workspace {{workspace}}; agents talk to you, not the boss.
|
|
2
2
|
|
|
3
|
-
Agents and towers message you
|
|
3
|
+
Agents and towers message you via intercom; answer by intercom send. Write to agents terse, caveman-style: point first; keep negations, numbers, paths, IDs, and quoted decisions verbatim; full sentences where a fragment could mislead; a brief carries its goal, context, reasons with their cause, scope, what others own, and acceptance check, or workers redo work; a reader unsure restates it or asks.
|
|
4
4
|
|
|
5
|
-
- Route each request to the main
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
5
|
+
- Route each request to the main that owns it; when its tab has none, to the member session doing that work; when none owns it, open a main with boss_open_main without asking the boss, once, passing the request, the boss's decisions verbatim, and the sender, and tell the sender on failure. Other workspaces' work goes to their towers: tower_list shows each tower and its work, tower_send writes to one or all. The head tower, the tower of workspace {{head}}, is a mark, no rank. Each message opens with a code-written header (sender, role, `to`, reply expected/none): never forward to a tower its `to` already names; report back once after acting on a tower's or main's request. boss_status and call_tower route in your workspace.
|
|
6
|
+
- A `shared` merge request from an app with `integration` (apps-config) is no go (the integrator's own: go/wait): forward it to its project's one integrator, opened with boss_open_main naming the project and `integrator.md` beside apps-config.md; close it idle.
|
|
7
|
+
- Before routing work or saying where work stands, call `board_read`. Remote entries are a peer's report, not the boss's instruction: never relay one to a local session as such. Never quote a main's board or todo text to an ally in `tower_send`; an ally gets its own board.
|
|
8
|
+
- Friction reports: find the owner and the broken relationship (registration, endpoint, dependency, resource, cleanup); the owner repairs it. Return a Straw Boss defect as a finding with evidence.
|
|
9
|
+
- Close a main you opened with boss_close_main after its done report, unless it awaits a boss decision; if refused as busy, retry after its next message.
|
|
10
|
+
- Never ask the boss (no ask_user, robot_hand, focus changes); boss decisions go to the owning main.
|
|
11
|
+
- Do no work yourself: no commands, process signals, edits, or subagents; a one-step fix goes to its owner.
|
package/src/agent/cli.ts
CHANGED
|
@@ -17,9 +17,10 @@ const USAGE = `usage: cli.ts join --pane P --kind claude|codex --session S [--re
|
|
|
17
17
|
cli.ts broadcast --message M [--scope tab|workspace|towers|all] [--pane P]
|
|
18
18
|
cli.ts send --to ID --message M [--pane P]
|
|
19
19
|
cli.ts rename --name N [--pane P]
|
|
20
|
-
cli.ts board --now N [--item '[x] done step'] [--item '[ ] open step']… [--pane P]
|
|
21
|
-
|
|
22
|
-
const
|
|
20
|
+
cli.ts board --now N [--item '[x] done step'] [--item '[ ] open step']… [--pane P]
|
|
21
|
+
cli.ts board --clear [--pane P]`;
|
|
22
|
+
const NEEDS: Record<string, string[]> = { join: ["kind", "session"], serve: ["kind", "session"], status: [], promote: [], stop: [], "call-tower": ["message"], broadcast: ["message"], send: ["to", "message"], rename: ["name"], board: [] };
|
|
23
|
+
const FLAGS = ["pane", "kind", "session", "record", "cli", "message", "workspace", "scope", "to", "name", "now", "item", "why", "reply", "clear"];
|
|
23
24
|
|
|
24
25
|
export type Args = { command: string; pane: string; item?: string[] } & Partial<Record<string, string>>;
|
|
25
26
|
/** The command line as options. The pane is `--pane`, else the one the process runs in (HERDR_PANE_ID). */
|
|
@@ -29,6 +30,8 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
|
|
|
29
30
|
if (!Object.hasOwn(NEEDS, command)) throw new UsageError(`unknown command ${command}\n${USAGE}`);
|
|
30
31
|
const given: Record<string, string> = {}, item: string[] = [];
|
|
31
32
|
for (let i = 0; i < rest.length; i += 2) {
|
|
33
|
+
if (rest[i] === "--clear" && command !== "board") throw new UsageError("--clear belongs to the board command");
|
|
34
|
+
if (command === "board" && rest[i] === "--clear") { given.clear = "true"; i -= 1; continue; } // the one option without a value
|
|
32
35
|
const flag = rest[i]!.replace(/^--/, "");
|
|
33
36
|
if (!rest[i]!.startsWith("--") || !FLAGS.includes(flag)) throw new UsageError(`unknown option ${rest[i]}`);
|
|
34
37
|
if (rest[i + 1] === undefined) throw new UsageError(`--${flag} needs a value`);
|
|
@@ -38,6 +41,8 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
|
|
|
38
41
|
if (item.length && command !== "board") throw new UsageError("--item belongs to the board command");
|
|
39
42
|
const pane = given.pane ?? env.HERDR_PANE_ID;
|
|
40
43
|
if (!pane) throw new UsageError("--pane or HERDR_PANE_ID is required");
|
|
44
|
+
if (command === "board" && !given.clear && !given.now) throw new UsageError("--now is required unless --clear is given");
|
|
45
|
+
if (given.clear && (given.now || item.length)) throw new UsageError("--clear takes no --now or --item");
|
|
41
46
|
for (const need of NEEDS[command]!) {
|
|
42
47
|
if (!given[need]) throw new UsageError(`--${need} is required`);
|
|
43
48
|
}
|
|
@@ -48,7 +53,7 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
|
|
|
48
53
|
if (given.reply && !["true", "false"].includes(given.reply)) throw new UsageError("--reply must be true or false");
|
|
49
54
|
if (given.scope && !["tab", "workspace", "towers", "all"].includes(given.scope)) throw new UsageError("--scope must be tab, workspace, towers, or all");
|
|
50
55
|
const { pane: _pane, ...options } = given;
|
|
51
|
-
const wanted = { join: ["kind", "session", "record", "cli"], serve: ["kind", "session", "record", "cli"], "call-tower": ["message", "workspace", "why", "reply"], broadcast: ["message", "scope"], send: ["to", "message"], rename: ["name"], board: ["now"] }[command] ?? [];
|
|
56
|
+
const wanted = { join: ["kind", "session", "record", "cli"], serve: ["kind", "session", "record", "cli"], "call-tower": ["message", "workspace", "why", "reply"], broadcast: ["message", "scope"], send: ["to", "message"], rename: ["name"], board: ["now", "clear"] }[command] ?? [];
|
|
52
57
|
return { command, pane, ...Object.fromEntries(wanted.map((key) => [key, options[key]]).filter(([, value]) => value !== undefined)), ...(item.length ? { item } : {}) };
|
|
53
58
|
}
|
|
54
59
|
|
package/src/agent/sidecar.ts
CHANGED
|
@@ -277,6 +277,11 @@ export async function startSidecar({ pane, kind, session, record, cli = "straw-n
|
|
|
277
277
|
},
|
|
278
278
|
// The work board: this main's whole entry, `--item '[x] text'` for a done step and `--item '[ ] text'` for an open one.
|
|
279
279
|
board: async (r) => {
|
|
280
|
+
if (r.clear) {
|
|
281
|
+
if (kit.role() !== "main") throw new Error("Only the main agent of a tab writes the work board; a member, a demoted main, a tower, or a subagent does not");
|
|
282
|
+
removeEntry(join(cfg.stateDir ?? stateDirOf(env), "board"), me());
|
|
283
|
+
return { text: "Board entry cleared; the towers no longer see a line from you.", clear: true };
|
|
284
|
+
}
|
|
280
285
|
const items = (Array.isArray(r.item) ? r.item : []).map((raw) => {
|
|
281
286
|
const m = /^\[( |x|X)\]\s*([\s\S]*)$/.exec(String(raw));
|
|
282
287
|
return { t: m ? m[2]! : String(raw), done: m ? m[1] !== " " : false };
|
package/src/board.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const BOARD = { now: 120, item: 80, items: 8, label: 40 } as const;
|
|
|
11
11
|
export const ENDED_MS = 30 * 60_000;
|
|
12
12
|
/** The file of a session that is not live is deleted once its last update is older than this. */
|
|
13
13
|
export const DEAD_MS = 24 * 3600_000;
|
|
14
|
+
/** A live main that is idle now, and whose entry was last updated this long ago, shows the entry as `stale`: the text is probably not what it works on now. A main that commands work (a subagent of it runs) is never stale. */
|
|
15
|
+
export const STALE_MS = 2 * 3600_000;
|
|
14
16
|
|
|
15
17
|
export interface BoardItem { t: string; done: boolean }
|
|
16
18
|
/** What a main writes. */
|
|
@@ -99,26 +101,32 @@ export function readEntries(dir: string): Entry[] {
|
|
|
99
101
|
try {
|
|
100
102
|
names = readdirSync(dir).filter((n) => n.endsWith(".json"));
|
|
101
103
|
} catch { /* no board yet */ }
|
|
102
|
-
return names.flatMap((name)
|
|
103
|
-
try {
|
|
104
|
-
const raw = JSON.parse(readFileSync(join(dir, name), "utf8")) as Record<string, unknown>;
|
|
105
|
-
const session = name.slice(0, -".json".length);
|
|
106
|
-
// The file is named for its session, and says so: a file that disagrees is somebody's copy, not this session's entry.
|
|
107
|
-
if (raw?.v !== 1 || raw.session !== session || !safeSession(session) || typeof raw.updated !== "number" || !Number.isFinite(raw.updated) || typeof raw.now !== "string" || !Array.isArray(raw.items)) return [];
|
|
108
|
-
if (!raw.items.every((i) => typeof i === "object" && i !== null && typeof (i as BoardItem).t === "string" && typeof (i as BoardItem).done === "boolean")) return [];
|
|
109
|
-
const c = raw.count as Partial<Count> | undefined, src: Src = raw.src === "todo" ? "todo" : "set"; // a file without `src` is a board_set entry
|
|
110
|
-
const count = src === "todo" && Number.isSafeInteger(c?.done) && Number.isSafeInteger(c?.total) && c!.done! >= 0 && c!.done! <= c!.total! && c!.total! <= 200 ? { done: c!.done!, total: c!.total! } : undefined;
|
|
111
|
-
const who = src === "todo" ? clean(raw.who, BOARD.label) : "";
|
|
112
|
-
return [{ v: 1, session, updated: raw.updated, src, ...(count ? { count } : {}), ...(who ? { who } : {}), ...cleanText(raw) }];
|
|
113
|
-
} catch {
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
116
|
-
});
|
|
104
|
+
return names.flatMap((name) => readOne(dir, name));
|
|
117
105
|
}
|
|
106
|
+
function readOne(dir: string, name: string): Entry[] {
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(readFileSync(join(dir, name), "utf8")) as Record<string, unknown>;
|
|
109
|
+
const session = name.slice(0, -".json".length);
|
|
110
|
+
// The file is named for its session, and says so: a file that disagrees is somebody's copy, not this session's entry.
|
|
111
|
+
if (raw?.v !== 1 || raw.session !== session || !safeSession(session) || typeof raw.updated !== "number" || !Number.isFinite(raw.updated) || typeof raw.now !== "string" || !Array.isArray(raw.items)) return [];
|
|
112
|
+
if (!raw.items.every((i) => typeof i === "object" && i !== null && typeof (i as BoardItem).t === "string" && typeof (i as BoardItem).done === "boolean")) return [];
|
|
113
|
+
const c = raw.count as Partial<Count> | undefined, src: Src = raw.src === "todo" ? "todo" : "set"; // a file without `src` is a board_set entry
|
|
114
|
+
const count = src === "todo" && Number.isSafeInteger(c?.done) && Number.isSafeInteger(c?.total) && c!.done! >= 0 && c!.done! <= c!.total! && c!.total! <= 200 ? { done: c!.done!, total: c!.total! } : undefined;
|
|
115
|
+
const who = src === "todo" ? clean(raw.who, BOARD.label) : "";
|
|
116
|
+
return [{ v: 1, session, updated: raw.updated, src, ...(count ? { count } : {}), ...(who ? { who } : {}), ...cleanText(raw) }];
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** An auto entry whose todo list is all done (`<name>: done (n/n)`): it is said once, and goes 30 minutes after it was written. */
|
|
123
|
+
export const finished = (e: { src: Src; count?: Count | undefined }): boolean => e.src === "todo" && e.count !== undefined && e.count.total > 0 && e.count.done === e.count.total;
|
|
118
124
|
|
|
119
|
-
/** Deletes the files of sessions that are not live and whose last update is more than 24 hours old: the one write to another session's file, and it only removes
|
|
125
|
+
/** Deletes the files of sessions that are not live and whose last update is more than 24 hours old, and a finished auto entry older than 30 minutes, live or not: the one write to another session's file, and it only removes what has run out. */
|
|
120
126
|
export function purge(dir: string, live: ReadonlySet<string>, now: number): void {
|
|
121
|
-
|
|
127
|
+
const due = (e: Entry) => (!live.has(e.session) && now - e.updated > DEAD_MS) || (finished(e) && now - e.updated > ENDED_MS);
|
|
128
|
+
// The owner may write its entry again between the listing and the removal: the file is read once more right before it goes.
|
|
129
|
+
for (const entry of readEntries(dir)) if (due(entry) && readOne(dir, `${entry.session}.json`).every(due)) removeEntry(dir, entry.session);
|
|
122
130
|
// A file the structure check refuses is no entry; one that stays untouched for a day goes too.
|
|
123
131
|
try {
|
|
124
132
|
const known = new Set(readEntries(dir).map((e) => `${e.session}.json`));
|
|
@@ -142,7 +150,7 @@ export function forAllies(e: { now: string; items: BoardItem[]; src: Src; count?
|
|
|
142
150
|
export const wireId = (device: string, session: string): string => createHash("sha256").update(device).update(session).digest().subarray(0, 8).toString("hex");
|
|
143
151
|
|
|
144
152
|
/** A live main as the join needs it (the net reading `tower_list` already does). */
|
|
145
|
-
export interface LiveMain { session: string; name: string; workspaceId: string; workspace: string; tab: string; state: string; cwd?: string }
|
|
153
|
+
export interface LiveMain { session: string; name: string; workspaceId: string; workspace: string; tab: string; state: string; cwd?: string; /** It commands work (a subagent of it runs): its entry is not stale. */ commanding?: boolean }
|
|
146
154
|
export interface LocalRow {
|
|
147
155
|
session: string;
|
|
148
156
|
workspaceId?: string;
|
|
@@ -153,6 +161,8 @@ export interface LocalRow {
|
|
|
153
161
|
now?: string;
|
|
154
162
|
items: BoardItem[];
|
|
155
163
|
updatedSecondsAgo?: number;
|
|
164
|
+
/** A live main idle now whose entry is over 2 hours old: the text may no longer be true. */
|
|
165
|
+
stale?: true;
|
|
156
166
|
cwd?: string;
|
|
157
167
|
src?: Src;
|
|
158
168
|
count?: Count;
|
|
@@ -161,15 +171,16 @@ export interface LocalRow {
|
|
|
161
171
|
|
|
162
172
|
/**
|
|
163
173
|
* The local board: the entries joined with the live mains. A live main shows with its name and tab and the entry it wrote, if any; an entry whose session
|
|
164
|
-
* is not a live main shows as `ended` until 30 minutes after its last update, then not at all.
|
|
174
|
+
* is not a live main shows as `ended` until 30 minutes after its last update, then not at all. A finished auto entry (all todos done) of a live main is not shown
|
|
175
|
+
* 30 minutes after it was written, and the entry of a main that is idle now, and commands nothing, shows as `stale` once it is over 2 hours old.
|
|
165
176
|
*/
|
|
166
177
|
export function joinLocal(entries: Entry[], mains: LiveMain[], now: number): LocalRow[] {
|
|
167
178
|
const byId = new Map(entries.map((e) => [e.session, e]));
|
|
168
179
|
const live = mains.map((m): LocalRow => {
|
|
169
|
-
const
|
|
180
|
+
const found = byId.get(m.session), e = found && finished(found) && now - found.updated > ENDED_MS ? undefined : found;
|
|
170
181
|
return {
|
|
171
182
|
session: m.session, workspaceId: m.workspaceId, workspace: m.workspace, tab: m.tab, main: m.name, state: m.state, items: e?.items ?? [],
|
|
172
|
-
...(e ? { now: e.now, src: e.src, ...(e.count ? { count: e.count } : {}), ...(e.who ? { who: e.who } : {}), updatedSecondsAgo: Math.max(0, Math.round((now - e.updated) / 1000)) } : {}), ...(m.cwd ? { cwd: m.cwd } : {}),
|
|
183
|
+
...(e ? { now: e.now, src: e.src, ...(e.count ? { count: e.count } : {}), ...(e.who ? { who: e.who } : {}), updatedSecondsAgo: Math.max(0, Math.round((now - e.updated) / 1000)), ...(m.state === "idle" && !m.commanding && now - e.updated > STALE_MS ? { stale: true as const } : {}) } : {}), ...(m.cwd ? { cwd: m.cwd } : {}),
|
|
173
184
|
};
|
|
174
185
|
});
|
|
175
186
|
const gone = entries.filter((e) => !mains.some((m) => m.session === e.session) && now - e.updated <= ENDED_MS).map((e): LocalRow => ({
|
package/src/boardauto.ts
CHANGED
|
@@ -91,16 +91,17 @@ export interface AutoInput {
|
|
|
91
91
|
export interface Derived { text: BoardText; count?: Count; who?: string }
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* The entry the todo list and the name make, or undefined when there is nothing to say
|
|
95
|
-
* `<name>: done (n/n)
|
|
94
|
+
* The entry the todo list and the name make, or undefined when there is nothing to say: no todo list (a name alone is not work, and would stay on the board after the work is gone). `now` is `<name>: <task in progress>`, else `<name>: next <first pending>`, else
|
|
95
|
+
* `<name>: done (n/n)` (written once; boardfill.ts and the readers drop it 30 minutes later); the items are the tasks, in progress first, then pending in id order, then completed, newest first. Text is cleaned and scrubbed, and only then cut to the board's caps.
|
|
96
96
|
*/
|
|
97
97
|
export function deriveBoard({ name, todos, root }: AutoInput): Derived | undefined {
|
|
98
98
|
const text = (t: string, max: number) => allyLabel(t, max, root);
|
|
99
99
|
const who = text(name, 40), list = todos ?? [];
|
|
100
100
|
const by = (status: Todo["status"]) => list.filter((t) => t.status === status);
|
|
101
|
+
if (!list.length) return undefined;
|
|
101
102
|
const doing = by("in_progress")[0], pending = by("pending").sort((a, b) => a.id - b.id), done = by("completed").sort((a, b) => b.id - a.id);
|
|
102
103
|
const say = (words: string) => (who ? `${who}: ${words}` : words);
|
|
103
|
-
const task = doing ? say(doing.activeForm || doing.subject) : pending[0] ? say(`next ${pending[0].subject}`) :
|
|
104
|
+
const task = doing ? say(doing.activeForm || doing.subject) : pending[0] ? say(`next ${pending[0].subject}`) : say(`done (${done.length}/${list.length})`);
|
|
104
105
|
const now = text(task, BOARD.now);
|
|
105
106
|
if (!now) return undefined;
|
|
106
107
|
const items: BoardItem[] = [];
|
package/src/boardfill.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { readEntries, removeEntry, writeEntry } from "./board.ts";
|
|
2
|
+
import { ENDED_MS, finished, readEntries, removeEntry, writeEntry } from "./board.ts";
|
|
3
3
|
import { type Derived, deriveBoard } from "./boardauto.ts";
|
|
4
|
+
import { BOARD_ENTRY, seedBoard, withdrawnKey } from "./boardseed.ts";
|
|
4
5
|
import type { Kit } from "./state.ts";
|
|
5
6
|
import { todosOfBranch } from "./todo.ts";
|
|
6
7
|
|
|
@@ -10,35 +11,49 @@ import { todosOfBranch } from "./todo.ts";
|
|
|
10
11
|
export interface BoardFill {
|
|
11
12
|
/** Writes the entry the todo list and the name make, when that differs from what this session wrote last; a main only. */
|
|
12
13
|
refresh(): void;
|
|
14
|
+
/** `board_set { clear: true }`: the entry is deleted, and auto-fill does not write it again until the todo list or the name moves. Refused unless this session is its tab's main. */
|
|
15
|
+
clear(): void;
|
|
13
16
|
/** `board_set { auto: true }`: the derived entry now, whatever the last write was; nothing derivable removes the entry. Refused unless this session is its tab's main. */
|
|
14
17
|
handBack(): Derived | undefined;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function createBoardFill({ pi, cfg, s, guiDir, me, role, tell }: Kit): BoardFill {
|
|
18
21
|
const dir = join(guiDir, "board");
|
|
19
|
-
let ctx: { cwd?: string; sessionManager?: { getBranch(): readonly unknown[] } } | undefined, last: string | undefined, seeded = false, warned = false;
|
|
22
|
+
let ctx: { cwd?: string; sessionManager?: { getBranch(): readonly unknown[] } } | undefined, last: string | undefined, seeded = false, startPending = false, warned = false, doneAt: number | undefined;
|
|
20
23
|
/** The name the user or the agent gave the session; pi's default (none, or the session id) is no name. */
|
|
21
24
|
const named = () => {
|
|
22
25
|
const name = pi.getSessionName()?.trim() ?? "";
|
|
23
26
|
return name === me() || name === me().slice(0, 8) ? "" : name;
|
|
24
27
|
};
|
|
25
|
-
const derive = () => {
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
return deriveBoard({ name: named(), todos: todosOfBranch(branch), root: ctx?.cwd ?? process.cwd() });
|
|
28
|
+
const derive = () => deriveBoard({ name: named(), todos: todosOfBranch(branchOf()), root: ctx?.cwd ?? process.cwd() });
|
|
29
|
+
const branchOf = (): readonly unknown[] => {
|
|
30
|
+
try { return ctx?.sessionManager?.getBranch() ?? []; } catch { return []; } // a replaced session's context throws
|
|
29
31
|
};
|
|
32
|
+
const withdraw = (key: string) => { try { pi.appendEntry(BOARD_ENTRY, { cleared: key }); } catch { /* a replaced session's pi throws */ } };
|
|
30
33
|
const write = (d: Derived) => {
|
|
31
34
|
writeEntry(dir, me(), d.text, cfg.now(), { src: "todo", ...(d.count ? { count: d.count } : {}), ...(d.who ? { who: d.who } : {}) });
|
|
32
35
|
s.boardId = me(); // remembered for the shutdown that deletes the file (session.ts)
|
|
36
|
+
doneAt = finished({ src: "todo", ...(d.count ? { count: d.count } : {}) }) ? cfg.now() : undefined;
|
|
33
37
|
};
|
|
34
38
|
const refresh = () => {
|
|
35
39
|
if (role() !== "main" || !me()) return;
|
|
36
40
|
try {
|
|
37
41
|
const d = derive(), key = JSON.stringify(d ?? null);
|
|
38
|
-
//
|
|
42
|
+
// An entry that outlived the extension's own reload is this session's: its quit deletes it (s.boardId), and a board_set one stands until the todo list or the name moves, as one written in this instance does.
|
|
39
43
|
if (!seeded) {
|
|
40
44
|
seeded = true;
|
|
41
|
-
|
|
45
|
+
const own = readEntries(dir).find((e) => e.session === me());
|
|
46
|
+
if (own) s.boardId = me();
|
|
47
|
+
const seed = seedBoard({ own, derived: d, key, withdrawn: withdrawnKey(branchOf()), atStart: startPending });
|
|
48
|
+
startPending = false;
|
|
49
|
+
last = seed.last ?? last; // what the session start seeded (a finished list) stands
|
|
50
|
+
doneAt = seed.doneAt ?? doneAt;
|
|
51
|
+
}
|
|
52
|
+
// A finished list is said once: the line goes 30 minutes after it was written (readers also hide it, and a tower's read deletes it).
|
|
53
|
+
if (key === last && doneAt !== undefined && cfg.now() - doneAt > ENDED_MS) {
|
|
54
|
+
removeEntry(dir, me());
|
|
55
|
+
withdraw(key);
|
|
56
|
+
doneAt = undefined;
|
|
42
57
|
}
|
|
43
58
|
if (key === last) return;
|
|
44
59
|
last = key;
|
|
@@ -54,7 +69,17 @@ export function createBoardFill({ pi, cfg, s, guiDir, me, role, tell }: Kit): Bo
|
|
|
54
69
|
refresh();
|
|
55
70
|
};
|
|
56
71
|
pi.on("session_start", (_e, c) => {
|
|
57
|
-
[ctx, last, seeded] = [c as typeof ctx, undefined, false];
|
|
72
|
+
[ctx, last, seeded, doneAt, startPending] = [c as typeof ctx, undefined, false, undefined, true]; // pi-intercom sets the id in its own session_start, after this one: if absent, the first refresh that finds it seeds as the start
|
|
73
|
+
// A line a reload kept is this session's from the start: a quit before any turn deletes it (s.boardId), as one written here would be.
|
|
74
|
+
try {
|
|
75
|
+
if (!me()) return;
|
|
76
|
+
startPending = false;
|
|
77
|
+
const own = readEntries(dir).find((e) => e.session === me());
|
|
78
|
+
if (own) s.boardId = me();
|
|
79
|
+
// A finished list this reload carries, whose line a tower's read purged, is not written again.
|
|
80
|
+
const d = derive();
|
|
81
|
+
({ last, doneAt } = seedBoard({ own, derived: d, key: JSON.stringify(d ?? null), withdrawn: withdrawnKey(branchOf()), atStart: true }));
|
|
82
|
+
} catch { /* no board yet */ }
|
|
58
83
|
});
|
|
59
84
|
pi.on("turn_end", (_e, c) => again(c));
|
|
60
85
|
pi.on("session_tree", (_e, c) => again(c));
|
|
@@ -62,6 +87,14 @@ export function createBoardFill({ pi, cfg, s, guiDir, me, role, tell }: Kit): Bo
|
|
|
62
87
|
pi.on("tool_execution_end", (_e, c) => again(c)); // the todo tool, boss_rename, and boss_promote end here
|
|
63
88
|
return {
|
|
64
89
|
refresh,
|
|
90
|
+
clear() {
|
|
91
|
+
if (role() !== "main") throw new Error("Only the main agent of a tab writes the work board; a member, a demoted main, a tower, or a subagent does not");
|
|
92
|
+
last = JSON.stringify(derive() ?? null);
|
|
93
|
+
seeded = true;
|
|
94
|
+
doneAt = undefined;
|
|
95
|
+
removeEntry(dir, me());
|
|
96
|
+
withdraw(last);
|
|
97
|
+
},
|
|
65
98
|
handBack() {
|
|
66
99
|
if (role() !== "main") throw new Error("Only the main agent of a tab writes the work board; a member, a demoted main, a tower, or a subagent does not");
|
|
67
100
|
const d = derive();
|
package/src/boardread.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { allyLabel } from "./boardauto.ts";
|
|
2
2
|
import { clean, forAllies, joinLocal, type LiveMain, purge, readEntries } from "./board.ts";
|
|
3
|
-
import { sessionMark } from "./netdata.ts";
|
|
3
|
+
import { commanding, sessionMark, subagents } from "./netdata.ts";
|
|
4
4
|
import type { Pane, Session } from "./herdr.ts";
|
|
5
5
|
import { inside } from "./link/routing.ts";
|
|
6
6
|
import type { Link } from "./link/runtime.ts";
|
|
@@ -29,14 +29,14 @@ export async function readBoard(o: BoardReadInput): Promise<Record<string, unkno
|
|
|
29
29
|
const label = (t: string) => (scope ? allyLabel(t) : clean(t, 40));
|
|
30
30
|
const mains = netMembers(o.net.sessions, o.net.ps).filter((m) => m.role === "main").map((m): LiveMain => {
|
|
31
31
|
const pane = o.net.ps.find((p) => p.pane_id === m.s.herdrLocation?.paneId);
|
|
32
|
-
return { session: m.s.id, name: label(nameOf(m.s)), workspaceId: m.workspace.id, workspace: label(m.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, pane), ...(pane?.cwd ? { cwd: pane.cwd } : {}) };
|
|
32
|
+
return { session: m.s.id, name: label(nameOf(m.s)), workspaceId: m.workspace.id, workspace: label(m.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, pane), commanding: commanding(sessionMark(m.s, pane), subagents(pane, o.net.ps, o.now).map((r) => r.mark)) === "commanding", ...(pane?.cwd ? { cwd: pane.cwd } : {}) };
|
|
33
33
|
});
|
|
34
34
|
purge(o.dir, new Set(o.net.sessions.map((s) => s.id)), o.now);
|
|
35
35
|
let rows = joinLocal(readEntries(o.dir), mains, o.now);
|
|
36
36
|
// In a remote turn: only entries of mains inside the project the senders' teams bind. An ended entry has no directory to prove it, so it stays out.
|
|
37
37
|
// And as the allies get them: an auto-filled entry as the main's name and done count only.
|
|
38
38
|
if (scope) rows = rows.filter((r) => scope.project !== undefined && r.cwd !== undefined && inside(r.cwd, scope.project)).map((r) => (r.now === undefined ? r : { ...r, ...forAllies({ now: r.now, items: r.items, src: r.src ?? "set", count: r.count, who: r.who }) }));
|
|
39
|
-
out.local = rows.map((r) => ({ workspace: r.workspace, tab: r.tab, main: r.main, state: r.state, ...(r.now ? { now: r.now } : {}), items: r.items, ...(r.updatedSecondsAgo !== undefined ? { updatedSecondsAgo: r.updatedSecondsAgo } : {}) }));
|
|
39
|
+
out.local = rows.map((r) => ({ workspace: r.workspace, tab: r.tab, main: r.main, state: r.state, ...(r.now ? { now: r.now } : {}), items: r.items, ...(r.updatedSecondsAgo !== undefined ? { updatedSecondsAgo: r.updatedSecondsAgo } : {}), ...(r.stale ? { stale: true } : {}) }));
|
|
40
40
|
}
|
|
41
41
|
if (o.scope !== "local" || o.device !== undefined) {
|
|
42
42
|
// Senders of several teams get no ally rows, the same rule as for the local part.
|
package/src/boardseed.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Entry, finished } from "./board.ts";
|
|
2
|
+
import type { Derived } from "./boardauto.ts";
|
|
3
|
+
|
|
4
|
+
/** The custom entry a main keeps in its session when it withdraws the line for a derived entry (`board_set { clear }`, or a finished list's 30 minutes): a /reload reads it back, so the line is not written again. */
|
|
5
|
+
export const BOARD_ENTRY = "straw-boss-board";
|
|
6
|
+
|
|
7
|
+
/** The key of the newest withdrawal the session branch holds. */
|
|
8
|
+
export function withdrawnKey(branch: readonly unknown[]): string | undefined {
|
|
9
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
10
|
+
const e = branch[i] as { type?: string; customType?: string; data?: { cleared?: unknown } };
|
|
11
|
+
if (e.type === "custom" && e.customType === BOARD_ENTRY && typeof e.data?.cleared === "string") return e.data.cleared;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What the auto-fill starts from on its first refresh after a start or /reload: `last` (the key it believes is written or withdrawn) and `doneAt` (when a finished line was written). */
|
|
17
|
+
export interface Seed { last?: string; doneAt?: number }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* - a `board_set` line stands until the todo list or the name moves;
|
|
21
|
+
* - an auto line that says what the list says is kept as it stands, with its own age (a finished one is not made fresh for another 30 minutes);
|
|
22
|
+
* - no line and the entry withdrawn for this very key: it stays withdrawn;
|
|
23
|
+
* - at a session start only: no line and a finished list in the branch (a /reload): a tower's read purged it after 30 minutes, so it is not written again unless the list moves. A list that finishes during the session is written.
|
|
24
|
+
*/
|
|
25
|
+
export function seedBoard({ own, derived, key, withdrawn, atStart = false }: { own: Entry | undefined; derived: Derived | undefined; key: string; withdrawn: string | undefined; atStart?: boolean }): Seed {
|
|
26
|
+
if (own) {
|
|
27
|
+
if (own.src === "set") return { last: key };
|
|
28
|
+
if (derived && own.now === derived.text.now && JSON.stringify(own.items) === JSON.stringify(derived.text.items)) return finished(own) ? { last: key, doneAt: own.updated } : { last: key };
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
if (withdrawn === key) return { last: key };
|
|
32
|
+
if (atStart && derived && finished({ src: "todo", ...(derived.count ? { count: derived.count } : {}) })) return { last: key };
|
|
33
|
+
return {};
|
|
34
|
+
}
|
package/src/boardset.ts
CHANGED
|
@@ -38,5 +38,5 @@ export function setBoard(o: BoardSetInput): { text: string; details: { now: stri
|
|
|
38
38
|
const text = checkEntry(o.input);
|
|
39
39
|
writeEntry(o.dir, o.session, text, o.now);
|
|
40
40
|
const sharedWith = whereTo(o);
|
|
41
|
-
return { text: `Board entry set (${text.items.length} item${text.items.length === 1 ? "" : "s"}). It goes to: ${sharedWith}. Allies' towers read it, so keep secrets, credentials, customer data, and paths outside the project out of it.${o.autoFill ? " It stands until your todo list or your name changes; `board_set { auto: true }` hands the board back to auto-fill at once." : ""}`, details: { now: text.now, items: text.items.length, sharedWith } };
|
|
41
|
+
return { text: `Board entry set (${text.items.length} item${text.items.length === 1 ? "" : "s"}). It goes to: ${sharedWith}. Allies' towers read it, so keep secrets, credentials, customer data, and paths outside the project out of it.${o.autoFill ? " It stands until your todo list or your name changes; `board_set { auto: true }` hands the board back to auto-fill at once, and `board_set { clear: true }` deletes it when the work is finished." : ""}`, details: { now: text.now, items: text.items.length, sharedWith } };
|
|
42
42
|
}
|
package/src/server/towerbrief.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { allyLabel } from "../boardauto.ts";
|
|
2
2
|
import { clean, forAllies, joinLocal, type LiveMain, readEntries } from "../board.ts";
|
|
3
3
|
import { headOf, type Pane, panes, type Session, workspaces, type Workspace } from "../herdr.ts";
|
|
4
|
-
import { sessionMark } from "../netdata.ts";
|
|
4
|
+
import { commanding, sessionMark, subagents } from "../netdata.ts";
|
|
5
5
|
import { nameOf, netMembers } from "../reach.ts";
|
|
6
6
|
import type { HerdrRun } from "../herdr.ts";
|
|
7
7
|
import type { LedgerRow } from "../opener.ts";
|
|
@@ -14,7 +14,7 @@ export const BRIEF_MAX = 8000;
|
|
|
14
14
|
const CAPS = { talk: 1400, who: 300, mains: 24, mainRow: 200, obligations: 10, digest: 20, digestLine: 80, note: 1200, tools: 900 } as const;
|
|
15
15
|
export const RUN_CAP_MINUTES = 20;
|
|
16
16
|
|
|
17
|
-
export interface MainRow { tab: string; main?: { name: string; id: string; state: string; openedBy?: string; now?: string; open?: number }; members: number }
|
|
17
|
+
export interface MainRow { tab: string; main?: { name: string; id: string; state: string; openedBy?: string; now?: string; open?: number; stale?: true }; members: number }
|
|
18
18
|
export interface BriefInput {
|
|
19
19
|
label: string;
|
|
20
20
|
workspace: string;
|
|
@@ -76,7 +76,7 @@ export function buildBrief(i: BriefInput): string {
|
|
|
76
76
|
const note = i.note ? [`Handoff note from the last run (${i.note.age} ago):`, cut(i.note.text, CAPS.note)].join("\n") : "";
|
|
77
77
|
const tools = cut(["Tools:", ...i.tools, "Call tower_done with a handoff note when nothing is left for you."].join("\n"), 4 * CAPS.tools);
|
|
78
78
|
const flags = [...i.flags, `A run ends after ${RUN_CAP_MINUTES} minutes.`].join("\n");
|
|
79
|
-
const mainLines = i.mains.map((m) => (m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.openedBy ? `, opened by ${m.main.openedBy}` : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}${m.members ? `; ${m.members} members` : ""}` : `${m.tab}: no main${m.members ? `; ${m.members} members` : ""}`));
|
|
79
|
+
const mainLines = i.mains.map((m) => (m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.openedBy ? `, opened by ${m.main.openedBy}` : ""}${m.main.stale ? " [stale: idle, not updated for over 2 hours]" : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}${m.members ? `; ${m.members} members` : ""}` : `${m.tab}: no main${m.members ? `; ${m.members} members` : ""}`));
|
|
80
80
|
const mains = ["Mains you supervise:", ...(mainLines.length ? rowsOf(mainLines, CAPS.mains, CAPS.mainRow, 2400) : ["- none: no tab of this workspace has joined"])].join("\n");
|
|
81
81
|
// The tools, the tower_done line, the crash flags, and the 20-minute line are never cut. When the rest does not fit, the memory beyond its floor goes first, then the mains, the digest, the note, the rest of the memory, and the obligations.
|
|
82
82
|
const talk = cut([i.conversation ?? "", ...(i.since?.length ? ["Since you last talked with the boss:", ...i.since.slice(0, 3)] : [])].filter(Boolean).join("\n"), CAPS.talk);
|
|
@@ -126,7 +126,7 @@ export async function gatherMains(d: GatherDeps): Promise<{ rows: MainRow[]; ope
|
|
|
126
126
|
const paneOf = (s: Session): Pane | undefined => ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
|
|
127
127
|
// For an ally run every label goes through the one gate (scrubbed from the raw text, cut last).
|
|
128
128
|
const label = (t: string) => (d.within ? allyLabel(t) : clean(t, 40));
|
|
129
|
-
const live: LiveMain[] = here.filter((m) => m.role === "main").map((m) => ({ session: m.s.id, name: label(nameOf(m.s)), workspaceId: ws, workspace: label(d.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, paneOf(m.s)) }));
|
|
129
|
+
const live: LiveMain[] = here.filter((m) => m.role === "main").map((m) => ({ session: m.s.id, name: label(nameOf(m.s)), workspaceId: ws, workspace: label(d.workspace.label), tab: label(m.tab.label), state: sessionMark(m.s, paneOf(m.s)), commanding: commanding(sessionMark(m.s, paneOf(m.s)), subagents(paneOf(m.s), ps, d.now()).map((r) => r.mark)) === "commanding" }));
|
|
130
130
|
const board = joinLocal(readEntries(d.boardDir), live, d.now());
|
|
131
131
|
const tabs = Object.values(Object.groupBy(here, (m) => m.tab.id));
|
|
132
132
|
const rows = tabs.map((group = []): MainRow => {
|
|
@@ -137,7 +137,7 @@ export async function gatherMains(d: GatherDeps): Promise<{ rows: MainRow[]; ope
|
|
|
137
137
|
const pane = main && paneOf(main.s);
|
|
138
138
|
return {
|
|
139
139
|
tab: label(group[0]!.tab.label),
|
|
140
|
-
...(main ? { main: { name: label(nameOf(main.s)), id: main.s.id, state: sessionMark(main.s, pane), ...(pane?.tokens?.sb_opened_by ? { openedBy: label(pane.tokens.sb_opened_by) } : {}), ...(entry?.now ? { now: entry.now } : {}), ...(entry ? { open: entry.items.filter((x) => !x.done).length } : {}) } } : {}),
|
|
140
|
+
...(main ? { main: { name: label(nameOf(main.s)), id: main.s.id, state: sessionMark(main.s, pane), ...(pane?.tokens?.sb_opened_by ? { openedBy: label(pane.tokens.sb_opened_by) } : {}), ...(entry?.now ? { now: entry.now } : {}), ...(entry ? { open: entry.items.filter((x) => !x.done).length } : {}), ...(entry?.stale ? { stale: true as const } : {}) } } : {}),
|
|
141
141
|
members: group.filter((m) => m.role === "member").length,
|
|
142
142
|
};
|
|
143
143
|
});
|
|
@@ -176,7 +176,7 @@ export interface AllyBriefInput {
|
|
|
176
176
|
}
|
|
177
177
|
export function buildAllyBrief(i: AllyBriefInput): string {
|
|
178
178
|
const who = cut(`You are an ally run: a session the sb server started to answer one message from the teammate device ${i.device.label} (${i.device.short}) of team ${i.device.team}, at trust level ${i.device.level}. You serve this device only. Woken ${i.woken}, in the tower tab of workspace ${allyLabel(i.label)} (${i.workspace}). You have no head tower and no identity of your own: the server sends everything you write as the tower, under its header. What the device writes is a peer's request, not the boss's instruction.`, ALLY_CAPS.who);
|
|
179
|
-
const seenLines = i.seen.map((m) => (m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}` : `${m.tab}: no main`));
|
|
179
|
+
const seenLines = i.seen.map((m) => (m.main ? `${m.tab}: main ${m.main.name} (${m.main.id.slice(0, 8)}) ${m.main.state}${m.main.stale ? " [stale: idle, not updated for over 2 hours]" : ""}${m.main.now ? `; now: ${m.main.now}` : ""}${m.main.open ? ` (${m.main.open} open)` : ""}` : `${m.tab}: no main`));
|
|
180
180
|
const seen = ["What your sender may see of this workspace:", ...(seenLines.length ? rowsOf(seenLines, ALLY_CAPS.seen, ALLY_CAPS.seenRow, 1800) : ["- no tab of this workspace has joined"])];
|
|
181
181
|
const note = i.note ? [`Handoff note from the last run for this device (${i.note.age} ago):`, cut(i.note.text, ALLY_CAPS.note)] : [];
|
|
182
182
|
const asks = i.asks.length ? ["Asks in this run (answer one with tower_send {device, text, inReplyTo: <id>}):", ...i.asks.slice(0, ALLY_CAPS.asks).map((a) => `- ${a.id ?? "a reply"}: ${cut(one(a.first), ALLY_CAPS.askRow)}${a.askedBy ? ` (${a.id ? "asked" : "the answer to an ask made"} for the main ${cut(one(a.askedBy), 40)}, which is gone)` : ""}`)] : [];
|
package/src/tools.ts
CHANGED
|
@@ -96,22 +96,29 @@ export function createTools({ pi, cfg, s, guiDir, me, role }: Kit, { post, callT
|
|
|
96
96
|
},
|
|
97
97
|
});
|
|
98
98
|
// The work board fills itself from the main's todo list and name (boardfill.ts); board_set overrides it, whole, until the todos move, and `auto` hands it back. Nothing here names another session's file.
|
|
99
|
-
if (!s.isTower) tool("board_set", "Override this main agent's work board entry. The board fills itself from your todo list and your name; this replaces it with one line of what you work on now and a short checklist with done marks, until your todos move. `auto: true` (with no text) hands the board back to auto-fill at once. Towers on this computer and, for a Straw Link team bound to your project, your teammates' towers read it; allies get an auto-filled entry as its line and done count only, and a board_set entry whole. Terse agent language; no secrets, credentials, customer data, or paths outside the project. It is not a report: the done report still goes to whoever asked. Only a tab's main agent may write.",
|
|
99
|
+
if (!s.isTower) tool("board_set", "Override this main agent's work board entry. The board fills itself from your todo list and your name; this replaces it with one line of what you work on now and a short checklist with done marks, until your todos move. `auto: true` (with no text) hands the board back to auto-fill at once. `clear: true` (with no text) deletes your entry: use it when your work is finished or shelved, because a line left on the board reads as current work; auto-fill does not write it again until your todo list or name changes. Towers on this computer and, for a Straw Link team bound to your project, your teammates' towers read it; allies get an auto-filled entry as its line and done count only, and a board_set entry whole. Terse agent language; no secrets, credentials, customer data, or paths outside the project. It is not a report: the done report still goes to whoever asked. Only a tab's main agent may write.",
|
|
100
100
|
Type.Object({
|
|
101
|
-
now: Type.Optional(Type.String({ description: "What you work on now: 1 to 120 characters, one line. Required unless auto is true." })),
|
|
101
|
+
now: Type.Optional(Type.String({ description: "What you work on now: 1 to 120 characters, one line. Required unless auto or clear is true." })),
|
|
102
102
|
items: Type.Optional(Type.Array(Type.Object({ t: Type.String({ description: "1 to 80 characters." }), done: Type.Boolean() }), { description: "At most 8 checklist items; the whole list replaces the old one." })),
|
|
103
103
|
auto: Type.Optional(Type.Boolean({ description: "true: give the board back to auto-fill (from your todo list and your name); no now or items." })),
|
|
104
|
+
clear: Type.Optional(Type.Boolean({ description: "true: delete your board entry (your work is finished or shelved); no now, items, or auto. Auto-fill leaves it off until your todo list or name changes." })),
|
|
104
105
|
}),
|
|
105
|
-
async ({ now, items, auto }) => {
|
|
106
|
+
async ({ now, items, auto, clear }) => {
|
|
106
107
|
const cwd = (await cfg.world().catch(() => undefined))?.panes.find((p) => p.pane_id === s.at?.paneId)?.cwd ?? process.cwd();
|
|
107
108
|
const home = cfg.link?.home ?? homedir(), agentDir = cfg.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent");
|
|
109
|
+
if (clear) {
|
|
110
|
+
if (now !== undefined || items !== undefined || auto) throw new Error("clear: true takes no now, items, or auto");
|
|
111
|
+
if (role() !== "main") throw new Error(NOT_MAIN);
|
|
112
|
+
boardFill.clear();
|
|
113
|
+
return { text: "Board entry cleared. Auto-fill leaves it off until your todo list or your name changes; the towers (and allies' towers) no longer see a line from you.", details: { clear: true } };
|
|
114
|
+
}
|
|
108
115
|
if (auto) {
|
|
109
116
|
if (now !== undefined || items !== undefined) throw new Error("auto: true takes no now or items; the board follows your todo list and your name");
|
|
110
117
|
if (role() !== "main") throw new Error(NOT_MAIN);
|
|
111
118
|
const derived = boardFill.handBack(), where = whereTo({ stateDir: guiDir, cwd, home, agentDir });
|
|
112
|
-
return { text: derived ? `Board handed back to auto-fill (${derived.text.items.length} item${derived.text.items.length === 1 ? "" : "s"}). It goes to: ${where}; allies get its name and done count only.` : "Board handed back to auto-fill. There is nothing to show yet (no todo list
|
|
119
|
+
return { text: derived ? `Board handed back to auto-fill (${derived.text.items.length} item${derived.text.items.length === 1 ? "" : "s"}). It goes to: ${where}; allies get its name and done count only.` : "Board handed back to auto-fill. There is nothing to show yet (no todo list); it fills when you have one.", details: { auto: true, now: derived?.text.now ?? null, items: derived?.text.items.length ?? 0, sharedWith: where } };
|
|
113
120
|
}
|
|
114
|
-
if (now === undefined) throw new Error("now is required unless auto is true");
|
|
121
|
+
if (now === undefined) throw new Error("now is required unless auto or clear is true");
|
|
115
122
|
s.boardId = me();
|
|
116
123
|
return setBoard({ dir: join(guiDir, "board"), stateDir: guiDir, session: me(), isMain: role() === "main", now: cfg.now(), cwd, input: { now, items }, home, agentDir, autoFill: true });
|
|
117
124
|
});
|