portable-agent-layer 0.86.3 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/rule-review/SKILL.md +6 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +1 -1
- package/src/cli/rule.ts +7 -0
- package/src/hooks/StopOrchestrator.ts +10 -0
- package/src/hooks/UserPromptOrchestrator.ts +2 -1
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/handlers/rating.ts +49 -7
- package/src/hooks/lib/adaptation-candidates.ts +89 -0
- package/src/hooks/lib/adaptation-rules.ts +12 -1
- package/src/hooks/lib/adaptation-steering.ts +87 -0
- package/src/hooks/lib/adaptation-turns.ts +20 -0
- package/src/hooks/lib/claim-log.ts +1 -1
- package/src/hooks/lib/rule-drafter.ts +168 -0
- package/src/hooks/lib/rule-effect.ts +87 -0
- package/src/hooks/lib/rule-proof.ts +53 -0
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/targets/opencode/plugin.ts +2 -1
- package/src/tools/control-room/attention.ts +18 -0
- package/src/tools/control-room/prefs.ts +2 -2
- package/src/tools/control-room/relationship.ts +101 -0
- package/src/tools/control-room/server.ts +24 -1
- package/src/tools/control-room/ui/app.tsx +20 -0
- package/src/tools/control-room/ui/dist/assets/index-Dd5J8No5.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-DeglMbfS.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +2 -2
- package/src/tools/control-room/ui/lib/write.ts +4 -0
- package/src/tools/control-room/ui/screens/relationship.tsx +224 -0
- package/src/tools/control-room/ui/screens/settings.tsx +1 -0
- package/src/tools/control-room/ui/dist/assets/index-D_o9Usd_.js +0 -49
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +0 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an approved rule did: how often it fired, and whether the user still
|
|
3
|
+
* corrected the reply it steered. A turn is stamped with when its message was
|
|
4
|
+
* sent, so the first turn in a session after a fire judges the steered reply.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type { RuleTrigger } from "./adaptation-rules";
|
|
10
|
+
import type { Turn } from "./adaptation-turns";
|
|
11
|
+
import { paths } from "./paths";
|
|
12
|
+
|
|
13
|
+
const WINDOW_DAYS = 30;
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
15
|
+
|
|
16
|
+
export interface RuleEvent {
|
|
17
|
+
ts: string;
|
|
18
|
+
rule: string;
|
|
19
|
+
side: RuleTrigger["side"];
|
|
20
|
+
session: string;
|
|
21
|
+
sentBack?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RuleEffect {
|
|
25
|
+
fired: number;
|
|
26
|
+
sentBack: number;
|
|
27
|
+
/** Fires followed by a labelled turn in the same session. */
|
|
28
|
+
judged: number;
|
|
29
|
+
correctedAfter: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ruleEventsPath(): string {
|
|
33
|
+
return resolve(paths.adaptation(), "rule-events.jsonl");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseLine(line: string): RuleEvent | null {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(line) as RuleEvent;
|
|
39
|
+
return typeof parsed.ts === "string" && typeof parsed.rule === "string"
|
|
40
|
+
? parsed
|
|
41
|
+
: null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readRuleEvents(days = WINDOW_DAYS, now = new Date()): RuleEvent[] {
|
|
48
|
+
const path = ruleEventsPath();
|
|
49
|
+
if (!existsSync(path)) return [];
|
|
50
|
+
const cutoff = now.getTime() - days * DAY_MS;
|
|
51
|
+
return readFileSync(path, "utf-8")
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map(parseLine)
|
|
54
|
+
.filter((e): e is RuleEvent => e !== null && Date.parse(e.ts) >= cutoff);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function turnAfter(event: RuleEvent, turns: Turn[]): Turn | undefined {
|
|
58
|
+
return turns.find((t) => t.session === event.session && t.ts > event.ts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isConfirmedCorrection(turn: Turn | undefined): boolean {
|
|
62
|
+
return turn?.confirmed === true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const emptyEffect = (): RuleEffect => ({
|
|
66
|
+
fired: 0,
|
|
67
|
+
sentBack: 0,
|
|
68
|
+
judged: 0,
|
|
69
|
+
correctedAfter: 0,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export function ruleEffects(
|
|
73
|
+
events: RuleEvent[],
|
|
74
|
+
turns: Turn[]
|
|
75
|
+
): Record<string, RuleEffect> {
|
|
76
|
+
const effects: Record<string, RuleEffect> = {};
|
|
77
|
+
for (const event of events) {
|
|
78
|
+
const effect = effects[event.rule] ?? emptyEffect();
|
|
79
|
+
const judge = turnAfter(event, turns);
|
|
80
|
+
effect.fired += 1;
|
|
81
|
+
if (event.sentBack) effect.sentBack += 1;
|
|
82
|
+
if (judge) effect.judged += 1;
|
|
83
|
+
if (isConfirmedCorrection(judge)) effect.correctedAfter += 1;
|
|
84
|
+
effects[event.rule] = effect;
|
|
85
|
+
}
|
|
86
|
+
return effects;
|
|
87
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proof: replay a candidate's trigger over the turn log. A trigger earns a
|
|
3
|
+
* draft when it fires on repeated corrections and stays quiet on ordinary turns.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RuleTrigger, TriggerProof } from "./adaptation-rules";
|
|
7
|
+
import type { RequestedTurn } from "./adaptation-turns";
|
|
8
|
+
|
|
9
|
+
const MIN_FIRED_CORRECTIONS = 2;
|
|
10
|
+
const MAX_ORDINARY_FIRE_RATE = 0.1;
|
|
11
|
+
const MIN_ORDINARY_TURNS = 20;
|
|
12
|
+
|
|
13
|
+
export type ProofVerdict = "passed" | "failed" | "waiting";
|
|
14
|
+
|
|
15
|
+
const CORRECTION_REACTIONS = new Set(["corrected", "repeated"]);
|
|
16
|
+
|
|
17
|
+
function isConfirmedCorrection(turn: RequestedTurn): boolean {
|
|
18
|
+
return CORRECTION_REACTIONS.has(turn.reaction) && turn.confirmed === true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isOrdinary(turn: RequestedTurn): boolean {
|
|
22
|
+
return !CORRECTION_REACTIONS.has(turn.reaction);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function triggerText(trigger: RuleTrigger, turn: RequestedTurn): string {
|
|
26
|
+
return trigger.side === "reply" ? turn.replyEnd : turn.prompt;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function proveTrigger(trigger: RuleTrigger, turns: RequestedTurn[]): TriggerProof {
|
|
30
|
+
const pattern = new RegExp(trigger.pattern, "i");
|
|
31
|
+
const testable = turns.filter((turn) => triggerText(trigger, turn) !== "");
|
|
32
|
+
const fires = (turn: RequestedTurn) => pattern.test(triggerText(trigger, turn));
|
|
33
|
+
const corrections = testable.filter(isConfirmedCorrection);
|
|
34
|
+
const ordinary = testable.filter(isOrdinary);
|
|
35
|
+
return {
|
|
36
|
+
firedCorrections: corrections.filter(fires).length,
|
|
37
|
+
corrections: corrections.length,
|
|
38
|
+
firedOrdinary: ordinary.filter(fires).length,
|
|
39
|
+
ordinary: ordinary.length,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ordinaryTurnsNeeded(proof: TriggerProof): number {
|
|
44
|
+
return Math.max(0, MIN_ORDINARY_TURNS - proof.ordinary);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function proofVerdict(proof: TriggerProof): ProofVerdict {
|
|
48
|
+
if (proof.firedCorrections < MIN_FIRED_CORRECTIONS) return "failed";
|
|
49
|
+
if (proof.ordinary < MIN_ORDINARY_TURNS) return "waiting";
|
|
50
|
+
return proof.firedOrdinary <= proof.ordinary * MAX_ORDINARY_FIRE_RATE
|
|
51
|
+
? "passed"
|
|
52
|
+
: "failed";
|
|
53
|
+
}
|
|
@@ -179,11 +179,12 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
179
179
|
const text = partsToText(output.parts ?? []);
|
|
180
180
|
if (!text.trim()) return;
|
|
181
181
|
|
|
182
|
+
const sentAt = new Date();
|
|
182
183
|
const injectedText = (await getPromptContext(text, input.sessionID)) ?? "";
|
|
183
184
|
logPromptSnapshot(text, injectedText || null);
|
|
184
185
|
|
|
185
186
|
await Promise.allSettled([
|
|
186
|
-
captureRating(text, input.sessionID),
|
|
187
|
+
captureRating(text, input.sessionID, sentAt),
|
|
187
188
|
captureSessionName(text, input.sessionID),
|
|
188
189
|
]);
|
|
189
190
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
|
+
import { readRules } from "../../hooks/lib/adaptation-rules";
|
|
12
13
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
13
14
|
import { readAllProjects } from "../../hooks/lib/projects";
|
|
14
15
|
import { queryLedger } from "../ledger/query";
|
|
@@ -127,10 +128,27 @@ function unranked(): AttentionItem[] {
|
|
|
127
128
|
}));
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
function ruleDrafts(): AttentionItem[] {
|
|
132
|
+
return readRules()
|
|
133
|
+
.filter((rule) => rule.status === "draft")
|
|
134
|
+
.map((rule) => ({
|
|
135
|
+
id: `rule:${rule.id}`,
|
|
136
|
+
source: "rules" as const,
|
|
137
|
+
severity: "waiting" as const,
|
|
138
|
+
title: `Rule draft waiting: ${rule.when}`,
|
|
139
|
+
detail: rule.steering,
|
|
140
|
+
at: rule.createdAt,
|
|
141
|
+
project: null,
|
|
142
|
+
href: "/relationship",
|
|
143
|
+
read: false,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
const SOURCES: Record<AttentionSource, (now: Date) => AttentionItem[]> = {
|
|
131
148
|
refusals,
|
|
132
149
|
waiting,
|
|
133
150
|
unranked: () => unranked(),
|
|
151
|
+
rules: () => ruleDrafts(),
|
|
134
152
|
};
|
|
135
153
|
|
|
136
154
|
export function attention(now: Date = new Date()): AttentionView {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
write as writeSettings,
|
|
15
15
|
} from "../../hooks/lib/settings";
|
|
16
16
|
|
|
17
|
-
const ATTENTION_SOURCES = ["refusals", "waiting", "unranked"] as const;
|
|
17
|
+
const ATTENTION_SOURCES = ["refusals", "waiting", "unranked", "rules"] as const;
|
|
18
18
|
export type AttentionSource = (typeof ATTENTION_SOURCES)[number];
|
|
19
19
|
|
|
20
20
|
export interface ControlRoomPrefs {
|
|
@@ -28,7 +28,7 @@ const PREF_DEFAULTS: ControlRoomPrefs = {
|
|
|
28
28
|
quietAfterDays: PROJECT_STALE_DAYS_DEFAULT,
|
|
29
29
|
urgentWithinDays: 14,
|
|
30
30
|
rankGoals: true,
|
|
31
|
-
attention: { refusals: true, waiting: true, unranked: true },
|
|
31
|
+
attention: { refusals: true, waiting: true, unranked: true, rules: true },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const DAY_LIMIT = 365;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Relationship tab: the rule drafts waiting on the user, what the approved
|
|
3
|
+
* rules did, the candidates still being proven, and the last 30 days of labelled
|
|
4
|
+
* turns. A decision goes through `decideRule`, the same rule the CLI and the
|
|
5
|
+
* rule-review skill use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type Candidate, readCandidates } from "../../hooks/lib/adaptation-candidates";
|
|
9
|
+
import {
|
|
10
|
+
type AdaptationRule,
|
|
11
|
+
decideRule,
|
|
12
|
+
readRules,
|
|
13
|
+
} from "../../hooks/lib/adaptation-rules";
|
|
14
|
+
import { readTurns, type Turn } from "../../hooks/lib/adaptation-turns";
|
|
15
|
+
import {
|
|
16
|
+
type RuleEffect,
|
|
17
|
+
readRuleEvents,
|
|
18
|
+
ruleEffects,
|
|
19
|
+
} from "../../hooks/lib/rule-effect";
|
|
20
|
+
import { ordinaryTurnsNeeded } from "../../hooks/lib/rule-proof";
|
|
21
|
+
import type { WriteOutcome } from "./writes";
|
|
22
|
+
|
|
23
|
+
const WINDOW_DAYS = 30;
|
|
24
|
+
const RECENT_CORRECTIONS = 10;
|
|
25
|
+
|
|
26
|
+
export interface ActiveRule extends AdaptationRule {
|
|
27
|
+
effect: RuleEffect;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PipelineCandidate extends Candidate {
|
|
31
|
+
ordinaryNeeded: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TurnStats {
|
|
35
|
+
total: number;
|
|
36
|
+
byReaction: Record<string, number>;
|
|
37
|
+
confirmedCorrections: number;
|
|
38
|
+
recentCorrections: { ts: string; issue: string; message: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RelationshipView {
|
|
42
|
+
windowDays: number;
|
|
43
|
+
drafts: AdaptationRule[];
|
|
44
|
+
active: ActiveRule[];
|
|
45
|
+
denied: AdaptationRule[];
|
|
46
|
+
pipeline: PipelineCandidate[];
|
|
47
|
+
turns: TurnStats;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function turnStats(turns: Turn[]): TurnStats {
|
|
51
|
+
const byReaction: Record<string, number> = {};
|
|
52
|
+
for (const turn of turns)
|
|
53
|
+
byReaction[turn.reaction] = (byReaction[turn.reaction] ?? 0) + 1;
|
|
54
|
+
const confirmed = turns.filter((turn) => turn.confirmed === true);
|
|
55
|
+
return {
|
|
56
|
+
total: turns.length,
|
|
57
|
+
byReaction,
|
|
58
|
+
confirmedCorrections: confirmed.length,
|
|
59
|
+
recentCorrections: confirmed
|
|
60
|
+
.slice(-RECENT_CORRECTIONS)
|
|
61
|
+
.reverse()
|
|
62
|
+
.map(({ ts, issue, message }) => ({ ts, issue, message })),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NO_EFFECT: RuleEffect = { fired: 0, sentBack: 0, judged: 0, correctedAfter: 0 };
|
|
67
|
+
|
|
68
|
+
export function relationship(now: Date = new Date()): RelationshipView {
|
|
69
|
+
const rules = readRules();
|
|
70
|
+
const turns = readTurns(WINDOW_DAYS, now);
|
|
71
|
+
const effects = ruleEffects(readRuleEvents(WINDOW_DAYS, now), turns);
|
|
72
|
+
const withStatus = (status: AdaptationRule["status"]) =>
|
|
73
|
+
rules.filter((rule) => rule.status === status);
|
|
74
|
+
return {
|
|
75
|
+
windowDays: WINDOW_DAYS,
|
|
76
|
+
drafts: withStatus("draft"),
|
|
77
|
+
active: withStatus("approved").map((rule) => ({
|
|
78
|
+
...rule,
|
|
79
|
+
effect: effects[rule.id] ?? NO_EFFECT,
|
|
80
|
+
})),
|
|
81
|
+
denied: withStatus("denied"),
|
|
82
|
+
pipeline: readCandidates()
|
|
83
|
+
.filter((candidate) => candidate.verdict !== "passed")
|
|
84
|
+
.map((candidate) => ({
|
|
85
|
+
...candidate,
|
|
86
|
+
ordinaryNeeded:
|
|
87
|
+
candidate.verdict === "waiting" ? ordinaryTurnsNeeded(candidate.proof) : 0,
|
|
88
|
+
})),
|
|
89
|
+
turns: turnStats(turns),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decideFromPage(
|
|
94
|
+
id: string,
|
|
95
|
+
decision: "approved" | "denied"
|
|
96
|
+
): WriteOutcome {
|
|
97
|
+
const known = readRules().some((rule) => rule.id === id);
|
|
98
|
+
const result = decideRule(id, decision);
|
|
99
|
+
if (result.ok) return { ok: true, changed: true };
|
|
100
|
+
return { ok: false, status: known ? 409 : 404, error: result.reason };
|
|
101
|
+
}
|
|
@@ -18,6 +18,7 @@ import { agenda, agentsAtWork, board, handoffs, signal, summary } from "./data";
|
|
|
18
18
|
import { projectDetail } from "./detail";
|
|
19
19
|
import { matrix } from "./matrix";
|
|
20
20
|
import { type PrefUpdate, readPrefs, validatePrefs, writePrefs } from "./prefs";
|
|
21
|
+
import { decideFromPage, relationship } from "./relationship";
|
|
21
22
|
import { DEFAULT_PORT, LOOPBACK } from "./server-config";
|
|
22
23
|
import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
|
|
23
24
|
import { indexHtml, staticAsset } from "./static";
|
|
@@ -39,7 +40,14 @@ export { DEFAULT_PORT, LOOPBACK };
|
|
|
39
40
|
* Every path the single-page app owns. Listed rather than wildcarded, because a
|
|
40
41
|
* blanket "/*" outranks the fetch handler and would swallow /api as well.
|
|
41
42
|
*/
|
|
42
|
-
const PAGE_ROUTES = [
|
|
43
|
+
const PAGE_ROUTES = [
|
|
44
|
+
"/",
|
|
45
|
+
"/projects",
|
|
46
|
+
"/projects/:slug",
|
|
47
|
+
"/log",
|
|
48
|
+
"/relationship",
|
|
49
|
+
"/settings",
|
|
50
|
+
];
|
|
43
51
|
|
|
44
52
|
export interface ServerStatus {
|
|
45
53
|
pid: number;
|
|
@@ -284,7 +292,20 @@ async function prefsWrite(request: Request): Promise<Response> {
|
|
|
284
292
|
return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
|
|
285
293
|
}
|
|
286
294
|
|
|
295
|
+
/** Approve or deny a rule draft — the same rule `pal cli rule` reaches. */
|
|
296
|
+
async function ruleWrite(request: Request): Promise<Response> {
|
|
297
|
+
const body = await readBody(request);
|
|
298
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
299
|
+
const { id, decision } = body;
|
|
300
|
+
if (typeof id !== "string" || !id) return json({ error: "id is required" }, 400);
|
|
301
|
+
if (decision !== "approved" && decision !== "denied") {
|
|
302
|
+
return json({ error: "decision must be approved or denied" }, 400);
|
|
303
|
+
}
|
|
304
|
+
return answer(decideFromPage(id, decision), { id, decision });
|
|
305
|
+
}
|
|
306
|
+
|
|
287
307
|
const WRITES: Record<string, (request: Request) => Promise<Response>> = {
|
|
308
|
+
"/api/rule": ruleWrite,
|
|
288
309
|
"/api/serves": overrideServes,
|
|
289
310
|
"/api/isc": iscWrite,
|
|
290
311
|
"/api/placement": placementWrite,
|
|
@@ -340,6 +361,8 @@ export function startControlRoom(port: number = DEFAULT_PORT) {
|
|
|
340
361
|
return json(autoUpdateStatus());
|
|
341
362
|
case "/api/attention":
|
|
342
363
|
return json(attention());
|
|
364
|
+
case "/api/relationship":
|
|
365
|
+
return json(relationship());
|
|
343
366
|
case "/api/status":
|
|
344
367
|
return status(server.port ?? port, startedAt);
|
|
345
368
|
default:
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
STATUS_FILTERS,
|
|
23
23
|
type StatusFilter,
|
|
24
24
|
} from "./screens/projects";
|
|
25
|
+
import { Relationship } from "./screens/relationship";
|
|
25
26
|
import { Settings } from "./screens/settings";
|
|
26
27
|
import { isLayout, LAYOUTS, type Layout, Today } from "./screens/today";
|
|
27
28
|
|
|
@@ -126,6 +127,23 @@ function LogRoute() {
|
|
|
126
127
|
);
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
function RelationshipRoute() {
|
|
131
|
+
return (
|
|
132
|
+
<Screen
|
|
133
|
+
title="Relationship"
|
|
134
|
+
aside={
|
|
135
|
+
<span className="text-[12px] text-neutral-700">
|
|
136
|
+
rules drafted from your corrections, and what they did
|
|
137
|
+
</span>
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
<Scroller className={MARK_ROOM}>
|
|
141
|
+
<Relationship />
|
|
142
|
+
</Scroller>
|
|
143
|
+
</Screen>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
function SettingsRoute() {
|
|
130
148
|
return (
|
|
131
149
|
<Screen title="Settings">
|
|
@@ -140,6 +158,7 @@ const TABS = [
|
|
|
140
158
|
{ to: "/", label: "Today", end: true },
|
|
141
159
|
{ to: "/projects", label: "Projects", end: false },
|
|
142
160
|
{ to: "/log", label: "Action log", end: false },
|
|
161
|
+
{ to: "/relationship", label: "Relationship", end: false },
|
|
143
162
|
{ to: "/settings", label: "Settings", end: false },
|
|
144
163
|
] as const;
|
|
145
164
|
|
|
@@ -194,6 +213,7 @@ function App() {
|
|
|
194
213
|
<Route path="/projects" element={<ProjectsRoute />} />
|
|
195
214
|
<Route path="/projects/:slug" element={<ProjectDetailRoute />} />
|
|
196
215
|
<Route path="/log" element={<LogRoute />} />
|
|
216
|
+
<Route path="/relationship" element={<RelationshipRoute />} />
|
|
197
217
|
<Route path="/settings" element={<SettingsRoute />} />
|
|
198
218
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
199
219
|
</Routes>
|