feat: company-prefixed routing, delete tests, docs favicon, and skills

Add company-prefix URL routing utilities and custom router wrapper.
Add CLI company delete confirmation tests. Add docs favicon and
para-memory-files skill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-03-02 09:07:09 -06:00
parent cc2c724ad2
commit 97d4f6c622
6 changed files with 391 additions and 0 deletions

View File

@@ -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>): 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/);
});
});

4
docs/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#2563EB"/>
<path d="M10 8h6a6 6 0 0 1 0 12h-2v4h-4V8zm4 8h2a2 2 0 0 0 0-4h-2v4z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 222 B

View File

@@ -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
<name>/
summary.md
items.yaml
areas/ # Ongoing responsibilities, no end date
people/<name>/
companies/<name>/
resources/ # Reference material, topics of interest
<topic>/
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.

View File

@@ -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.

View File

@@ -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}`;
}

76
ui/src/lib/router.tsx Normal file
View File

@@ -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<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
function CompanyLink({ to, ...props }, ref) {
const companyPrefix = useActiveCompanyPrefix();
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
},
);
export const NavLink = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.NavLink>>(
function CompanyNavLink({ to, ...props }, ref) {
const companyPrefix = useActiveCompanyPrefix();
return <RouterDom.NavLink ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
},
);
export function Navigate({ to, ...props }: React.ComponentProps<typeof RouterDom.Navigate>) {
const companyPrefix = useActiveCompanyPrefix();
return <RouterDom.Navigate to={resolveTo(to, companyPrefix)} {...props} />;
}
export function useNavigate(): ReturnType<typeof RouterDom.useNavigate> {
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<typeof RouterDom.useNavigate>,
[navigate, companyPrefix],
);
}