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:
87
cli/src/__tests__/company-delete.test.ts
Normal file
87
cli/src/__tests__/company-delete.test.ts
Normal 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
4
docs/favicon.svg
Normal 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 |
104
skills/para-memory-files/SKILL.md
Normal file
104
skills/para-memory-files/SKILL.md
Normal 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.
|
||||
35
skills/para-memory-files/references/schemas.md
Normal file
35
skills/para-memory-files/references/schemas.md
Normal 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.
|
||||
85
ui/src/lib/company-routes.ts
Normal file
85
ui/src/lib/company-routes.ts
Normal 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
76
ui/src/lib/router.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user