portable-agent-layer 0.85.19 → 0.85.21

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.
@@ -88,11 +88,11 @@
88
88
  },
89
89
  "claimCheck": {
90
90
  "type": "boolean",
91
- "description": "At each stop, check whether the final reply claims a result (tests pass, CI green, works now) that no command in that turn could have shown, and log the verdict to memory/signals/claim-checks/. Stores the claiming sentence only when nothing backed it."
91
+ "description": "At each stop, check whether the final reply claims a result (tests pass, CI green, works now) or a status (not pushed, merged, live) that no command in that turn could have shown, and log the verdict to memory/signals/claim-checks/. A claim about what is committed, pushed, merged or released is backed only by a git or gh command. Stores the claiming sentence only when nothing backed it."
92
92
  },
93
93
  "claimCheckBlocks": {
94
94
  "type": "boolean",
95
- "description": "Opt-in, off by default. Send a reply with an unbacked result claim back to the agent once, asking it to show the output or say the result is unverified. Needs claimCheck. Not on Cursor, whose stop payload carries no reply text."
95
+ "description": "Opt-in, off by default. Send a reply with an unbacked result or status claim back to the agent once, asking it to show the output or say the result is unverified. Needs claimCheck. Not on Cursor, whose stop payload carries no reply text."
96
96
  },
97
97
  "steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
98
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.19",
3
+ "version": "0.85.21",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Whether a reply claims a result (tests pass, CI green, it works now) that no
3
- * command in the same turn could have shown. Precision over recall: a hedged or
4
- * negated sentence is never a claim, and an unreadable turn is never unbacked.
2
+ * Whether a reply claims a result (tests pass, CI green, it works now) or a status
3
+ * (not pushed, merged, live) that no command in the same turn could have shown.
4
+ * Precision over recall: a hedged sentence or a question is never a claim, and an
5
+ * unreadable turn is never unbacked.
5
6
  */
6
7
 
7
8
  import { isSystemText } from "./prompt-text";
@@ -12,6 +13,17 @@ const CLAIM_RE =
12
13
  const HEDGED_RE =
13
14
  /\b(?:not|no longer|fail(?:s|ed|ing)?|until|when|if|unless|should|would|will|might|could|may)\b|n't\b/i;
14
15
 
