mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-03 12:32:32 +08:00
chore: bump version to 13.4.1
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"source": "./plugin",
|
"source": "./plugin",
|
||||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman"
|
"name": "Alex Newman"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman",
|
"name": "Alex Newman",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "Claude-Mem (Persistent Memory)",
|
"name": "Claude-Mem (Persistent Memory)",
|
||||||
"description": "OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
|
"description": "OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
|
||||||
"kind": "memory",
|
"kind": "memory",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"author": "thedotmack",
|
"author": "thedotmack",
|
||||||
"homepage": "https://claude-mem.ai",
|
"homepage": "https://claude-mem.ai",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
234
plans/inbox/2026-05-21-viewer-screenshot-references.md
Normal file
234
plans/inbox/2026-05-21-viewer-screenshot-references.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Viewer Screenshot References
|
||||||
|
|
||||||
|
**Status:** inbox
|
||||||
|
**Created:** 2026-05-21
|
||||||
|
**Goal:** Surface screenshot images (PNG/JPG/GIF/WebP) referenced by tool calls as inline thumbnails in the observation feed at the worker viewer (`http://127.0.0.1:<worker-port>`), with a click-to-zoom lightbox. Worker serves the image bytes from disk through a path-safe endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a session uses screenshot tools (gstack/browse `screenshot`, MCP screenshot tools) or reads/writes image files, those paths flow through `pending_messages.tool_input` but are **dropped** before reaching the `observations` table. The viewer feed only sees text summaries — never the actual images that were captured. We want screenshots inline in the feed.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Local-only:** worker binds to `127.0.0.1`, so an image endpoint that reads arbitrary local paths is reachable only by the user — but path-traversal must still be guarded (defense in depth, and to avoid leaking unrelated files via a malicious request from a browser tab).
|
||||||
|
- **Open-source core:** no Pro-only gating. Endpoint and viewer component ship in the core.
|
||||||
|
- **No new dependencies:** reuse Express, esbuild, existing modal/CSS patterns.
|
||||||
|
- **Backwards-compatible storage:** new column on `observations` must be nullable, no migration of historical rows required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: Documentation Discovery (consolidated)
|
||||||
|
|
||||||
|
### Storage layer
|
||||||
|
- **Schema:** `src/services/sqlite/schema.sql:57-92` — `observations` table. Columns include `files_read`, `files_modified` (both `TEXT`, JSON-stringified arrays), `metadata` (`TEXT`, JSON), `facts`, `narrative`, `concepts`. `UNIQUE(memory_session_id, content_hash)` with `ON CONFLICT DO NOTHING`.
|
||||||
|
- **Queue:** `src/services/sqlite/schema.sql:126-152` — `pending_messages` table holds raw `tool_input` / `tool_response` JSON during processing. **Discarded after the AI summarization step.**
|
||||||
|
- **Insert path:** `src/services/sqlite/observations/store.ts:19-80` — `storeObservation()` writes 17 columns from `ObservationInput`. Raw tool I/O is **not** persisted here today.
|
||||||
|
- **Read row type:** `ObservationRow` in `src/services/sqlite/types.ts` — what every observation API returns.
|
||||||
|
|
||||||
|
### Worker HTTP
|
||||||
|
- **App setup:** `src/services/server/Server.ts:98-105` — `setupCors()`, body-parser, route registration. CORS already on.
|
||||||
|
- **Observations endpoint:** `GET /api/observations` registered in `DataRoutes.ts` via `handleGetObservations` → `paginationHelper.getObservations(...)`.
|
||||||
|
- **Search:** `GET /api/search/observations` in `src/services/worker/http/routes/SearchRoutes.ts:109`.
|
||||||
|
- **Static assets:** `src/services/worker/http/routes/ViewerRoutes.ts:49` — `app.use(express.static(path.join(packageRoot, 'ui')))`. Serves built viewer assets only; no tool-artifact endpoint.
|
||||||
|
- **Port resolution:** `src/shared/worker-utils.ts:64-73` — `getWorkerPort()` reads `CLAUDE_MEM_WORKER_PORT` from settings.json; default `37700 + (uid % 100)`. User's local port happens to be `37777`.
|
||||||
|
|
||||||
|
### Viewer (React, esbuild)
|
||||||
|
- **Entry:** `src/ui/viewer/index.tsx` → `App.tsx`.
|
||||||
|
- **Feed component:** `src/ui/viewer/components/Feed.tsx` — merges `observations`, `summaries`, `prompts`, sorts by `created_at_epoch`, renders `<ObservationCard>` / `<SummaryCard>` / `<PromptCard>`.
|
||||||
|
- **API constants:** `src/ui/viewer/constants/api.ts` — central list of endpoint paths.
|
||||||
|
- **Realtime updates:** SSE `/stream` (plus paginated GET via `usePagination`).
|
||||||
|
- **Build:** `scripts/build-viewer.js` (esbuild) bundles to `plugin/ui/viewer-bundle.js`; `viewer-template.html` → `plugin/ui/viewer.html`. Invoked from `scripts/build-hooks.js`.
|
||||||
|
- **Reusable modal pattern:** `ContextSettingsModal.tsx` (backdrop + centered panel + close). No existing lightbox lib — build a small overlay component.
|
||||||
|
|
||||||
|
### Allowed APIs (verified)
|
||||||
|
- `express.static(dir)` — already imported.
|
||||||
|
- `res.sendFile(absPath, { headers, dotfiles: 'deny' })` — Express built-in, streams the file.
|
||||||
|
- `fs.promises.stat`, `fs.promises.open` (for magic-byte sniff), `path.resolve`, `path.extname`.
|
||||||
|
- React `useState` / `useEffect` / portals (already used elsewhere).
|
||||||
|
|
||||||
|
### Anti-patterns (do NOT do)
|
||||||
|
- Do **not** read raw `tool_input` JSON from the live `pending_messages` queue inside an HTTP handler. That table is a transient processing buffer.
|
||||||
|
- Do **not** decode base64 image blobs into the database. Reference by absolute filesystem path only.
|
||||||
|
- Do **not** wire CORS to allow `*` for the image endpoint — keep it scoped like the rest of the worker (same-origin for the viewer).
|
||||||
|
- Do **not** invent a `tool_use` table read path — observations are the surface area for the feed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Detect & persist image references on observation write
|
||||||
|
|
||||||
|
**Goal:** When an observation is created, record any image file paths that were touched by the underlying tool calls so the viewer can render them.
|
||||||
|
|
||||||
|
**What to implement:**
|
||||||
|
|
||||||
|
1. New nullable column on `observations`:
|
||||||
|
- Column: `image_refs TEXT` (JSON-stringified array of absolute paths). Mirrors the pattern of `files_read` / `files_modified` already present at `src/services/sqlite/schema.sql:57-92`.
|
||||||
|
- Add a migration entry alongside existing schema migrations (find the migration runner near `src/services/sqlite/schema.sql`; copy the additive pattern used for any prior column add — `ALTER TABLE observations ADD COLUMN image_refs TEXT`).
|
||||||
|
- **Anti-pattern guard:** do not drop/recreate the table; additive ALTER only.
|
||||||
|
|
||||||
|
2. Extend `ObservationInput` in `src/services/sqlite/observations/store.ts` with `image_refs?: string[]` and include it in the INSERT statement (lines 35-41). Stringify on the way in.
|
||||||
|
|
||||||
|
3. Add `image_refs: string[] | null` to `ObservationRow` in `src/services/sqlite/types.ts` so it flows out of every read API.
|
||||||
|
|
||||||
|
4. Populate `image_refs` at observation generation time. Two sources, applied in order:
|
||||||
|
- **Source A (primary):** while the AI summarization step is still holding raw `pending_messages.tool_input` / `tool_response`, scan for absolute file paths whose extension is in `IMAGE_EXTENSIONS = ['.png','.jpg','.jpeg','.gif','.webp']`. Find the file that builds `ObservationInput` from pending messages (search for the call site of `storeObservation` — likely `src/services/observations/` or `src/services/queue/`) and inject the extraction there.
|
||||||
|
- **Source B (fallback):** post-filter the already-extracted `files_read` ∪ `files_modified` for image extensions. This catches images that survived only in the summary.
|
||||||
|
- Dedupe + sort the final list before persisting.
|
||||||
|
|
||||||
|
5. Add a tiny pure helper `extractImagePaths(toolInput: unknown, toolResponse: unknown): string[]` in `src/utils/image-refs.ts`. Unit-testable, no I/O, no DB access. Handles:
|
||||||
|
- `Read` tool: `tool_input.file_path`.
|
||||||
|
- `Write` / `Edit`: `tool_input.file_path`.
|
||||||
|
- Generic `image_path`, `screenshot_path`, `output_path` keys.
|
||||||
|
- Arrays / nested objects (recurse one level).
|
||||||
|
- String tool_response containing `file://...png` or absolute paths.
|
||||||
|
- Returns only absolute paths (`path.isAbsolute`) with image extensions.
|
||||||
|
|
||||||
|
**Documentation references:**
|
||||||
|
- Copy the column-add pattern from any prior `ALTER TABLE observations ADD COLUMN ...` in `src/services/sqlite/schema.sql`.
|
||||||
|
- Mirror the `files_read` / `files_modified` lifecycle: written by the same function that builds `ObservationInput`, parsed by the viewer as `JSON.parse(row.files_read ?? '[]')`.
|
||||||
|
|
||||||
|
**Verification checklist:**
|
||||||
|
- [ ] `sqlite3 ~/.claude-mem/claude-mem.db ".schema observations"` shows `image_refs TEXT`.
|
||||||
|
- [ ] Unit test for `extractImagePaths` covering each tool shape (Read, Write, screenshot, nested arrays, non-image extensions excluded, relative paths excluded).
|
||||||
|
- [ ] Trigger a session that calls a screenshot tool; confirm `image_refs` is populated on the resulting row via `sqlite3` query.
|
||||||
|
- [ ] Existing rows still load — `image_refs` returns as `null` and is tolerated by readers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Safe image-serving endpoint on the worker
|
||||||
|
|
||||||
|
**Goal:** Add `GET /api/images?path=<absolute-encoded-path>` that streams an image file from disk if-and-only-if it passes path-safety checks.
|
||||||
|
|
||||||
|
**What to implement:**
|
||||||
|
|
||||||
|
1. New route module: `src/services/worker/http/routes/ImageRoutes.ts`. Copy the handler-wrap pattern from `DataRoutes.ts` (`this.wrapHandler((req, res) => { ... })`).
|
||||||
|
|
||||||
|
2. Register the route in the same place existing `DataRoutes` / `SearchRoutes` are registered (find the `setupCoreRoutes`/`setupRoutes` call chain off `Server.ts:98-105`). Register **before** `express.static` in `ViewerRoutes.ts:49` so it takes precedence on `/api/*`.
|
||||||
|
|
||||||
|
3. Handler contract:
|
||||||
|
- Input: `req.query.path` (URL-encoded absolute path).
|
||||||
|
- Reject (`400`) if missing, not a string, or `path.isAbsolute(decoded) === false`.
|
||||||
|
- `path.resolve(decoded)` — if the resolved string differs from the decoded input, reject (catches `..` traversal).
|
||||||
|
- Reject (`415`) if `path.extname(resolved).toLowerCase()` not in `['.png','.jpg','.jpeg','.gif','.webp']`.
|
||||||
|
- Allowlist root: must live under **one of** `process.env.HOME`, the `CLAUDE_MEM_DATA_DIR`, the OS temp dir, **or** any absolute path currently present in *any* observation's `image_refs` column. The DB-membership check is the strongest guard — only paths the system has already chosen to surface can be fetched.
|
||||||
|
- Implementation: `SELECT 1 FROM observations WHERE image_refs LIKE '%' || ? || '%' LIMIT 1` against the resolved path (parameterized; the `LIKE` is safe because the path is already absolute and we further `JSON.parse` and `.includes()` to confirm exact match).
|
||||||
|
- Magic-byte sniff: open the file, read first 12 bytes, confirm PNG / JPEG / GIF / WebP signature. Reject (`415`) on mismatch.
|
||||||
|
- Set `Content-Type` from extension. Set `Cache-Control: private, max-age=60`. Stream with `res.sendFile(resolved, { dotfiles: 'deny' })`.
|
||||||
|
- On any error: `404` with no body (don't leak existence).
|
||||||
|
|
||||||
|
4. Add `IMAGES: '/api/images'` to `src/ui/viewer/constants/api.ts`.
|
||||||
|
|
||||||
|
**Documentation references:**
|
||||||
|
- `DataRoutes.ts` for the `wrapHandler` + `req.query` parsing convention.
|
||||||
|
- `ViewerRoutes.ts:49` for static-mount ordering reference.
|
||||||
|
|
||||||
|
**Verification checklist:**
|
||||||
|
- [ ] `curl 'http://127.0.0.1:<port>/api/images?path=<encoded>'` returns 200 + correct `Content-Type` for a known image referenced in an observation.
|
||||||
|
- [ ] `curl '.../api/images?path=../../etc/passwd'` → 400.
|
||||||
|
- [ ] `curl '.../api/images?path=/etc/passwd'` → 404 (absolute, but not in DB and wrong magic bytes).
|
||||||
|
- [ ] Renaming an extension `.png` → `.txt` after the path lands in DB still rejects on magic bytes.
|
||||||
|
- [ ] Unit test the path-safety predicate in isolation.
|
||||||
|
|
||||||
|
**Anti-pattern guards:**
|
||||||
|
- Do not accept `path` as a request body — query string only, keeps it GET-cacheable.
|
||||||
|
- Do not bypass the DB-membership check, even for "obviously safe" paths.
|
||||||
|
- Do not `fs.readFile` the whole image into memory — `res.sendFile` streams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Viewer types + data plumbing
|
||||||
|
|
||||||
|
**Goal:** Get `image_refs` from the API into the React `Observation` shape and parsed into `string[]`.
|
||||||
|
|
||||||
|
**What to implement:**
|
||||||
|
|
||||||
|
1. Find the viewer-side `Observation` TypeScript type (likely `src/ui/viewer/types.ts` — confirm via grep `interface Observation`). Add `image_refs: string[]` (parsed) plus update the raw `ObservationRowFromApi` (or equivalent) with `image_refs: string | null` for the JSON-string form.
|
||||||
|
2. Find the place where API rows are normalized for the feed (search for `JSON.parse(row.files_read` or similar). Add a sibling `image_refs: row.image_refs ? JSON.parse(row.image_refs) : []` line.
|
||||||
|
3. SSE consumer: confirm the `/stream` payload reflects the new column (same row shape — no separate change needed if it reuses the same serializer).
|
||||||
|
|
||||||
|
**Verification checklist:**
|
||||||
|
- [ ] React DevTools / a temporary `console.log` shows `image_refs: [...]` on observation objects that have images.
|
||||||
|
- [ ] `tsc --noEmit` passes (no type drift).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Render thumbnails + lightbox in `ObservationCard`
|
||||||
|
|
||||||
|
**Goal:** Show a horizontal strip of small thumbnails inside each observation card; clicking opens a lightbox overlay.
|
||||||
|
|
||||||
|
**What to implement:**
|
||||||
|
|
||||||
|
1. New component `src/ui/viewer/components/ImageStrip.tsx`:
|
||||||
|
- Props: `paths: string[]`, `onOpen: (path: string) => void`.
|
||||||
|
- Renders nothing if `paths.length === 0`.
|
||||||
|
- Each thumbnail: `<img src={`/api/images?path=${encodeURIComponent(p)}`} loading="lazy" />`, sized ~80px height, rounded corners, `cursor: pointer`.
|
||||||
|
- On `<img onError>`, swap to a placeholder div (`title="missing image"`) — handles deleted files gracefully.
|
||||||
|
|
||||||
|
2. New component `src/ui/viewer/components/Lightbox.tsx`:
|
||||||
|
- Copy the backdrop/portal pattern from `ContextSettingsModal.tsx`.
|
||||||
|
- Props: `path: string | null`, `onClose: () => void`.
|
||||||
|
- Renders nothing when `path === null`.
|
||||||
|
- Centered `<img>` with `max-width: 90vw; max-height: 90vh`.
|
||||||
|
- Close on backdrop click, on `Escape` key, and on close button.
|
||||||
|
- **Do not** add zoom/pan in this phase — keep it simple. Future enhancement.
|
||||||
|
|
||||||
|
3. Wire into `ObservationCard.tsx`:
|
||||||
|
- Add `const [lightboxPath, setLightboxPath] = useState<string | null>(null);`.
|
||||||
|
- Render `<ImageStrip paths={observation.image_refs} onOpen={setLightboxPath} />` after the existing card body content.
|
||||||
|
- Render `<Lightbox path={lightboxPath} onClose={() => setLightboxPath(null)} />`.
|
||||||
|
|
||||||
|
4. Add minimal CSS in the viewer's existing stylesheet (find it via grep `.observation-card`):
|
||||||
|
```css
|
||||||
|
.image-strip { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||||
|
.image-strip img { height: 80px; width: auto; border-radius: 6px; object-fit: cover; cursor: zoom-in; }
|
||||||
|
.lightbox-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
|
.lightbox-backdrop img { max-width: 90vw; max-height: 90vh; }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Rebuild: `npm run build-and-sync` regenerates `plugin/ui/viewer-bundle.js` and `plugin/ui/viewer.html`.
|
||||||
|
|
||||||
|
**Documentation references:**
|
||||||
|
- `src/ui/viewer/components/ContextSettingsModal.tsx` for backdrop + close pattern.
|
||||||
|
- `src/ui/viewer/components/Feed.tsx` for how `ObservationCard` is invoked.
|
||||||
|
|
||||||
|
**Verification checklist:**
|
||||||
|
- [ ] Open `http://127.0.0.1:<port>/`. Observations with `image_refs` show a thumbnail strip.
|
||||||
|
- [ ] Clicking a thumbnail opens the lightbox; Escape and backdrop click both close it.
|
||||||
|
- [ ] Observations without images render unchanged (no empty container, no layout shift).
|
||||||
|
- [ ] Deleted-on-disk image shows the placeholder, doesn't crash.
|
||||||
|
|
||||||
|
**Anti-pattern guards:**
|
||||||
|
- Do not fetch image bytes via JS and convert to blob URLs — let the `<img src>` do it.
|
||||||
|
- Do not block the feed render on image load — `loading="lazy"`.
|
||||||
|
- Do not store image data in component state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: End-to-end verification
|
||||||
|
|
||||||
|
1. **Fresh session with screenshot tool**
|
||||||
|
- Start a clean session, trigger a tool that produces a PNG (e.g., `gstack` screenshot).
|
||||||
|
- Wait for observation to be generated.
|
||||||
|
- Hit `GET /api/observations?limit=1` — confirm `image_refs` is a JSON array containing the screenshot's absolute path.
|
||||||
|
2. **Endpoint security**
|
||||||
|
- Run the four `curl` cases listed in Phase 2.
|
||||||
|
3. **Viewer**
|
||||||
|
- Confirm thumbnails appear, lightbox works, missing files fall back to placeholder.
|
||||||
|
4. **Regression sweep**
|
||||||
|
- Run any existing viewer tests (search for `viewer` in test directories — Vitest or Playwright).
|
||||||
|
- Confirm older observations (no `image_refs`) still render and pass type checks.
|
||||||
|
5. **Build & sync**
|
||||||
|
- `npm run build-and-sync` succeeds without warnings.
|
||||||
|
- Worker restarts cleanly with the new route registered (check startup log for `/api/images`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (future inbox items)
|
||||||
|
|
||||||
|
- Video / GIF playback controls beyond `<img>` autoplay-on-GIF.
|
||||||
|
- Zoom-and-pan in the lightbox.
|
||||||
|
- Side-by-side image diffs.
|
||||||
|
- Thumbnail caching layer (the worker re-streams each request; cheap enough at single-user scale).
|
||||||
|
- Pro Memory Stream UI integration — that UI hits the same `/api/images` endpoint, no extra core work.
|
||||||
|
- Backfilling `image_refs` on historical observations.
|
||||||
BIN
plans/inbox/2026-05-24-prompt-clarity-scorer-slides.pdf
Normal file
BIN
plans/inbox/2026-05-24-prompt-clarity-scorer-slides.pdf
Normal file
Binary file not shown.
145
plans/inbox/2026-05-24-prompt-clarity-scorer.md
Normal file
145
plans/inbox/2026-05-24-prompt-clarity-scorer.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Prompt Clarity Scorer
|
||||||
|
|
||||||
|
A pre-flight gate for prompts. Before Claude ever sees what you typed, a lightweight scorer evaluates the prompt across clarity dimensions, assigns a score, and — if the score is low — surfaces targeted follow-up questions that get answered *before* the main turn runs.
|
||||||
|
|
||||||
|
The goal is not to nag. The goal is to catch the failure modes that waste the most tokens: ambiguous referents ("fix it"), missing scope ("make it better"), unstated constraints ("add a button" without where, what color, what behavior), and unclear success criteria ("when it's done").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this matters
|
||||||
|
|
||||||
|
Most agent failures are not capability failures. They are **specification failures**. The agent did exactly what was asked. What was asked was not what was wanted.
|
||||||
|
|
||||||
|
The cost compounds:
|
||||||
|
|
||||||
|
- The agent guesses. It guesses wrong ~30% of the time on ambiguous prompts.
|
||||||
|
- The user reviews the output. Wrong direction.
|
||||||
|
- The user re-prompts. "No, I meant..."
|
||||||
|
- The agent re-does the work. Tokens, time, context window burned.
|
||||||
|
- Sometimes the user gives up and does it themselves.
|
||||||
|
|
||||||
|
A 200ms clarity check that catches even half of these saves enormous amounts of downstream work. The economics are obvious: cheap scoring upstream beats expensive regeneration downstream.
|
||||||
|
|
||||||
|
This is the same principle behind compile-time type checks vs. runtime errors. Catch the bug where it's cheap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The dimensions
|
||||||
|
|
||||||
|
A clarity score is not one number. It's a vector across distinct failure modes. Five candidate dimensions:
|
||||||
|
|
||||||
|
**1. Referential clarity (0-10)**
|
||||||
|
Does every pronoun and demonstrative ("it", "this", "that", "the one") have an unambiguous referent? "Fix it" with no prior turn scores 0. "Fix the SearchManager constructor at line 47" scores 10.
|
||||||
|
|
||||||
|
**2. Scope definition (0-10)**
|
||||||
|
Is the boundary of the task clear? "Refactor the codebase" scores 2. "Refactor src/services/sqlite/ to use the new migration pattern from src/services/migrations/" scores 9.
|
||||||
|
|
||||||
|
**3. Success criteria (0-10)**
|
||||||
|
How will we know it's done? "Make it faster" scores 1. "Reduce p95 query latency below 50ms, measured by the existing benchmark suite" scores 10.
|
||||||
|
|
||||||
|
**4. Constraint surfacing (0-10)**
|
||||||
|
Are the things-it-must-not-do stated? "Add a logging endpoint" with no mention of auth, rate limits, or PII handling scores 4. The same prompt with "no PII in logs, gated behind the existing admin middleware" scores 9.
|
||||||
|
|
||||||
|
**5. Context completeness (0-10)**
|
||||||
|
Does the agent have what it needs to start? "Use the same pattern as before" without saying which pattern scores 3. With a file path or commit reference, scores 9.
|
||||||
|
|
||||||
|
Composite score is a weighted sum. Weights tune based on observed failure modes — initially equal, learned over time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
Three components:
|
||||||
|
|
||||||
|
**The Scorer**
|
||||||
|
A small, fast model (Haiku 4.5, or a fine-tuned local model) runs as a UserPromptSubmit hook. Input: the prompt + recent conversation context. Output: a JSON blob with per-dimension scores, total score, and a list of specific clarity gaps.
|
||||||
|
|
||||||
|
Budget: 200-400ms, <1000 tokens per scoring pass. This is the critical constraint. If it's slow, users will disable it.
|
||||||
|
|
||||||
|
**The Gate**
|
||||||
|
A threshold function. If score > 7, pass through silently. If score is 5-7, inject a "soft" clarifying preface ("Note: I'm interpreting X as Y — say if that's wrong") and proceed. If score < 5, *block* the prompt and surface follow-up questions via AskUserQuestion before running the main turn.
|
||||||
|
|
||||||
|
The gate is configurable. Power users want fewer interruptions; new users want more guardrails. Per-user, per-project, even per-skill thresholds.
|
||||||
|
|
||||||
|
**The Learner**
|
||||||
|
Every scored prompt becomes training data. We track: did the user accept the agent's first output? Did they re-prompt? Did the conversation contain an "actually I meant..." correction? These signals retroactively label whether the score was right.
|
||||||
|
|
||||||
|
Over time, the scorer learns the user's idiolect. "Fix the thing" from a user who has consistently meant "fix the most recent failing test" is no longer ambiguous *to this user*. The scorer becomes personalized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The follow-up question design
|
||||||
|
|
||||||
|
This is where most "ask clarifying questions" systems fail. They ask too many, or too generic, or at the wrong moment.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **Maximum two follow-ups per prompt.** More than that, the user feels interrogated and disables the tool.
|
||||||
|
|
||||||
|
2. **Questions must be specific and offer concrete options.** Not "what do you mean by fix?" but "Which behavior should change? (a) the 500 error on POST, (b) the slow query on GET, (c) something else."
|
||||||
|
|
||||||
|
3. **Defaults must be sensible.** Every follow-up offers a recommended option marked clearly. Hitting enter does the most-likely thing.
|
||||||
|
|
||||||
|
4. **Never ask if confidence is high enough.** A score of 7 with one ambiguity is *not* a follow-up moment — it's an inline clarification: "Interpreting X as Y, proceeding."
|
||||||
|
|
||||||
|
5. **The follow-up is part of the prompt, not separate.** When the user answers, the *combined* prompt + answer goes to the agent as a single, fully-specified turn. The agent doesn't see the score or the original ambiguous version. It sees a clean, scored-10 prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Failure modes of this idea
|
||||||
|
|
||||||
|
Honest reckoning. The ways this could go wrong:
|
||||||
|
|
||||||
|
**Latency tax.** Every prompt now waits for the scorer. Even 200ms is noticeable. Mitigation: run scoring in parallel with the main turn for high-confidence prompts; only block if the scorer returns a low score before the main turn has produced useful work.
|
||||||
|
|
||||||
|
**Annoying user experience.** "Stop asking me questions, just do the thing." Mitigation: aggressive personalization, easy disable, and the per-dimension threshold tuning. Users who want zero friction should be able to get it.
|
||||||
|
|
||||||
|
**Wrong follow-ups.** The scorer asks about the wrong ambiguity, missing the real one. Mitigation: this is what the Learner exists for. Wrong follow-ups become training signal. Also: never ask if confidence is moderate — just proceed with an inline note.
|
||||||
|
|
||||||
|
**Gaming the metric.** Users start writing pseudo-formal prompts to satisfy the scorer, not because formality helps. Mitigation: the scorer is *advisory*, never required. The metric should reflect downstream success, not surface formality.
|
||||||
|
|
||||||
|
**The scorer itself is wrong.** A small model misjudging clarity. Mitigation: the score is a probability distribution, not a verdict. The threshold is conservative. False positives (asking when not needed) cost a question; false negatives (not asking when needed) cost a regeneration. Tune for the cheaper failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Why this fits claude-mem
|
||||||
|
|
||||||
|
Claude-mem already runs UserPromptSubmit hooks. It already has cross-session context about how the user phrases things. It already has the worker service, the SQLite store, and the observation pipeline.
|
||||||
|
|
||||||
|
The Learner can ride on top of the existing observation infrastructure. Every prompt's score becomes an observation. Every "actually I meant" correction becomes a labeled signal. The cross-session memory means the personalization is *real* — not per-conversation, but per-user, accumulating over months.
|
||||||
|
|
||||||
|
The viewer UI can show clarity trends. "Your prompts scored 6.2 average this week, up from 5.8 last week." Or "Your most common clarity gap is missing scope — try specifying paths." This is dogfoodable. It's the kind of thing that makes the tool stick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions
|
||||||
|
|
||||||
|
- Should the scorer run server-side (in the worker) or inline in the hook?
|
||||||
|
- Is there a "no-score" escape hatch — a prefix like `!` that bypasses the gate entirely?
|
||||||
|
- How do we handle multi-turn prompts where clarity is supposed to be partial? Conversations build up context; an early-turn prompt that scores low might be perfectly fine.
|
||||||
|
- Do we expose the score to the agent itself? Could be useful ("the user is being vague — ask before acting"), could be harmful (the agent argues with the score).
|
||||||
|
- What's the right onboarding? Most users won't know this exists. Surface it the first time it catches something useful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Minimum viable test
|
||||||
|
|
||||||
|
Two weeks of work:
|
||||||
|
|
||||||
|
1. Add a UserPromptSubmit hook that calls Haiku with a fixed prompt template and gets back a JSON score.
|
||||||
|
2. Log every score. Don't gate anything yet.
|
||||||
|
3. After a week, look at the data. Did low scores predict re-prompts? Did high scores predict first-try success?
|
||||||
|
4. If signal exists, add the gate and one follow-up question for the lowest-scoring 10% of prompts.
|
||||||
|
5. Measure: does this reduce total turns-per-task?
|
||||||
|
|
||||||
|
That's the test. Build the minimum that produces a signal. Decide based on the signal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. The bigger frame
|
||||||
|
|
||||||
|
This is one instance of a general pattern: **agents should grade their inputs, not just produce outputs.** Today's agents accept any prompt and try their best. Tomorrow's agents will recognize when a prompt is under-specified and route accordingly — sometimes by asking, sometimes by inferring, sometimes by proceeding with an explicit acknowledgment of the gap.
|
||||||
|
|
||||||
|
Clarity scoring is the first step. The same machinery — score, gate, learn — applies to plans before execution, to code before commits, to deployments before release. Pre-flight checks at every boundary where cheap evaluation beats expensive regeneration.
|
||||||
|
|
||||||
|
The unlock is not "ask more questions." The unlock is **knowing when to ask**.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman"
|
"name": "Alex Newman"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alex Newman",
|
"name": "Alex Newman",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem-plugin",
|
"name": "claude-mem-plugin",
|
||||||
"version": "13.4.0",
|
"version": "13.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
157
plugin/skills/standup/SKILL.md
Normal file
157
plugin/skills/standup/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: standup
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Convene the recently-active git worktrees as named agents in a markdown group
|
||||||
|
chat, have them reconcile what each changed, and converge on ONE consolidated
|
||||||
|
worktree. Each branch is an agent that posts to a shared STANDUP.md (YAML front
|
||||||
|
matter holds the GOAL + PROMPT; the body is the chat log), then the room agrees
|
||||||
|
on a merge order and a single consolidation plan in the SUMMATION. The room is
|
||||||
|
filled either by time window (past 1h / 4h / 24h / 7d / all — only worktrees
|
||||||
|
active in it join, so a 40-worktree machine collapses to the handful you
|
||||||
|
actually touched) or by hand-picking specific worktrees and/or open GitHub PRs
|
||||||
|
from a checkbox-style list. Use this skill whenever the user says "standup",
|
||||||
|
"run the standup", "reconcile my worktrees", "consolidate worktrees", "merge
|
||||||
|
these branches together", "merge these PRs", "combine these worktrees and pull
|
||||||
|
requests", "what's everyone at", "have my worktrees check in", or wants several
|
||||||
|
branch-named agents to coordinate / resolve overlapping work into one
|
||||||
|
deliverable through a shared chat file — even if they don't say the word
|
||||||
|
"standup".
|
||||||
|
allowed-tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Edit
|
||||||
|
- Task
|
||||||
|
- AskUserQuestion
|
||||||
|
---
|
||||||
|
|
||||||
|
# standup — facilitate a group chat between branch-agents
|
||||||
|
|
||||||
|
You're the **facilitator**. Each of the user's git worktrees (and any PRs they
|
||||||
|
pick) joins a shared markdown chat as its own agent, and the agents reconcile
|
||||||
|
their scattered work into ONE consolidated worktree. You convene the room, run
|
||||||
|
the conversation in rounds, and carry the outcome back — the reconciling happens
|
||||||
|
in the chat, between the agents.
|
||||||
|
|
||||||
|
The room is one shared file (default `~/.claude-mem/STANDUP.md`): YAML front
|
||||||
|
matter holds the `goal` + `prompt`; the body is the transcript. Writes are
|
||||||
|
atomically locked, so agents speak at once. It is **read-only** — agents decide
|
||||||
|
how the merge *should* go; nobody commits or merges inside the room. Real git
|
||||||
|
work happens afterward via `/do`.
|
||||||
|
|
||||||
|
## 1. Fill the room
|
||||||
|
|
||||||
|
Two ways, mixable:
|
||||||
|
|
||||||
|
- **By recency** (common) — worktrees active in a window:
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" worktrees --since <1h|4h|24h|7d|all> --json
|
||||||
|
```
|
||||||
|
Active = a commit *or* an uncommitted/staged/untracked edit in the window. If
|
||||||
|
the user didn't name a window, offer 1h / 4h / 24h / 7d / all.
|
||||||
|
|
||||||
|
- **By hand** — specific branches and/or open PRs:
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" worktrees --json # local branches
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" prs --json # open PRs (via gh)
|
||||||
|
```
|
||||||
|
Show one numbered list (worktrees + PRs, with age/title); their reply is the
|
||||||
|
"checkbox." If `prs` errors (no `gh` / not GitHub), carry on worktrees-only.
|
||||||
|
|
||||||
|
Zero or one candidate isn't a standup — say so, offer to widen, stop. Otherwise
|
||||||
|
echo the roster to confirm before you start.
|
||||||
|
|
||||||
|
## 2. Open the room
|
||||||
|
|
||||||
|
Set a goal + prompt that invite a conversation, not one-shot status reports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" open --force --agent facilitator \
|
||||||
|
--goal "Collapse these branches/PRs into ONE consolidated worktree: what each changed, where they overlap, which becomes the target, and the merge order." \
|
||||||
|
--prompt "Facilitated rounds. Round 1: introduce your branch and its state. Then resolve the conflicts the facilitator surfaces, round by round, until the room lands on one concrete plan (target worktree + merge order + conflict resolutions). Read-only: decide, don't merge. Register AGREE when you back the plan."
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Run it as rounds
|
||||||
|
|
||||||
|
You drive the turns — if agents watch-loop on their own the room can stall with
|
||||||
|
nothing decided. Each agent speaks once per round (read → post → return); you
|
||||||
|
read between rounds and bring back whoever's still needed.
|
||||||
|
|
||||||
|
Spawned agents don't inherit `CLAUDE_SKILL_DIR`, so resolve it once and paste the
|
||||||
|
real path into each brief:
|
||||||
|
```bash
|
||||||
|
echo "${CLAUDE_SKILL_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Round 1 — intros (everyone, one Task message so they run together).** Brief
|
||||||
|
each:
|
||||||
|
|
||||||
|
> You're **`<branch>`** (a PR is **`pr-<number>`**) in a standup group chat. Read
|
||||||
|
> `<skill-dir>/agent-brief.md` and play your part by it. The room is
|
||||||
|
> `~/.claude-mem/STANDUP.md`; speak with `node "<skill-dir>/standup.mjs" post …`,
|
||||||
|
> catch up with `… read`. Get your bearings (`cd "<path>"`,
|
||||||
|
> `git log --oneline origin/main..HEAD`, `git status --short`,
|
||||||
|
> `git diff --stat origin/main...HEAD`; a PR uses `gh pr view/diff <number>`),
|
||||||
|
> then post ONE turn: your branch, its real state, and how it should fold in.
|
||||||
|
> Read-only. Then return.
|
||||||
|
|
||||||
|
**Reconcile.** Once they've returned, `read` the room and list the **open
|
||||||
|
items** — overlaps, conflicts, competing implementations, undecided
|
||||||
|
target/order. None? Skip to the close.
|
||||||
|
|
||||||
|
**Resolution rounds (cap ~4).** Per open item, re-spawn only the agents it
|
||||||
|
implicates, with the specific question. Tell them to `read --since <their-name>`
|
||||||
|
first, then post their position and `--agree` if convinced. `read` again, update
|
||||||
|
the list. Repeat.
|
||||||
|
|
||||||
|
**Close — you always write it.** Stop when the list is empty, you hit the cap, or
|
||||||
|
an agent errors (note "didn't report," don't block). Then write the SUMMATION
|
||||||
|
yourself — don't wait for an agent to volunteer. Write it as plain prose a human
|
||||||
|
can skim, not a field dump: which worktree is the target and why, the merge order
|
||||||
|
in a sentence, and what's left for the human:
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" summation --agent facilitator \
|
||||||
|
--text "Build on <worktree> — it's the only one with real code. Layer <branch>'s changes on top, then drop in the doc-only branches; skip <empty branch>. Your call before it's safe: <the one or two real decisions>. Done when it all sits in <target> and builds clean."
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Brief the human in plain language
|
||||||
|
|
||||||
|
This is the payoff — don't hand them the raw SUMMATION, **translate it.** A human
|
||||||
|
who didn't watch the room should understand the outcome without decoding paths,
|
||||||
|
line counts, or commit hashes. Lead with the answer, then the few choices only
|
||||||
|
they can make:
|
||||||
|
|
||||||
|
- **What you found** — one plain line per branch: who has real code, who's just
|
||||||
|
docs, who's empty.
|
||||||
|
- **The plan** — target + merge order in a sentence or two.
|
||||||
|
- **Their call** — only the decisions a human must make (which implementation
|
||||||
|
wins, what to drop, anything risky), as concrete questions. Use
|
||||||
|
`AskUserQuestion` for the clear-cut ones.
|
||||||
|
|
||||||
|
Keep git internals out unless they ask. Once they've settled the open calls, hand
|
||||||
|
the plan to **`/do`** to perform the merges — don't merge anything yourself
|
||||||
|
outside `/do`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node "${CLAUDE_SKILL_DIR}/standup.mjs" <command> [--flags]
|
||||||
|
```
|
||||||
|
Defaults: agent = git branch, file = `~/.claude-mem/STANDUP.md`. Every write is
|
||||||
|
atomically locked.
|
||||||
|
|
||||||
|
| command | what it does |
|
||||||
|
|---|---|
|
||||||
|
| `worktrees [--since 4h] [--json]` | worktrees newest-first; `--since N{m,h,d,w}` keeps those active in the window |
|
||||||
|
| `prs [--since 4h] [--json]` | open GitHub PRs (via `gh`) newest-first |
|
||||||
|
| `open --goal "…" --prompt "…" [--force]` | create the room (`--force` rotates an old one aside) |
|
||||||
|
| `join [--message "…"]` | add yourself + say Hello |
|
||||||
|
| `post --message "…" [--agree "…"]` | append a turn |
|
||||||
|
| `agree --deliverable "…"` | append an AGREE turn |
|
||||||
|
| `watch [--timeout SEC] [--interval SEC]` | block until someone else posts, print it (exit 2 on timeout) |
|
||||||
|
| `read [--tail N] [--since AGENT]` | print the chat (or only turns after AGENT's last) |
|
||||||
|
| `status` | participants + AGREEs + consensus check |
|
||||||
|
| `summation --text "…"` | write the SUMMATION, flip `status: agreed` |
|
||||||
|
|
||||||
|
Each spawned agent plays its turns by **`agent-brief.md`** (bundled here) — the
|
||||||
|
playbook for being one voice in the room.
|
||||||
47
plugin/skills/standup/agent-brief.md
Normal file
47
plugin/skills/standup/agent-brief.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# You're in a standup — a group chat with the other branches
|
||||||
|
|
||||||
|
You're one voice in a room of coding agents, each embodying a git branch or PR,
|
||||||
|
all sharing **one markdown file** as the chat. This is a conversation, not a form
|
||||||
|
to fill in: state your case, react, push back, change your mind — together the
|
||||||
|
room lands on one plan.
|
||||||
|
|
||||||
|
The point is in the file's front matter — a `goal` and a `prompt`. Read them
|
||||||
|
first; trust them over this page. Usually: collapse everyone's work into one
|
||||||
|
consolidated worktree.
|
||||||
|
|
||||||
|
A **facilitator** runs the rounds and decides when it's done. So **you don't loop
|
||||||
|
or wait** — you're brought in, you take your turn, you return (you'll likely be
|
||||||
|
called back). Scheduling the next speaker and closing the room are the
|
||||||
|
facilitator's job, not yours.
|
||||||
|
|
||||||
|
## Your turn
|
||||||
|
|
||||||
|
A tiny CLI to speak and listen (the facilitator gives you the path to
|
||||||
|
`standup.mjs`):
|
||||||
|
|
||||||
|
- `read` — the whole room; `read --since <you>` — just what's new. Always catch
|
||||||
|
up before you speak.
|
||||||
|
- `post --message "…"` — say something; add `--agree "<deliverable>"` to back a
|
||||||
|
decision.
|
||||||
|
- `status` — who's agreed so far.
|
||||||
|
|
||||||
|
Each time you're brought in:
|
||||||
|
|
||||||
|
1. **Catch up** — `read --since <you>` (or `read` on your first turn).
|
||||||
|
2. **Say one substantive thing** — `post` one turn. First turn: introduce your
|
||||||
|
branch and its honest state (changed what, committed or not, merged or not,
|
||||||
|
where it overlaps). Later: engage the facilitator's question, address people
|
||||||
|
by `@branch`, agree or disagree *with reasons*, propose or concede. Move the
|
||||||
|
room toward one plan — don't restate status.
|
||||||
|
3. **Take a position** — back the plan with `AGREE: <deliverable>`, quoting it
|
||||||
|
precisely (consensus = the *same* words). Not convinced? Say what would
|
||||||
|
convince you — that's the next round's open item.
|
||||||
|
4. **Return** — then stop. Don't watch, loop, or write the summation; the
|
||||||
|
facilitator does that.
|
||||||
|
|
||||||
|
## Stay in your lane
|
||||||
|
|
||||||
|
Only ever speak as yourself — never post as another branch. **Read-only**:
|
||||||
|
introspect, discuss, decide — do not commit, merge, push, or deploy. Execution
|
||||||
|
happens later via `/do`, under the human's eye. A sharp, honest turn beats a long
|
||||||
|
one.
|
||||||
662
plugin/skills/standup/standup.mjs
Normal file
662
plugin/skills/standup/standup.mjs
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// standup — a markdown-based group chat for multiple AI coding agents.
|
||||||
|
//
|
||||||
|
// Each agent embodies its git branch name and talks to the others by appending
|
||||||
|
// turns to a single shared markdown file (default ~/.claude-mem/STANDUP.md).
|
||||||
|
// The file has YAML front matter holding the shared GOAL and PROMPT the group
|
||||||
|
// must converge on; the body is the chat log. Agents `watch` the file to listen,
|
||||||
|
// `post` to speak, `agree` to register consensus, and `summation` to close it.
|
||||||
|
//
|
||||||
|
// Zero deps. Node 20+ (top-level await, fs/promises). No network.
|
||||||
|
//
|
||||||
|
// Concurrency: every write takes an atomic lock (mkdir <file>.lock) so two
|
||||||
|
// agents posting at the same instant can't clobber each other — the exact
|
||||||
|
// failure mode that silently reverts work when multiple agents share a target.
|
||||||
|
//
|
||||||
|
// Config / resolution order:
|
||||||
|
// --file <path> | STANDUP_FILE | ~/.claude-mem/STANDUP.md
|
||||||
|
// --agent <name> | STANDUP_AGENT | current git branch | "agent"
|
||||||
|
//
|
||||||
|
// Commands:
|
||||||
|
// worktrees [--since 4h] [--json] list worktrees, newest
|
||||||
|
// first; --since N{m,h,d,w}
|
||||||
|
// keeps only those with a
|
||||||
|
// commit or uncommitted edit
|
||||||
|
// in the window ("all"=off)
|
||||||
|
// prs [--since 4h] [--json] list open GitHub PRs via
|
||||||
|
// gh, newest first; --since
|
||||||
|
// filters by last update
|
||||||
|
// open --goal "..." --prompt "..." [--agent N] create the channel
|
||||||
|
// join [--agent N] [--message "..."] add self + say hello
|
||||||
|
// post --message "..." [--agree "..."] [--agent N] append a turn
|
||||||
|
// agree --deliverable "..." [--agent N] append an AGREE turn
|
||||||
|
// watch [--agent N] [--timeout SEC] [--interval SEC] block until someone
|
||||||
|
// ELSE posts; prints their turn
|
||||||
|
// read [--tail N] [--since AGENT] print the chat
|
||||||
|
// status participants + consensus
|
||||||
|
// summation --text "..." [--agent N] close the room (status: agreed)
|
||||||
|
//
|
||||||
|
// Exit codes: 0 ok / change seen, 2 watch timeout, 1 usage or error.
|
||||||
|
|
||||||
|
import { readFile, writeFile, mkdir, rmdir, rename, stat } from "node:fs/promises";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------- args
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const cmd = argv[0];
|
||||||
|
const opts = {};
|
||||||
|
for (let i = 1; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) opts[key] = true;
|
||||||
|
else {
|
||||||
|
opts[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cmd, opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cmd, opts } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
function defaultFile() {
|
||||||
|
return (
|
||||||
|
opts.file ||
|
||||||
|
process.env.STANDUP_FILE ||
|
||||||
|
join(homedir(), ".claude-mem", "STANDUP.md")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitBranch() {
|
||||||
|
try {
|
||||||
|
return execSync("git rev-parse --abbrev-ref HEAD", {
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentName() {
|
||||||
|
const n = opts.agent || process.env.STANDUP_AGENT || gitBranch() || "agent";
|
||||||
|
return String(n).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE = defaultFile();
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- helpers
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(p) {
|
||||||
|
try {
|
||||||
|
await stat(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read() {
|
||||||
|
return (await readFile(FILE, "utf8")).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic lock via mkdir (fails if the dir already exists). Retries with a
|
||||||
|
// short backoff so simultaneous agents serialize instead of clobbering.
|
||||||
|
async function withLock(fn) {
|
||||||
|
const lock = FILE + ".lock";
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await mkdir(lock);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
// Stale lock? Take it rather than deadlock forever.
|
||||||
|
try {
|
||||||
|
await rmdir(lock);
|
||||||
|
} catch {}
|
||||||
|
await mkdir(lock).catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
await rmdir(lock).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a standup doc into { yaml (raw text), body }.
|
||||||
|
function splitDoc(text) {
|
||||||
|
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!m) return { yaml: "", body: text };
|
||||||
|
return { yaml: m[1], body: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal front-matter readers (zero-dep; we only need a few fields).
|
||||||
|
function yamlScalar(yaml, key) {
|
||||||
|
const re = new RegExp(`^${key}:\\s*(.*)$`, "m");
|
||||||
|
const m = yaml.match(re);
|
||||||
|
if (!m) return null;
|
||||||
|
const inline = m[1].trim();
|
||||||
|
// Block scalar (>- , >, | , |-): value is the indented lines that follow.
|
||||||
|
if (/^[|>][+-]?$/.test(inline)) {
|
||||||
|
const after = yaml.slice(m.index + m[0].length).split("\n").slice(1);
|
||||||
|
const lines = [];
|
||||||
|
for (const l of after) {
|
||||||
|
if (/^\s+\S/.test(l) || l.trim() === "") lines.push(l.trim());
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return lines.join(" ").trim();
|
||||||
|
}
|
||||||
|
return inline.replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function yamlList(yaml, key) {
|
||||||
|
// Matches: key:\n - a\n - b (until a non-indented line)
|
||||||
|
const re = new RegExp(`^${key}:\\s*\\n((?:\\s*-\\s*.+\\n?)*)`, "m");
|
||||||
|
const m = yaml.match(re);
|
||||||
|
if (!m) return [];
|
||||||
|
return m[1]
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.replace(/^\s*-\s*/, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse chat turns: each starts with "### <agent> — <iso>".
|
||||||
|
function parseTurns(body) {
|
||||||
|
const turns = [];
|
||||||
|
const re = /^###\s+(.+?)\s+—\s+(\S+)\s*$/gm;
|
||||||
|
let m;
|
||||||
|
const heads = [];
|
||||||
|
while ((m = re.exec(body))) {
|
||||||
|
heads.push({ agent: m[1].trim(), ts: m[2].trim(), idx: m.index, end: re.lastIndex });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < heads.length; i++) {
|
||||||
|
const start = heads[i].end;
|
||||||
|
const stop = i + 1 < heads.length ? heads[i + 1].idx : body.length;
|
||||||
|
turns.push({
|
||||||
|
agent: heads[i].agent,
|
||||||
|
ts: heads[i].ts,
|
||||||
|
text: body.slice(start, stop).trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastTurn(body) {
|
||||||
|
const t = parseTurns(body);
|
||||||
|
return t.length ? t[t.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a turn under "## Chat", taking the lock. Adds the author to
|
||||||
|
// participants if missing. Optionally appends an AGREE line.
|
||||||
|
async function appendTurn({ agent, message, agree }) {
|
||||||
|
await withLock(async () => {
|
||||||
|
let text = await read();
|
||||||
|
const { yaml, body } = splitDoc(text);
|
||||||
|
|
||||||
|
// ensure participant listed
|
||||||
|
let newYaml = yaml;
|
||||||
|
const participants = yamlList(yaml, "participants");
|
||||||
|
if (!participants.includes(agent)) {
|
||||||
|
newYaml = yaml.replace(
|
||||||
|
/^participants:\s*\n((?:\s*-\s*.+\n?)*)/m,
|
||||||
|
(full) => full.replace(/\n?$/, `\n - ${agent}\n`),
|
||||||
|
);
|
||||||
|
if (newYaml === yaml) {
|
||||||
|
// no participants block — append one
|
||||||
|
newYaml = yaml.trimEnd() + `\nparticipants:\n - ${agent}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = `\n### ${agent} — ${nowIso()}\n\n${message.trim()}\n`;
|
||||||
|
if (agree) block += `\nAGREE: ${agree.trim()}\n`;
|
||||||
|
|
||||||
|
// ensure a "## Chat" section exists
|
||||||
|
let newBody = body;
|
||||||
|
if (!/^##\s+Chat\s*$/m.test(newBody)) newBody += `\n## Chat\n`;
|
||||||
|
newBody = newBody.replace(/\s*$/, "\n") + block;
|
||||||
|
|
||||||
|
text = `---\n${newYaml.replace(/\n?$/, "\n")}---\n${newBody}`;
|
||||||
|
await writeFile(FILE, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- commands
|
||||||
|
// Parse a time window like "1h", "4h", "24h", "7d", "30m", "2w" into
|
||||||
|
// milliseconds. "all" / "any" / "none" (or nothing) → null, meaning no filter.
|
||||||
|
// Anything unrecognized → null with a warning, so a typo widens rather than
|
||||||
|
// silently hiding worktrees.
|
||||||
|
function parseWindowMs(s) {
|
||||||
|
if (!s || s === true) return null;
|
||||||
|
const v = String(s).trim().toLowerCase();
|
||||||
|
if (v === "all" || v === "any" || v === "none" || v === "*") return null;
|
||||||
|
const m = v.match(/^(\d+)\s*([mhdw])$/);
|
||||||
|
if (!m) {
|
||||||
|
console.error(`standup: unrecognized window "${s}" — showing all worktrees`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unit = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 }[m[2]];
|
||||||
|
return Number(m[1]) * unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-friendly "time since" for a unix-ms timestamp.
|
||||||
|
function humanAge(ms) {
|
||||||
|
if (!ms) return "unknown";
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.round(m / 60);
|
||||||
|
if (h < 48) return `${h}h ago`;
|
||||||
|
return `${Math.round(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most recent moment a worktree saw work: the newest of its last commit
|
||||||
|
// time and any uncommitted change (staged, modified, or untracked). This is
|
||||||
|
// what "active in the last N hours" keys off — a branch with live unpushed
|
||||||
|
// edits counts as active even if its last commit is old. Returns unix ms, or 0
|
||||||
|
// when nothing can be determined.
|
||||||
|
function worktreeActivityMs(path) {
|
||||||
|
let last = 0;
|
||||||
|
try {
|
||||||
|
const sec = execSync("git log -1 --format=%ct", {
|
||||||
|
cwd: path,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (sec) last = Math.max(last, Number(sec) * 1000);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const out = execSync("git status --porcelain", {
|
||||||
|
cwd: path,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).toString();
|
||||||
|
for (const line of out.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
// porcelain rows are "XY <path>" or, for renames, "XY old -> new".
|
||||||
|
let p = line.slice(3).trim();
|
||||||
|
const arrow = p.indexOf(" -> ");
|
||||||
|
if (arrow >= 0) p = p.slice(arrow + 4);
|
||||||
|
p = p.replace(/^"|"$/g, "");
|
||||||
|
try {
|
||||||
|
const mt = statSync(join(path, p)).mtimeMs;
|
||||||
|
if (mt > last) last = mt;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List git worktrees as { branch, path }. Used by the /standup orchestrator to
|
||||||
|
// discover who's in the room. Skips detached / bare entries.
|
||||||
|
function gitWorktrees() {
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = execSync("git worktree list --porcelain", {
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).toString();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items = [];
|
||||||
|
let cur = {};
|
||||||
|
for (const line of out.split("\n")) {
|
||||||
|
if (line.startsWith("worktree ")) cur = { path: line.slice(9).trim() };
|
||||||
|
else if (line.startsWith("branch "))
|
||||||
|
cur.branch = line.slice(7).replace("refs/heads/", "").trim();
|
||||||
|
else if (line.trim() === "") {
|
||||||
|
if (cur.path && cur.branch) items.push(cur);
|
||||||
|
cur = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.path && cur.branch) items.push(cur);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open PRs via the gh CLI — the other kind of room candidate. A PR is a branch
|
||||||
|
// living on the remote; including one means an agent fetches it read-only,
|
||||||
|
// reports what it changes, and the consolidation plan folds it in alongside the
|
||||||
|
// local worktrees. Returns null when gh is unavailable / unauthenticated / the
|
||||||
|
// repo has no GitHub remote, so the orchestrator can degrade to worktrees-only.
|
||||||
|
function ghPRs() {
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = execSync(
|
||||||
|
"gh pr list --state open --limit 200 --json number,title,headRefName,updatedAt,author,isDraft",
|
||||||
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
||||||
|
).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(out);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdPRs() {
|
||||||
|
const prs = ghPRs();
|
||||||
|
if (prs == null)
|
||||||
|
die("gh unavailable, unauthenticated, or no GitHub remote (try `gh auth login`)");
|
||||||
|
const windowMs = parseWindowMs(opts.since);
|
||||||
|
const now = Date.now();
|
||||||
|
let rows = prs.map((p) => ({
|
||||||
|
number: p.number,
|
||||||
|
title: p.title,
|
||||||
|
branch: p.headRefName,
|
||||||
|
author: p.author?.login || "",
|
||||||
|
isDraft: !!p.isDraft,
|
||||||
|
updatedAt: p.updatedAt || null,
|
||||||
|
updatedMs: p.updatedAt ? Date.parse(p.updatedAt) : 0,
|
||||||
|
age: p.updatedAt ? humanAge(Date.parse(p.updatedAt)) : "unknown",
|
||||||
|
}));
|
||||||
|
if (windowMs != null) rows = rows.filter((p) => p.updatedMs && now - p.updatedMs <= windowMs);
|
||||||
|
rows.sort((a, b) => b.updatedMs - a.updatedMs);
|
||||||
|
if (opts.json) {
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
console.error(windowMs != null ? `no open PRs updated within "${opts.since}"` : `no open PRs`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const p of rows) {
|
||||||
|
const draft = p.isDraft ? " [draft]" : "";
|
||||||
|
console.log(`#${p.number}\t${p.age.padEnd(8)}\t${p.branch}\t${p.title}${draft}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdWorktrees() {
|
||||||
|
const here = process.cwd();
|
||||||
|
const windowMs = parseWindowMs(opts.since);
|
||||||
|
const now = Date.now();
|
||||||
|
let rows = gitWorktrees().map((w) => {
|
||||||
|
const activityMs = worktreeActivityMs(w.path);
|
||||||
|
return {
|
||||||
|
branch: w.branch,
|
||||||
|
path: w.path,
|
||||||
|
current: w.path === here,
|
||||||
|
lastActivity: activityMs
|
||||||
|
? new Date(activityMs).toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||||
|
: null,
|
||||||
|
lastActivityMs: activityMs,
|
||||||
|
age: humanAge(activityMs),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Scope to the window (commits OR uncommitted edits inside it), newest first.
|
||||||
|
if (windowMs != null) {
|
||||||
|
rows = rows.filter((w) => w.lastActivityMs && now - w.lastActivityMs <= windowMs);
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
console.error(
|
||||||
|
windowMs != null
|
||||||
|
? `no worktrees active within "${opts.since}" — widen the window or use --since all`
|
||||||
|
: `no worktrees found`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const w of rows) {
|
||||||
|
const mine = w.current ? " (current)" : "";
|
||||||
|
console.log(`${w.age.padEnd(8)}\t${w.branch}\t${w.path}${mine}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdOpen() {
|
||||||
|
const agent = agentName();
|
||||||
|
const goal = opts.goal;
|
||||||
|
const prompt = opts.prompt;
|
||||||
|
if (!goal || !prompt) die("open needs --goal and --prompt");
|
||||||
|
if (await exists(FILE)) {
|
||||||
|
if (!opts.force) die(`channel exists: ${FILE} (use --force to rotate it aside)`);
|
||||||
|
const archived = FILE.replace(/\.md$/, `-${nowIso().replace(/[:T]/g, "-").replace("Z", "")}.md`);
|
||||||
|
await rename(FILE, archived);
|
||||||
|
console.error(`rotated existing channel → ${archived}`);
|
||||||
|
}
|
||||||
|
await mkdir(dirname(FILE), { recursive: true });
|
||||||
|
const ts = nowIso();
|
||||||
|
const fold = (s) => s.replace(/\s+/g, " ").trim();
|
||||||
|
const doc = `---
|
||||||
|
channel: STANDUP
|
||||||
|
opened_by: ${agent}
|
||||||
|
opened_at: ${ts}
|
||||||
|
status: open
|
||||||
|
goal: >-
|
||||||
|
${fold(goal)}
|
||||||
|
prompt: >-
|
||||||
|
${fold(prompt)}
|
||||||
|
participants:
|
||||||
|
- ${agent}
|
||||||
|
protocol: |
|
||||||
|
1. Before each turn, READ the whole file. Only respond to messages posted
|
||||||
|
after your previous turn.
|
||||||
|
2. Append your turn at the end of "## Chat" as:
|
||||||
|
### <agent-name> — <ISO-8601 UTC>
|
||||||
|
<your message>
|
||||||
|
3. To register consensus, include a line exactly: "AGREE: <deliverable>"
|
||||||
|
4. When ALL participants have an AGREE line for the same deliverable, the
|
||||||
|
last agent to agree appends "## SUMMATION" and flips status: agreed.
|
||||||
|
5. New agent joining? Add yourself to participants and say Hello in Chat.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Standup — group chat
|
||||||
|
|
||||||
|
The room is open. Post under **## Chat** following the protocol above.
|
||||||
|
|
||||||
|
## Chat
|
||||||
|
|
||||||
|
### ${agent} — ${ts}
|
||||||
|
|
||||||
|
Hello! 👋 I'm \`${agent}\`. The room is open — goal and prompt are in the
|
||||||
|
front matter. Counter-proposals welcome. I'm listening.
|
||||||
|
`;
|
||||||
|
await writeFile(FILE, doc);
|
||||||
|
console.log(`opened ${FILE} as "${agent}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdJoin() {
|
||||||
|
const agent = agentName();
|
||||||
|
if (!(await exists(FILE))) die(`no channel at ${FILE} — run "open" first`);
|
||||||
|
const msg =
|
||||||
|
opts.message ||
|
||||||
|
`Hello! 👋 I'm \`${agent}\`. Joining the room and listening.`;
|
||||||
|
await appendTurn({ agent, message: msg });
|
||||||
|
console.log(`joined as "${agent}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdPost() {
|
||||||
|
const agent = agentName();
|
||||||
|
if (!opts.message) die("post needs --message");
|
||||||
|
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
||||||
|
await appendTurn({ agent, message: opts.message, agree: opts.agree });
|
||||||
|
console.log(`posted as "${agent}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdAgree() {
|
||||||
|
const agent = agentName();
|
||||||
|
const d = opts.deliverable;
|
||||||
|
if (!d) die("agree needs --deliverable");
|
||||||
|
await appendTurn({
|
||||||
|
agent,
|
||||||
|
message: opts.message || `I'm in. AGREE on the deliverable below.`,
|
||||||
|
agree: d,
|
||||||
|
});
|
||||||
|
console.log(`agreed as "${agent}": ${d}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdWatch() {
|
||||||
|
const agent = agentName();
|
||||||
|
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
||||||
|
const timeout = Number(opts.timeout || 1800) * 1000;
|
||||||
|
const interval = Number(opts.interval || 5) * 1000;
|
||||||
|
const baselineText = await read();
|
||||||
|
let baseTurns = parseTurns(splitDoc(baselineText).body).length;
|
||||||
|
const start = Date.now();
|
||||||
|
process.stderr.write(
|
||||||
|
`watching ${FILE} as "${agent}" (every ${interval / 1000}s, timeout ${
|
||||||
|
timeout / 1000
|
||||||
|
}s)…\n`,
|
||||||
|
);
|
||||||
|
for (;;) {
|
||||||
|
if (Date.now() - start > timeout) {
|
||||||
|
console.log("TIMEOUT — no one else posted.");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
await sleep(interval);
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = await read();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turns = parseTurns(splitDoc(text).body);
|
||||||
|
if (turns.length <= baseTurns) continue;
|
||||||
|
const fresh = turns.slice(baseTurns);
|
||||||
|
baseTurns = turns.length;
|
||||||
|
// ignore our own turns — keep listening for someone ELSE
|
||||||
|
const others = fresh.filter((t) => t.agent !== agent);
|
||||||
|
if (!others.length) continue;
|
||||||
|
console.log(`NEW (${others.length}) after ${Math.round((Date.now() - start) / 1000)}s:\n`);
|
||||||
|
for (const t of others) {
|
||||||
|
console.log(`### ${t.agent} — ${t.ts}\n${t.text}\n`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRead() {
|
||||||
|
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
||||||
|
const { body } = splitDoc(await read());
|
||||||
|
let turns = parseTurns(body);
|
||||||
|
if (opts.since) {
|
||||||
|
// turns after the named agent's last post
|
||||||
|
let lastIdx = -1;
|
||||||
|
turns.forEach((t, i) => {
|
||||||
|
if (t.agent === opts.since) lastIdx = i;
|
||||||
|
});
|
||||||
|
if (lastIdx >= 0) turns = turns.slice(lastIdx + 1);
|
||||||
|
}
|
||||||
|
if (opts.tail) turns = turns.slice(-Number(opts.tail));
|
||||||
|
for (const t of turns) console.log(`### ${t.agent} — ${t.ts}\n${t.text}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdStatus() {
|
||||||
|
if (!(await exists(FILE))) die(`no channel at ${FILE}`);
|
||||||
|
const { yaml, body } = splitDoc(await read());
|
||||||
|
const participants = yamlList(yaml, "participants");
|
||||||
|
const status = yamlScalar(yaml, "status") || "open";
|
||||||
|
const turns = parseTurns(body);
|
||||||
|
// latest AGREE per agent
|
||||||
|
const agreeByAgent = new Map();
|
||||||
|
for (const t of turns) {
|
||||||
|
const m = t.text.match(/^AGREE:\s*(.+)$/m);
|
||||||
|
if (m) agreeByAgent.set(t.agent, m[1].trim());
|
||||||
|
}
|
||||||
|
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||||
|
const agreedValues = participants.map((p) => agreeByAgent.get(p) || null);
|
||||||
|
const allAgreed =
|
||||||
|
participants.length > 0 &&
|
||||||
|
agreedValues.every(Boolean) &&
|
||||||
|
new Set(agreedValues.map(norm)).size === 1;
|
||||||
|
console.log(`channel : ${FILE}`);
|
||||||
|
console.log(`status : ${status}`);
|
||||||
|
console.log(`goal : ${yamlScalar(yaml, "goal") || "(see file)"}`);
|
||||||
|
console.log(`turns : ${turns.length}`);
|
||||||
|
console.log(`participants (${participants.length}):`);
|
||||||
|
for (const p of participants) {
|
||||||
|
const a = agreeByAgent.get(p);
|
||||||
|
console.log(` - ${p}${a ? ` ✓ AGREE: ${a}` : " … no agree yet"}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
allAgreed
|
||||||
|
? "consensus: REACHED — all participants agree. Write a ## SUMMATION."
|
||||||
|
: "consensus: not yet",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdSummation() {
|
||||||
|
const agent = agentName();
|
||||||
|
if (!opts.text) die("summation needs --text");
|
||||||
|
await withLock(async () => {
|
||||||
|
let text = await read();
|
||||||
|
const { yaml, body } = splitDoc(text);
|
||||||
|
const newYaml = yaml.replace(/^status:\s*.+$/m, "status: agreed");
|
||||||
|
const block = `\n## SUMMATION\n\n_by ${agent} — ${nowIso()}_\n\n${opts.text.trim()}\n`;
|
||||||
|
text = `---\n${newYaml.replace(/\n?$/, "\n")}---\n${body.replace(/\s*$/, "\n")}${block}`;
|
||||||
|
await writeFile(FILE, text);
|
||||||
|
});
|
||||||
|
console.log(`summation written; status → agreed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function die(msg) {
|
||||||
|
console.error(`standup: ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `standup — markdown group chat for multiple coding agents
|
||||||
|
|
||||||
|
usage: standup <command> [--flags]
|
||||||
|
|
||||||
|
open --goal "..." --prompt "..." create the channel (you say hello)
|
||||||
|
[--force rotates an existing room aside]
|
||||||
|
worktrees [--since 4h] [--json] list worktrees, newest first; --since
|
||||||
|
N{m,h,d,w} keeps only those active in the
|
||||||
|
window (commit OR uncommitted edit)
|
||||||
|
prs [--since 4h] [--json] list open GitHub PRs (via gh), newest
|
||||||
|
first; --since filters by last update
|
||||||
|
join [--message "..."] add yourself + say hello
|
||||||
|
post --message "..." [--agree "..."] append a turn
|
||||||
|
agree --deliverable "..." append an AGREE turn
|
||||||
|
watch [--timeout SEC] [--interval SEC] block until someone ELSE posts
|
||||||
|
read [--tail N] [--since AGENT] print the chat
|
||||||
|
status participants + consensus check
|
||||||
|
summation --text "..." close the room (status: agreed)
|
||||||
|
|
||||||
|
agent name defaults to your git branch; override with --agent or STANDUP_AGENT.
|
||||||
|
file defaults to ~/.claude-mem/STANDUP.md; override with --file or STANDUP_FILE.`;
|
||||||
|
|
||||||
|
const table = {
|
||||||
|
open: cmdOpen,
|
||||||
|
worktrees: cmdWorktrees,
|
||||||
|
prs: cmdPRs,
|
||||||
|
join: cmdJoin,
|
||||||
|
post: cmdPost,
|
||||||
|
agree: cmdAgree,
|
||||||
|
watch: cmdWatch,
|
||||||
|
read: cmdRead,
|
||||||
|
status: cmdStatus,
|
||||||
|
summation: cmdSummation,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
||||||
|
console.log(USAGE);
|
||||||
|
process.exit(cmd ? 0 : 1);
|
||||||
|
}
|
||||||
|
const fn = table[cmd];
|
||||||
|
if (!fn) die(`unknown command "${cmd}"\n\n${USAGE}`);
|
||||||
|
await fn();
|
||||||
Reference in New Issue
Block a user