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