lazyclaw 3.99.28 → 4.2.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/daemon.mjs CHANGED
@@ -1605,6 +1605,177 @@ export function makeHandler(ctx) {
1605
1605
  }, m.headers || {});
1606
1606
  }
1607
1607
  }
1608
+ // ──── Multi-agent dashboard surface (Phase 15) ────────────────
1609
+ // Routes share the same JSON-only shape the rest of the daemon
1610
+ // uses. The on-disk state is owned by agents.mjs / teams.mjs /
1611
+ // tasks.mjs; we don't touch the files directly here so the CLI
1612
+ // and the dashboard stay coherent.
1613
+ case route === 'GET /agents': {
1614
+ const mod = await import('./agents.mjs');
1615
+ return writeJson(res, 200, mod.listAgents());
1616
+ }
1617
+ case route === 'POST /agents': {
1618
+ const mod = await import('./agents.mjs');
1619
+ let body;
1620
+ try { body = await readJson(req); }
1621
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1622
+ try { return writeJson(res, 200, mod.registerAgent(body)); }
1623
+ catch (err) {
1624
+ const code = err?.code === 'AGENT_EXISTS' ? 409 : 400;
1625
+ return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
1626
+ }
1627
+ }
1628
+ case req.method === 'GET' && /^\/agents\/([^/]+)$/.test(url.pathname): {
1629
+ const name = url.pathname.split('/').pop();
1630
+ const mod = await import('./agents.mjs');
1631
+ const a = mod.getAgent(name);
1632
+ if (!a) return writeJson(res, 404, { error: `no agent "${name}"` });
1633
+ return writeJson(res, 200, a);
1634
+ }
1635
+ case req.method === 'PATCH' && /^\/agents\/([^/]+)$/.test(url.pathname): {
1636
+ const name = url.pathname.split('/').pop();
1637
+ const mod = await import('./agents.mjs');
1638
+ let body;
1639
+ try { body = await readJson(req); }
1640
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1641
+ try { return writeJson(res, 200, mod.patchAgent(name, body)); }
1642
+ catch (err) {
1643
+ const code = err?.code === 'AGENT_NO_AGENT' ? 404 : 400;
1644
+ return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
1645
+ }
1646
+ }
1647
+ case req.method === 'DELETE' && /^\/agents\/([^/]+)$/.test(url.pathname): {
1648
+ const name = url.pathname.split('/').pop();
1649
+ const mod = await import('./agents.mjs');
1650
+ try { return writeJson(res, 200, mod.removeAgent(name)); }
1651
+ catch (err) {
1652
+ return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
1653
+ }
1654
+ }
1655
+ case req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
1656
+ const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
1657
+ const memMod = await import('./mas/agent_memory.mjs');
1658
+ const text = memMod.readMemory(name);
1659
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
1660
+ return res.end(text);
1661
+ }
1662
+ case req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
1663
+ const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
1664
+ const memMod = await import('./mas/agent_memory.mjs');
1665
+ // Read raw text body — content-type defaults to text/markdown
1666
+ // but JSON {"text": "..."} is also accepted for tooling that
1667
+ // prefers structured bodies.
1668
+ let body = '';
1669
+ await new Promise((resolve) => {
1670
+ req.on('data', (c) => { body += c.toString(); });
1671
+ req.on('end', resolve);
1672
+ });
1673
+ let text = body;
1674
+ if (req.headers['content-type']?.includes('application/json')) {
1675
+ try { text = (JSON.parse(body || '{}').text) || ''; } catch { /* leave raw */ }
1676
+ }
1677
+ try {
1678
+ const p = memMod.writeRaw(name, text);
1679
+ return writeJson(res, 200, { path: p, bytes: Buffer.byteLength(text, 'utf8') });
1680
+ } catch (err) {
1681
+ return writeJson(res, 400, { error: err?.message || String(err), code: err?.code });
1682
+ }
1683
+ }
1684
+ case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
1685
+ const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
1686
+ const memMod = await import('./mas/agent_memory.mjs');
1687
+ const removed = memMod.clear(name);
1688
+ return writeJson(res, 200, { name, cleared: removed });
1689
+ }
1690
+
1691
+ case route === 'GET /teams': {
1692
+ const mod = await import('./teams.mjs');
1693
+ return writeJson(res, 200, mod.listTeams());
1694
+ }
1695
+ case route === 'POST /teams': {
1696
+ const mod = await import('./teams.mjs');
1697
+ let body;
1698
+ try { body = await readJson(req); }
1699
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1700
+ try { return writeJson(res, 200, mod.registerTeam(body)); }
1701
+ catch (err) {
1702
+ const code = err?.code === 'TEAM_EXISTS' ? 409 : 400;
1703
+ return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
1704
+ }
1705
+ }
1706
+ case req.method === 'GET' && /^\/teams\/([^/]+)$/.test(url.pathname): {
1707
+ const name = url.pathname.split('/').pop();
1708
+ const mod = await import('./teams.mjs');
1709
+ const t = mod.getTeam(name);
1710
+ if (!t) return writeJson(res, 404, { error: `no team "${name}"` });
1711
+ return writeJson(res, 200, t);
1712
+ }
1713
+ case req.method === 'PATCH' && /^\/teams\/([^/]+)$/.test(url.pathname): {
1714
+ const name = url.pathname.split('/').pop();
1715
+ const mod = await import('./teams.mjs');
1716
+ let body;
1717
+ try { body = await readJson(req); }
1718
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
1719
+ try { return writeJson(res, 200, mod.patchTeam(name, body)); }
1720
+ catch (err) {
1721
+ const code = err?.code === 'TEAM_NO_TEAM' ? 404 : 400;
1722
+ return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
1723
+ }
1724
+ }
1725
+ case req.method === 'DELETE' && /^\/teams\/([^/]+)$/.test(url.pathname): {
1726
+ const name = url.pathname.split('/').pop();
1727
+ const mod = await import('./teams.mjs');
1728
+ try { return writeJson(res, 200, mod.removeTeam(name)); }
1729
+ catch (err) {
1730
+ return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
1731
+ }
1732
+ }
1733
+
1734
+ case route === 'GET /tasks': {
1735
+ const mod = await import('./tasks.mjs');
1736
+ return writeJson(res, 200, mod.listTasks());
1737
+ }
1738
+ case req.method === 'GET' && /^\/tasks\/([^/]+)\/transcript$/.test(url.pathname): {
1739
+ const m = url.pathname.match(/^\/tasks\/([^/]+)\/transcript$/);
1740
+ const id = m[1];
1741
+ const mod = await import('./tasks.mjs');
1742
+ const t = mod.getTask(id);
1743
+ if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
1744
+ const fmt = String(url.searchParams.get('format') || 'text');
1745
+ if (fmt === 'json') return writeJson(res, 200, t);
1746
+ const body = mod.formatTranscript(t, fmt);
1747
+ const mime = fmt === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
1748
+ res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-cache' });
1749
+ return res.end(body);
1750
+ }
1751
+ case req.method === 'GET' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
1752
+ const id = url.pathname.split('/').pop();
1753
+ const mod = await import('./tasks.mjs');
1754
+ const t = mod.getTask(id);
1755
+ if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
1756
+ return writeJson(res, 200, t);
1757
+ }
1758
+ case req.method === 'DELETE' && /^\/tasks\/([^/]+)$/.test(url.pathname): {
1759
+ const id = url.pathname.split('/').pop();
1760
+ const mod = await import('./tasks.mjs');
1761
+ try { return writeJson(res, 200, mod.removeTask(id)); }
1762
+ catch (err) {
1763
+ return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
1764
+ }
1765
+ }
1766
+ case req.method === 'POST' && /^\/tasks\/([^/]+)\/(done|abandon)$/.test(url.pathname): {
1767
+ const m = url.pathname.match(/^\/tasks\/([^/]+)\/(done|abandon)$/);
1768
+ const id = m[1];
1769
+ const action = m[2];
1770
+ const mod = await import('./tasks.mjs');
1771
+ try {
1772
+ const next = mod.patchTask(id, { status: action === 'done' ? 'done' : 'abandoned' });
1773
+ return writeJson(res, 200, next);
1774
+ } catch (err) {
1775
+ return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
1776
+ }
1777
+ }
1778
+
1608
1779
  default:
