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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last exchange before a compaction, and what is safe to do with it after.
|
|
3
|
+
*
|
|
4
|
+
* A summary can collapse the turn that was in flight when the window filled, so
|
|
5
|
+
* the originals are re-injected verbatim on the next session. Everything here
|
|
6
|
+
* used to sit inside the spawned hook: the budget split, the order the candidate
|
|
7
|
+
* files are tried in, and whether the file just read may be deleted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { paths } from "./paths";
|
|
13
|
+
|
|
14
|
+
/** Hook output is capped at 10,000 chars; the rest is headroom for the framing. */
|
|
15
|
+
const MAX_OUTPUT = 9_000;
|
|
16
|
+
|
|
17
|
+
/** The user's half. The assistant's reply is the longer of the two in most turns. */
|
|
18
|
+
const USER_SHARE = 0.4;
|
|
19
|
+
|
|
20
|
+
/** Held back for the reminder's headings and framing text. */
|
|
21
|
+
const FRAMING_RESERVE = 300;
|
|
22
|
+
|
|
23
|
+
export interface SavedExchange {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
trigger: string | null;
|
|
27
|
+
customInstructions: string | null;
|
|
28
|
+
userMessage: string;
|
|
29
|
+
assistantMessage: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecallBudget {
|
|
33
|
+
user: number;
|
|
34
|
+
assistant: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recallBudget(max: number = MAX_OUTPUT): RecallBudget {
|
|
38
|
+
const user = Math.floor(max * USER_SHARE);
|
|
39
|
+
return { user, assistant: max - user - FRAMING_RESERVE };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Says how much was dropped, so a truncated message cannot read as a complete one. */
|
|
43
|
+
export function truncate(s: string, max: number): string {
|
|
44
|
+
if (s.length <= max) return s;
|
|
45
|
+
return `${s.slice(0, max)}\n[... truncated ${s.length - max} chars]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function exchangeDir(): string {
|
|
49
|
+
return resolve(paths.state(), "last-exchange");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The session's own file first, then the fallback. latest.json is overwritten by
|
|
54
|
+
* every compaction, so it is right only when nothing more specific exists.
|
|
55
|
+
*/
|
|
56
|
+
export function findSavedExchange(sessionId?: string): string | null {
|
|
57
|
+
const candidates = [
|
|
58
|
+
sessionId ? resolve(exchangeDir(), `${sessionId}.json`) : null,
|
|
59
|
+
resolve(exchangeDir(), "latest.json"),
|
|
60
|
+
].filter((path): path is string => path !== null);
|
|
61
|
+
return candidates.find((path) => existsSync(path)) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Consume-on-read, but only for the session's own file: latest.json is the
|
|
66
|
+
* safety fallback and deleting it would leave the next compaction with nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function isConsumable(file: string, sessionId?: string): boolean {
|
|
69
|
+
if (!sessionId) return false;
|
|
70
|
+
return file === resolve(exchangeDir(), `${sessionId}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildRecall(saved: SavedExchange, budget = recallBudget()): string {
|
|
74
|
+
return [
|
|
75
|
+
"<system-reminder>",
|
|
76
|
+
"## Last exchange before compaction",
|
|
77
|
+
"_Restored verbatim from PAL state. The compaction summary may have collapsed this; the originals are below._",
|
|
78
|
+
"",
|
|
79
|
+
"**User:**",
|
|
80
|
+
truncate(saved.userMessage || "(no user message captured)", budget.user),
|
|
81
|
+
"",
|
|
82
|
+
"**Assistant:**",
|
|
83
|
+
truncate(
|
|
84
|
+
saved.assistantMessage || "(no assistant message captured)",
|
|
85
|
+
budget.assistant
|
|
86
|
+
),
|
|
87
|
+
"</system-reminder>",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a low-rated session is asked about, and what is kept from the answer.
|
|
3
|
+
*
|
|
4
|
+
* The handler around this is spawned detached — claude --print's cold start
|
|
5
|
+
* outruns the Stop hook's budget — so none of it was reachable from a test. The
|
|
6
|
+
* decisions are here instead: how much of the transcript the question carries,
|
|
7
|
+
* what the question is, and which of the two sources wins for each field.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { extractContent, parseMessages } from "./transcript";
|
|
11
|
+
|
|
12
|
+
/** Enough of the ending to see what went wrong, without paying for the whole session. */
|
|
13
|
+
const MAX_MESSAGES = 10;
|
|
14
|
+
|
|
15
|
+
/** Per message, so one long tool dump cannot crowd out the other nine. */
|
|
16
|
+
const MAX_CHARS_PER_MESSAGE = 300;
|
|
17
|
+
|
|
18
|
+
export interface PendingFailure {
|
|
19
|
+
rating: number;
|
|
20
|
+
context: string;
|
|
21
|
+
detailedContext?: string;
|
|
22
|
+
principle?: string;
|
|
23
|
+
responsePreview?: string;
|
|
24
|
+
userPreview?: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InferredPrinciple {
|
|
29
|
+
principle?: string;
|
|
30
|
+
detailedContext?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recentExchange(transcript: string): string {
|
|
34
|
+
return parseMessages(transcript)
|
|
35
|
+
.slice(-MAX_MESSAGES)
|
|
36
|
+
.map(
|
|
37
|
+
(message) =>
|
|
38
|
+
`${message.role.toUpperCase()}: ${extractContent(message).slice(0, MAX_CHARS_PER_MESSAGE)}`
|
|
39
|
+
)
|
|
40
|
+
.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The parent may already have a principle, in which case there is nothing to ask. */
|
|
44
|
+
export function needsInference(pending: PendingFailure): boolean {
|
|
45
|
+
return !pending.principle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function principleRequest(pending: PendingFailure, recent: string) {
|
|
49
|
+
return {
|
|
50
|
+
system: `Analyze this failed AI interaction (rated ${pending.rating}/10). Return JSON: {"principle": "<verb-first actionable rule, 10-20 words — write a full sentence, not a fragment>", "detailed_context": "<root cause and what to do differently, 50-150 words>"}.`,
|
|
51
|
+
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
52
|
+
maxTokens: 400,
|
|
53
|
+
timeout: 90_000,
|
|
54
|
+
jsonSchema: {
|
|
55
|
+
type: "object" as const,
|
|
56
|
+
properties: {
|
|
57
|
+
principle: { type: "string" as const },
|
|
58
|
+
detailed_context: { type: "string" as const },
|
|
59
|
+
},
|
|
60
|
+
required: ["principle", "detailed_context"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
caller: "failure-principle",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseInferred(output: string | null): InferredPrinciple {
|
|
68
|
+
if (!output) return {};
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(output) as {
|
|
71
|
+
principle?: string;
|
|
72
|
+
detailed_context?: string;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
principle: parsed.principle || undefined,
|
|
76
|
+
detailedContext: parsed.detailed_context || undefined,
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The two fields resolve differently on purpose. A principle the parent already
|
|
85
|
+
* had means inference never ran, so there is nothing to lose to; a detailed
|
|
86
|
+
* context it already had was written from the full session and outranks one
|
|
87
|
+
* inferred from ten messages.
|
|
88
|
+
*/
|
|
89
|
+
export function mergeInferredPrinciple(
|
|
90
|
+
pending: PendingFailure,
|
|
91
|
+
output: string | null
|
|
92
|
+
): InferredPrinciple {
|
|
93
|
+
const inferred = parseInferred(output);
|
|
94
|
+
return {
|
|
95
|
+
principle: pending.principle || inferred.principle,
|
|
96
|
+
detailedContext: pending.detailedContext ?? inferred.detailedContext,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which projects serve which stated goal, and how far along that makes it.
|
|
3
|
+
*
|
|
4
|
+
* The linkage needs judgement — "Catalyst is the starter that pays for the rest"
|
|
5
|
+
* serves "land two retained clients" only if you know what both mean — so a model
|
|
6
|
+
* draws it. The progress does not: it is criteria closed over criteria written,
|
|
7
|
+
* counted here, because a model returning "64%" says something unfalsifiable
|
|
8
|
+
* about a goal it cannot measure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { writeFile } from "node:fs/promises";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { paths } from "./paths";
|
|
15
|
+
|
|
16
|
+
interface GoalLink {
|
|
17
|
+
goalId: string;
|
|
18
|
+
projects: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GoalLinks {
|
|
22
|
+
generatedAt: string;
|
|
23
|
+
/** Hash of the goals and projects the linkage was drawn from. */
|
|
24
|
+
inputs: string;
|
|
25
|
+
links: GoalLink[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GoalProgress {
|
|
29
|
+
projects: string[];
|
|
30
|
+
closed: number;
|
|
31
|
+
written: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function goalLinksPath(): string {
|
|
35
|
+
return resolve(paths.state(), "goal-links.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readGoalLinks(): GoalLinks | null {
|
|
39
|
+
const path = goalLinksPath();
|
|
40
|
+
if (!existsSync(path)) return null;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as GoalLinks;
|
|
43
|
+
return Array.isArray(parsed.links) && parsed.generatedAt ? parsed : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeGoalLinks(links: GoalLinks): Promise<void> {
|
|
50
|
+
await writeFile(goalLinksPath(), `${JSON.stringify(links, null, 2)}\n`, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A stable fingerprint of what the linkage was drawn from, so a stop where
|
|
55
|
+
* neither the goals nor the projects moved costs nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function linkInputs(goalIds: string[], projectSlugs: string[]): string {
|
|
58
|
+
return Bun.hash(
|
|
59
|
+
JSON.stringify([[...goalIds].sort(), [...projectSlugs].sort()])
|
|
60
|
+
).toString(36);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Counted, not estimated. A goal nothing serves has no progress rather than
|
|
65
|
+
* zero — "no project serves this yet" is the answer worth showing.
|
|
66
|
+
*/
|
|
67
|
+
export function progressFor(
|
|
68
|
+
goalId: string,
|
|
69
|
+
links: GoalLinks | null,
|
|
70
|
+
criteriaBySlug: Map<string, { closed: number; written: number }>
|
|
71
|
+
): GoalProgress | null {
|
|
72
|
+
const projects = links?.links.find((l) => l.goalId === goalId)?.projects ?? [];
|
|
73
|
+
const known = projects.filter((slug) => criteriaBySlug.has(slug));
|
|
74
|
+
if (known.length === 0) return null;
|
|
75
|
+
let closed = 0;
|
|
76
|
+
let written = 0;
|
|
77
|
+
for (const slug of known) {
|
|
78
|
+
const counts = criteriaBySlug.get(slug) as { closed: number; written: number };
|
|
79
|
+
closed += counts.closed;
|
|
80
|
+
written += counts.written;
|
|
81
|
+
}
|
|
82
|
+
return { projects: known, closed, written };
|
|
83
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
claimPending,
|
|
14
14
|
type LedgerEntry,
|
|
15
15
|
type LedgerOutcome,
|
|
16
|
+
type PendingSnapshot,
|
|
16
17
|
reapStalePending,
|
|
17
18
|
recordAction,
|
|
18
19
|
savePending,
|
|
@@ -245,6 +246,40 @@ export function commitApplied(call: LedgeredCall): LedgerEntry | null {
|
|
|
245
246
|
return entry;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The snapshot is the trustworthy source, but its absence is recoverable here in
|
|
251
|
+
* a way it never is after a successful edit: nothing landed, so whatever is on
|
|
252
|
+
* disk now is still the before-state.
|
|
253
|
+
*/
|
|
254
|
+
export function unappliedBefore(
|
|
255
|
+
pending: PendingSnapshot | null,
|
|
256
|
+
target: string
|
|
257
|
+
): string | null {
|
|
258
|
+
if (pending) return pending.before;
|
|
259
|
+
return contentsOf(target);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Record a call that did not land. Unlike the applied half this writes with or
|
|
264
|
+
* without a parked snapshot, because a missing snapshot here is recoverable and
|
|
265
|
+
* dropping the entry would lose the only record that the attempt happened.
|
|
266
|
+
*/
|
|
267
|
+
export function commitUnapplied(
|
|
268
|
+
call: LedgeredCall,
|
|
269
|
+
verdict: UnappliedVerdict
|
|
270
|
+
): LedgerEntry {
|
|
271
|
+
return recordAction({
|
|
272
|
+
tool: call.tool,
|
|
273
|
+
target: call.target,
|
|
274
|
+
outcome: verdict.outcome,
|
|
275
|
+
before: unappliedBefore(claimPending(call.toolUseId), call.target),
|
|
276
|
+
// Nothing landed. That is what this event means, and it is the difference
|
|
277
|
+
// between this entry and an applied one.
|
|
278
|
+
after: null,
|
|
279
|
+
reason: verdict.reason,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
248
283
|
export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
249
284
|
for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
|
|
250
285
|
const value = payload[key];
|
package/src/hooks/lib/ledger.ts
CHANGED
|
@@ -46,8 +46,14 @@ import { isSensitivePath } from "./sensitive-path";
|
|
|
46
46
|
* lose the only signal in the record that says where the boundary was drawn,
|
|
47
47
|
* and "what did I try that was refused" is a question worth being able to ask
|
|
48
48
|
* separately from "what did I try that broke".
|
|
49
|
+
*
|
|
50
|
+
* `blocked` is a rule refusing, on the same reasoning: a person can be asked to
|
|
51
|
+
* reconsider and a rule cannot, so "PAL would not let me" and "you would not
|
|
52
|
+
* let me" are different facts about where the boundary sits. It is also the
|
|
53
|
+
* only outcome PAL itself decides, which is what makes the declare-enforce-record
|
|
54
|
+
* triad demonstrable rather than merely wired.
|
|
49
55
|
*/
|
|
50
|
-
export type LedgerOutcome = "applied" | "failed" | "denied";
|
|
56
|
+
export type LedgerOutcome = "applied" | "failed" | "denied" | "blocked";
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* One side of a change, identified rather than reproduced. The hash ties the
|
|
@@ -107,6 +113,12 @@ export interface LedgerEntry extends RecordAttribution {
|
|
|
107
113
|
delta?: LedgerDelta;
|
|
108
114
|
/** Why the action did not land. Absent on an applied one. */
|
|
109
115
|
reason?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The shell command a rule refused. Only a blocked shell action carries one:
|
|
118
|
+
* it has no file to name, so without this the entry could say a command was
|
|
119
|
+
* refused but not which.
|
|
120
|
+
*/
|
|
121
|
+
command?: string;
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
export interface RecordActionInput {
|
|
@@ -121,6 +133,7 @@ export interface RecordActionInput {
|
|
|
121
133
|
/** Resulting content; null when nothing landed. */
|
|
122
134
|
after: string | null;
|
|
123
135
|
reason?: string;
|
|
136
|
+
command?: string;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
/**
|
|
@@ -254,6 +267,7 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
254
267
|
after: stateOf(input.after),
|
|
255
268
|
...(delta ? { delta } : {}),
|
|
256
269
|
...(input.reason ? { reason: input.reason } : {}),
|
|
270
|
+
...(input.command ? { command: input.command } : {}),
|
|
257
271
|
};
|
|
258
272
|
|
|
259
273
|
const file = ledgerPath();
|
|
@@ -262,6 +276,39 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
262
276
|
return entry;
|
|
263
277
|
}
|
|
264
278
|
|
|
279
|
+
/** A refused command is quoted back, not stored whole. */
|
|
280
|
+
const MAX_COMMAND_CHARS = 500;
|
|
281
|
+
|
|
282
|
+
export interface RecordBlockedInput {
|
|
283
|
+
tool: string;
|
|
284
|
+
/** Absolute path of the file, or of the directory a refused command ran in. */
|
|
285
|
+
target: string;
|
|
286
|
+
/** The refused command, when the tool was a shell rather than an editor. */
|
|
287
|
+
command?: string;
|
|
288
|
+
/** What the rule told the agent — the same words, so both records agree. */
|
|
289
|
+
reason: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A rule refused this before it ran. Written by whoever enforces the rule, at
|
|
294
|
+
* the moment it fires, because a refusal produces no other event: nothing runs,
|
|
295
|
+
* so no post-tool hook reports it and nothing downstream can infer it happened.
|
|
296
|
+
*
|
|
297
|
+
* There is no before or after. The file is untouched, and a delta claiming
|
|
298
|
+
* otherwise would be the ledger describing a change that never occurred.
|
|
299
|
+
*/
|
|
300
|
+
export function recordBlocked(input: RecordBlockedInput): LedgerEntry {
|
|
301
|
+
return recordAction({
|
|
302
|
+
tool: input.tool,
|
|
303
|
+
target: input.target,
|
|
304
|
+
outcome: "blocked",
|
|
305
|
+
before: null,
|
|
306
|
+
after: null,
|
|
307
|
+
reason: input.reason,
|
|
308
|
+
...(input.command ? { command: input.command.slice(0, MAX_COMMAND_CHARS) } : {}),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
265
312
|
/**
|
|
266
313
|
* The before-state, held between the two halves of one tool call.
|
|
267
314
|
*
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -45,6 +45,9 @@ export interface ProjectProgress {
|
|
|
45
45
|
serves?: ServesKind;
|
|
46
46
|
serves_note?: string;
|
|
47
47
|
serves_by?: ServesAuthority;
|
|
48
|
+
/** The user's own placement on the grid, which overrules both guesses. */
|
|
49
|
+
placed?: string;
|
|
50
|
+
placed_by?: ServesAuthority;
|
|
48
51
|
// ISA body sections
|
|
49
52
|
problem?: string;
|
|
50
53
|
goal?: string;
|
|
@@ -284,6 +287,8 @@ export function writeProject(p: ProjectProgress): void {
|
|
|
284
287
|
if (p.serves) meta.serves = p.serves;
|
|
285
288
|
if (p.serves_note) meta.serves_note = p.serves_note;
|
|
286
289
|
if (p.serves_by) meta.serves_by = p.serves_by;
|
|
290
|
+
if (p.placed) meta.placed = p.placed;
|
|
291
|
+
if (p.placed_by) meta.placed_by = p.placed_by;
|
|
287
292
|
writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
|
|
288
293
|
}
|
|
289
294
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate's decision, separated from the process that carries it out.
|
|
3
|
+
*
|
|
4
|
+
* SecurityValidator is spawned, so nothing can import it and nothing measures
|
|
5
|
+
* it — every rule about which tool names run a shell, which argument spells the
|
|
6
|
+
* path, and what the agent is told was unreachable from a test. The decision
|
|
7
|
+
* lives here instead; the entrypoint is left with stdin, stdout and the ledger.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { normalizeToolUse } from "./agent";
|
|
11
|
+
import { logDebug } from "./log";
|
|
12
|
+
import { checkBashCommand, checkFilePath } from "./security";
|
|
13
|
+
|
|
14
|
+
/** beforeShellExecution (Cursor only) — flat, no tool-name wrapper. */
|
|
15
|
+
interface ShellExecInput {
|
|
16
|
+
command: string;
|
|
17
|
+
sandbox?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SecurityInput = Record<string, unknown> | ShellExecInput;
|
|
21
|
+
|
|
22
|
+
export interface GateRefusal {
|
|
23
|
+
tool: string;
|
|
24
|
+
/** The file, or the directory a refused command would have run in. */
|
|
25
|
+
target: string;
|
|
26
|
+
/** Set only when a shell was refused: it has no file to name. */
|
|
27
|
+
command?: string;
|
|
28
|
+
/** Why, for the ledger. */
|
|
29
|
+
reason: string;
|
|
30
|
+
/** Why, in the words the agent is given — the two differ only by framing. */
|
|
31
|
+
message: string;
|
|
32
|
+
hookEventName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A name this list misses is a command this hook waves through, so both sets mirror
|
|
36
|
+
// the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
|
|
37
|
+
const SHELL_TOOLS = [
|
|
38
|
+
"bash",
|
|
39
|
+
"shell",
|
|
40
|
+
"powershell",
|
|
41
|
+
"local_shell",
|
|
42
|
+
"runinterminal",
|
|
43
|
+
"run_in_terminal",
|
|
44
|
+
"terminal",
|
|
45
|
+
"execute_command",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const FILE_WRITE_TOOLS = [
|
|
49
|
+
"write",
|
|
50
|
+
"edit",
|
|
51
|
+
"multiedit",
|
|
52
|
+
"write_file",
|
|
53
|
+
"apply_patch",
|
|
54
|
+
"applypatch",
|
|
55
|
+
"create",
|
|
56
|
+
"create_file",
|
|
57
|
+
"createfile",
|
|
58
|
+
"str_replace",
|
|
59
|
+
"str_replace_editor",
|
|
60
|
+
"insert",
|
|
61
|
+
"insert_edit_into_file",
|
|
62
|
+
"replace_string_in_file",
|
|
63
|
+
"multi_replace_string_in_file",
|
|
64
|
+
"replacestring",
|
|
65
|
+
"edit_notebook_file",
|
|
66
|
+
"notebookedit",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function isShellExec(input: SecurityInput): input is ShellExecInput {
|
|
70
|
+
return !("tool_name" in input) && !("toolName" in input) && "command" in input;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
|
|
74
|
+
function firstStringArg(
|
|
75
|
+
args: Record<string, unknown>,
|
|
76
|
+
keys: string[]
|
|
77
|
+
): string | undefined {
|
|
78
|
+
for (const key of keys) {
|
|
79
|
+
const value = args[key];
|
|
80
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Tool names that run a shell command, across every agent's naming. */
|
|
86
|
+
function runsShellCommand(toolName: string): boolean {
|
|
87
|
+
return SHELL_TOOLS.includes(toolName.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Tool names that write to a file, across every agent's naming. */
|
|
91
|
+
function writesFile(toolName: string): boolean {
|
|
92
|
+
return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What the gate would do with this call, or null to let it through.
|
|
97
|
+
*
|
|
98
|
+
* `cwd` is passed rather than read so a test can pin the target of a refused
|
|
99
|
+
* command, which has no file of its own to name.
|
|
100
|
+
*/
|
|
101
|
+
export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal | null {
|
|
102
|
+
if (isShellExec(input)) {
|
|
103
|
+
const reason = checkBashCommand(input.command);
|
|
104
|
+
if (!reason) return null;
|
|
105
|
+
return {
|
|
106
|
+
tool: "shell",
|
|
107
|
+
target: cwd,
|
|
108
|
+
command: input.command,
|
|
109
|
+
reason,
|
|
110
|
+
message: `Blocked: ${reason}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const toolUse = normalizeToolUse(input);
|
|
115
|
+
if (!toolUse) return null;
|
|
116
|
+
|
|
117
|
+
// Each agent names its shell/write tools differently; log the real name so an
|
|
118
|
+
// unrecognized one shows up here instead of silently skipping the check.
|
|
119
|
+
logDebug(
|
|
120
|
+
"SecurityValidator",
|
|
121
|
+
`toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
|
|
125
|
+
if (runsShellCommand(toolUse.toolName) && command) {
|
|
126
|
+
const reason = checkBashCommand(command);
|
|
127
|
+
// "No output" from a downstream tool is indistinguishable between "denied,
|
|
128
|
+
// never ran" and "ran, produced nothing" — logging the verdict here, next
|
|
129
|
+
// to the literal command, is what actually tells the two apart.
|
|
130
|
+
const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
|
|
131
|
+
logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
|
|
132
|
+
if (reason) {
|
|
133
|
+
return {
|
|
134
|
+
tool: toolUse.toolName,
|
|
135
|
+
target: cwd,
|
|
136
|
+
command,
|
|
137
|
+
reason,
|
|
138
|
+
message: `Blocked: ${reason}`,
|
|
139
|
+
hookEventName: toolUse.hookEventName,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
|
|
145
|
+
if (writesFile(toolUse.toolName) && filePath) {
|
|
146
|
+
const reason = checkFilePath(filePath);
|
|
147
|
+
if (reason) {
|
|
148
|
+
return {
|
|
149
|
+
tool: toolUse.toolName,
|
|
150
|
+
target: filePath,
|
|
151
|
+
reason,
|
|
152
|
+
message: reason,
|
|
153
|
+
hookEventName: toolUse.hookEventName,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each agent is handed at session start, and in which envelope.
|
|
3
|
+
*
|
|
4
|
+
* The four runtimes disagree on all of it: whether AGENTS.md is already loaded,
|
|
5
|
+
* which JSON key carries injected context, and whether it is read from stdout at
|
|
6
|
+
* all. This has been wrong in production before — Copilot silently received no
|
|
7
|
+
* context for weeks — and none of it was reachable from a test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ContextEnvelope {
|
|
11
|
+
/** Whether the agent parses stdout as JSON or reads it as raw text. */
|
|
12
|
+
kind: "json" | "text";
|
|
13
|
+
payload: string;
|
|
14
|
+
/** Copilot only: the same context, also written where its extension reads. */
|
|
15
|
+
file?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A subagent gets none of this. Its parent already carries the context, and
|
|
20
|
+
* paying for it again on every spawn is the whole cost of a cheap subagent.
|
|
21
|
+
*/
|
|
22
|
+
export function isSubagentSession(env: NodeJS.ProcessEnv): boolean {
|
|
23
|
+
if (env.CLAUDE_AGENT_TYPE !== undefined) return true;
|
|
24
|
+
return env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ?? false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copilot and Cursor read AGENTS.md natively from nothing, so it is prepended
|
|
29
|
+
* here; Codex reaches it through a symlink and Claude Code loads it itself.
|
|
30
|
+
*/
|
|
31
|
+
export function needsAgentsMd(agent: string): boolean {
|
|
32
|
+
return agent === "copilot" || agent === "cursor";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Copilot also writes the same text to a file, because its CLI reads stdout
|
|
37
|
+
* while the VS Code extension reads only ~/.copilot/instructions/.
|
|
38
|
+
*/
|
|
39
|
+
export function contextEnvelope(
|
|
40
|
+
agent: string,
|
|
41
|
+
reminder: string,
|
|
42
|
+
agentsMd: string
|
|
43
|
+
): ContextEnvelope | null {
|
|
44
|
+
const merged = needsAgentsMd(agent)
|
|
45
|
+
? [agentsMd, reminder].filter(Boolean).join("\n\n")
|
|
46
|
+
: reminder;
|
|
47
|
+
if (!merged) return null;
|
|
48
|
+
|
|
49
|
+
if (agent === "copilot") {
|
|
50
|
+
return {
|
|
51
|
+
kind: "json",
|
|
52
|
+
payload: JSON.stringify({ additionalContext: merged }),
|
|
53
|
+
file: merged,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (agent === "cursor") {
|
|
57
|
+
return { kind: "json", payload: JSON.stringify({ additional_context: merged }) };
|
|
58
|
+
}
|
|
59
|
+
if (agent === "codex") {
|
|
60
|
+
return {
|
|
61
|
+
kind: "json",
|
|
62
|
+
payload: JSON.stringify({
|
|
63
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: merged },
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Claude Code, and opencode which uses the plugin path rather than this hook.
|
|
68
|
+
return { kind: "text", payload: merged };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The file Copilot's VS Code extension reads, with the applyTo header it needs. */
|
|
72
|
+
export function copilotInstructions(context: string): string {
|
|
73
|
+
return `---\napplyTo: "**"\n---\n\n${context}`;
|
|
74
|
+
}
|
|
@@ -30,6 +30,8 @@ export interface PalSettingsData {
|
|
|
30
30
|
* paths whose contents are never stored; it cannot shrink it.
|
|
31
31
|
*/
|
|
32
32
|
ledger?: { redactPaths?: string[] };
|
|
33
|
+
/** Control-room ranking knobs and attention sources — see src/tools/control-room/prefs.ts. */
|
|
34
|
+
controlRoom?: Record<string, unknown>;
|
|
33
35
|
/** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
|
|
34
36
|
steering?: {
|
|
35
37
|
disable?: string[];
|