portable-agent-layer 0.69.0 → 0.71.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.
Files changed (43) hide show
  1. package/README.md +5 -0
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/onboarding/SKILL.md +109 -0
  4. package/assets/skills/projects/SKILL.md +11 -2
  5. package/assets/templates/pal-settings.json +1 -0
  6. package/package.json +5 -1
  7. package/src/cli/index.ts +45 -10
  8. package/src/cli/ledger.ts +1 -17
  9. package/src/cli/personal-context.ts +67 -0
  10. package/src/cli/server.ts +201 -0
  11. package/src/cli/setup-identity.ts +13 -1
  12. package/src/hooks/handlers/agenda.ts +223 -0
  13. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  14. package/src/hooks/lib/agenda-store.ts +41 -0
  15. package/src/hooks/lib/paths.ts +1 -0
  16. package/src/hooks/lib/projects.ts +16 -1
  17. package/src/hooks/lib/serves.ts +60 -0
  18. package/src/hooks/lib/stop.ts +14 -0
  19. package/src/hooks/lib/telos-goals.ts +144 -0
  20. package/src/hooks/lib/telos-topics.ts +68 -0
  21. package/src/hooks/lib/token-usage.ts +3 -1
  22. package/src/hooks/lib/wall-clock.ts +58 -0
  23. package/src/tools/agent/handoff-note.ts +38 -20
  24. package/src/tools/agent/project.ts +36 -4
  25. package/src/tools/control-room/data.ts +332 -0
  26. package/src/tools/control-room/matrix.ts +182 -0
  27. package/src/tools/control-room/server.ts +150 -0
  28. package/src/tools/control-room/ui/agenda.tsx +43 -0
  29. package/src/tools/control-room/ui/agents.tsx +67 -0
  30. package/src/tools/control-room/ui/app.css +857 -0
  31. package/src/tools/control-room/ui/app.tsx +74 -0
  32. package/src/tools/control-room/ui/board.tsx +82 -0
  33. package/src/tools/control-room/ui/format.ts +31 -0
  34. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  35. package/src/tools/control-room/ui/index.html +19 -0
  36. package/src/tools/control-room/ui/ledger.tsx +136 -0
  37. package/src/tools/control-room/ui/matrix.tsx +117 -0
  38. package/src/tools/control-room/ui/panel.tsx +60 -0
  39. package/src/tools/control-room/ui/signal.tsx +161 -0
  40. package/src/tools/ledger/query.ts +18 -0
  41. package/src/tools/ledger/view.ts +130 -0
  42. package/src/cli/setup-telos.ts +0 -52
  43. package/src/hooks/lib/setup.ts +0 -60
