diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts new file mode 100644 index 00000000..a0cf5bf0 --- /dev/null +++ b/cli/src/__tests__/company-delete.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { Company } from "@paperclip/shared"; +import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js"; + +function makeCompany(overrides: Partial): Company { + return { + id: "11111111-1111-1111-1111-111111111111", + name: "Alpha", + description: null, + status: "active", + issuePrefix: "ALP", + issueCounter: 1, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + requireBoardApprovalForNewAgents: false, + brandColor: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("resolveCompanyForDeletion", () => { + const companies: Company[] = [ + makeCompany({ + id: "11111111-1111-1111-1111-111111111111", + name: "Alpha", + issuePrefix: "ALP", + }), + makeCompany({ + id: "22222222-2222-2222-2222-222222222222", + name: "Paperclip", + issuePrefix: "PAP", + }), + ]; + + it("resolves by ID in auto mode", () => { + const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto"); + expect(result.issuePrefix).toBe("PAP"); + }); + + it("resolves by prefix in auto mode", () => { + const result = resolveCompanyForDeletion(companies, "pap", "auto"); + expect(result.id).toBe("22222222-2222-2222-2222-222222222222"); + }); + + it("throws when selector is not found", () => { + expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/); + }); + + it("respects explicit id mode", () => { + expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/); + }); + + it("respects explicit prefix mode", () => { + expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix")) + .toThrow(/No company found by shortname/); + }); +}); + +describe("assertDeleteConfirmation", () => { + const company = makeCompany({ + id: "22222222-2222-2222-2222-222222222222", + issuePrefix: "PAP", + }); + + it("requires --yes", () => { + expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/); + }); + + it("accepts matching prefix confirmation", () => { + expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow(); + }); + + it("accepts matching id confirmation", () => { + expect(() => + assertDeleteConfirmation(company, { + yes: true, + confirm: "22222222-2222-2222-2222-222222222222", + })).not.toThrow(); + }); + + it("rejects mismatched confirmation", () => { + expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" })) + .toThrow(/does not match target company/); + }); +}); diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 00000000..9820288f --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/skills/para-memory-files/SKILL.md b/skills/para-memory-files/SKILL.md new file mode 100644 index 00000000..99da8069 --- /dev/null +++ b/skills/para-memory-files/SKILL.md @@ -0,0 +1,104 @@ +--- +name: para-memory-files +description: > + File-based memory system using Tiago Forte's PARA method. Use this skill whenever + you need to store, retrieve, update, or organize knowledge across sessions. Covers + three memory layers: (1) Knowledge graph in PARA folders with atomic YAML facts, + (2) Daily notes as raw timeline, (3) Tacit knowledge about user patterns. Also + handles planning files, memory decay, weekly synthesis, and recall via qmd. + Trigger on any memory operation: saving facts, writing daily notes, creating + entities, running weekly synthesis, recalling past context, or managing plans. +--- + +# PARA Memory Files + +Persistent, file-based memory organized by Tiago Forte's PARA method. Three layers: a knowledge graph, daily notes, and tacit knowledge. All paths are relative to `$AGENT_HOME`. + +## Three Memory Layers + +### Layer 1: Knowledge Graph (`$AGENT_HOME/life/` -- PARA) + +Entity-based storage. Each entity gets a folder with two tiers: + +1. `summary.md` -- quick context, load first. +2. `items.yaml` -- atomic facts, load on demand. + +```text +$AGENT_HOME/life/ + projects/ # Active work with clear goals/deadlines + / + summary.md + items.yaml + areas/ # Ongoing responsibilities, no end date + people// + companies// + resources/ # Reference material, topics of interest + / + archives/ # Inactive items from the other three + index.md +``` + +**PARA rules:** + +- **Projects** -- active work with a goal or deadline. Move to archives when complete. +- **Areas** -- ongoing (people, companies, responsibilities). No end date. +- **Resources** -- reference material, topics of interest. +- **Archives** -- inactive items from any category. + +**Fact rules:** + +- Save durable facts immediately to `items.yaml`. +- Weekly: rewrite `summary.md` from active facts. +- Never delete facts. Supersede instead (`status: superseded`, add `superseded_by`). +- When an entity goes inactive, move its folder to `$AGENT_HOME/life/archives/`. + +**When to create an entity:** + +- Mentioned 3+ times, OR +- Direct relationship to the user (family, coworker, partner, client), OR +- Significant project or company in the user's life. +- Otherwise, note it in daily notes. + +For the atomic fact YAML schema and memory decay rules, see [references/schemas.md](references/schemas.md). + +### Layer 2: Daily Notes (`$AGENT_HOME/memory/YYYY-MM-DD.md`) + +Raw timeline of events -- the "when" layer. + +- Write continuously during conversations. +- Extract durable facts to Layer 1 during heartbeats. + +### Layer 3: Tacit Knowledge (`$AGENT_HOME/MEMORY.md`) + +How the user operates -- patterns, preferences, lessons learned. + +- Not facts about the world; facts about the user. +- Update whenever you learn new operating patterns. + +## Write It Down -- No Mental Notes + +Memory does not survive session restarts. Files do. + +- Want to remember something -> WRITE IT TO A FILE. +- "Remember this" -> update `$AGENT_HOME/memory/YYYY-MM-DD.md` or the relevant entity file. +- Learn a lesson -> update AGENTS.md, TOOLS.md, or the relevant skill file. +- Make a mistake -> document it so future-you does not repeat it. +- On-disk text files are always better than holding it in temporary context. + +## Memory Recall -- Use qmd + +Use `qmd` rather than grepping files: + +```bash +qmd query "what happened at Christmas" # Semantic search with reranking +qmd search "specific phrase" # BM25 keyword search +qmd vsearch "conceptual question" # Pure vector similarity +``` + +Index your personal folder: `qmd index $AGENT_HOME` + +Vectors + BM25 + reranking finds things even when the wording differs. + +## Planning + +Keep plans in timestamped files in `plans/` at the project root (outside personal memory so other agents can access them). Use `qmd` to search plans. Plans go stale -- if a newer plan exists, do not confuse yourself with an older version. If you notice staleness, update the file to note what it is supersededBy. diff --git a/skills/para-memory-files/references/schemas.md b/skills/para-memory-files/references/schemas.md new file mode 100644 index 00000000..840cac99 --- /dev/null +++ b/skills/para-memory-files/references/schemas.md @@ -0,0 +1,35 @@ +# Schemas and Memory Decay + +## Atomic Fact Schema (items.yaml) + +```yaml +- id: entity-001 + fact: "The actual fact" + category: relationship | milestone | status | preference + timestamp: "YYYY-MM-DD" + source: "YYYY-MM-DD" + status: active # active | superseded + superseded_by: null # e.g. entity-002 + related_entities: + - companies/acme + - people/jeff + last_accessed: "YYYY-MM-DD" + access_count: 0 +``` + +## Memory Decay + +Facts decay in retrieval priority over time so stale info does not crowd out recent context. + +**Access tracking:** When a fact is used in conversation, bump `access_count` and set `last_accessed` to today. During heartbeat extraction, scan the session for referenced entity facts and update their access metadata. + +**Recency tiers (for summary.md rewriting):** + +- **Hot** (accessed in last 7 days) -- include prominently in summary.md. +- **Warm** (8-30 days ago) -- include at lower priority. +- **Cold** (30+ days or never accessed) -- omit from summary.md. Still in items.yaml, retrievable on demand. +- High `access_count` resists decay -- frequently used facts stay warm longer. + +**Weekly synthesis:** Sort by recency tier, then by access_count within tier. Cold facts drop out of the summary but remain in items.yaml. Accessing a cold fact reheats it. + +No deletion. Decay only affects retrieval priority via summary.md curation. The full record always lives in items.yaml. diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts new file mode 100644 index 00000000..ac8c3f7e --- /dev/null +++ b/ui/src/lib/company-routes.ts @@ -0,0 +1,85 @@ +const BOARD_ROUTE_ROOTS = new Set([ + "dashboard", + "companies", + "company", + "org", + "agents", + "projects", + "issues", + "goals", + "approvals", + "costs", + "activity", + "inbox", + "design-guide", +]); + +const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs"]); + +export function normalizeCompanyPrefix(prefix: string): string { + return prefix.trim().toUpperCase(); +} + +function splitPath(path: string): { pathname: string; search: string; hash: string } { + const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); + return { + pathname: match?.[1] ?? path, + search: match?.[2] ?? "", + hash: match?.[3] ?? "", + }; +} + +function getRootSegment(pathname: string): string | null { + const segment = pathname.split("/").filter(Boolean)[0]; + return segment ?? null; +} + +export function isGlobalPath(pathname: string): boolean { + if (pathname === "/") return true; + const root = getRootSegment(pathname); + if (!root) return true; + return GLOBAL_ROUTE_ROOTS.has(root.toLowerCase()); +} + +export function isBoardPathWithoutPrefix(pathname: string): boolean { + const root = getRootSegment(pathname); + if (!root) return false; + return BOARD_ROUTE_ROOTS.has(root.toLowerCase()); +} + +export function extractCompanyPrefixFromPath(pathname: string): string | null { + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) return null; + const first = segments[0]!.toLowerCase(); + if (GLOBAL_ROUTE_ROOTS.has(first) || BOARD_ROUTE_ROOTS.has(first)) { + return null; + } + return normalizeCompanyPrefix(segments[0]!); +} + +export function applyCompanyPrefix(path: string, companyPrefix: string | null | undefined): string { + const { pathname, search, hash } = splitPath(path); + if (!pathname.startsWith("/")) return path; + if (isGlobalPath(pathname)) return path; + if (!companyPrefix) return path; + + const prefix = normalizeCompanyPrefix(companyPrefix); + const activePrefix = extractCompanyPrefixFromPath(pathname); + if (activePrefix) return path; + + return `/${prefix}${pathname}${search}${hash}`; +} + +export function toCompanyRelativePath(path: string): string { + const { pathname, search, hash } = splitPath(path); + const segments = pathname.split("/").filter(Boolean); + + if (segments.length >= 2) { + const second = segments[1]!.toLowerCase(); + if (!GLOBAL_ROUTE_ROOTS.has(segments[0]!.toLowerCase()) && BOARD_ROUTE_ROOTS.has(second)) { + return `/${segments.slice(1).join("/")}${search}${hash}`; + } + } + + return `${pathname}${search}${hash}`; +} diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx new file mode 100644 index 00000000..5cf81c8d --- /dev/null +++ b/ui/src/lib/router.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import * as RouterDom from "react-router-dom"; +import type { NavigateOptions, To } from "react-router-dom"; +import { useCompany } from "@/context/CompanyContext"; +import { + applyCompanyPrefix, + extractCompanyPrefixFromPath, + normalizeCompanyPrefix, +} from "@/lib/company-routes"; + +function resolveTo(to: To, companyPrefix: string | null): To { + if (typeof to === "string") { + return applyCompanyPrefix(to, companyPrefix); + } + + if (to.pathname && to.pathname.startsWith("/")) { + const pathname = applyCompanyPrefix(to.pathname, companyPrefix); + if (pathname !== to.pathname) { + return { ...to, pathname }; + } + } + + return to; +} + +function useActiveCompanyPrefix(): string | null { + const { selectedCompany } = useCompany(); + const params = RouterDom.useParams<{ companyPrefix?: string }>(); + const location = RouterDom.useLocation(); + + if (params.companyPrefix) { + return normalizeCompanyPrefix(params.companyPrefix); + } + + const pathPrefix = extractCompanyPrefixFromPath(location.pathname); + if (pathPrefix) return pathPrefix; + + return selectedCompany ? normalizeCompanyPrefix(selectedCompany.issuePrefix) : null; +} + +export * from "react-router-dom"; + +export const Link = React.forwardRef>( + function CompanyLink({ to, ...props }, ref) { + const companyPrefix = useActiveCompanyPrefix(); + return ; + }, +); + +export const NavLink = React.forwardRef>( + function CompanyNavLink({ to, ...props }, ref) { + const companyPrefix = useActiveCompanyPrefix(); + return ; + }, +); + +export function Navigate({ to, ...props }: React.ComponentProps) { + const companyPrefix = useActiveCompanyPrefix(); + return ; +} + +export function useNavigate(): ReturnType { + const navigate = RouterDom.useNavigate(); + const companyPrefix = useActiveCompanyPrefix(); + + return React.useCallback( + ((to: To | number, options?: NavigateOptions) => { + if (typeof to === "number") { + navigate(to); + return; + } + navigate(resolveTo(to, companyPrefix), options); + }) as ReturnType, + [navigate, companyPrefix], + ); +}