portable-agent-layer 0.71.0 → 0.73.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/package.json +10 -7
- package/src/cli/migrate.ts +1 -1
- package/src/cli/server.ts +7 -0
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +34 -153
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +78 -7
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +92 -20
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +34 -10
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +102 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +212 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -136
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- package/src/tools/control-room/ui/signal.tsx +0 -161
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Industry, as Tailwind sees it. Source file — `bun run build:ui` compiles this
|
|
3
|
+
* to tailwind.css, which is what index.html actually links.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
@layer theme, base, components, utilities;
|
|
7
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
8
|
+
@import "tailwindcss/preflight.css" layer(base);
|
|
9
|
+
@import "tailwindcss/utilities.css" layer(utilities) source(none);
|
|
10
|
+
|
|
11
|
+
@source "./*.tsx";
|
|
12
|
+
@source "./**/*.tsx";
|
|
13
|
+
@source "./index.html";
|
|
14
|
+
|
|
15
|
+
@theme static {
|
|
16
|
+
--color-bg: #f2f2f3;
|
|
17
|
+
--color-surface: #e9e9ea;
|
|
18
|
+
--color-ink: #1d1f20;
|
|
19
|
+
--color-divider: color-mix(in srgb, #1d1f20 16%, transparent);
|
|
20
|
+
|
|
21
|
+
--color-neutral-100: #f5f5f8;
|
|
22
|
+
--color-neutral-200: #e7e7ea;
|
|
23
|
+
--color-neutral-300: #d4d4d7;
|
|
24
|
+
--color-neutral-400: #b7b7ba;
|
|
25
|
+
--color-neutral-500: #98989b;
|
|
26
|
+
--color-neutral-600: #7a7a7d;
|
|
27
|
+
--color-neutral-700: #5d5d60;
|
|
28
|
+
--color-neutral-800: #424244;
|
|
29
|
+
--color-neutral-900: #2b2b2d;
|
|
30
|
+
|
|
31
|
+
--color-accent: #5980a6;
|
|
32
|
+
--color-accent-100: #eef6ff;
|
|
33
|
+
--color-accent-200: #d6ebff;
|
|
34
|
+
--color-accent-300: #b5d9fd;
|
|
35
|
+
--color-accent-400: #94bce3;
|
|
36
|
+
--color-accent-500: #749dc4;
|
|
37
|
+
--color-accent-600: #597ea3;
|
|
38
|
+
--color-accent-700: #416180;
|
|
39
|
+
--color-accent-800: #2c455d;
|
|
40
|
+
--color-accent-900: #1d2d3d;
|
|
41
|
+
|
|
42
|
+
--color-alarm: oklch(0.55 0.17 27);
|
|
43
|
+
|
|
44
|
+
--font-heading: "Barlow Condensed", system-ui, sans-serif;
|
|
45
|
+
--font-body: "Barlow", system-ui, sans-serif;
|
|
46
|
+
--font-mono: ui-monospace, Menlo, monospace;
|
|
47
|
+
|
|
48
|
+
/* Industry's --space-N is exactly N x 3.4px, which is Tailwind's own scale
|
|
49
|
+
with a retuned base — so p-4 here is Industry's --space-4 to the pixel. */
|
|
50
|
+
--spacing: 0.2125rem;
|
|
51
|
+
|
|
52
|
+
--radius-sm: 2px;
|
|
53
|
+
--radius-md: 4px;
|
|
54
|
+
--radius-lg: 7px;
|
|
55
|
+
|
|
56
|
+
--shadow-sm: 0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent);
|
|
57
|
+
--shadow-md: 0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent);
|
|
58
|
+
--shadow-lg: 0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent);
|
|
59
|
+
|
|
60
|
+
/* Industry draws an 11px crosshair in 55% ink whose centre sits on the
|
|
61
|
+
border line — .corner is 11px at top/left -6px over a 1px border. */
|
|
62
|
+
--registration-size: 11px;
|
|
63
|
+
--registration-inset: -6px;
|
|
64
|
+
--registration-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11'%3E%3Cpath d='M5.5 0v11M0 5.5h11' stroke='%231d1f20' stroke-opacity='.55' stroke-width='1'/%3E%3C/svg%3E");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@layer base {
|
|
68
|
+
body {
|
|
69
|
+
background: var(--color-bg);
|
|
70
|
+
color: var(--color-ink);
|
|
71
|
+
font-family: var(--font-body);
|
|
72
|
+
font-size: 13px;
|
|
73
|
+
line-height: 1.45;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
h1,
|
|
77
|
+
h2,
|
|
78
|
+
h3,
|
|
79
|
+
h4,
|
|
80
|
+
h5,
|
|
81
|
+
h6 {
|
|
82
|
+
font-family: var(--font-heading);
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
letter-spacing: 0.02em;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
:focus-visible {
|
|
88
|
+
outline: 2px solid var(--color-accent);
|
|
89
|
+
outline-offset: 2px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
::selection {
|
|
93
|
+
background: var(--color-accent-200);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The wireframe frame every card, figure and primary button wears: square
|
|
99
|
+
* corners, a hairline border and four "+" registration marks drawn outside the
|
|
100
|
+
* box. Industry ships it as a class plus four <i> children; here the marks are
|
|
101
|
+
* one pseudo-element, so the markup stays clean.
|
|
102
|
+
*/
|
|
103
|
+
@utility blueprint {
|
|
104
|
+
position: relative;
|
|
105
|
+
border: 1px solid var(--color-divider);
|
|
106
|
+
border-radius: 0;
|
|
107
|
+
|
|
108
|
+
&::after {
|
|
109
|
+
content: "";
|
|
110
|
+
position: absolute;
|
|
111
|
+
inset: var(--registration-inset);
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
background-image: var(--registration-mark), var(--registration-mark),
|
|
114
|
+
var(--registration-mark), var(--registration-mark);
|
|
115
|
+
background-repeat: no-repeat;
|
|
116
|
+
background-size: var(--registration-size) var(--registration-size);
|
|
117
|
+
background-position: left top, right top, left bottom, right bottom;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Industry's uppercase eyebrow, used for every panel heading and column label. */
|
|
122
|
+
@utility eyebrow {
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
letter-spacing: 0.12em;
|
|
125
|
+
text-transform: uppercase;
|
|
126
|
+
color: var(--color-neutral-700);
|
|
127
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control room's build. Bun serves the page and owns every API route, but
|
|
3
|
+
* Bun's CSS bundler passes Tailwind's directives through as unknown at-rules —
|
|
4
|
+
* so the page is built here, by the first-party plugin that moves in lockstep
|
|
5
|
+
* with Tailwind itself, and Bun serves the result out of dist/.
|
|
6
|
+
*
|
|
7
|
+
* `server.proxy` is what makes `bun run dev:ui` usable: the page comes from Vite
|
|
8
|
+
* with hot reload while every /api call goes to the real control room.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
12
|
+
import react from "@vitejs/plugin-react";
|
|
13
|
+
import { defineConfig } from "vite";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mirrors DEFAULT_PORT in ../server-config.ts. A config the loader reads before
|
|
17
|
+
* TypeScript resolution cannot import from the source tree, so a test asserts
|
|
18
|
+
* the two agree.
|
|
19
|
+
*/
|
|
20
|
+
const API_ORIGIN = "http://127.0.0.1:7250";
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
root: import.meta.dirname,
|
|
24
|
+
base: "/",
|
|
25
|
+
plugins: [react(), tailwindcss()],
|
|
26
|
+
build: {
|
|
27
|
+
outDir: "dist",
|
|
28
|
+
emptyOutDir: true,
|
|
29
|
+
sourcemap: false,
|
|
30
|
+
},
|
|
31
|
+
server: {
|
|
32
|
+
proxy: {
|
|
33
|
+
"/api": API_ORIGIN,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three things the page may change without an agent.
|
|
3
|
+
*
|
|
4
|
+
* Each one is a single named field on a single record, reached through the same
|
|
5
|
+
* rule the CLI uses — the page is a second hand on the same instrument, not a
|
|
6
|
+
* write surface over ~/.pal.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readProject, writeProject } from "../../hooks/lib/projects";
|
|
10
|
+
import {
|
|
11
|
+
raw as rawSettings,
|
|
12
|
+
reload,
|
|
13
|
+
write as writeSettings,
|
|
14
|
+
} from "../../hooks/lib/settings";
|
|
15
|
+
import { completeIsc, type IscSections, reopenIsc } from "../lib/project-isc";
|
|
16
|
+
|
|
17
|
+
export type WriteOutcome =
|
|
18
|
+
| { ok: true; changed: boolean }
|
|
19
|
+
| { ok: false; status: number; error: string };
|
|
20
|
+
|
|
21
|
+
const missing = (project: string): WriteOutcome => ({
|
|
22
|
+
ok: false,
|
|
23
|
+
status: 404,
|
|
24
|
+
error: `no such project: ${project}`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function setIscStatus(project: string, id: number, close: boolean): WriteOutcome {
|
|
28
|
+
const p = readProject(project);
|
|
29
|
+
if (!p) return missing(project);
|
|
30
|
+
const sections: IscSections = {
|
|
31
|
+
criteria: p.criteria ?? "",
|
|
32
|
+
changelog: p.changelog ?? "",
|
|
33
|
+
};
|
|
34
|
+
const move = close ? completeIsc(sections, id) : reopenIsc(sections, id);
|
|
35
|
+
if (!move.ok) return { ok: false, status: 404, error: move.reason };
|
|
36
|
+
if (move.already) return { ok: true, changed: false };
|
|
37
|
+
p.criteria = move.criteria;
|
|
38
|
+
p.changelog = move.changelog;
|
|
39
|
+
p.updated = new Date().toISOString();
|
|
40
|
+
writeProject(p);
|
|
41
|
+
return { ok: true, changed: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const QUADRANTS = ["now", "plan", "noise", "later"] as const;
|
|
45
|
+
export type Quadrant = (typeof QUADRANTS)[number];
|
|
46
|
+
|
|
47
|
+
export function isQuadrant(value: unknown): value is Quadrant {
|
|
48
|
+
return typeof value === "string" && (QUADRANTS as readonly string[]).includes(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Importance already has a home in `serves`; urgency is read off the files.
|
|
53
|
+
* A placement overrules the pair, and says who decided, so the next guess can
|
|
54
|
+
* run freely without overwriting the answer.
|
|
55
|
+
*/
|
|
56
|
+
export function setPlacement(project: string, quadrant: Quadrant | null): WriteOutcome {
|
|
57
|
+
const p = readProject(project);
|
|
58
|
+
if (!p) return missing(project);
|
|
59
|
+
if (quadrant) {
|
|
60
|
+
p.placed = quadrant;
|
|
61
|
+
p.placed_by = "user";
|
|
62
|
+
} else {
|
|
63
|
+
p.placed = undefined;
|
|
64
|
+
p.placed_by = undefined;
|
|
65
|
+
}
|
|
66
|
+
p.updated = new Date().toISOString();
|
|
67
|
+
writeProject(p);
|
|
68
|
+
return { ok: true, changed: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InstallSettings {
|
|
72
|
+
actor: string;
|
|
73
|
+
timezone: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readInstallSettings(): InstallSettings {
|
|
77
|
+
const identity = rawSettings().identity ?? {};
|
|
78
|
+
return {
|
|
79
|
+
actor: identity.principal?.name ?? "",
|
|
80
|
+
timezone: identity.principal?.timezone ?? "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Only the two fields the Settings panel offers; everything else is left alone. */
|
|
85
|
+
export function writeInstallSettings(update: Partial<InstallSettings>): WriteOutcome {
|
|
86
|
+
if (update.timezone && !isTimezone(update.timezone)) {
|
|
87
|
+
return { ok: false, status: 400, error: `not a timezone: ${update.timezone}` };
|
|
88
|
+
}
|
|
89
|
+
const data = rawSettings();
|
|
90
|
+
const identity = data.identity ?? {};
|
|
91
|
+
const principal = { ...identity.principal };
|
|
92
|
+
if (update.actor !== undefined) principal.name = update.actor;
|
|
93
|
+
if (update.timezone !== undefined) principal.timezone = update.timezone;
|
|
94
|
+
writeSettings({ ...data, identity: { ...identity, principal } });
|
|
95
|
+
reload();
|
|
96
|
+
return { ok: true, changed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isTimezone(value: string): boolean {
|
|
100
|
+
try {
|
|
101
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outcomes a page groups by. Kept apart from view.ts because the browser
|
|
3
|
+
* needs this list and nothing else from that module — importing it there would
|
|
4
|
+
* drag the actor and machine registries, and node:fs behind them, into the
|
|
5
|
+
* page bundle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PAGE_OUTCOMES = ["applied", "failed", "denied", "blocked"] as const;
|
|
9
|
+
export type PageOutcome = (typeof PAGE_OUTCOMES)[number];
|
|
@@ -34,6 +34,7 @@ export interface LedgerFilter {
|
|
|
34
34
|
actor?: string;
|
|
35
35
|
machine?: string;
|
|
36
36
|
runtime?: string;
|
|
37
|
+
authority?: string;
|
|
37
38
|
outcome?: string;
|
|
38
39
|
tool?: string;
|
|
39
40
|
target?: string;
|
|
@@ -100,6 +101,7 @@ function matchesFilter(entry: LedgerEntry, filter: LedgerFilter): boolean {
|
|
|
100
101
|
if (filter.actor && entry.actor !== filter.actor) return false;
|
|
101
102
|
if (filter.machine && entry.machine !== filter.machine) return false;
|
|
102
103
|
if (filter.runtime && entry.runtime !== filter.runtime) return false;
|
|
104
|
+
if (filter.authority && entry.authority !== filter.authority) return false;
|
|
103
105
|
if (filter.outcome && entry.outcome !== filter.outcome) return false;
|
|
104
106
|
if (filter.tool && entry.tool.toLowerCase() !== filter.tool.toLowerCase()) return false;
|
|
105
107
|
if (filter.target && !entry.target.includes(filter.target)) return false;
|
package/src/tools/ledger/view.ts
CHANGED
|
@@ -12,10 +12,16 @@ import {
|
|
|
12
12
|
readActorRegistry,
|
|
13
13
|
} from "../../hooks/lib/actor";
|
|
14
14
|
import type { LedgerEntry } from "../../hooks/lib/ledger";
|
|
15
|
+
import {
|
|
16
|
+
loadMachine,
|
|
17
|
+
displayName as machineDisplayName,
|
|
18
|
+
type RegistryEntry,
|
|
19
|
+
readRegistry as readMachineRegistry,
|
|
20
|
+
} from "../../hooks/lib/machine";
|
|
21
|
+
import { PAGE_OUTCOMES, type PageOutcome } from "./outcomes";
|
|
15
22
|
import { anchorSlugOf, changedLines, type LedgerFilter, queryLedger } from "./query";
|
|
16
23
|
|
|
17
|
-
export
|
|
18
|
-
export type PageOutcome = (typeof PAGE_OUTCOMES)[number];
|
|
24
|
+
export { PAGE_OUTCOMES, type PageOutcome };
|
|
19
25
|
|
|
20
26
|
export interface OutcomeCount {
|
|
21
27
|
total: number;
|
|
@@ -32,6 +38,8 @@ export interface LedgerViewStats {
|
|
|
32
38
|
export interface LedgerViewRow {
|
|
33
39
|
id: string;
|
|
34
40
|
ts: string;
|
|
41
|
+
/** Where the record was written. Local-only in the record; the page names it. */
|
|
42
|
+
machine: string;
|
|
35
43
|
actor: string;
|
|
36
44
|
authority: string;
|
|
37
45
|
runtime: string;
|
|
@@ -40,6 +48,8 @@ export interface LedgerViewRow {
|
|
|
40
48
|
change: string;
|
|
41
49
|
outcome: string;
|
|
42
50
|
reason?: string;
|
|
51
|
+
/** Present only on a blocked shell action, where the target is a directory. */
|
|
52
|
+
command?: string;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
export interface LedgerView {
|
|
@@ -87,11 +97,17 @@ export function displayTarget(target: string): string {
|
|
|
87
97
|
return rest ? `${slug} ${rest}` : slug;
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
interface Names {
|
|
101
|
+
actors: ActorRegistryEntry[];
|
|
102
|
+
machines: RegistryEntry[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toRow(entry: LedgerEntry, { actors, machines }: Names): LedgerViewRow {
|
|
91
106
|
const row: LedgerViewRow = {
|
|
92
107
|
id: entry.id,
|
|
93
108
|
ts: entry.ts,
|
|
94
|
-
|
|
109
|
+
machine: machineDisplayName(entry.machine, machines),
|
|
110
|
+
actor: actorDisplayName(entry.actor, actors),
|
|
95
111
|
authority: entry.authority,
|
|
96
112
|
runtime: entry.runtime,
|
|
97
113
|
tool: entry.tool,
|
|
@@ -100,21 +116,29 @@ function toRow(entry: LedgerEntry, registry: ActorRegistryEntry[]): LedgerViewRo
|
|
|
100
116
|
outcome: entry.outcome,
|
|
101
117
|
};
|
|
102
118
|
if (entry.reason) row.reason = entry.reason;
|
|
119
|
+
if (entry.command) row.command = entry.command;
|
|
103
120
|
return row;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
|
-
/**
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
|
|
123
|
+
/** This install first: on a fresh machine the registries may not list it yet. */
|
|
124
|
+
function knownNames(): Names {
|
|
125
|
+
const actor = loadActor();
|
|
126
|
+
const machine = loadMachine();
|
|
127
|
+
return {
|
|
128
|
+
actors: [{ id: actor.id, label: actor.label }, ...readActorRegistry()],
|
|
129
|
+
machines: [
|
|
130
|
+
{ id: machine.id, label: machine.label, os: machine.os },
|
|
131
|
+
...readMachineRegistry(),
|
|
132
|
+
],
|
|
133
|
+
};
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
export function viewRows(entries: LedgerEntry[]): LedgerViewRow[] {
|
|
113
|
-
const
|
|
137
|
+
const names = knownNames();
|
|
114
138
|
return entries
|
|
115
139
|
.slice()
|
|
116
140
|
.reverse()
|
|
117
|
-
.map((entry) => toRow(entry,
|
|
141
|
+
.map((entry) => toRow(entry, names));
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
export function ledgerView(filter: LedgerFilter = {}): LedgerView {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of an algorithm reflection: what a LEARN phase records, and the
|
|
3
|
+
* defaults and clamps applied to whatever the CLI was handed.
|
|
4
|
+
*
|
|
5
|
+
* The tool around this is only ever spawned, so the stamping, the clamp and the
|
|
6
|
+
* scope default were reachable only through argv. They take their clock and
|
|
7
|
+
* their directory as parameters here, which is what makes them assertable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
|
|
11
|
+
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
12
|
+
|
|
13
|
+
const MIN_SENTIMENT = 1;
|
|
14
|
+
const MAX_SENTIMENT = 10;
|
|
15
|
+
const DEFAULT_SENTIMENT = 5;
|
|
16
|
+
|
|
17
|
+
export interface AlgorithmReflection extends RecordAttribution {
|
|
18
|
+
timestamp: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
task: string;
|
|
21
|
+
criteria_count: number;
|
|
22
|
+
criteria_passed: number;
|
|
23
|
+
criteria_failed: number;
|
|
24
|
+
sentiment: number;
|
|
25
|
+
q1: string;
|
|
26
|
+
q2: string;
|
|
27
|
+
q3: string;
|
|
28
|
+
/** general = the improvement ideas generalize to the algorithm; task-specific = bound to this task. */
|
|
29
|
+
scope: "general" | "task-specific";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReflectionInput {
|
|
33
|
+
task: string;
|
|
34
|
+
q1: string;
|
|
35
|
+
q2: string;
|
|
36
|
+
q3: string;
|
|
37
|
+
criteria_count?: number;
|
|
38
|
+
criteria_passed?: number;
|
|
39
|
+
criteria_failed?: number;
|
|
40
|
+
sentiment?: number;
|
|
41
|
+
scope?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An absent flag reads as its default; a non-numeric one reads as NaN, not zero. */
|
|
45
|
+
export function intOr(value: string | undefined, fallback: number): number {
|
|
46
|
+
return Number.parseInt(value || String(fallback), 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clampSentiment(sentiment: number | undefined): number {
|
|
50
|
+
return Math.max(MIN_SENTIMENT, Math.min(MAX_SENTIMENT, sentiment ?? DEFAULT_SENTIMENT));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* General is the ~94% case, so only an explicit "task-specific" opts out of
|
|
55
|
+
* algorithm-update's clustering — anything else, including a typo, stays general.
|
|
56
|
+
*/
|
|
57
|
+
export function scopeOf(scope: string | undefined): AlgorithmReflection["scope"] {
|
|
58
|
+
return scope === "task-specific" ? "task-specific" : "general";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildReflection(
|
|
62
|
+
input: ReflectionInput,
|
|
63
|
+
now: Date = new Date(),
|
|
64
|
+
cwd: string = process.cwd()
|
|
65
|
+
): AlgorithmReflection {
|
|
66
|
+
return {
|
|
67
|
+
timestamp: now.toISOString(),
|
|
68
|
+
cwd: encodeAnchor(cwd),
|
|
69
|
+
...currentAttribution(),
|
|
70
|
+
task: input.task,
|
|
71
|
+
criteria_count: input.criteria_count ?? 0,
|
|
72
|
+
criteria_passed: input.criteria_passed ?? 0,
|
|
73
|
+
criteria_failed: input.criteria_failed ?? 0,
|
|
74
|
+
sentiment: clampSentiment(input.sentiment),
|
|
75
|
+
q1: input.q1,
|
|
76
|
+
q2: input.q2,
|
|
77
|
+
q3: input.q3,
|
|
78
|
+
scope: scopeOf(input.scope),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function reflectionLine(reflection: AlgorithmReflection): string {
|
|
83
|
+
return `${JSON.stringify(reflection)}\n`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The learning-analysis report, as lines rather than as console output.
|
|
3
|
+
*
|
|
4
|
+
* The tool around it is spawned, so every judgement in here — which colour a
|
|
5
|
+
* rating average earns, whether an entry came from a failure or a learning,
|
|
6
|
+
* what is shown when there is nothing to show — was written straight into
|
|
7
|
+
* console.log and could not be read back by a test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AnalysisResult } from "../../hooks/lib/graduation";
|
|
11
|
+
|
|
12
|
+
type PatternGroup = AnalysisResult["candidates"][number];
|
|
13
|
+
type AnalysisEntry = PatternGroup["entries"][number];
|
|
14
|
+
type RatingsSummary = NonNullable<AnalysisResult["ratings"]>;
|
|
15
|
+
|
|
16
|
+
const c = {
|
|
17
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
18
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
19
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
20
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
21
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
22
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
23
|
+
magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const RULE = c.dim("─────────────────────────────────────────────────");
|
|
27
|
+
|
|
28
|
+
/** Green from 7, red at 4 and below, amber for the band between them. */
|
|
29
|
+
function averageColour(average: number) {
|
|
30
|
+
if (average >= 7) return c.green;
|
|
31
|
+
return average <= 4 ? c.red : c.yellow;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ratingsLines(r: RatingsSummary): string[] {
|
|
35
|
+
const average = averageColour(r.average)(`${r.average.toFixed(1)}/10`);
|
|
36
|
+
const low = c.red(`Low (≤4): ${r.low.count}`);
|
|
37
|
+
const high = c.green(`High (≥7): ${r.high.count}`);
|
|
38
|
+
return [
|
|
39
|
+
`\n ${c.bold("Ratings:")} ${average} avg (${r.total} total)`,
|
|
40
|
+
` ${low} | ${high}`,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The source string carries where the entry came from; the tag says which. */
|
|
45
|
+
function entryLine(entry: AnalysisEntry, maxChars: number): string {
|
|
46
|
+
const kind = entry.source.startsWith("failure:") ? "failure" : "learning";
|
|
47
|
+
const tag = kind === "failure" ? c.red(`[${kind}]`) : c.yellow(`[${kind}]`);
|
|
48
|
+
return ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, maxChars)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function headingOf(group: PatternGroup): string {
|
|
52
|
+
const domain = c.cyan(`[${group.domain}]`);
|
|
53
|
+
const count = c.bold(`${group.entries.length}x`);
|
|
54
|
+
return ` ${domain} ${count}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fileLines(group: PatternGroup): string[] {
|
|
58
|
+
return group.entries.map((entry) => ` ${c.dim(entry.path)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function candidateLines(candidate: PatternGroup): string[] {
|
|
62
|
+
const framePath = c.magenta(`memory/wisdom/frames/${candidate.domain}.md`);
|
|
63
|
+
return [
|
|
64
|
+
`${headingOf(candidate)} occurrences`,
|
|
65
|
+
"",
|
|
66
|
+
...candidate.entries.map((entry) => entryLine(entry, 100)),
|
|
67
|
+
`\n ${c.dim("Files:")}`,
|
|
68
|
+
...fileLines(candidate),
|
|
69
|
+
"",
|
|
70
|
+
` Target frame: ${framePath}`,
|
|
71
|
+
` ${RULE}\n`,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emergingLines(group: PatternGroup): string[] {
|
|
76
|
+
return [
|
|
77
|
+
headingOf(group),
|
|
78
|
+
...group.entries.map((entry) => entryLine(entry, 80)),
|
|
79
|
+
" Files:",
|
|
80
|
+
...fileLines(group),
|
|
81
|
+
"",
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function reportLines(result: AnalysisResult): string[] {
|
|
86
|
+
const hasPatterns = result.candidates.length > 0 || result.emerging.length > 0;
|
|
87
|
+
if (!hasPatterns && result.ratings === null) {
|
|
88
|
+
return ["\n No patterns or ratings data found.\n"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
|
|
93
|
+
if (result.ratings) lines.push(...ratingsLines(result.ratings));
|
|
94
|
+
|
|
95
|
+
if (result.candidates.length > 0) {
|
|
96
|
+
const header = `Graduation Report — ${result.candidates.length} pattern(s) detected`;
|
|
97
|
+
lines.push(`\n ${c.bold(c.green(header))}\n`, ` ${RULE}\n`);
|
|
98
|
+
for (const candidate of result.candidates) lines.push(...candidateLines(candidate));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result.emerging.length > 0) {
|
|
102
|
+
lines.push(` ${c.bold(c.yellow("Emerging (2x — one more to graduate)"))}\n`);
|
|
103
|
+
for (const group of result.emerging) lines.push(...emergingLines(group));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (result.recommendations.length > 0) {
|
|
107
|
+
lines.push(` ${c.bold("Recommendations:")}\n`);
|
|
108
|
+
for (const rec of result.recommendations) lines.push(` ${rec}`);
|
|
109
|
+
lines.push("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (result.candidates.length > 0) {
|
|
113
|
+
lines.push(
|
|
114
|
+
" To crystallize: add a line to the wisdom frame file.",
|
|
115
|
+
` Format: ${c.green("- Your principle here [CRYSTAL: 85%]")}\n`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff store: what an unfinished session leaves behind for the next one,
|
|
3
|
+
* keyed by the directory the work happened in.
|
|
4
|
+
*
|
|
5
|
+
* The tool around this is only ever spawned, so the shape of an entry and the
|
|
6
|
+
* trim that keeps the file a working set were reachable only by running the CLI
|
|
7
|
+
* and reading the file back. Everything here takes the store it operates on and
|
|
8
|
+
* the clock it stamps with.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
14
|
+
|
|
15
|
+
export interface HandoffEntry {
|
|
16
|
+
timestamp: string;
|
|
17
|
+
title: string;
|
|
18
|
+
status: "in-progress" | "completed";
|
|
19
|
+
handoff: string;
|
|
20
|
+
artifacts: string[];
|
|
21
|
+
source: "deliberate" | "auto";
|
|
22
|
+
/** What the work needs from the human before it can move — the one thing an agent cannot unblock. */
|
|
23
|
+
waitingOn?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NoteInput {
|
|
27
|
+
cwd: string;
|
|
28
|
+
title: string;
|
|
29
|
+
text: string;
|
|
30
|
+
done: boolean;
|
|
31
|
+
waitingOn?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type HandoffStore = Record<string, HandoffEntry>;
|
|
35
|
+
|
|
36
|
+
/** The file is a working set, not an archive: the oldest keys fall off first. */
|
|
37
|
+
const MAX_ENTRIES = 20;
|
|
38
|
+
|
|
39
|
+
export function handoffFile(): string {
|
|
40
|
+
return resolve(ensureDir(paths.state()), "last-handoff.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseHandoffs(content: string): HandoffStore {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(content) as HandoffStore;
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readHandoffs(file: string = handoffFile()): HandoffStore {
|
|
52
|
+
if (!existsSync(file)) return {};
|
|
53
|
+
try {
|
|
54
|
+
return parseHandoffs(readFileSync(file, "utf-8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function trimHandoffs(handoffs: HandoffStore): HandoffStore {
|
|
61
|
+
const entries = Object.entries(handoffs);
|
|
62
|
+
if (entries.length <= MAX_ENTRIES) return handoffs;
|
|
63
|
+
return Object.fromEntries(entries.slice(-MAX_ENTRIES));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const statusOf = (note: NoteInput): HandoffEntry["status"] =>
|
|
67
|
+
note.done ? "completed" : "in-progress";
|
|
68
|
+
|
|
69
|
+
/** A closed session carries no waiting line, so the key is absent rather than empty. */
|
|
70
|
+
export function entryFor(note: NoteInput, now: Date): HandoffEntry {
|
|
71
|
+
return {
|
|
72
|
+
timestamp: now.toISOString(),
|
|
73
|
+
title: note.title,
|
|
74
|
+
status: statusOf(note),
|
|
75
|
+
handoff: note.text,
|
|
76
|
+
artifacts: [],
|
|
77
|
+
source: "deliberate",
|
|
78
|
+
...(note.waitingOn ? { waitingOn: note.waitingOn } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function recordNote(
|
|
83
|
+
store: HandoffStore,
|
|
84
|
+
note: NoteInput,
|
|
85
|
+
now: Date
|
|
86
|
+
): HandoffStore {
|
|
87
|
+
return trimHandoffs({ ...store, [note.cwd]: entryFor(note, now) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fills in the one field an auto handoff cannot write for itself. Never
|
|
92
|
+
* overwrites an answer already on the entry, and never invents an entry.
|
|
93
|
+
*/
|
|
94
|
+
export function withWaitingOn(
|
|
95
|
+
store: HandoffStore,
|
|
96
|
+
cwd: string,
|
|
97
|
+
question: string
|
|
98
|
+
): HandoffStore {
|
|
99
|
+
const entry = store[cwd];
|
|
100
|
+
if (!entry || entry.waitingOn) return store;
|
|
101
|
+
return { ...store, [cwd]: { ...entry, waitingOn: question } };
|
|
102
|
+
}
|