@@ -0,0 +1,161 @@
1
+ import type { DueBadge, RatingPoint, SignalView } from "../data";
2
+ import { percent, tenths } from "./format";
3
+ import { Panel, Pending, useLoaded } from "./panel";
4
+
5
+ const LOW_RATING = 3;
6
+ const W = 320;
7
+ const H = 72;
8
+ const PAD = 4;
9
+
10
+ function sparkPath(points: RatingPoint[]): {
11
+ line: string;
12
+ area: string;
13
+ xy: [number, number][];
14
+ } {
15
+ if (points.length === 0) return { line: "", area: "", xy: [] };
16
+ const step = points.length > 1 ? (W - PAD * 2) / (points.length - 1) : 0;
17
+ const xy = points.map<[number, number]>((p, i) => [
18
+ PAD + i * step,
19
+ H - PAD - ((p.rating - 1) / 9) * (H - PAD * 2),
20
+ ]);
21
+ const line = xy
22
+ .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`)
23
+ .join(" ");
24
+ const area = `${line} L${xy.at(-1)?.[0].toFixed(1)} ${H} L${xy[0][0].toFixed(1)} ${H} Z`;
25
+ return { line, area, xy };
26
+ }
27
+
28
+ function Sparkline({ points }: { points: RatingPoint[] }) {
29
+ const { line, area, xy } = sparkPath(points);
30
+ const last = xy.at(-1);
31
+ const midY = H - PAD - (4 / 9) * (H - PAD * 2);
32
+ return (
33
+ <svg
34
+ className="spark"
35
+ viewBox={`0 0 ${W} ${H}`}
36
+ preserveAspectRatio="none"
37
+ role="img"
38
+ aria-label="ratings"
39
+ >
40
+ <title>ratings, oldest to newest</title>
41
+ <defs>
42
+ <linearGradient id="sparkfill" x1="0" x2="0" y1="0" y2="1">
43
+ <stop offset="0" stopColor="#f0b14a" stopOpacity="0.28" />
44
+ <stop offset="1" stopColor="#f0b14a" stopOpacity="0" />
45
+ </linearGradient>
46
+ </defs>
47
+ <line className="rule" x1={PAD} x2={W - PAD} y1={midY} y2={midY} />
48
+ <path className="area" d={area} />
49
+ <path className="line" d={line} />
50
+ {points.map((p, i) =>
51
+ p.rating <= LOW_RATING ? (
52
+ <circle key={p.ts} className="low" cx={xy[i][0]} cy={xy[i][1]} r="2" />
53
+ ) : null
54
+ )}
55
+ {last && <circle className="last" cx={last[0]} cy={last[1]} r="3" />}
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ function Figure({ value, label, tone }: { value: string; label: string; tone?: string }) {
61
+ return (
62
+ <div className="figure">
63
+ <div className={`value ${tone ?? ""}`}>{value}</div>
64
+ <div className="label">{label}</div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ const BADGE_LOOK: Record<DueBadge["state"], { row: string; tag: string }> = {
70
+ due: { row: "due", tag: "amber" },
71
+ clear: { row: "clear", tag: "good" },
72
+ "n/a": { row: "na", tag: "ghost" },
73
+ };
74
+
75
+ function Due({ name, badge }: { name: string; badge: DueBadge }) {
76
+ const look = BADGE_LOOK[badge.state];
77
+ return (
78
+ <div className={`due-row ${look.row}`}>
79
+ <span className={`tag ${look.tag}`}>{badge.state}</span>
80
+ <span className="detail">
81
+ {name}
82
+ {badge.detail ? ` — ${badge.detail}` : ""}
83
+ </span>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ function ratingTone(avg: number): string {
89
+ if (avg < 5) return "bad";
90
+ if (avg >= 7) return "good";
91
+ return "";
92
+ }
93
+
94
+ export function Signal() {
95
+ const view = useLoaded<SignalView>("/api/signal");
96
+ return (
97
+ <Panel
98
+ index="02 · feedback"
99
+ title="Signal"
100
+ span={4}
101
+ order={1}
102
+ aside={
103
+ view.state === "ready" && view.data.synthesizedAt
104
+ ? `synthesised ${view.data.synthesizedAt.slice(0, 10)}`
105
+ : ""
106
+ }
107
+ >
108
+ <Pending value={view} />
109
+ {view.state === "ready" && (
110
+ <>
111
+ <div className="figures">
112
+ <Figure
113
+ value={view.data.ratings ? tenths(view.data.ratings.recentAvg) : "–"}
114
+ label="last 10"
115
+ tone={view.data.ratings ? ratingTone(view.data.ratings.recentAvg) : ""}
116
+ />
117
+ <Figure
118
+ value={view.data.ratings ? tenths(view.data.ratings.avg) : "–"}
119
+ label={`avg of ${view.data.ratings?.count ?? 0}`}
120
+ />
121
+ <Figure
122
+ value={view.data.ratings ? String(view.data.ratings.lowCount) : "–"}
123
+ label="low (≤3)"
124
+ tone={view.data.ratings && view.data.ratings.lowCount > 5 ? "bad" : ""}
125
+ />
126
+ </div>
127
+ {view.data.series.length > 0 ? (
128
+ <Sparkline points={view.data.series} />
129
+ ) : (
130
+ <div className="empty">No ratings yet.</div>
131
+ )}
132
+ <div className="spark-caption">
133
+ <span>last {view.data.series.length} ratings</span>
134
+ <span>{view.data.ratings?.trend ?? ""}</span>
135
+ </div>
136
+ <div className="figures" style={{ marginTop: 16 }}>
137
+ <Figure
138
+ value={
139
+ view.data.algorithm ? String(view.data.algorithm.reflectionCount) : "–"
140
+ }
141
+ label="reflections"
142
+ />
143
+ <Figure
144
+ value={view.data.algorithm ? percent(view.data.algorithm.passRate) : "–"}
145
+ label="criteria pass"
146
+ />
147
+ <Figure
148
+ value={view.data.algorithm ? tenths(view.data.algorithm.avgSentiment) : "–"}
149
+ label="sentiment"
150
+ />
151
+ </div>
152
+ <div className="due">
153
+ <Due name="learning analysis" badge={view.data.due.analysis} />
154
+ <Due name="algorithm review" badge={view.data.due.algorithmReview} />
155
+ <Due name="relationship reflect" badge={view.data.due.relationshipReflect} />
156
+ </div>
157
+ </>
158
+ )}
159
+ </Panel>
160
+ );
161
+ }
@@ -158,6 +158,24 @@ export function changeShape(entry: LedgerEntry): ChangeShape {
158
158
  * its before-state again. `reverted` is exactly that case, and there the delta
159
159
  * can be run forward for real, which is why it reports whether it did.
160
160
  */
161
+ /** The change as a reader would want it summarised: a line count, or why there is none. */
162
+ export function changedLines(entry: LedgerEntry): string {
163
+ const shape = changeShape(entry);
164
+ switch (shape.kind) {
165
+ case "redacted":
166
+ return "withheld";
167
+ case "truncated":
168
+ return "too large";
169
+ case "none":
170
+ return "no change";
171
+ default: {
172
+ const added = shape.delta.hunks.reduce((n, h) => n + h.insert.length, 0);
173
+ const removed = shape.delta.hunks.reduce((n, h) => n + h.remove, 0);
174
+ return `+${added} -${removed}`;
175
+ }
176
+ }
177
+ }
178
+
161
179
  export type Standing =
162
180
  | { state: "in-place" }
163
181
  | { state: "reverted"; replays: boolean }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The ledger as a page reads it: counts first, rows second, every field
3
+ * already in the words a person would use. Ids become labels, anchors become
4
+ * "project / path", deltas become line counts. Nothing here is computed in
5
+ * the browser, so the numbers on the page are the numbers the tests check.
6
+ */
7
+
8
+ import {
9
+ type ActorRegistryEntry,
10
+ actorDisplayName,
11
+ loadActor,
12
+ readActorRegistry,
13
+ } from "../../hooks/lib/actor";
14
+ import type { LedgerEntry } from "../../hooks/lib/ledger";
15
+ import { anchorSlugOf, changedLines, type LedgerFilter, queryLedger } from "./query";
16
+
17
+ export const PAGE_OUTCOMES = ["applied", "failed", "denied", "blocked"] as const;
18
+ export type PageOutcome = (typeof PAGE_OUTCOMES)[number];
19
+
20
+ export interface OutcomeCount {
21
+ total: number;
22
+ byAuthority: Record<string, number>;
23
+ byRuntime: Record<string, number>;
24
+ }
25
+
26
+ export interface LedgerViewStats {
27
+ total: number;
28
+ refusals: number;
29
+ outcomes: Record<PageOutcome, OutcomeCount>;
30
+ }
31
+
32
+ export interface LedgerViewRow {
33
+ id: string;
34
+ ts: string;
35
+ actor: string;
36
+ authority: string;
37
+ runtime: string;
38
+ tool: string;
39
+ target: string;
40
+ change: string;
41
+ outcome: string;
42
+ reason?: string;
43
+ }
44
+
45
+ export interface LedgerView {
46
+ window: { since: string | null; until: string | null };
47
+ stats: LedgerViewStats;
48
+ rows: LedgerViewRow[];
49
+ }
50
+
51
+ function emptyCount(): OutcomeCount {
52
+ return { total: 0, byAuthority: {}, byRuntime: {} };
53
+ }
54
+
55
+ function bump(counts: Record<string, number>, key: string): void {
56
+ counts[key] = (counts[key] ?? 0) + 1;
57
+ }
58
+
59
+ function isPageOutcome(outcome: string): outcome is PageOutcome {
60
+ return (PAGE_OUTCOMES as readonly string[]).includes(outcome);
61
+ }
62
+
63
+ export function viewStats(entries: LedgerEntry[]): LedgerViewStats {
64
+ const outcomes = Object.fromEntries(
65
+ PAGE_OUTCOMES.map((outcome) => [outcome, emptyCount()])
66
+ ) as Record<PageOutcome, OutcomeCount>;
67
+
68
+ for (const entry of entries) {
69
+ if (!isPageOutcome(entry.outcome)) continue;
70
+ const count = outcomes[entry.outcome];
71
+ count.total++;
72
+ bump(count.byAuthority, entry.authority);
73
+ bump(count.byRuntime, entry.runtime);
74
+ }
75
+
76
+ return {
77
+ total: entries.length,
78
+ refusals: outcomes.denied.total + outcomes.blocked.total,
79
+ outcomes,
80
+ };
81
+ }
82
+
83
+ export function displayTarget(target: string): string {
84
+ const slug = anchorSlugOf(target);
85
+ if (!slug) return target;
86
+ const rest = target.slice(`{proj:${slug}}`.length);
87
+ return rest ? `${slug} ${rest}` : slug;
88
+ }
89
+
90
+ function toRow(entry: LedgerEntry, registry: ActorRegistryEntry[]): LedgerViewRow {
91
+ const row: LedgerViewRow = {
92
+ id: entry.id,
93
+ ts: entry.ts,
94
+ actor: actorDisplayName(entry.actor, registry),
95
+ authority: entry.authority,
96
+ runtime: entry.runtime,
97
+ tool: entry.tool,
98
+ target: displayTarget(entry.target),
99
+ change: changedLines(entry),
100
+ outcome: entry.outcome,
101
+ };
102
+ if (entry.reason) row.reason = entry.reason;
103
+ return row;
104
+ }
105
+
106
+ /** The local actor first: on a fresh install the registry may not list them yet. */
107
+ function knownActors(): ActorRegistryEntry[] {
108
+ const self = loadActor();
109
+ return [{ id: self.id, label: self.label }, ...readActorRegistry()];
110
+ }
111
+
112
+ export function viewRows(entries: LedgerEntry[]): LedgerViewRow[] {
113
+ const registry = knownActors();
114
+ return entries
115
+ .slice()
116
+ .reverse()
117
+ .map((entry) => toRow(entry, registry));
118
+ }
119
+
120
+ export function ledgerView(filter: LedgerFilter = {}): LedgerView {
121
+ const entries = queryLedger(filter);
122
+ return {
123
+ window: {
124
+ since: filter.since?.toISOString() ?? null,
125
+ until: filter.until?.toISOString() ?? null,
126
+ },
127
+ stats: viewStats(entries),
128
+ rows: viewRows(entries),
129
+ };
130
+ }
@@ -1,52 +0,0 @@
1
- /**
2
- * Interactive TELOS setup — prompts for personal context during `pal install`.
3
- * Skips any step whose TELOS file already has real content.
4
- */
5
-
6
- import { writeFileSync } from "node:fs";
7
- import { resolve } from "node:path";
8
- import * as clack from "@clack/prompts";
9
- import { palHome } from "../hooks/lib/paths";
10
- import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
11
-
12
- /** Prompt for missing TELOS context. Skips any step whose file already has real content. */
13
- export async function promptTelos(): Promise<void> {
14
- // Skip interactive prompts in non-TTY environments (tests, CI)
15
- if (!process.stdin.isTTY) return;
16
-
17
- const home = palHome();
18
- const pending = STEP_ORDER.filter(
19
- (key) => !hasRealContent(resolve(home, SETUP_STEPS[key].file))
20
- );
21
-
22
- if (pending.length === 0) {
23
- clack.log.info("TELOS already configured");
24
- return;
25
- }
26
-
27
- clack.intro("Personal Context Setup");
28
- clack.note(
29
- "Answer in a sentence or two — you can edit the files in ~/.pal/telos/ for more detail later.",
30
- "Quick setup"
31
- );
32
-
33
- for (const key of pending) {
34
- const step = SETUP_STEPS[key];
35
- const title = key.charAt(0).toUpperCase() + key.slice(1);
36
-
37
- const answer = await clack.text({
38
- message: step.question,
39
- placeholder: step.hint,
40
- });
41
-
42
- if (clack.isCancel(answer)) {
43
- clack.cancel("Setup cancelled");
44
- return;
45
- }
46
-
47
- const filePath = resolve(home, step.file);
48
- writeFileSync(filePath, `# ${title}\n\n${answer}\n`, "utf-8");
49
- }
50
-
51
- clack.outro("Personal context saved ✓");
52
- }
@@ -1,60 +0,0 @@
1
- /**
2
- * Setup state management for PAL first-run wizard.
3
- *
4
- * State lives in memory/state/setup.json. Each step maps to a TELOS file.
5
- * The AI is instructed to mark steps done after writing each file.
6
- */
7
-
8
- import { existsSync, readFileSync } from "node:fs";
9
-
10
- interface SetupStep {
11
- done: boolean;
12
- file: string;
13
- question: string;
14
- hint: string;
15
- }
16
-
17
- /** Ordered setup steps — defines the wizard flow */
18
- export const SETUP_STEPS: Record<string, Omit<SetupStep, "done">> = {
19
- mission: {
20
- file: "telos/MISSION.md",
21
- question:
22
- "What do you do? What's your role and core purpose? (~/.pal/telos/MISSION.md)",
23
- hint: "e.g. Senior software engineer building developer tooling at Acme Corp",
24
- },
25
- goals: {
26
- file: "telos/GOALS.md",
27
- question:
28
- "What are your current goals? (short-term, medium-term, long-term) (~/.pal/telos/GOALS.md)",
29
- hint: "e.g. Ship v2 by Q3, learn Rust, get promoted to staff engineer",
30
- },
31
- beliefs: {
32
- file: "telos/BELIEFS.md",
33
- question: "What principles or values guide your work? (~/.pal/telos/BELIEFS.md)",
34
- hint: "e.g. Simple code > clever code, ship early and iterate, always write tests",
35
- },
36
- challenges: {
37
- file: "telos/CHALLENGES.md",
38
- question: "What are your biggest current challenges? (~/.pal/telos/CHALLENGES.md)",
39
- hint: "e.g. Context switching between projects, unclear requirements, work-life balance",
40
- },
41
- };
42
-
43
- export const STEP_ORDER = Object.keys(SETUP_STEPS);
44
-
45
- /** Check if a TELOS file has real content (not just template scaffolding) */
46
- export function hasRealContent(filePath: string): boolean {
47
- if (!existsSync(filePath)) return false;
48
- try {
49
- const content = readFileSync(filePath, "utf-8").trim();
50
- return content.split("\n").some((l) => {
51
- if (!l.trim()) return false;
52
- if (l.startsWith("#")) return false;
53
- if (l.startsWith("<!--") || l.startsWith("-->")) return false;
54
- if (/^\s*-\s*$/.test(l)) return false;
55
- return true; // includes table rows (| ... |) — counts as real content
56
- });
57
- } catch {
58
- return false;
59
- }
60
- }