1609
1780
  return writeJson(res, 404, { error: 'not found', route });
1610
1781
  } /* eslint-disable-line no-fallthrough */
@@ -0,0 +1,256 @@
1
+ # Multi-Agent Slack System — spec v0.1
2
+
3
+ > Status: **confirmed 2026-05-18** — §10 decisions locked, Phase 9 may begin.
4
+
5
+ ---
6
+
7
+ ## 1. Goals (in user's words)
8
+
9
+ 1. User opens the **dashboard**, registers agents, defines roles, optionally groups them into teams.
10
+ 2. User assigns a task to **one lead agent** (e.g. `@planner`) via the dashboard.
11
+ 3. The lead agent picks up the task in **one Slack thread**, decides which teammates to involve, and **@-mentions** them in the same thread.
12
+ 4. Mentioned agents (e.g. `@backend`, `@frontend`) do their part — they may call tools (read files, run commands, edit code) — and post their results in the same thread.
13
+ 5. Results bubble back to the lead. Lead synthesises and reports the final outcome to the user (dashboard + thread summary).
14
+ 6. All conversation history per task lives in one Slack thread + one lazyclaw session, so anyone (incl. the user) can audit.
15
+
16
+ Concretely: **one Slack channel, many virtual agents, mention-driven routing, with tool-use enabled per agent.**
17
+
18
+ ---
19
+
20
+ ## 2. Vocabulary
21
+
22
+ | Term | Definition |
23
+ |---|---|
24
+ | **Agent** | A named identity with a role (system prompt), a provider/model, a tool whitelist, and a persona/avatar. Not a Slack user — a virtual identity surfaced through the one real bot. |
25
+ | **Team** | A named set of agents + a default Slack channel + a default lead. Teams scope routing: `@backend` in team `shop` is a different agent than `@backend` in team `growth`. |
26
+ | **Task** | A unit of work with a title, description, owning team, lead agent, status, and a Slack thread ts. Each task = exactly one thread. |
27
+ | **Turn** | One agent saying one thing in a thread. Stored as `{ agent, text, tool_calls?, tool_results?, ts }`. |
28
+ | **Handoff** | An agent's turn that contains `@OtherAgent` mention(s). The router schedules `OtherAgent` to take the next turn(s) with full thread history. |
29
+
30
+ ---
31
+
32
+ ## 3. Data model
33
+
34
+ Files live under `~/.lazyclaw/`, gitignored, schema versioned via `version` field.
35
+
36
+ ### 3.1 Agent — `~/.lazyclaw/agents/<name>.json`
37
+
38
+ ```jsonc
39
+ {
40
+ "version": 1,
41
+ "name": "planner", // unique identifier, used in @mentions
42
+ "displayName": "Planner", // shown in dashboard + Slack header
43
+ "role": "You are the project planner. Break work down…", // system prompt
44
+ "provider": "claude-cli", // any lazyclaw provider name
45
+ "model": "claude-opus-4-7",
46
+ "tools": ["bash", "read", "write", "grep", "web_search"], // whitelist
47
+ "tags": ["lead"], // free-form labels (used by router for fallback)
48
+ "createdAt": "2026-05-18T…",
49
+ "updatedAt": "2026-05-18T…"
50
+ }
51
+ ```
52
+
53
+ ### 3.2 Team — `~/.lazyclaw/teams/<name>.json`
54
+
55
+ ```jsonc
56
+ {
57
+ "version": 1,
58
+ "name": "shop",
59
+ "displayName": "Shop squad",
60
+ "agents": ["planner", "backend", "frontend"],
61
+ "lead": "planner", // default task lead, overridable per task
62
+ "slackChannel": "C0B5AGCP8PJ", // channel id (preferred) or "#shop"
63
+ "createdAt": "2026-05-18T…"
64
+ }
65
+ ```
66
+
67
+ ### 3.3 Task — `~/.lazyclaw/tasks/<id>.json`
68
+
69
+ ```jsonc
70
+ {
71
+ "version": 1,
72
+ "id": "t_2026-05-18_001",
73
+ "title": "ship checkout flow",
74
+ "description": "…",
75
+ "team": "shop",
76
+ "lead": "planner",
77
+ "status": "running", // pending | running | done | failed | abandoned
78
+ "slackChannel": "C0B5AGCP8PJ",
79
+ "slackThreadTs": "1700000000.000100", // root message ts
80
+ "createdAt": "…",
81
+ "updatedAt": "…",
82
+ "turns": [
83
+ { "agent": "user", "text": "ship checkout flow", "ts": "1700000000.000100" },
84
+ { "agent": "planner", "text": "Step 1 … @backend implement …", "ts": "…" },
85
+ { "agent": "backend", "text": "Done — diff at …", "tool_calls": […], "ts": "…" }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ## 4. Slack routing model
93
+
94
+ ### 4.1 One bot, many virtual agents
95
+
96
+ There is **one** real Slack app/bot (the existing lazyclaw bot). Each agent appears in Slack as a message prefixed with the agent name and (optionally) a custom username/icon via `chat.postMessage`'s `username` + `icon_emoji` params (requires `chat:write.customize` scope).
97
+
98
+ Why not multiple bots:
99
+ - Slack workspace pollution (1 app per agent ≠ scalable)
100
+ - Re-installation + token management per agent is a ceremony
101
+ - Permission model is per-bot, not per-message — easier with one trusted bot
102
+
103
+ ### 4.2 Inbound trigger
104
+
105
+ A thread becomes "live" when either:
106
+ - The user starts a task from the dashboard → lazyclaw posts a root message in the team's channel
107
+ - A human posts in any channel where the bot is a member and **@-mentions a specific agent**, e.g. `@planner build me X`. The bot maps `@planner` (by display name → agent name → team) and treats the message as the kickoff turn.
108
+
109
+ ### 4.3 Mention parsing
110
+
111
+ Agents address each other with **plain text `@AgentName`** (no Slack user_id), because virtual agents have no Slack identity. Router scans every agent-authored message for `@(\w+)` and:
112
+
113
+ 1. Resolves each match to an agent in the **same team** as the speaker (scoped lookup; ambiguous matches across teams are an error reported in-thread).
114
+ 2. Schedules each mentioned agent for a turn. Turns are taken **sequentially** (not parallel) to keep the conversation linear and avoid token explosions; future enhancement can fan out.
115
+ 3. After the last mentioned agent finishes, **control returns to the lead** if the lead wasn't part of the mentions — the lead gets a synthesised view and decides whether to keep iterating or mark the task done.
116
+
117
+ ### 4.4 Termination
118
+
119
+ A task is `done` when:
120
+ - The lead emits a turn containing the literal marker `[[TASK_DONE]]` (chosen over `DONE` to avoid false positives when agents discuss the word "done" naturally), **or**
121
+ - A per-task `maxTurns` budget is exhausted (default 30, configurable), **or**
122
+ - The user marks it done from the dashboard or via slash command.
123
+
124
+ ### 4.5 Bot's own messages
125
+
126
+ Slack delivers `message` events for the bot's own posts. The router **must** filter `subtype === 'bot_message'` and `bot_id === <our bot>` so the bot never reacts to its own agent posts. This is already done in `channels/slack.mjs`.
127
+
128
+ ---
129
+
130
+ ## 5. Tool-use per agent
131
+
132
+ This is the largest delta vs current code. Today, the Slack handler calls `prov.sendMessage(messages, …)` once and returns text. Multi-agent needs a **tool-use loop**: the model returns either a final text or a tool call; lazyclaw executes the tool and feeds results back; repeat until final text.
133
+
134
+ ### 5.1 Tool whitelist (per agent)
135
+
136
+ ```
137
+ bash — run a shell command, get stdout/stderr/exit
138
+ read — read a file (path-scoped)
139
+ write — write/edit a file
140
+ grep — search the workspace
141
+ web_search — call Tavily / Serper / duckduckgo
142
+ web_fetch — fetch a URL
143
+ slack_post — post a message into the task's thread (rarely needed — output text auto-posts)
144
+ ```
145
+
146
+ Each agent declares the subset it can use. Tools the agent didn't request are not advertised to the model. **Default whitelist for a dashboard-created agent: `[bash, read, write, grep]`** (per §10 #3 — user opted for the full set; restrict per-agent if needed).
147
+
148
+ ### 5.2 Permission and audit
149
+
150
+ - Every tool call is logged to `~/.lazyclaw/tasks/<id>.audit.jsonl` with `{ agent, tool, args, result_hash, ts }`.
151
+ - Bash commands are *not* sandboxed by default (per user decision §0). Workspace is constrained to the cwd of the lazyclaw process.
152
+ - **Bash destructive-pattern confirmation is OFF by default** (per §10 #6). The dashboard exposes a per-team toggle so individual teams can opt into the "ask before `rm -rf` / `git push --force` / `DROP TABLE` …" gate; the patterns themselves live in a config file the user can edit.
153
+ - Audit log captures destructive commands either way so post-hoc forensics is possible.
154
+
155
+ ### 5.3 Provider compatibility
156
+
157
+ Tool-use is supported on **anthropic, openai, and gemini** from Phase 12 (per §10 #5 — user opted for parallel implementation). Each provider has its own tool-call schema; the implementation routes through a small adapter layer (`providers/tool_use.mjs`) that normalises:
158
+
159
+ - Anthropic: `tools` array + `tool_use` / `tool_result` content blocks
160
+ - OpenAI: `tools` (functions) + `tool_calls` / `tool` role messages
161
+ - Gemini: `function_declarations` + `function_call` / `function_response` parts
162
+
163
+ claude-cli (subprocess) does NOT support tool-use — those agents are flagged "tool-use disabled" in the dashboard and can only chat.
164
+
165
+ ---
166
+
167
+ ## 6. Dashboard UX
168
+
169
+ The existing `lazyclaw dashboard` (browser-rendered HTML, served by the daemon) gets four new screens:
170
+
171
+ ```
172
+ ┌─────────────────────────────────────────────────────────┐
173
+ │ Lazyclaw dashboard [search …] │
174
+ ├─[ Agents ][ Teams ][ Tasks ][ Live threads ][ …existing ]┤
175
+ │ │
176
+ │ Agents [ + New agent ]│
177
+ │ ┌──────────────────────────────────────────────────┐ │
178
+ │ │ planner Planner claude-opus-4-7 ✎ ✕ │ │
179
+ │ │ backend Backend dev claude-sonnet-4-6 ✎ ✕ │ │
180
+ │ │ frontend Frontend dev claude-haiku-4-5 ✎ ✕ │ │
181
+ │ └──────────────────────────────────────────────────┘ │
182
+ └─────────────────────────────────────────────────────────┘
183
+ ```
184
+
185
+ - **Agents tab** — list / create / edit / delete agents. Edit form has role textarea, provider+model dropdown, tool checkboxes.
186
+ - **Teams tab** — list / create / edit teams. Team form picks agents from registered list + Slack channel selector (calls `conversations.list`).
187
+ - **Tasks tab** — list of recent tasks with status, links to live Slack thread, and a "View transcript" detail view that renders the `turns` array as a chat-like timeline.
188
+ - **Live threads** — currently-running tasks with a kill button (sets `status: abandoned`, posts a closing message in thread).
189
+
190
+ CLI parity (so anyone can manage without a browser):
191
+
192
+ ```
193
+ lazyclaw agent add planner --role "…" --provider claude-cli --model claude-opus-4-7 --tools bash,read,write
194
+ lazyclaw agent list / show / edit / remove
195
+ lazyclaw team add shop --lead planner --agents planner,backend,frontend --channel C0B5AGCP8PJ
196
+ lazyclaw team list / show / edit / remove
197
+ lazyclaw task start --team shop --title "ship checkout flow"
198
+ lazyclaw task list / show / abandon / done
199
+ ```
200
+
201
+ REPL slash equivalents (`/agent`, `/team`, `/task`).
202
+
203
+ ---
204
+
205
+ ## 7. Phase plan
206
+
207
+ Each phase exits 0 on its Playwright suite before the next phase starts.
208
+
209
+ | Phase | Scope | Tests |
210
+ |---|---|---|
211
+ | **9** | Agent registry + CRUD CLI + dashboard list view | `phase9-agent-registry.spec.ts` |
212
+ | **10** | Team registry + CRUD CLI + dashboard. Channel resolver. | `phase10-team-registry.spec.ts` |
213
+ | **11** | Task registry + `lazyclaw task start` opens a Slack thread with the lead's intro turn. | `phase11-task-start.spec.ts` |
214
+ | **12** | **Tool-use loop** (the big one). Provider abstraction for tool calls, audit log, file/path scoping. | `phase12-tool-use.spec.ts` |
215
+ | **13** | Mention router — agent A's `@B` schedules B for next turn, with full thread context. Handoff back to lead when mentions run out. | `phase13-mention-router.spec.ts` |
216
+ | **14** | Termination policies (DONE marker, maxTurns, manual). Final summary post. | `phase14-termination.spec.ts` |
217
+ | **15** | Dashboard UI screens for agents/teams/tasks. WebSocket live updates. | `phase15-dashboard-mas.spec.ts` |
218
+ | **16** | Polish — `chat:write.customize` per-agent username/icon, agent typing indicators, transcript export. | `phase16-polish.spec.ts` |
219
+
220
+ Phase 9 + 10 + 11 alone get a working "post-from-dashboard-into-Slack" pipeline (no tool-use, no handoff yet) — that's the first user-visible milestone, ~3-4 days of work.
221
+
222
+ ---
223
+
224
+ ## 8. Cross-cutting
225
+
226
+ - **Security** — tokens stay in `~/.lazyclaw/.env`, never logged, never in task records. Bash tool runs as the user (no privilege drop) — *only* enable for trusted teams/workspaces.
227
+ - **Rate limits** — each agent turn counts against its provider's RL. Router pauses (not crashes) when an agent hits 429.
228
+ - **Concurrency** — one task = one thread; multiple tasks can run concurrently. Per-task state is independent.
229
+ - **Storage** — flat JSON files (matches lazyclaw's existing pattern: `~/.lazyclaw/goals/`, `~/.lazyclaw/loops/`). Migration story: bump `version` field per schema change, write a one-shot migrator.
230
+
231
+ ---
232
+
233
+ ## 9. Out of scope (v0.1 — deferred)
234
+
235
+ - Cross-team handoffs (`@team:other/agent` syntax)
236
+ - Parallel mention fan-out (multiple agents replying at the same time)
237
+ - Persistent agent memory / self-improvement (orthogonal — uses `~/.lazyclaw/memory/`)
238
+ - Non-Slack channels (Discord/Telegram) — pattern is identical once Phase 7 channel interface lands. Add post-v0.1.
239
+ - Multi-workspace (one lazyclaw daemon serving more than one Slack workspace)
240
+ - Voice / file attachments inside threads
241
+ - Agent-vs-agent direct messages outside a task thread
242
+
243
+ ---
244
+
245
+ ## 10. Decisions — confirmed 2026-05-18
246
+
247
+ | # | Question | Decision |
248
+ |---|---|---|
249
+ | 1 | Agent `@mention` form | **Full names** — `@planner`, `@backend`, `@frontend` |
250
+ | 2 | Termination marker | **`[[TASK_DONE]]`** — explicit token, no false positives |
251
+ | 3 | Default tool whitelist for new agents | **`[bash, read, write, grep]`** — full set, restrict per-agent if needed |
252
+ | 4 | Slack channel topology | **One channel per team** — matches §3.2 |
253
+ | 5 | Tool-use provider coverage in Phase 12 | **anthropic + openai + gemini** — all three from launch |
254
+ | 6 | Bash destructive-pattern confirmation | **OFF by default** — dashboard per-team toggle to enable; audit log always on |
255
+
256
+ Phase 9 work begins immediately after this commit lands.
package/goals.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // Persistent goal registry for `/goal` REPL command and `lazyclaw goal`
2
+ // subcommand.
3
+ //
4
+ // Storage layout under <configDir>/goals/<name>.json. One file per goal so
5
+ // concurrent `close` / `tick` writes don't race over a global index. The
6
+ // canonical field set lives in `defaultShape()` below; new fields default
7
+ // to null/empty so reading an older record stays forward-compatible.
8
+ //
9
+ // The name validator is delegated to cron.ensureValidName — the spec
10
+ // requires identical error wording so a fat-finger like `/goal add "has
11
+ // spaces"` produces the same message a user already knows from `cron add`.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { ensureValidName as cronEnsureValidName } from './cron.mjs';
17
+
18
+ const GOALS_DIRNAME = 'goals';
19
+
20
+ export class GoalError extends Error {
21
+ constructor(message, code) {
22
+ super(message);
23
+ this.name = 'GoalError';
24
+ this.code = code || 'GOAL_ERR';
25
+ }
26
+ }
27
+
28
+ export function defaultConfigDir() {
29
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
30
+ }
31
+
32
+ export function goalsDir(configDir = defaultConfigDir()) {
33
+ return path.join(configDir, GOALS_DIRNAME);
34
+ }
35
+
36
+ export function goalPath(name, configDir = defaultConfigDir()) {
37
+ ensureValidName(name);
38
+ return path.join(goalsDir(configDir), `${name}.json`);
39
+ }
40
+
41
+ export function ensureValidName(name) {
42
+ try { cronEnsureValidName(name); }
43
+ catch (e) { throw new GoalError(e.message, 'GOAL_BAD_NAME'); }
44
+ }
45
+
46
+ function defaultShape(name) {
47
+ return {
48
+ name,
49
+ description: '',
50
+ createdAt: new Date().toISOString(),
51
+ status: 'active',
52
+ schedule: null,
53
+ channels: [],
54
+ sessionId: `goal:${name}`,
55
+ checkIns: [],
56
+ memoryPath: null,
57
+ };
58
+ }
59
+
60
+ function writeAtomic(filePath, obj) {
61
+ const dir = path.dirname(filePath);
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ const tmp = filePath + '.tmp';
64
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
65
+ fs.renameSync(tmp, filePath);
66
+ }
67
+
68
+ export function registerGoal({ name, description = '', schedule = null, channels = [] } = {}, configDir = defaultConfigDir()) {
69
+ ensureValidName(name);
70
+ const p = goalPath(name, configDir);
71
+ if (fs.existsSync(p)) {
72
+ throw new GoalError(`goal "${name}" already exists`, 'GOAL_EXISTS');
73
+ }
74
+ const data = {
75
+ ...defaultShape(name),
76
+ description,
77
+ schedule,
78
+ channels: Array.isArray(channels) ? channels : [],
79
+ };
80
+ writeAtomic(p, data);
81
+ return data;
82
+ }
83
+
84
+ export function getGoal(name, configDir = defaultConfigDir()) {
85
+ let p;
86
+ try { p = goalPath(name, configDir); }
87
+ catch { return null; }
88
+ if (!fs.existsSync(p)) return null;
89
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
90
+ catch { return null; }
91
+ }
92
+
93
+ export function listGoals(configDir = defaultConfigDir()) {
94
+ const dir = goalsDir(configDir);
95
+ if (!fs.existsSync(dir)) return [];
96
+ const out = [];
97
+ for (const f of fs.readdirSync(dir)) {
98
+ if (!f.endsWith('.json')) continue;
99
+ const name = f.slice(0, -5);
100
+ const g = getGoal(name, configDir);
101
+ if (g) out.push(g);
102
+ }
103
+ out.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
104
+ return out;
105
+ }
106
+
107
+ export function patchGoal(name, patch, configDir = defaultConfigDir()) {
108
+ const g = getGoal(name, configDir);
109
+ if (!g) throw new GoalError(`no goal "${name}"`, 'GOAL_NO_GOAL');
110
+ const next = { ...g, ...patch };
111
+ writeAtomic(goalPath(name, configDir), next);
112
+ return next;
113
+ }
114
+
115
+ export function closeGoal(name, outcome = 'done', configDir = defaultConfigDir()) {
116
+ if (outcome !== 'done' && outcome !== 'abandoned') {
117
+ throw new GoalError(`outcome must be done or abandoned, got "${outcome}"`, 'GOAL_BAD_OUTCOME');
118
+ }
119
+ return patchGoal(name, { status: outcome, closedAt: new Date().toISOString() }, configDir);
120
+ }
121
+
122
+ export function appendCheckIn(name, summary, configDir = defaultConfigDir()) {
123
+ const g = getGoal(name, configDir);
124
+ if (!g) throw new GoalError(`no goal "${name}"`, 'GOAL_NO_GOAL');
125
+ const next = { ...g, checkIns: [...(g.checkIns || []), { at: new Date().toISOString(), summary }] };
126
+ writeAtomic(goalPath(name, configDir), next);
127
+ return next;
128
+ }