16
+ const stateClaim = (states: string) =>
17
+ new RegExp(
18
+ String.raw`(?:\b(?:is|are|was|were|been|not|yet|still|already|now)|n't|n’t|'s|’s)\s+(?:${states})\b|\bun(?:${states})\b`,
19
+ "i"
20
+ );
21
+ const VERSION_CONTROL_CLAIM_RE = stateClaim("committed|pushed|merged|released|tagged");
22
+ const DEPLOY_CLAIM_RE = stateClaim("deployed|live|installed|shipped");
23
+ const STATUS_HEDGED_RE =
24
+ /\b(?:once|until|when|if|unless|should|would|will|might|could|may)\b/i;
25
+ const READS_VERSION_CONTROL_RE = /\b(?:git|gh)\b/;
26
+
15
27
  const COMMAND_TOOLS = new Set([
16
28
  "Bash",
17
29
  "BashOutput",
@@ -29,11 +41,12 @@ export interface ClaimCheck {
29
41
  claims: string[];
30
42
  }
31
43
 
32
- function sentences(reply: string): string[] {
44
+ function statements(reply: string): string[] {
33
45
  return reply
34
46
  .split(/(?<=[.!?])\s+|\n+/)
35
- .map((s) => s.trim().replace(/[.!?]+$/, ""))
36
- .filter(Boolean);
47
+ .map((s) => s.trim())
48
+ .filter((s) => s && !s.endsWith("?"))
49
+ .map((s) => s.replace(/[.!]+$/, ""));
37
50
  }
38
51
 
39
52
  /** A quoted claim is being talked about, not made. */
@@ -42,16 +55,47 @@ function withoutQuotes(sentence: string): string {
42
55
  }
43
56
 
44
57
  export function resultClaims(reply: string): string[] {
45
- return sentences(reply).filter((s) => {
58
+ return statements(reply).filter((s) => {
46
59
  const own = withoutQuotes(s);
47
60
  return CLAIM_RE.test(own) && !HEDGED_RE.test(own);
48
61
  });
49
62
  }
50
63
 
64
+ /** A checklist item states a goal and a line ending in a colon introduces one. */
65
+ function isGoalOrHeading(sentence: string): boolean {
66
+ return /^[-*]\s*\[[ xX]\]/.test(sentence) || /:\**$/.test(sentence);
67
+ }
68
+
69
+ function isStatusClaim(sentence: string): boolean {
70
+ const own = withoutQuotes(sentence);
71
+ if (isGoalOrHeading(own)) return false;
72
+ return (
73
+ (VERSION_CONTROL_CLAIM_RE.test(own) || DEPLOY_CLAIM_RE.test(own)) &&
74
+ !STATUS_HEDGED_RE.test(own)
75
+ );
76
+ }
77
+
78
+ export function statusClaims(reply: string): string[] {
79
+ return statements(reply).filter(isStatusClaim);
80
+ }
81
+
82
+ /** What is committed, pushed, merged or released only git can say. */
83
+ export function isVersionControlClaim(claim: string): boolean {
84
+ return VERSION_CONTROL_CLAIM_RE.test(withoutQuotes(claim));
85
+ }
86
+
51
87
  interface TranscriptLine {
52
88
  type?: string;
53
89
  message?: { content?: unknown };
54
- payload?: { type?: string; role?: string; name?: string; content?: unknown };
90
+ payload?: {
91
+ type?: string;
92
+ role?: string;
93
+ name?: string;
94
+ content?: unknown;
95
+ input?: unknown;
96
+ arguments?: unknown;
97
+ action?: unknown;
98
+ };
55
99
  }
56
100
 
57
101
  function parsed(line: string): TranscriptLine | null {
@@ -62,7 +106,9 @@ function parsed(line: string): TranscriptLine | null {
62
106
  }
63
107
  }
64
108
 
65
- function blocks(content: unknown): { type?: string; name?: string; text?: string }[] {
109
+ function blocks(
110
+ content: unknown
111
+ ): { type?: string; name?: string; text?: string; input?: unknown }[] {
66
112
  return Array.isArray(content) ? content : [];
67
113
  }
68
114
 
@@ -86,37 +132,51 @@ function isUserPrompt(entry: TranscriptLine): boolean {
86
132
  return text !== null && !isSystemText(text) && !isCodexInjectedContext(text);
87
133
  }
88
134
 
89
- function commandsIn(entry: TranscriptLine): number {
135
+ const ran = (input: unknown) => JSON.stringify(input ?? {});
136
+
137
+ function commandsIn(entry: TranscriptLine): string[] {
90
138
  if (entry.type === "assistant")
91
- return blocks(entry.message?.content).filter(
92
- (b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? "")
93
- ).length;
139
+ return blocks(entry.message?.content)
140
+ .filter((b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? ""))
141
+ .map((b) => ran(b.input));
94
142
  const call = entry.payload;
95
- if (entry.type !== "response_item" || !call) return 0;
96
- if (call.type === "local_shell_call") return 1;
143
+ if (entry.type !== "response_item" || !call) return [];
144
+ const input = call.input ?? call.arguments ?? call.action;
145
+ if (call.type === "local_shell_call") return [ran(input)];
97
146
  const isCall = call.type === "function_call" || call.type === "custom_tool_call";
98
- return isCall && COMMAND_TOOLS.has(call.name ?? "") ? 1 : 0;
147
+ return isCall && COMMAND_TOOLS.has(call.name ?? "") ? [ran(input)] : [];
99
148
  }
100
149
 
101
150
  function isTurnEntry(entry: TranscriptLine): boolean {
102
151
  return ["user", "assistant", "response_item"].includes(entry.type ?? "");
103
152
  }
104
153
 
105
- /** Null when no line reads as a turn, so an unknown transcript format never counts as zero. */
106
- export function commandsThisTurn(lines: string[]): number | null {
154
+ /** What each command this turn ran. Null when no line reads as a turn, so an
155
+ * unknown transcript format never counts as none. */
156
+ export function commandsThisTurn(lines: string[]): string[] | null {
107
157
  const entries = lines.map(parsed).filter((e): e is TranscriptLine => e !== null);
108
158
  if (!entries.some(isTurnEntry)) return null;
109
- let commands = 0;
159
+ let commands: string[] = [];
110
160
  for (const entry of entries) {
111
- if (isUserPrompt(entry)) commands = 0;
112
- commands += commandsIn(entry);
161
+ if (isUserPrompt(entry)) commands = [];
162
+ commands.push(...commandsIn(entry));
113
163
  }
114
164
  return commands;
115
165
  }
116
166
 
117
- export function checkClaims(reply: string, commands: number | null): ClaimCheck {
118
- const claims = resultClaims(reply);
167
+ function isBacked(claim: string, commands: string[]): boolean {
168
+ if (!isVersionControlClaim(claim)) return commands.length > 0;
169
+ return commands.some((command) => READS_VERSION_CONTROL_RE.test(command));
170
+ }
171
+
172
+ export function checkClaims(reply: string, commands: string[] | null): ClaimCheck {
173
+ const claims = statements(reply).filter(
174
+ (s) => resultClaims(s).length > 0 || isStatusClaim(s)
175
+ );
119
176
  if (claims.length === 0) return { verdict: "none", claims };
120
177
  if (commands === null) return { verdict: "unknown", claims };
121
- return { verdict: commands > 0 ? "backed" : "unbacked", claims };
178
+ const unbacked = claims.filter((claim) => !isBacked(claim, commands));
179
+ return unbacked.length > 0
180
+ ? { verdict: "unbacked", claims: unbacked }
181
+ : { verdict: "backed", claims };
122
182
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The claim check at stop: every reply that makes a result claim is logged to
2
+ * The claim check at stop: every reply that makes a result or status claim is logged to
3
3
  * memory/signals/claim-checks/ with its verdict. The claiming sentence is kept
4
4
  * only when no command backed it, so the log can be audited. Sending the reply
5
5
  * back is opt-in (dynamicContext.claimCheckBlocks).
@@ -13,6 +13,7 @@ import {
13
13
  type ClaimVerdict,
14
14
  checkClaims,
15
15
  commandsThisTurn,
16
+ isVersionControlClaim,
16
17
  } from "./claim-check";
17
18
  import { hookFinalReply, hookSessionId } from "./hook-turn";
18
19
  import { ensureDir, paths } from "./paths";
@@ -85,11 +86,14 @@ function sendsBack(payload: StopTurnPayload, check: ClaimCheck): boolean {
85
86
 
86
87
  function sendBackReason(claims: string[]): string {
87
88
  const quoted = claims.map((c) => `"${c.slice(0, CLAIM_MAX)}"`).join(", ");
88
- return `Your reply claims a result no command in this turn showed: ${quoted}. Run the check and show its output, or say it is unverified.`;
89
+ const ask = claims.some(isVersionControlClaim)
90
+ ? "Read what is committed, pushed, merged or released with git or gh and show it"
91
+ : "Run the check and show its output";
92
+ return `Your reply claims what no command in this turn showed: ${quoted}. ${ask}, or say it is unverified.`;
89
93
  }
90
94
 
91
95
  /**
92
- * Checks the final reply against the turn's commands and logs any result claim.
96
+ * Checks the final reply against the turn's commands and logs any claim.
93
97
  * Returns the reason to send the reply back with, when that is opted in.
94
98
  */
95
99
  export function watchClaims(
@@ -103,7 +107,8 @@ export function watchClaims(
103
107
  const check = checkClaims(reply, commands);
104
108
  if (check.verdict === "none") return null;
105
109
  const blocked = sendsBack(payload, check);
106
- record({ check, commands, session: hookSessionId(payload) ?? "unknown", blocked }, now);
110
+ const session = hookSessionId(payload) ?? "unknown";
111
+ record({ check, commands: commands?.length ?? null, session, blocked }, now);
107
112
  return blocked ? sendBackReason(check.claims) : null;
108
113
  }
109
114
 
@@ -208,7 +208,7 @@ export function claimCheckLines(records: ClaimRecord[]): string[] {
208
208
  const sentBack = records.filter((r) => r.blocked).length;
209
209
  return [
210
210
  "",
211
- `Result claims: ${plural(records.length, "reply", "replies")} · ${unbacked.length} with no command behind it · ${sentBack} sent back · ${unknown} unreadable`,
211
+ `Result and status claims: ${plural(records.length, "reply", "replies")} · ${unbacked.length} with no command behind it · ${sentBack} sent back · ${unknown} unreadable`,
212
212
  ...unbacked
213
213
  .slice(-UNBACKED_LISTED)
214
214
  .flatMap((r) => (r.claims ?? []).map((c) => ` no command: ${c}`)),