diff --git a/.gitignore b/.gitignore index 9ccc775..fd23ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ *.sqlite *.sqlite-* /me.md +/projects/ /approvals.local.md /layout.local.md /.agent-output/ diff --git a/README.md b/README.md index d9f77c5..15ebc07 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,16 @@ A cadence written in a profile does not itself run anything. | [SKILL.md](skills/agency/SKILL.md) | How Agency learns, delegates, proves results and handles decisions | | [LAYOUT.md](skills/agency/LAYOUT.md) | Editable card appearance, graphics, typography and explanation style | | [APPROVALS.md](skills/agency/APPROVALS.md) | Editable defaults for what the agent may do and when it asks | -| `me.md`, private | Your dream, writing style, preferences, sources and constraints | +| `me.md`, private | Your writing style, preferences and constraints, shared by every project | +| `projects//me.md`, private | One project's dream, goals and sources | | This README | Setup, profile synchronization, access and the app API | Keep evidence, exact drafts, one-off approvals and outcomes in local card/job history. Keep credentials in your runner's secret store. For personal approval settings, copy the default to a private location such as checkout-root -`approvals.local.md` and tell the agent its absolute `APPROVALS_PATH`. That filename and `me.md` -are Git-ignored. The installer also gives you your own editable policy beside the installed skill. +`approvals.local.md` and tell the agent its absolute `APPROVALS_PATH`. That filename, `me.md` +and `projects/` are Git-ignored. The installer also gives you your own editable policy beside the installed skill. The agent reads the policy; the app does not enforce it. Current explicit restrictions and runner rules take precedence. Do not infer broader permission from past acceptance. @@ -141,21 +142,35 @@ instructions, not automatic file injection, a website setting or a CSS theme eng Updating the file alone does not restyle existing cards or update older installed skill copies. Themes change presentation, not approvals, evidence, scores or host controls. -#### Synchronize My dream and me.md +#### Projects -Use `ME_PATH` for your private file and `RADAR_URL` for the running app. The default profile path -is checkout-root `me.md`. Check both sides before selecting a direction. +Agency keeps separate projects, for example one per company or product. Each project has its own +dream, topics, cards, jobs and stats. Your shared `me.md` applies to all of them. Switch projects +from the menu in the top bar. Add, rename or remove empty projects in Settings. + +A new install starts with one project, `default`. On upgrade, existing cards, dreams and topics +move into it. Tell the agent which project to work in, or set `AGENCY_PROJECT=acme`. Keep each +project's research in `agent-work//`. + +#### Synchronize a project's dream and me.md + +A project's dream in the app and its private `projects//me.md` are one document. Use +`--project` (or `AGENCY_PROJECT`) for the project and `RADAR_URL` for the running app. Check both +sides before selecting a direction. ```sh export RADAR_URL=http://localhost:3100 -export ME_PATH=/absolute/path/to/me.md -node scripts/sync-me.mjs --check +node scripts/sync-me.mjs --project acme --check ``` -- Existing file into a fresh app: `node scripts/sync-me.mjs --file-to-app`. -- Newly entered app dream into a new file: `node scripts/sync-me.mjs --app-to-file`. +- Existing file into a fresh app: `node scripts/sync-me.mjs --project acme --file-to-app`. +- Newly entered app dream into a new file: `node scripts/sync-me.mjs --project acme --app-to-file`. - No flag: synchronize whichever side has the newer timestamp. +`PROJECT_ME_PATH` overrides the file for that one project. The shared `me.md` is never synchronized. +Coming from a single `me.md`, run `node scripts/sync-me.mjs --project default --app-to-file` once, +then keep only your voice, preferences and constraints in `me.md`. + An empty source cannot overwrite a nonempty profile. Legacy `--pull` (file to app) and `--push` (app to file) remain supported. The agent handles this copying; the user need not manage it. Synchronize before work waves and after authorized profile edits. The agent learns from evidence @@ -227,6 +242,17 @@ Use the supplied app root and `RADAR_URL`. Inspect the current routes if this ch a cloud worker does not automatically have local app access. Keep the app on loopback. Include `x-radar-local-agent: 1` for local agent requests. +#### Projects + +Pass the project on every call: reads take `?project=` and writes take `projectId` in the JSON +body. A missing project means `default`; an unknown project returns 404. + +- `GET /api/projects` lists each project's `id`, `label`, `open` (New cards) and `cards`. + `POST /api/projects {label}` creates a project with a slug id; `{id,label}` creates or renames. + `DELETE /api/projects?id=` removes only an empty project. +- `GET /api/agent-jobs?project=` narrows the queue to one project. Without it, jobs from every + project are listed, each with its `projectId`. The ten concurrent slots are shared. + #### Read, coordinate and claim - `GET /api/agent-jobs` returns available latest jobs, not an exclusive claim. Read `cardContext`, @@ -242,7 +268,8 @@ Include `x-radar-local-agent: 1` for local agent requests. The API has no exclusive worker token. Coordinate other active agents and recheck live state; do not assume a successful running update prevents another worker from acting. - Reuse the canonical `dedupeKey`, based on project, subject, problem, outcome and stable anchors. - A different title, timestamp or agent is not new work. Enrich existing New/Working cards; revive + A different title, timestamp or agent is not new work. Keys are unique across projects, + so start them with the project id; a key owned by another project returns 409. Enrich existing New/Working cards; revive completed/rejected work only when fresh evidence addresses the prior outcome or rejection. #### Finish every job explicitly @@ -271,11 +298,12 @@ Never add an Acknowledge/Keep blocked no-op. Keep actual in-flight checks Workin Write one HTML file and a metadata JSON file in the configured app. The metadata requires `project`, `category`, `headline`, `dedupeKey`, the four `rise` components, and `cardHtmlFile` -relative to the JSON file. Include `effortSeconds`, `effortReason`, `agentContext`, `sourceLabel`, +relative to the JSON file. `projectId` (or `AGENCY_PROJECT`) selects the Agency project; `project` +names the product or repository area within it. Include `effortSeconds`, `effortReason`, `agentContext`, `sourceLabel`, `sourceUrl` and `agentName` where useful. Run: ```sh -RADAR_URL=http://localhost:3100 npm run card:push -- path/to/card.json +AGENCY_PROJECT=acme RADAR_URL=http://localhost:3100 npm run card:push -- path/to/card.json ``` The script reads the file and POSTs `cardHtml` to `/api/ideas`. HTML must be 80-250,000 characters; @@ -324,14 +352,14 @@ unchanged; missing estimates show no value. The active-time counter is separate. Default UI sorting is displayed score, then stored total, then ID; the user may choose effort or newest. Backend result order is not necessarily the user's current order. -`GET /api/topics` returns topic names (`label`) and descriptions (`hint`). Reuse topics; for a -new lane use `POST /api/topics {label,hint}`. Set the card category to the full topic ID or +`GET /api/topics?project=` returns that project's topic names (`label`) and descriptions (`hint`). +Reuse topics; for a new lane use `POST /api/topics {projectId,label,hint}`. Set the card category to the full topic ID or name, for example `Support`. The agent chooses it; the app does not match keywords. New installs have no preset topics. Existing topics stay editable. Unmatched cards stay in All. Do not rename/delete the user's topics without approval. -Authoritative source paths in the configured checkout: `app/api/{agent-jobs,ideas,state,topics}`, -`lib/job-lifecycle.ts`, `lib/blocked-card.ts`, `lib/rise.ts`, `lib/card-focus.ts` and +Authoritative source paths in the configured checkout: `app/api/{agent-jobs,ideas,projects,state,topics}`, +`lib/project.ts`, `lib/job-lifecycle.ts`, `lib/blocked-card.ts`, `lib/rise.ts`, `lib/card-focus.ts` and `scripts/{push-card,sync-me}.mjs`. The agent authors each card’s HTML and actions. @@ -346,7 +374,7 @@ The local `.openai/hosting.json` declares only local bindings; it contains no shared hosting project. The included build helper is required by Vite. Each checkout has its own database, profile and assets. Keep backups private. Never commit -`.wrangler`, `me.md`, personal approval settings, environment files, browser state or customer media. +`.wrangler`, `me.md`, `projects/`, personal approval settings, environment files, browser state or customer media. Sharing this source does not share your cards or credentials. The private `.agents/skills/design/` bundle and local migration state are ignored too; do not force-add them. Shared defaults belong in the tracked Agency instructions, without personal ticket examples. diff --git a/app/agency.tsx b/app/agency.tsx index 5e39054..795e3b9 100644 --- a/app/agency.tsx +++ b/app/agency.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cardDraftKey, keepSelectedCard, nextCardAfterRemoval } from "../lib/card-focus"; import { cardShortcut } from "../lib/card-shortcut"; import { clusterForCard, type Topic } from "../lib/card-cluster"; +import { DEFAULT_PROJECT_ID, projectHref, readActiveProject, rememberActiveProject, type Project } from "../lib/project"; import { compareByImpact, impactPoints } from "../lib/rise"; import { MAX_TASK_LENGTH, submitNewTask } from "../lib/task-submission"; @@ -40,6 +41,8 @@ type Idea = { }; type RadarState = { + project: Project | null; + projects: Project[]; context: { text: string; createdAt: string } | null; topics: Topic[]; ideas: Idea[]; @@ -77,6 +80,8 @@ type AttentionTracker = { }; const emptyState: RadarState = { + project: null, + projects: [], context: null, topics: [], ideas: [], @@ -243,14 +248,14 @@ function dayLabel(key: string) { return new Date(`${key}T12:00:00`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } -function DoneList({ ideas, topics, onAction, onInteraction }: { ideas: Idea[]; topics: Topic[]; onAction: (idea: Idea, action: CardAction) => void; onInteraction: (idea: Idea, action: string, label: string) => void }) { +function DoneList({ ideas, topics, projectId, onAction, onInteraction }: { ideas: Idea[]; topics: Topic[]; projectId: string; onAction: (idea: Idea, action: CardAction) => void; onInteraction: (idea: Idea, action: string, label: string) => void }) { const [day, setDay] = useState("all"); const [openId, setOpenId] = useState(null); const [cardHtml, setCardHtml] = useState>({}); useEffect(() => { if (!openId || cardHtml[openId]) return; let cancelled = false; - fetch(`/api/state?view=done&only=${openId}`, { cache: "no-store" }) + fetch(`/api/state?view=done&only=${openId}&project=${encodeURIComponent(projectId)}`, { cache: "no-store" }) .then((response) => response.json()) .then((state: { ideas: Idea[] }) => { const html = state.ideas[0]?.cardHtml ?? ""; @@ -258,7 +263,7 @@ function DoneList({ ideas, topics, onAction, onInteraction }: { ideas: Idea[]; t }) .catch(() => undefined); return () => { cancelled = true; }; - }, [openId, cardHtml]); + }, [openId, cardHtml, projectId]); const groups = useMemo(() => { const map = new Map(); for (const idea of ideas) { @@ -313,7 +318,29 @@ function DoneList({ ideas, topics, onAction, onInteraction }: { ideas: Idea[]; t ); } +const MANAGE_PROJECTS = "__manage"; + +function ProjectSwitcher({ projects, projectId, onSelect }: { projects: Project[]; projectId: string; onSelect: (id: string) => void }) { + if (!projects.length) return null; + return ( + + ); +} + export function Agency() { + const [projectId, setProjectId] = useState(readActiveProject); const [data, setData] = useState(emptyState); const [view, setView] = useState<"new" | "working" | "done">("new"); const [cluster, setCluster] = useState("all"); @@ -357,6 +384,7 @@ export function Agency() { const requestId = ++loadRequestRef.current; const stateUrl = new URL("/api/state", window.location.origin); stateUrl.searchParams.set("view", targetView); + stateUrl.searchParams.set("project", projectId); if (targetView === "done") stateUrl.searchParams.set("light", "1"); const deepLinkedCardId = Number(new URLSearchParams(window.location.search).get("card")); const requestedCardId = selection?.preferred?.id @@ -364,8 +392,17 @@ export function Agency() { ?? (!deepLinkHandledRef.current && Number.isInteger(deepLinkedCardId) && deepLinkedCardId > 0 ? deepLinkedCardId : null); if (requestedCardId) stateUrl.searchParams.set("card", String(requestedCardId)); const response = await fetch(stateUrl, { cache: "no-store" }); + if (response.status === 404) { + // A remembered project was removed. Fall back to the default project. + if (requestId === loadRequestRef.current && projectId !== DEFAULT_PROJECT_ID) { + rememberActiveProject(DEFAULT_PROJECT_ID); + setProjectId(DEFAULT_PROJECT_ID); + } + return; + } const next = (await response.json()) as RadarState; if (requestId !== loadRequestRef.current) return; + rememberActiveProject(projectId); if (!deepLinkHandledRef.current) { deepLinkHandledRef.current = true; const requestedId = Number(new URLSearchParams(window.location.search).get("card")); @@ -383,15 +420,16 @@ export function Agency() { const anchor = selection ? selection.preferred : selectedIdeaRef.current; selectIdea(keepSelectedCard(anchor, visible, next.ideas)); setLoading(false); - }, [selectIdea, view]); + }, [projectId, selectIdea, view]); useEffect(() => { if (typeof window === "undefined") return; - if (!selectedIdea) return; const url = new URL(window.location.href); - url.searchParams.set("card", String(selectedIdea.id)); + if (projectId === DEFAULT_PROJECT_ID) url.searchParams.delete("project"); + else url.searchParams.set("project", projectId); + if (selectedIdea) url.searchParams.set("card", String(selectedIdea.id)); window.history.replaceState(null, "", url); - }, [selectedIdea]); + }, [projectId, selectedIdea]); useEffect(() => { let cancelled = false; @@ -675,7 +713,7 @@ export function Agency() { setComposerError(""); try { if (composer === "task") { - const { jobId } = await submitNewTask(task); + const { jobId } = await submitNewTask(task, projectId); const newIdeas = ideasForView(data.ideas, "new", sort); const filtered = newIdeas.filter((idea) => cluster === "all" || clusterForCard(idea, data.topics) === cluster); const candidates = filtered.length ? filtered : newIdeas; @@ -689,7 +727,7 @@ export function Agency() { // Refresh failure must not make a saved task look unsent. void load("new", { preferred: next }).catch(() => undefined); } else { - const response = await fetch("/api/context", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: dream }) }); + const response = await fetch("/api/context", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: dream, projectId }) }); if (!response.ok) { const result = await response.json().catch(() => null) as { error?: string } | null; throw new Error(result?.error || "Your context was not saved. Try again."); @@ -725,6 +763,24 @@ export function Agency() { setMessage(""); } + function switchProject(next: string) { + if (next === projectId) return; + recordCardInteraction(active, "lane", `project:${next}`); + const url = new URL(window.location.href); + url.searchParams.delete("card"); + window.history.replaceState(null, "", url); + rememberActiveProject(next); + setComposer(null); + setComposerError(""); + setMessage(""); + setView("new"); + setCluster("all"); + selectIdea(null); + setData(emptyState); + setLoading(true); + setProjectId(next); + } + function updateFeedback(value: string) { if (!active) return; const key = cardDraftKey(active); @@ -744,9 +800,12 @@ export function Agency() { if (!data.context?.text?.trim()) { return (
+
+ +
-

What’s your dream right now?

+

{data.projects.length > 1 && data.project ? <>What’s your dream for {data.project.label}? : <>What’s your dream right now?}

Your coding agent can learn this from your recent work and fill it in. Or write a few words below. This page saves your dream; your coding agent creates the cards.