@piagent/platform 1.2.13 → 1.2.15

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 (67) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +11 -3
  3. package/README.vi.md +90 -0
  4. package/architecture/layers.json +117 -0
  5. package/docs/README.md +23 -0
  6. package/docs/architecture.md +7 -61
  7. package/docs/command-reference-vietnamese.md +6 -6
  8. package/docs/distribution-standard.md +7 -7
  9. package/docs/en/README.md +13 -0
  10. package/docs/en/architecture.md +81 -0
  11. package/docs/en/maintainer-guide.md +121 -0
  12. package/docs/languages.json +40 -0
  13. package/docs/operator-manual-vietnamese.md +7 -7
  14. package/docs/package-architecture-notes.md +9 -0
  15. package/docs/release-install-policy.md +6 -6
  16. package/docs/security-threat-model.md +1 -1
  17. package/docs/team-onboarding.md +2 -2
  18. package/docs/vi/README.md +13 -0
  19. package/docs/vi/architecture.md +81 -0
  20. package/docs/vi/maintainer-guide.md +121 -0
  21. package/docs-site/architecture.html +38 -14
  22. package/docs-site/benchmark.html +3 -1
  23. package/docs-site/commands.html +3 -1
  24. package/docs-site/content/architecture.html +35 -13
  25. package/docs-site/context-engine.html +3 -1
  26. package/docs-site/index.html +3 -1
  27. package/docs-site/mcp-approval.html +3 -1
  28. package/docs-site/mcp-auth.html +3 -1
  29. package/docs-site/mcp-servers.html +3 -1
  30. package/docs-site/mcp.html +3 -1
  31. package/docs-site/permissions.html +3 -1
  32. package/docs-site/profiles.html +3 -1
  33. package/docs-site/quickstart.html +3 -1
  34. package/docs-site/runtime.html +3 -1
  35. package/docs-site/scope.html +3 -1
  36. package/docs-site/security.html +3 -1
  37. package/docs-site/team.html +3 -1
  38. package/docs-site/workflows.html +3 -1
  39. package/package.json +5 -1
  40. package/packages/piagent-core/README.md +6 -2
  41. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  42. package/packages/piagent-core/capabilities/runtime-integrity.js +18 -0
  43. package/packages/piagent-core/extensions/guard-types.ts +0 -37
  44. package/packages/piagent-core/extensions/piagent-guard.ts +170 -2099
  45. package/packages/piagent-core/extensions/task-state.js +22 -3
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +213 -0
  48. package/packages/piagent-core/runtime/hooks/completion-hook.ts +203 -0
  49. package/packages/piagent-core/runtime/hooks/input-hook.ts +122 -0
  50. package/packages/piagent-core/runtime/hooks/session-hooks.ts +104 -0
  51. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +140 -0
  52. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +316 -0
  53. package/packages/piagent-core/runtime/input/chat-images.ts +299 -0
  54. package/packages/piagent-core/runtime/runtime-limits.ts +21 -0
  55. package/packages/piagent-core/runtime/session/message-signals.ts +64 -0
  56. package/packages/piagent-core/runtime/session/runtime-state.ts +175 -0
  57. package/packages/piagent-core/runtime/session/system-prompt.ts +52 -0
  58. package/packages/piagent-core/runtime/session/tool-result-compaction.ts +352 -0
  59. package/packages/piagent-core/runtime/session/usage.ts +178 -0
  60. package/packages/piagent-core/runtime/tools/tool-groups.ts +86 -0
  61. package/packages/piagent-core/runtime/workflows/input-routing.ts +113 -0
  62. package/packages/piagent-core/runtime/workflows/task-intake.ts +73 -0
  63. package/scripts/build-docs-site.mjs +4 -1
  64. package/scripts/check-architecture.mjs +132 -0
  65. package/scripts/check-doc-languages.mjs +67 -0
  66. package/scripts/install-global.sh +14 -2
  67. package/scripts/verify-local.sh +31 -0
@@ -20,12 +20,10 @@ import {
20
20
  shellHasFileWriteRedirection
21
21
  } from "./policy-core.js";
22
22
  import {
23
- appendObservedBashResult,
24
23
  commandMatchesVerifyPlan,
25
24
  createBashResultLedger,
26
25
  findMatchingObservedBashResult,
27
- readObservedBashResults,
28
- observedBashResultFromToolResultEvent
26
+ readObservedBashResults
29
27
  } from "./runtime-evidence.js";
30
28
  import {
31
29
  findPackageRoot,
@@ -99,7 +97,6 @@ import {
99
97
  createTaskRunId,
100
98
  isGitWorkingTree,
101
99
  listTaskContracts,
102
- migrateTaskState,
103
100
  priorTaskAttempts,
104
101
  resolveTaskContract,
105
102
  safeTaskId,
@@ -113,12 +110,56 @@ import {
113
110
  runtimeLifecycleMode,
114
111
  workingTreeEvidenceDigest
115
112
  } from "./task-lifecycle.js";
116
- import {
117
- appendJsonlBounded,
118
- pruneCaptureFiles,
119
- readJsonlTail
120
- } from "./state-retention.js";
113
+ import { appendJsonlBounded } from "./state-retention.js";
121
114
  import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
115
+ import {
116
+ TOOL_RESULT_CAPTURE_MAX_CHARS,
117
+ TOOL_RESULT_COMPACT_CHAR_THRESHOLD,
118
+ TOOL_RESULT_COMPACT_LINE_THRESHOLD,
119
+ TOOL_RESULT_PREVIEW_MAX_CHARS
120
+ } from "../runtime/runtime-limits.ts";
121
+ import {
122
+ cleanSessionNameInput,
123
+ currentSessionName,
124
+ hasOperatorSessionName
125
+ } from "../runtime/session/message-signals.ts";
126
+ import {
127
+ buildContextPreflight,
128
+ buildUsageSnapshot,
129
+ formatContextPreflight,
130
+ formatCount,
131
+ formatPercent,
132
+ formatUsageSnapshot
133
+ } from "../runtime/session/usage.ts";
134
+ import {
135
+ formatToolResultCaptureStatus,
136
+ readRecentToolResultCaptures
137
+ } from "../runtime/session/tool-result-compaction.ts";
138
+ import { RuntimeSessionState } from "../runtime/session/runtime-state.ts";
139
+ import type { ObservedTaskContext } from "../runtime/session/runtime-state.ts";
140
+ import {
141
+ PIAGENT_TOOL_GROUPS,
142
+ PIAGENT_TOOL_NAMES,
143
+ PIAGENT_TOOL_ORDER,
144
+ activeTaskToolGroups
145
+ } from "../runtime/tools/tool-groups.ts";
146
+ import type { PiagentToolGroup } from "../runtime/tools/tool-groups.ts";
147
+ import { shortTaskLabel } from "../runtime/workflows/input-routing.ts";
148
+ import {
149
+ automaticReviewLenses,
150
+ automaticTaskIntakeEligible,
151
+ automaticTaskScope,
152
+ validTaskScopePattern
153
+ } from "../runtime/workflows/task-intake.ts";
154
+ import { readChatImage } from "../runtime/input/chat-images.ts";
155
+ import { registerInputHook } from "../runtime/hooks/input-hook.ts";
156
+ import { registerAgentStartHook } from "../runtime/hooks/agent-start-hook.ts";
157
+ import { registerCompletionHook } from "../runtime/hooks/completion-hook.ts";
158
+ import { registerSessionHooks } from "../runtime/hooks/session-hooks.ts";
159
+ import { registerSessionStartHook } from "../runtime/hooks/session-start-hook.ts";
160
+ import { registerToolResultHook } from "../runtime/hooks/tool-result-hook.ts";
161
+
162
+ export { readChatImage };
122
163
 
123
164
  import type { ActionClassification } from "./guard-shell-analysis.ts";
124
165
 
@@ -132,7 +173,6 @@ import type {
132
173
  ContextIndexNode,
133
174
  ContextIndexNodeKind,
134
175
  ContextIndexSettings,
135
- ContextPreflight,
136
176
  ExecPolicyConfig,
137
177
  ExternalActionPolicyConfig,
138
178
  FinalGateConfig,
@@ -159,43 +199,13 @@ import type {
159
199
  TechStackEntry,
160
200
  TechStackManifest,
161
201
  ToolRegistryConfig,
162
- UsageSnapshot,
163
202
  WorkPlanStep
164
203
  } from "./guard-types.js";
165
204
 
166
205
 
167
206
  const PIAGENT_TRACE_STATE_TYPE = "piagent-task-trace";
168
- const CONTEXT_WATCH_PERCENT = 50;
169
- const CONTEXT_COMPACT_PERCENT = 70;
170
- const CONTEXT_FRESH_PERCENT = 82;
171
- const LONG_INPUT_CHARS = 8000;
172
207
  const BOILERPLATE_COLLAPSE_CHARS = 300;
173
- const MAX_INLINE_COLLAPSED_TASK_CHARS = 2200;
174
- const MAX_CHAT_IMAGE_ATTACHMENTS = 4;
175
- const MAX_CHAT_IMAGE_BYTES = 8 * 1024 * 1024;
176
- const TOOL_RESULT_COMPACT_CHAR_THRESHOLD = 12_000;
177
- const TOOL_RESULT_COMPACT_LINE_THRESHOLD = 180;
178
- const TOOL_RESULT_PREVIEW_HEAD_LINES = 24;
179
- const TOOL_RESULT_PREVIEW_TAIL_LINES = 24;
180
- const TOOL_RESULT_PREVIEW_INTERESTING_LINES = 24;
181
- const TOOL_RESULT_PREVIEW_MAX_CHARS = 6_000;
182
- const TOOL_RESULT_CAPTURE_MAX_CHARS = 500_000;
183
208
  const TRACE_MAX_BYTES = 8 * 1024 * 1024;
184
- const CAPTURE_INDEX_MAX_BYTES = 4 * 1024 * 1024;
185
- const CAPTURE_RETENTION_MAX_BYTES = 128 * 1024 * 1024;
186
- const CAPTURE_RETENTION_MAX_FILES = 500;
187
- const CAPTURE_RETENTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
188
- const CAPTURE_WRITES_SINCE_PRUNE = new Map<string, number>();
189
- const LEGACY_PROJECT_INSTRUCTIONS_START = "Before implementation:\n\n1. Load `.pi/piagent-profile.json` with `piagent_context`.";
190
- const LEGACY_PROJECT_INSTRUCTIONS_END = "18. If the bundled `pi-subagents` parent skill is available, use it for delegation patterns, review loops, native supervisor coordination, and safety boundaries.";
191
- const RUNTIME_MANAGED_PROJECT_INSTRUCTIONS = [
192
- "Piagent runtime-managed task flow:",
193
- "1. Runtime creates the task contract automatically for a bounded source-changing request before the model starts.",
194
- "2. Only when runtime intake pauses for broad, high-risk, or ambiguous scope, call `piagent_task_start` exactly once with project-relative path/glob scope; reuse an active contract.",
195
- "3. Read the narrow target and nearest relevant test, then use ordinary read/edit/bash tools.",
196
- "4. Run every exact runtime-provided verifier after the latest mutation. Automatic tasks need no management calls.",
197
- "5. Runtime hooks enforce policy and record context, changes, current-tree verification, trace and final gate. Diagnostic groups appear only for explicit requests or manual high-risk checkpoints."
198
- ].join("\n");
199
209
  type TaskStartParameters = {
200
210
  taskId?: string;
201
211
  summary: string;
@@ -219,7 +229,6 @@ type TaskStartParameters = {
219
229
  note?: string;
220
230
  }>;
221
231
  };
222
- const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"] as const;
223
232
  const ORCHESTRATION_MODES = ["solo-first", "bounded-subagents", "parallel-readonly"] as const;
224
233
  const REVIEW_LENSES = ["correctness", "tests", "scope", "security", "docs", "release", "package"] as const;
225
234
  const ORCHESTRATION_ROLES = ["parent", "piagent-scout", "piagent-planner", "piagent-worker", "piagent-reviewer", "piagent-oracle"] as const;
@@ -228,55 +237,6 @@ const CONTEXT_INDEX_EDGE_KINDS = ["uses_tech", "depends_on", "verified_by", "pro
228
237
  const CONTEXT_INDEX_FILE = ".pi/context-index.json";
229
238
  const TECH_STACK_MANIFEST_FILE = ".pi/tech-stack.json";
230
239
  const TECH_CONTEXT_DIR = ".pi/tech-context";
231
- const PIAGENT_TOOL_GROUPS = {
232
- loader: ["piagent_tools"],
233
- intake: ["piagent_task_start"],
234
- governance: [
235
- "piagent_context",
236
- "piagent_context_preflight"
237
- ],
238
- task: ["piagent_task_progress"],
239
- recovery: [
240
- "piagent_context_record",
241
- "piagent_verify_record",
242
- "piagent_trace_record",
243
- "piagent_task_gate_check"
244
- ],
245
- policy: [
246
- "piagent_permission_status",
247
- "piagent_exec_policy_check",
248
- "piagent_tool_policy_check"
249
- ],
250
- retrieval: [
251
- "piagent_context_engine",
252
- "piagent_context_budget",
253
- "piagent_context_index_status",
254
- "piagent_context_index_search"
255
- ],
256
- knowledge: [
257
- "piagent_memory_status",
258
- "piagent_memory_search",
259
- "piagent_memory_citation_record",
260
- "piagent_document_read",
261
- "piagent_source_checkout",
262
- "piagent_orchestration_policy"
263
- ],
264
- onboarding: [
265
- "piagent_profile_options",
266
- "piagent_profile_apply",
267
- "piagent_profile_tech_options",
268
- "piagent_profile_tech_apply",
269
- "piagent_profile_tech_context_record",
270
- "piagent_project_onboarding_record",
271
- "piagent_context_index_record",
272
- "piagent_memory_note"
273
- ],
274
- usage: ["piagent_usage_snapshot"]
275
- } as const;
276
- type PiagentToolGroup = keyof typeof PIAGENT_TOOL_GROUPS;
277
- const PIAGENT_TOOL_ORDER = Object.values(PIAGENT_TOOL_GROUPS).flat();
278
- const PIAGENT_TOOL_NAMES = new Set<string>(PIAGENT_TOOL_ORDER);
279
-
280
240
  const TECH_OPTIONS: TechOption[] = [
281
241
  { id: "nextjs", label: "Next.js", role: "frontend", description: "React framework with App Router/SSR/static rendering patterns.", context7Query: "next.js", topics: ["app-router", "routing", "data-fetching", "server-components"] },
282
242
  { id: "react-vite", label: "React + Vite", role: "frontend", description: "Client-side React app built with Vite.", context7Query: "react vite", topics: ["components", "hooks", "vite", "testing"] },
@@ -327,21 +287,6 @@ const PROFILE_TECH_ROLES: Record<string, TechRole[]> = {
327
287
  generic: ["runtime"]
328
288
  };
329
289
 
330
- type ChatImageAttachmentResult = {
331
- text: string;
332
- images: Array<{ type: "image"; data: string; mimeType: string }>;
333
- attached: Array<{ marker: string; path: string; mimeType: string; bytes: number }>;
334
- skipped: Array<{ path: string; reason: string }>;
335
- };
336
-
337
- type ChatImageAccessPolicy = {
338
- roots: ReturnType<typeof resolveDocumentRoots>;
339
- readProtectedPaths: string[];
340
- filesystemRead?: string[];
341
- enforceFilesystemRead: boolean;
342
- onImageInspected?: (path: string) => void;
343
- };
344
-
345
290
  const DEFAULT_MEMORY_SETTINGS: Required<MemorySettings> = {
346
291
  enabled: true,
347
292
  mode: "manual",
@@ -1357,39 +1302,12 @@ function shellGlobTargetsProtectedPath(
1357
1302
  return undefined;
1358
1303
  }
1359
1304
 
1360
- function countChangedStringLeaves(before: unknown, after: unknown): number {
1361
- if (typeof before === "string" && typeof after === "string") return before === after ? 0 : 1;
1362
- if (Array.isArray(before) && Array.isArray(after)) {
1363
- return before.reduce((total, item, index) => total + countChangedStringLeaves(item, after[index]), 0);
1364
- }
1365
- if (!before || !after || typeof before !== "object" || typeof after !== "object") return 0;
1366
- return Object.entries(before as Record<string, unknown>).reduce(
1367
- (total, [key, value]) => total + countChangedStringLeaves(value, (after as Record<string, unknown>)[key]),
1368
- 0
1369
- );
1370
- }
1371
-
1372
1305
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
1373
1306
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1374
1307
  const prototype = Object.getPrototypeOf(value);
1375
1308
  return prototype === Object.prototype || prototype === null;
1376
1309
  }
1377
1310
 
1378
- function redactToolResultTextContent(content: unknown): { content: unknown; redacted: number } {
1379
- if (!Array.isArray(content)) return { content, redacted: 0 };
1380
- let redacted = 0;
1381
- const safeContent = content.map((block) => {
1382
- if (!block || typeof block !== "object") return block;
1383
- const typed = block as { type?: unknown; text?: unknown };
1384
- if (typed.type !== "text" || typeof typed.text !== "string") return block;
1385
- const safeText = redactSensitiveText(typed.text);
1386
- if (!safeText.redacted) return block;
1387
- redacted += 1;
1388
- return { ...block, text: safeText.text };
1389
- });
1390
- return { content: safeContent, redacted };
1391
- }
1392
-
1393
1311
  function evaluatePathLikeToolAccess(
1394
1312
  cwd: string,
1395
1313
  toolName: string,
@@ -1513,107 +1431,6 @@ function evaluatePathLikeToolAccess(
1513
1431
  return { block: false };
1514
1432
  }
1515
1433
 
1516
- function grepOutputLinePath(line: string): string | undefined {
1517
- const match = line.match(/^(.+?)(?::\d+:|-\d+-)/);
1518
- return match?.[1];
1519
- }
1520
-
1521
- function filterGrepProtectedContent(content: unknown, protectedPatterns: string[]): {
1522
- changed: boolean;
1523
- content?: unknown;
1524
- redactedLines: number;
1525
- } {
1526
- if (!Array.isArray(content)) return { changed: false, redactedLines: 0 };
1527
-
1528
- let changed = false;
1529
- let redactedLines = 0;
1530
- const filtered = content.map((block) => {
1531
- if (!block || typeof block !== "object") return block;
1532
- const text = (block as { type?: unknown; text?: unknown }).text;
1533
- if ((block as { type?: unknown }).type !== "text" || typeof text !== "string") return block;
1534
-
1535
- const kept: string[] = [];
1536
- let blockRedactedLines = 0;
1537
- for (const line of text.split(/\r?\n/)) {
1538
- const linePath = grepOutputLinePath(line);
1539
- if (linePath && matchesProtectedPath(linePath, protectedPatterns)) {
1540
- changed = true;
1541
- redactedLines += 1;
1542
- blockRedactedLines += 1;
1543
- continue;
1544
- }
1545
- kept.push(line);
1546
- }
1547
-
1548
- if (blockRedactedLines === 0) return block;
1549
- const notice = `[Piagent Pi guard redacted ${blockRedactedLines} protected grep line${blockRedactedLines === 1 ? "" : "s"}.]`;
1550
- const nextText = kept.join("\n").trim().length > 0
1551
- ? `${kept.join("\n")}\n${notice}`
1552
- : `No matches found in non-protected paths.\n${notice}`;
1553
- return { ...block, text: nextText };
1554
- });
1555
-
1556
- return { changed, content: filtered, redactedLines };
1557
- }
1558
-
1559
- function resultLineProtectedPathCandidates(cwd: string, basePath: string, line: string): string[] {
1560
- const trimmed = line.trim();
1561
- if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("No ") || trimmed === "(empty directory)") return [];
1562
- const entry = trimmed.replace(/[\\/]+$/, "");
1563
- const candidates = new Set<string>();
1564
- const add = (value: string | undefined) => {
1565
- if (value !== undefined) candidates.add(value);
1566
- };
1567
-
1568
- add(normalizeRelative(cwd, entry));
1569
- add(normalizeRelative(cwd, path.posix.join(basePath || ".", entry)));
1570
- return [...candidates];
1571
- }
1572
-
1573
- function filterProtectedPathListContent(
1574
- cwd: string,
1575
- content: unknown,
1576
- protectedPatterns: string[],
1577
- basePath: string,
1578
- toolName: string
1579
- ): {
1580
- changed: boolean;
1581
- content?: unknown;
1582
- redactedLines: number;
1583
- } {
1584
- if (!Array.isArray(content)) return { changed: false, redactedLines: 0 };
1585
-
1586
- let changed = false;
1587
- let redactedLines = 0;
1588
- const filtered = content.map((block) => {
1589
- if (!block || typeof block !== "object") return block;
1590
- const text = (block as { type?: unknown; text?: unknown }).text;
1591
- if ((block as { type?: unknown }).type !== "text" || typeof text !== "string") return block;
1592
-
1593
- const kept: string[] = [];
1594
- let blockRedactedLines = 0;
1595
- for (const line of text.split(/\r?\n/)) {
1596
- const candidates = resultLineProtectedPathCandidates(cwd, basePath, line);
1597
- if (candidates.some((candidate) => matchesProtectedPath(candidate, protectedPatterns))) {
1598
- changed = true;
1599
- redactedLines += 1;
1600
- blockRedactedLines += 1;
1601
- continue;
1602
- }
1603
- kept.push(line);
1604
- }
1605
-
1606
- if (blockRedactedLines === 0) return block;
1607
- const notice = `[Piagent Pi guard redacted ${blockRedactedLines} protected ${toolName} line${blockRedactedLines === 1 ? "" : "s"}.]`;
1608
- const nextText = kept.join("\n").trim().length > 0
1609
- ? `${kept.join("\n")}\n${notice}`
1610
- : `No entries found in non-protected paths.\n${notice}`;
1611
- return { ...block, text: nextText };
1612
- });
1613
-
1614
- return { changed, content: filtered, redactedLines };
1615
- }
1616
-
1617
1434
  function stateRoot(cwd: string): string {
1618
1435
  return path.join(cwd, ".pi", "piagent-state");
1619
1436
  }
@@ -1634,14 +1451,6 @@ function observedBashLedgerPath(cwd: string): string {
1634
1451
  return path.join(stateRoot(cwd), "observed-bash.jsonl");
1635
1452
  }
1636
1453
 
1637
- function toolResultCaptureRoot(cwd: string): string {
1638
- return path.join(stateRoot(cwd), "tool-results");
1639
- }
1640
-
1641
- function toolResultCaptureIndexPath(cwd: string): string {
1642
- return path.join(toolResultCaptureRoot(cwd), "index.jsonl");
1643
- }
1644
-
1645
1454
  function nowIso(): string {
1646
1455
  return new Date().toISOString();
1647
1456
  }
@@ -2250,8 +2059,6 @@ function taskMutationTargets(cwd: string, toolName: string, input: Record<string
2250
2059
  )));
2251
2060
  }
2252
2061
 
2253
- type ObservedTaskContext = { path: string; reason: string };
2254
-
2255
2062
  function observedTaskContextFromToolResult(
2256
2063
  cwd: string,
2257
2064
  event: { toolName: string; input?: unknown; isError?: boolean },
@@ -4005,913 +3812,35 @@ function evaluateTaskGate(
4005
3812
  ))) {
4006
3813
  warnings.push("Verification evidence from a different working-tree snapshot is stale and is ignored.");
4007
3814
  }
4008
- let missingVerifyCommands: string[] = [];
4009
- if (task.changeMode === "source-change" && finalGate.requirePassingVerify && plannedVerifyCommands.length > 0) {
4010
- const passingCommands = new Set(task.verifyEvidence
4011
- .filter((evidence) => (
4012
- evidence.exitCode === 0
4013
- && evidence.observed === true
4014
- && evidence.matchedProfileCommand === true
4015
- && evidence.workingTreeDigest === currentWorkingTreeDigest
4016
- ))
4017
- .map((evidence) => evidence.command.trim()));
4018
- missingVerifyCommands = plannedVerifyCommands.filter((command) => !passingCommands.has(command.trim()));
4019
- if (missingVerifyCommands.length > 0) missing.push(`observed passing verify evidence for every configured command (${missingVerifyCommands.length} missing)`);
4020
- }
4021
- if (finalGate.requireTrace && task.trace.outcome !== "completed") missing.push("completed final trace");
4022
- const incompleteSteps = (task.workPlan ?? []).filter((step) => step.status === "pending" || step.status === "in-progress" || step.status === "failed");
4023
- if (task.trace.outcome === "completed" && incompleteSteps.length > 0) {
4024
- missing.push(`completed work plan (${incompleteSteps.map((step) => `${step.id}:${step.status}`).join(", ")})`);
4025
- }
4026
- const changedFileEvidence = taskChangedFileEvidence(cwd, task, currentDigests);
4027
- if (task.changeMode === "source-change" && task.trace.outcome === "completed") {
4028
- if (task.changedFiles.length === 0) missing.push("changed files");
4029
- if (changedFileEvidence.undeclared.length > 0) missing.push(`declared observed changes (${changedFileEvidence.undeclared.join(", ")})`);
4030
- if (changedFileEvidence.unsupportedClaims.length > 0) missing.push(`supported changed-file claims (${changedFileEvidence.unsupportedClaims.join(", ")})`);
4031
- if (changedFileEvidence.outsideScope.length > 0) missing.push(`changes within task scope (${changedFileEvidence.outsideScope.join(", ")})`);
4032
- }
4033
- if (task.changeMode === "read-only" && changedFileEvidence.expected.length > 0) {
4034
- missing.push(`read-only task has observed changes (${changedFileEvidence.expected.join(", ")})`);
4035
- }
4036
- return { decision: missing.length === 0 ? "pass" : "fail", missing, missingVerifyCommands, warnings, changedFileEvidence };
4037
- }
4038
-
4039
- function assistantMessageText(message: unknown): string {
4040
- if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return "";
4041
- const content = (message as { content?: unknown }).content;
4042
- if (!Array.isArray(content)) return "";
4043
- return content
4044
- .filter((item): item is { type: "text"; text: string } => Boolean(item && typeof item === "object" && (item as { type?: unknown }).type === "text" && typeof (item as { text?: unknown }).text === "string"))
4045
- .map((item) => item.text)
4046
- .join("\n");
4047
- }
4048
-
4049
- function assistantMessageHasToolCall(message: unknown): boolean {
4050
- const content = message && typeof message === "object" ? (message as { content?: unknown }).content : undefined;
4051
- return Array.isArray(content) && content.some((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "toolCall");
4052
- }
4053
-
4054
- function normalizeLanguageSignal(text: string): string {
4055
- return text
4056
- .normalize("NFKD")
4057
- .replace(/\p{M}/gu, "")
4058
- .toLowerCase()
4059
- .replaceAll("đ", "d");
4060
- }
4061
-
4062
- function looksLikeIncompleteHandoff(text: string): boolean {
4063
- const normalized = normalizeLanguageSignal(text);
4064
- return /\b(?:not done|not complete|not finished|still working|blocked|cannot complete|unable to complete|need clarification|tests? (?:fail|failed|failing)|verification (?:fail|failed|failing)|chua xong|chua hoan tat|chua hoan thanh|dang lam|bi chan|can lam them|test (?:loi|fail))\b/.test(normalized);
4065
- }
4066
-
4067
- function looksLikeCompletionClaim(text: string): boolean {
4068
- const normalized = normalizeLanguageSignal(text);
4069
- if (!normalized.trim() || normalized.includes("[piagent completion gate:")) return false;
4070
- if (looksLikeIncompleteHandoff(text)) return false;
4071
- return /\b(?:done|completed|complete|finished|fixed|implemented|resolved|shipped|all tests pass(?:ed)?|tests? pass(?:ed)?|da xong|da sua|da fix|da hoan tat|da hoan thanh|da trien khai|test da pass|kiem tra da pass)\b/.test(normalized);
4072
- }
4073
-
4074
- function formatCount(value: number | null | undefined): string {
4075
- if (value === null || value === undefined || Number.isNaN(value)) return "unknown";
4076
- return Math.round(value).toLocaleString("en-US");
4077
- }
4078
-
4079
- function formatPercent(value: number | null | undefined): string {
4080
- if (value === null || value === undefined || Number.isNaN(value)) return "unknown";
4081
- return `${value.toFixed(1)}%`;
4082
- }
4083
-
4084
- function modelLabel(ctx: ExtensionContext): string {
4085
- const model = ctx.model as { provider?: string; id?: string; name?: string } | undefined;
4086
- if (!model) return "none";
4087
- if (model.provider && model.id) return `${model.provider}/${model.id}`;
4088
- return model.name ?? model.id ?? "unknown";
4089
- }
4090
-
4091
- function currentSessionName(ctx: ExtensionContext): string {
4092
- try {
4093
- return String(ctx.sessionManager.getSessionName() ?? "").trim();
4094
- } catch {
4095
- return "";
4096
- }
4097
- }
4098
-
4099
- function hasOperatorSessionName(name: string): boolean {
4100
- const normalized = name.trim().toLowerCase();
4101
- return Boolean(normalized && normalized !== "session");
4102
- }
4103
-
4104
- function cleanSessionNameInput(input: string): string {
4105
- let name = input.trim();
4106
- if ((name.startsWith("\"") && name.endsWith("\"")) || (name.startsWith("'") && name.endsWith("'"))) {
4107
- name = name.slice(1, -1).trim();
4108
- }
4109
- return name;
4110
- }
4111
-
4112
- type ToolResultCaptureSummary = {
4113
- path?: string;
4114
- error?: string;
4115
- source: string;
4116
- toolName: string;
4117
- originalChars: number;
4118
- originalLines: number;
4119
- previewChars?: number;
4120
- storedChars?: number;
4121
- storedTruncated?: boolean;
4122
- sha256: string;
4123
- };
4124
-
4125
- function normalizeToolResultText(text: string): string {
4126
- return text.replace(/\r\n/g, "\n");
4127
- }
4128
-
4129
- function toolResultLineCount(text: string): number {
4130
- if (!text) return 0;
4131
- return normalizeToolResultText(text).split("\n").length;
4132
- }
4133
-
4134
- function shouldCompactToolResultText(text: string): boolean {
4135
- return text.length > TOOL_RESULT_COMPACT_CHAR_THRESHOLD
4136
- || toolResultLineCount(text) > TOOL_RESULT_COMPACT_LINE_THRESHOLD;
4137
- }
4138
-
4139
- function clipPreviewLine(line: string, maxChars = 260): string {
4140
- if (line.length <= maxChars) return line;
4141
- return `${line.slice(0, maxChars).trimEnd()} ... [line clipped]`;
4142
- }
4143
-
4144
- function boundedPreview(text: string): string {
4145
- if (text.length <= TOOL_RESULT_PREVIEW_MAX_CHARS) return text;
4146
- const marker = "\n[Piagent preview shortened further to stay light.]\n";
4147
- const edge = Math.floor((TOOL_RESULT_PREVIEW_MAX_CHARS - marker.length) / 2);
4148
- return `${text.slice(0, edge).trimEnd()}${marker}${text.slice(-edge).trimStart()}`;
4149
- }
4150
-
4151
- function interestingToolResultLines(lines: string[]): string[] {
4152
- const interesting = /\b(?:error|errors|err!|failed?|failure|warning|warn|exception|traceback|assert(?:ion)?|panic|fatal|timeout|timed out|denied|not found|cannot|eacces|enoent|ts\d{3,5}|err_[a-z0-9_]+)\b/i;
4153
- const seen = new Set<string>();
4154
- const result: string[] = [];
4155
- for (let index = 0; index < lines.length; index += 1) {
4156
- const line = lines[index];
4157
- if (!interesting.test(line)) continue;
4158
- const rendered = `${index + 1}: ${clipPreviewLine(line)}`;
4159
- if (seen.has(rendered)) continue;
4160
- seen.add(rendered);
4161
- result.push(rendered);
4162
- if (result.length >= TOOL_RESULT_PREVIEW_INTERESTING_LINES) break;
4163
- }
4164
- return result;
4165
- }
4166
-
4167
- function storedToolResultText(text: string): { text: string; truncated: boolean } {
4168
- if (text.length <= TOOL_RESULT_CAPTURE_MAX_CHARS) return { text, truncated: false };
4169
- const marker = `\n\n[Piagent capture truncated: omitted ${formatCount(text.length - TOOL_RESULT_CAPTURE_MAX_CHARS)} chars from the middle.]\n\n`;
4170
- const edge = Math.floor((TOOL_RESULT_CAPTURE_MAX_CHARS - marker.length) / 2);
4171
- return {
4172
- text: `${text.slice(0, edge).trimEnd()}${marker}${text.slice(-edge).trimStart()}`,
4173
- truncated: true
4174
- };
4175
- }
4176
-
4177
- function compactToolResultPreview(toolName: string, source: string, text: string, capture: ToolResultCaptureSummary): string {
4178
- const normalized = normalizeToolResultText(text);
4179
- const lines = normalized.split("\n");
4180
- const head = lines.slice(0, TOOL_RESULT_PREVIEW_HEAD_LINES).map((line) => clipPreviewLine(line));
4181
- const tailStart = Math.max(TOOL_RESULT_PREVIEW_HEAD_LINES, lines.length - TOOL_RESULT_PREVIEW_TAIL_LINES);
4182
- const tail = lines.slice(tailStart).map((line) => clipPreviewLine(line));
4183
- const omitted = Math.max(0, lines.length - head.length - tail.length);
4184
- const interesting = interestingToolResultLines(lines);
4185
- const captureLine = capture.path
4186
- ? `capture: ${capture.path}${capture.storedTruncated ? " (stored head/tail sample; original too large)" : ""}`
4187
- : `capture: unavailable (${capture.error ?? "write failed"})`;
4188
- const sections = [
4189
- `[Piagent compacted large ${toolName} output from ${source}: ${formatCount(capture.originalChars)} chars / ${formatCount(capture.originalLines)} lines.]`,
4190
- `[${captureLine}]`,
4191
- "[Preview keeps head, notable lines, and tail.]",
4192
- "",
4193
- "head:",
4194
- ...head
4195
- ];
4196
- if (interesting.length > 0) {
4197
- sections.push("", "notable:", ...interesting);
4198
- }
4199
- if (omitted > 0) {
4200
- sections.push("", `... ${formatCount(omitted)} middle line${omitted === 1 ? "" : "s"} omitted ...`);
4201
- }
4202
- sections.push("", "tail:", ...tail);
4203
- return boundedPreview(sections.join("\n"));
4204
- }
4205
-
4206
- function maybeNumber(value: unknown): number | undefined {
4207
- if (typeof value === "number" && Number.isFinite(value)) return value;
4208
- if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number.parseInt(value, 10);
4209
- return undefined;
4210
- }
4211
-
4212
- function currentSessionField(ctx: ExtensionContext, field: "getSessionFile" | "getSessionId" | "getSessionName"): string | undefined {
4213
- try {
4214
- const value = ctx.sessionManager[field]?.();
4215
- return value === undefined || value === null ? undefined : redactText(String(value));
4216
- } catch {
4217
- return undefined;
4218
- }
4219
- }
4220
-
4221
- function writeToolResultCapture(
4222
- cwd: string,
4223
- event: any,
4224
- ctx: ExtensionContext,
4225
- source: string,
4226
- text: string,
4227
- cache: Map<string, ToolResultCaptureSummary>
4228
- ): ToolResultCaptureSummary {
4229
- const normalized = normalizeToolResultText(text);
4230
- const sha256 = crypto.createHash("sha256").update(normalized).digest("hex");
4231
- const originalLines = toolResultLineCount(normalized);
4232
- const cached = cache.get(sha256);
4233
- if (cached?.path) return { ...cached, source };
4234
-
4235
- const toolName = String(event?.toolName ?? "tool");
4236
- const recordedAt = nowIso();
4237
- const date = recordedAt.slice(0, 10);
4238
- const filename = `${recordedAt.replace(/[:.]/g, "-")}-${slugify(toolName)}-${sha256.slice(0, 12)}.log`;
4239
- const relativePath = [".pi", "piagent-state", "tool-results", date, filename].join("/");
4240
- const absolutePath = path.join(cwd, ".pi", "piagent-state", "tool-results", date, filename);
4241
- const capture = storedToolResultText(normalized);
4242
- const details = event?.details && typeof event.details === "object" ? event.details : {};
4243
- const exitCode = maybeNumber(details.exitCode ?? details.status ?? event?.exitCode);
4244
- const metadata = {
4245
- schemaVersion: 1,
4246
- recordedAt,
4247
- cwd: redactText(cwd),
4248
- sessionId: currentSessionField(ctx, "getSessionId"),
4249
- sessionName: currentSessionField(ctx, "getSessionName"),
4250
- sessionFile: currentSessionField(ctx, "getSessionFile"),
4251
- toolName,
4252
- source,
4253
- isError: event?.isError === true,
4254
- exitCode,
4255
- input: redactForStorage(event?.input),
4256
- path: relativePath,
4257
- originalChars: normalized.length,
4258
- originalLines,
4259
- storedChars: capture.text.length,
4260
- storedTruncated: capture.truncated,
4261
- sha256
4262
- };
4263
-
4264
- try {
4265
- ensurePrivateStateDirectory(cwd, path.dirname(absolutePath), "Tool-result capture directory");
4266
- const capturePath = resolveLocalStatePath(cwd, absolutePath, { label: "Tool-result capture" });
4267
- fs.writeFileSync(capturePath, [
4268
- "# Piagent compacted tool result capture",
4269
- JSON.stringify(metadata),
4270
- "---",
4271
- capture.text
4272
- ].join("\n"), { mode: 0o600 });
4273
- appendJsonlBounded(toolResultCaptureIndexPath(cwd), metadata, { maxBytes: CAPTURE_INDEX_MAX_BYTES, mode: 0o600, projectRoot: cwd });
4274
- const writes = (CAPTURE_WRITES_SINCE_PRUNE.get(cwd) ?? 0) + 1;
4275
- if (writes >= 25) {
4276
- pruneCaptureFiles(toolResultCaptureRoot(cwd), {
4277
- maxFiles: CAPTURE_RETENTION_MAX_FILES,
4278
- maxBytes: CAPTURE_RETENTION_MAX_BYTES,
4279
- maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
4280
- projectRoot: cwd
4281
- });
4282
- CAPTURE_WRITES_SINCE_PRUNE.set(cwd, 0);
4283
- } else {
4284
- CAPTURE_WRITES_SINCE_PRUNE.set(cwd, writes);
4285
- }
4286
- const summary = {
4287
- path: relativePath,
4288
- source,
4289
- toolName,
4290
- originalChars: normalized.length,
4291
- originalLines,
4292
- storedChars: capture.text.length,
4293
- storedTruncated: capture.truncated,
4294
- sha256
4295
- };
4296
- cache.set(sha256, summary);
4297
- return summary;
4298
- } catch (error) {
4299
- const message = error instanceof Error ? error.message : String(error);
4300
- const summary = {
4301
- error: message,
4302
- source,
4303
- toolName,
4304
- originalChars: normalized.length,
4305
- originalLines,
4306
- sha256
4307
- };
4308
- cache.set(sha256, summary);
4309
- return summary;
4310
- }
4311
- }
4312
-
4313
- function compactToolResultTextContent(
4314
- cwd: string,
4315
- event: any,
4316
- ctx: ExtensionContext,
4317
- content: unknown,
4318
- cache: Map<string, ToolResultCaptureSummary>
4319
- ): { content: unknown; captures: ToolResultCaptureSummary[] } {
4320
- if (!Array.isArray(content)) return { content, captures: [] };
4321
- const captures: ToolResultCaptureSummary[] = [];
4322
- const compacted = content.map((block, index) => {
4323
- if (!block || typeof block !== "object") return block;
4324
- const typed = block as { type?: unknown; text?: unknown };
4325
- if (typed.type !== "text" || typeof typed.text !== "string" || !shouldCompactToolResultText(typed.text)) return block;
4326
- const source = `content[${index}].text`;
4327
- const capture = writeToolResultCapture(cwd, event, ctx, source, typed.text, cache);
4328
- captures.push(capture);
4329
- return {
4330
- ...block,
4331
- text: compactToolResultPreview(String(event?.toolName ?? "tool"), source, typed.text, capture)
4332
- };
4333
- });
4334
- return { content: compacted, captures };
4335
- }
4336
-
4337
- function compactToolResultDetails(
4338
- cwd: string,
4339
- event: any,
4340
- ctx: ExtensionContext,
4341
- value: unknown,
4342
- cache: Map<string, ToolResultCaptureSummary>,
4343
- captures: ToolResultCaptureSummary[],
4344
- source = "details",
4345
- depth = 0
4346
- ): unknown {
4347
- if (typeof value === "string") {
4348
- if (!shouldCompactToolResultText(value)) return value;
4349
- const capture = writeToolResultCapture(cwd, event, ctx, source, value, cache);
4350
- captures.push(capture);
4351
- return compactToolResultPreview(String(event?.toolName ?? "tool"), source, value, capture);
4352
- }
4353
- if (Array.isArray(value)) {
4354
- if (depth >= 6) return value;
4355
- return value.map((item, index) => compactToolResultDetails(cwd, event, ctx, item, cache, captures, `${source}[${index}]`, depth + 1));
4356
- }
4357
- if (isPlainRecord(value)) {
4358
- if (depth >= 6) return value;
4359
- return Object.fromEntries(
4360
- Object.entries(value).map(([key, item]) => [
4361
- key,
4362
- compactToolResultDetails(cwd, event, ctx, item, cache, captures, `${source}.${key}`, depth + 1)
4363
- ])
4364
- );
4365
- }
4366
- return value;
4367
- }
4368
-
4369
- function attachToolResultCompactionDetails(details: unknown, captures: ToolResultCaptureSummary[]): unknown {
4370
- if (captures.length === 0) return details;
4371
- const compacted = captures.map((capture) => ({
4372
- path: capture.path,
4373
- error: capture.error,
4374
- source: capture.source,
4375
- toolName: capture.toolName,
4376
- originalChars: capture.originalChars,
4377
- originalLines: capture.originalLines,
4378
- storedChars: capture.storedChars,
4379
- storedTruncated: capture.storedTruncated,
4380
- sha256: capture.sha256
4381
- }));
4382
- if (isPlainRecord(details)) return { ...details, piagentCompactedToolResults: compacted };
4383
- if (details === undefined) return { piagentCompactedToolResults: compacted };
4384
- return { value: details, piagentCompactedToolResults: compacted };
4385
- }
4386
-
4387
- function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
4388
- const indexPath = toolResultCaptureIndexPath(cwd);
4389
- const captures: Record<string, unknown>[] = [];
4390
- for (const parsed of readJsonlTail(indexPath, { limit: Math.max(limit * 3, limit), maxBytes: CAPTURE_INDEX_MAX_BYTES, projectRoot: cwd })) {
4391
- if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
4392
- }
4393
- return captures.slice(-limit);
4394
- }
4395
-
4396
- function formatToolResultCaptureStatus(cwd: string, captures: Record<string, unknown>[]): string {
4397
- const root = normalizeRelative(cwd, toolResultCaptureRoot(cwd)) ?? ".pi/piagent-state/tool-results";
4398
- const lines = [
4399
- `logPolicy: compact above ${formatCount(TOOL_RESULT_COMPACT_CHAR_THRESHOLD)} chars or ${formatCount(TOOL_RESULT_COMPACT_LINE_THRESHOLD)} lines`,
4400
- `captureRoot: ${root}`,
4401
- `recent: ${captures.length || "none"}`
4402
- ];
4403
- for (const capture of captures) {
4404
- const originalLines = typeof capture.originalLines === "number" ? formatCount(capture.originalLines) : "unknown";
4405
- const originalChars = typeof capture.originalChars === "number" ? formatCount(capture.originalChars) : "unknown";
4406
- const exit = capture.exitCode === undefined ? "" : ` exit=${capture.exitCode}`;
4407
- lines.push(`- ${capture.recordedAt ?? "unknown"} ${capture.toolName ?? "tool"}${exit}: ${originalLines} lines / ${originalChars} chars -> ${capture.path ?? "no capture path"}`);
4408
- }
4409
- return lines.join("\n");
4410
- }
4411
-
4412
- function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): UsageSnapshot {
4413
- const contextUsage = ctx.getContextUsage();
4414
- const contextWithThinking = ctx as ExtensionContext & { getThinkingLevel?: () => string };
4415
- return {
4416
- sessionFile: ctx.sessionManager.getSessionFile(),
4417
- sessionId: ctx.sessionManager.getSessionId(),
4418
- sessionName: ctx.sessionManager.getSessionName(),
4419
- cwd: ctx.cwd,
4420
- mode: ctx.mode,
4421
- model: modelLabel(ctx),
4422
- thinkingLevel: thinkingLevel ?? contextWithThinking.getThinkingLevel?.() ?? "unknown",
4423
- entries: {
4424
- total: ctx.sessionManager.getEntries().length,
4425
- branch: ctx.sessionManager.getBranch().length
4426
- },
4427
- contextUsage: contextUsage
4428
- ? {
4429
- tokens: contextUsage.tokens,
4430
- contextWindow: contextUsage.contextWindow,
4431
- percent: contextUsage.percent
4432
- }
4433
- : undefined,
4434
- exactTotals: {
4435
- availableInCommand: false,
4436
- howToRead: [
4437
- "Inside Pi TUI: run /session for exact tokens and cost.",
4438
- "Outside Pi: run piagent-usage /path/to/project or scripts/pi-session-stats.sh /path/to/project.",
4439
- "Historical totals: run piagent-usage --history /path/to/project --days 7."
4440
- ]
4441
- }
4442
- };
4443
- }
4444
-
4445
- function formatUsageSnapshot(snapshot: UsageSnapshot): string {
4446
- const context = snapshot.contextUsage
4447
- ? `${formatCount(snapshot.contextUsage.tokens)} / ${formatCount(snapshot.contextUsage.contextWindow)} tokens (${formatPercent(snapshot.contextUsage.percent)})`
4448
- : "unavailable";
4449
- return [
4450
- `usage: ${context}`,
4451
- `session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
4452
- `model: ${snapshot.model}; thinking: ${snapshot.thinkingLevel}`,
4453
- `entries: ${formatCount(snapshot.entries.branch)} active / ${formatCount(snapshot.entries.total)} total`,
4454
- `file: ${snapshot.sessionFile ?? "not persisted"}`,
4455
- "exact: /session | piagent-usage /path/to/project",
4456
- "history: piagent-usage --history /path/to/project --days 7"
4457
- ].join("\n");
4458
- }
4459
-
4460
- function estimateTokensFromChars(chars: number): number {
4461
- return Math.max(0, Math.ceil(chars / 4));
4462
- }
4463
-
4464
- function buildContextPreflight(snapshot: UsageSnapshot, workflow = "task", inputChars = 0): ContextPreflight {
4465
- const inputTokenEstimate = estimateTokensFromChars(inputChars);
4466
- const live = snapshot.contextUsage;
4467
- let projectedContext: ContextPreflight["projectedContext"];
4468
- let recommendation: ContextPreflight["recommendation"] = "unknown";
4469
- let reason = "Context usage is unavailable; use /session or /usage if the task is large.";
4470
-
4471
- if (live && live.tokens !== null && live.percent !== null) {
4472
- const projectedTokens = live.tokens + inputTokenEstimate;
4473
- const projectedPercent = live.contextWindow > 0 ? (projectedTokens / live.contextWindow) * 100 : live.percent;
4474
- projectedContext = {
4475
- tokens: projectedTokens,
4476
- percent: projectedPercent
4477
- };
4478
-
4479
- if (live.percent >= CONTEXT_FRESH_PERCENT || projectedPercent >= CONTEXT_FRESH_PERCENT || inputChars >= LONG_INPUT_CHARS) {
4480
- recommendation = "fresh-session";
4481
- reason = "Use a fresh governed session before this task to avoid provider context overflow and stale task state.";
4482
- } else if (live.percent >= CONTEXT_COMPACT_PERCENT || projectedPercent >= CONTEXT_COMPACT_PERCENT) {
4483
- recommendation = "compact";
4484
- reason = "Compact before continuing; the current session is close to the high-context zone.";
4485
- } else if (live.percent >= CONTEXT_WATCH_PERCENT || projectedPercent >= CONTEXT_WATCH_PERCENT) {
4486
- recommendation = "watch";
4487
- reason = "Proceed, but keep context targeted and avoid broad file injection.";
4488
- } else {
4489
- recommendation = "ok";
4490
- reason = "Context is within the normal range for a bounded task.";
4491
- }
4492
- } else if (inputChars >= LONG_INPUT_CHARS) {
4493
- recommendation = "fresh-session";
4494
- reason = "The incoming request is large; start a fresh governed session and keep the full intake in a file.";
4495
- }
4496
-
4497
- return {
4498
- workflow,
4499
- inputChars,
4500
- inputTokenEstimate,
4501
- liveContext: live,
4502
- projectedContext,
4503
- recommendation,
4504
- reason,
4505
- commands: [
4506
- "/task-preflight",
4507
- "/task-preflight compact",
4508
- `/fresh ${workflow === "be-to-fe" ? "be-to-fe" : workflow === "scout" ? "scout" : "task"} <request>`,
4509
- "/usage",
4510
- "/session"
4511
- ]
4512
- };
4513
- }
4514
-
4515
- function formatContextPreflight(preflight: ContextPreflight, snapshot: UsageSnapshot): string {
4516
- const live = preflight.liveContext
4517
- ? `${formatCount(preflight.liveContext.tokens)} / ${formatCount(preflight.liveContext.contextWindow)} (${formatPercent(preflight.liveContext.percent)})`
4518
- : "unavailable";
4519
- const projected = preflight.projectedContext
4520
- ? `${formatCount(preflight.projectedContext.tokens)} (${formatPercent(preflight.projectedContext.percent)})`
4521
- : "unavailable";
4522
- return [
4523
- `preflight: ${preflight.recommendation}`,
4524
- `workflow: ${preflight.workflow}`,
4525
- `session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
4526
- `model: ${snapshot.model}; thinking: ${snapshot.thinkingLevel}`,
4527
- `context: ${live}; projected: ${projected}`,
4528
- `input: ~${formatCount(preflight.inputTokenEstimate)} tokens from ${formatCount(preflight.inputChars)} chars`,
4529
- `reason: ${preflight.reason}`,
4530
- `next: ${preflight.commands.join(" | ")}`
4531
- ].join("\n");
4532
- }
4533
-
4534
- function looksLikeGovernedBoilerplate(text: string): boolean {
4535
- const lower = text.toLowerCase();
4536
- const markers = [
4537
- "mandatory flow",
4538
- "piagent_context",
4539
- "piagent_task_start",
4540
- "piagent_context_record",
4541
- "piagent_verify_record",
4542
- "piagent_task_gate_check",
4543
- "output format"
4544
- ];
4545
- return markers.filter((marker) => lower.includes(marker)).length >= 3;
4546
- }
4547
-
4548
- function extractFencedBlockAfter(label: RegExp, text: string): string | undefined {
4549
- const labelMatch = label.exec(text);
4550
- if (!labelMatch || labelMatch.index === undefined) return undefined;
4551
- const rest = text.slice(labelMatch.index + labelMatch[0].length);
4552
- const fenced = /```(?:text|md|markdown)?\s*([\s\S]*?)```/i.exec(rest);
4553
- return fenced?.[1]?.trim();
4554
- }
4555
-
4556
- function extractTaskRequest(text: string): string {
4557
- const labeled =
4558
- extractFencedBlockAfter(/(?:implement|scout|review|plan)\s+(?:this\s+)?task\s*:?\s*/i, text) ??
4559
- extractFencedBlockAfter(/request\s*:?\s*/i, text);
4560
- if (labeled) return stripLeadingWorkflowCommand(labeled);
4561
-
4562
- const firstFence = /```(?:text|md|markdown)?\s*([\s\S]*?)```/i.exec(text);
4563
- if (firstFence?.[1]?.trim()) return stripLeadingWorkflowCommand(firstFence[1].trim());
4564
-
4565
- return stripLeadingWorkflowCommand(text.trim());
4566
- }
4567
-
4568
- function stripLeadingWorkflowCommand(input: string): string {
4569
- return input
4570
- .replace(/^\/(?:piagent-workflow|workflow)\s+(?:task|scout|be-to-fe|review|plan|platform-improve|discuss|commit|pr)\b\s*/i, "")
4571
- .replace(/^\/(?:task|scout|be-to-fe|review|plan|platform-improve|discuss|commit|pr)\b\s*/i, "")
4572
- .trim();
4573
- }
4574
-
4575
- function trimTaskForInline(input: string): string {
4576
- const normalized = stripLeadingWorkflowCommand(input).trim().replace(/\n{3,}/g, "\n\n");
4577
- if (normalized.length <= MAX_INLINE_COLLAPSED_TASK_CHARS) return normalized;
4578
- return `${normalized.slice(0, MAX_INLINE_COLLAPSED_TASK_CHARS).trim()}\n\n[Input truncated by piagent preflight. Put the full spec in a project file and reference that file.]`;
4579
- }
4580
-
4581
- function chooseFreshWorkflow(original: string, task: string): "task" | "scout" | "be-to-fe" {
4582
- const semantic = stripLeadingWorkflowCommand(task || original).toLowerCase();
4583
- const starts = original.trim().toLowerCase();
4584
- const workflowStart = starts.match(/^\/(?:piagent-workflow|workflow)\s+(task|scout|be-to-fe)\b/);
4585
- if (workflowStart?.[1] === "be-to-fe") return "be-to-fe";
4586
- if (workflowStart?.[1] === "scout") return "scout";
4587
- if (workflowStart?.[1] === "task") return "task";
4588
- if (starts.startsWith("/be-to-fe")) return "be-to-fe";
4589
- if (starts.startsWith("/scout")) return "scout";
4590
- const asksForWrite = /\b(implement|support|surface|consume|write|change|fix)\b/.test(semantic);
4591
- if (/\b(scout|read-only|read only|audit|mapping|mapping matrix|map contract)\b/.test(semantic) && !asksForWrite) {
4592
- return "scout";
4593
- }
4594
- if (/\b(be|backend)\b/.test(semantic) && /\b(fe|frontend)\b/.test(semantic) && asksForWrite) {
4595
- return "be-to-fe";
4596
- }
4597
- return "task";
4598
- }
4599
-
4600
- function isPiagentWorkflowInput(text: string): boolean {
4601
- return /^\/(?:piagent-workflow|workflow|task|be-to-fe|scout|review|plan|platform-improve)\b/i.test(text.trim());
4602
- }
4603
-
4604
- function isFreshOrUtilityInput(text: string): boolean {
4605
- return /^\/(?:usage|logs|context|commands|permission|memory|onboard|name|fresh|fresh-task|fresh-scout|fresh-be-to-fe|task-preflight|piagent-usage|piagent-logs|piagent-session|piagent-context|piagent-commands|piagent-permission|model-options|memory-policy|onboard-project|setname|session|compact)\b/i.test(text.trim());
4606
- }
4607
-
4608
- function taskInboxDir(cwd: string): string {
4609
- return path.join(cwd, ".pi", "task-inbox");
4610
- }
4611
-
4612
- function writeTaskInbox(cwd: string, workflow: string, text: string): string {
4613
- fs.mkdirSync(taskInboxDir(cwd), { recursive: true });
4614
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
4615
- const safeWorkflow = workflow.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "task";
4616
- const fileName = `${stamp}-${safeWorkflow}.md`;
4617
- const absolute = path.join(taskInboxDir(cwd), fileName);
4618
- fs.writeFileSync(absolute, `${redactText(text)}\n`);
4619
- return path.relative(cwd, absolute).split(path.sep).join("/");
4620
- }
4621
-
4622
- function buildFreshCommand(cwd: string, workflow: "task" | "scout" | "be-to-fe", originalText: string, reason: string): string {
4623
- const task = extractTaskRequest(originalText);
4624
- if (originalText.length >= LONG_INPUT_CHARS) {
4625
- const intakePath = writeTaskInbox(cwd, workflow, originalText);
4626
- return `/fresh ${workflow} Read task intake from ${intakePath}. ${reason}`;
4627
- }
4628
- return `/fresh ${workflow} ${trimTaskForInline(task)}`;
4629
- }
4630
-
4631
- function shortTaskLabel(text: string): string {
4632
- const compact = text
4633
- .replace(/```[\s\S]*?```/g, " ")
4634
- .replace(/[^A-Za-z0-9\u00C0-\u1EF9_-]+/g, " ")
4635
- .trim()
4636
- .slice(0, 64);
4637
- return compact || "piagent task";
4638
- }
4639
-
4640
- function escapeRegExp(input: string): string {
4641
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4642
- }
4643
-
4644
- function supportedImageMimeType(bytes: Buffer): string | undefined {
4645
- if (
4646
- bytes.length >= 24
4647
- && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
4648
- && bytes.readUInt32BE(8) === 13
4649
- && bytes.subarray(12, 16).toString("ascii") === "IHDR"
4650
- && bytes.readUInt32BE(16) > 0
4651
- && bytes.readUInt32BE(20) > 0
4652
- ) {
4653
- return "image/png";
4654
- }
4655
- if (
4656
- bytes.length >= 4
4657
- && bytes[0] === 0xff
4658
- && bytes[1] === 0xd8
4659
- && bytes[2] === 0xff
4660
- && bytes[bytes.length - 2] === 0xff
4661
- && bytes[bytes.length - 1] === 0xd9
4662
- ) {
4663
- return "image/jpeg";
4664
- }
4665
- if (bytes.length >= 10) {
4666
- const head = bytes.subarray(0, 6).toString("ascii");
4667
- if (
4668
- (head === "GIF87a" || head === "GIF89a")
4669
- && bytes.readUInt16LE(6) > 0
4670
- && bytes.readUInt16LE(8) > 0
4671
- ) {
4672
- return "image/gif";
4673
- }
4674
- }
4675
- if (
4676
- bytes.length >= 16
4677
- && bytes.subarray(0, 4).toString("ascii") === "RIFF"
4678
- && bytes.subarray(8, 12).toString("ascii") === "WEBP"
4679
- && ["VP8 ", "VP8L", "VP8X"].includes(bytes.subarray(12, 16).toString("ascii"))
4680
- && bytes.readUInt32LE(4) + 8 <= bytes.length
4681
- ) {
4682
- return "image/webp";
4683
- }
4684
- if (
4685
- bytes.length >= 26
4686
- && bytes[0] === 0x42
4687
- && bytes[1] === 0x4d
4688
- && bytes.readUInt32LE(2) <= bytes.length
4689
- && bytes.readUInt32LE(14) >= 12
4690
- ) {
4691
- return "image/bmp";
4692
- }
4693
- return undefined;
4694
- }
4695
-
4696
- function normalizeImagePathCandidate(candidate: string, cwd: string, options: { allowBareRelative?: boolean } = {}): string | undefined {
4697
- let raw = candidate.trim().replace(/^['"`<]+|['"`>,.;:!?]+$/g, "");
4698
- if (!raw) return undefined;
4699
- const allowBareRelative = options.allowBareRelative !== false;
4700
- const hasExplicitPathPrefix = raw.startsWith("file://") || raw.startsWith("~/") || path.isAbsolute(raw) || raw.startsWith("./") || raw.startsWith("../");
4701
- if (!allowBareRelative && !hasExplicitPathPrefix) return undefined;
4702
- try {
4703
- if (raw.startsWith("file://")) raw = fileURLToPath(raw);
4704
- } catch {
4705
- return undefined;
4706
- }
4707
- if (raw.startsWith("~/")) {
4708
- const home = process.env.HOME;
4709
- if (!home) return undefined;
4710
- raw = path.join(home, raw.slice(2));
4711
- }
4712
- const ext = path.extname(raw).toLowerCase().replace(/^\./, "");
4713
- if (!IMAGE_EXTENSIONS.includes(ext as typeof IMAGE_EXTENSIONS[number])) return undefined;
4714
- const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
4715
- return absolute;
4716
- }
4717
-
4718
- function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
4719
- const candidates = new Set<string>();
4720
- const imageExt = "(?:png|jpe?g|gif|webp|bmp)";
4721
- const wholeTextPath = normalizeImagePathCandidate(text, cwd, { allowBareRelative: false });
4722
- if (wholeTextPath) candidates.add(wholeTextPath);
4723
-
4724
- const quoted = new RegExp(`(?:path=)?["']([^"']+\\.${imageExt})["']`, "gi");
4725
- for (const match of text.matchAll(quoted)) {
4726
- const normalized = normalizeImagePathCandidate(match[1], cwd);
4727
- if (normalized) candidates.add(normalized);
4728
- }
4729
-
4730
- const fileUrl = new RegExp(`file://[^\\s"'<>]+\\.${imageExt}`, "gi");
4731
- for (const match of text.matchAll(fileUrl)) {
4732
- const normalized = normalizeImagePathCandidate(match[0], cwd);
4733
- if (normalized) candidates.add(normalized);
4734
- }
4735
-
4736
- const linePathPattern = '\\s((?:/|~\\/|\\.\\.?/)[^\\n\\r"\\\'<>]*?\\.' + imageExt + ')(?=$|\\s|["\\\'`)>])';
4737
- const linePath = new RegExp(linePathPattern, "gi");
4738
- for (const match of text.matchAll(linePath)) {
4739
- const normalized = normalizeImagePathCandidate(match[1], cwd);
4740
- if (normalized) candidates.add(normalized);
4741
- }
4742
-
4743
- return [...candidates];
4744
- }
4745
-
4746
- type ResolvedChatImage =
4747
- | { status: "ok"; absolutePath: string; bytes: Buffer; mimeType: string }
4748
- | { status: "error"; reason: string };
4749
-
4750
- function pathContainedBy(root: string, candidate: string): boolean {
4751
- if (candidate === root) return true;
4752
- const relative = path.relative(root, candidate);
4753
- return relative.length > 0
4754
- && relative !== ".."
4755
- && !relative.startsWith(`..${path.sep}`)
4756
- && !path.isAbsolute(relative);
4757
- }
4758
-
4759
- export function readChatImage(
4760
- requestedPath: string,
4761
- cwd: string,
4762
- access: ChatImageAccessPolicy
4763
- ): ResolvedChatImage {
4764
- const lexicalRequestedPath = path.isAbsolute(requestedPath)
4765
- ? path.normalize(requestedPath)
4766
- : path.resolve(cwd, requestedPath);
4767
- const lexicalProjectRoot = path.resolve(cwd);
4768
- const requestedProjectLexicalRelative = pathContainedBy(lexicalProjectRoot, lexicalRequestedPath)
4769
- ? path.relative(lexicalProjectRoot, lexicalRequestedPath).split(path.sep).join("/") || "."
4770
- : undefined;
4771
- let canonicalPath: string;
4772
- let inspected: fs.Stats;
4773
- try {
4774
- canonicalPath = fs.realpathSync.native(requestedPath);
4775
- inspected = fs.statSync(canonicalPath);
4776
- } catch {
4777
- return { status: "error", reason: "file does not exist or cannot be inspected" };
4778
- }
4779
- if (!inspected.isFile()) return { status: "error", reason: "not a regular file" };
4780
- if (inspected.size <= 0) return { status: "error", reason: "empty file" };
4781
- if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
4782
- return {
4783
- status: "error",
4784
- reason: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
4785
- };
4786
- }
4787
-
4788
- const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath));
4789
- if (!root) return { status: "error", reason: "outside the project and every granted additionalReadRoots directory" };
4790
-
4791
- const rootRelative = path.relative(root.path, canonicalPath).split(path.sep).join("/") || ".";
4792
- const projectRoot = access.roots.find((candidate) => candidate.source === "project");
4793
- let canonicalRequestedLocation = requestedPath;
4794
- try {
4795
- canonicalRequestedLocation = path.join(
4796
- fs.realpathSync.native(path.dirname(requestedPath)),
4797
- path.basename(requestedPath)
4798
- );
4799
- } catch {
4800
- // The full target already resolved. This location form only preserves the
4801
- // final symlink's policy position, so failure falls back to the raw path.
4802
- }
4803
- const requestedProjectRelative = projectRoot && pathContainedBy(projectRoot.path, canonicalRequestedLocation)
4804
- ? path.relative(projectRoot.path, canonicalRequestedLocation).split(path.sep).join("/") || "."
4805
- : undefined;
4806
- const protectedMatch = matchesProtectedPath(canonicalPath, access.readProtectedPaths)
4807
- ?? matchesProtectedPath(rootRelative, access.readProtectedPaths)
4808
- ?? matchesProtectedPath(canonicalRequestedLocation, access.readProtectedPaths)
4809
- ?? matchesProtectedPath(requestedPath, access.readProtectedPaths)
4810
- ?? (requestedProjectLexicalRelative
4811
- ? matchesProtectedPath(requestedProjectLexicalRelative, access.readProtectedPaths)
4812
- : undefined)
4813
- ?? (requestedProjectRelative ? matchesProtectedPath(requestedProjectRelative, access.readProtectedPaths) : undefined);
4814
- if (protectedMatch) return { status: "error", reason: `matches protected path ${protectedMatch}` };
4815
-
4816
- if (
4817
- root.source === "project"
4818
- && access.enforceFilesystemRead
4819
- && access.filesystemRead
4820
- && !matchesAnyPath(rootRelative, access.filesystemRead)
4821
- ) {
4822
- return {
4823
- status: "error",
4824
- reason: `outside the resolved filesystem read scope (${access.filesystemRead.join(", ")})`
4825
- };
4826
- }
4827
-
4828
- access.onImageInspected?.(canonicalPath);
4829
- const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW;
4830
- let fd: number;
4831
- try {
4832
- fd = fs.openSync(canonicalPath, openFlags);
4833
- } catch {
4834
- return { status: "error", reason: "file changed before it could be opened" };
3815
+ let missingVerifyCommands: string[] = [];
3816
+ if (task.changeMode === "source-change" && finalGate.requirePassingVerify && plannedVerifyCommands.length > 0) {
3817
+ const passingCommands = new Set(task.verifyEvidence
3818
+ .filter((evidence) => (
3819
+ evidence.exitCode === 0
3820
+ && evidence.observed === true
3821
+ && evidence.matchedProfileCommand === true
3822
+ && evidence.workingTreeDigest === currentWorkingTreeDigest
3823
+ ))
3824
+ .map((evidence) => evidence.command.trim()));
3825
+ missingVerifyCommands = plannedVerifyCommands.filter((command) => !passingCommands.has(command.trim()));
3826
+ if (missingVerifyCommands.length > 0) missing.push(`observed passing verify evidence for every configured command (${missingVerifyCommands.length} missing)`);
4835
3827
  }
4836
- try {
4837
- const opened = fs.fstatSync(fd);
4838
- if (
4839
- !opened.isFile()
4840
- || opened.dev !== inspected.dev
4841
- || opened.ino !== inspected.ino
4842
- || opened.size !== inspected.size
4843
- ) {
4844
- return { status: "error", reason: "file changed between the safety checks and the read" };
4845
- }
4846
- const bytes = Buffer.alloc(opened.size);
4847
- let offset = 0;
4848
- while (offset < bytes.length) {
4849
- const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
4850
- if (read <= 0) break;
4851
- offset += read;
4852
- }
4853
- if (offset !== bytes.length) return { status: "error", reason: "file changed while it was being read" };
4854
- const mimeType = supportedImageMimeType(bytes);
4855
- if (!mimeType) return { status: "error", reason: "file bytes are not a supported image" };
4856
- return { status: "ok", absolutePath: canonicalPath, bytes, mimeType };
4857
- } finally {
4858
- fs.closeSync(fd);
3828
+ if (finalGate.requireTrace && task.trace.outcome !== "completed") missing.push("completed final trace");
3829
+ const incompleteSteps = (task.workPlan ?? []).filter((step) => step.status === "pending" || step.status === "in-progress" || step.status === "failed");
3830
+ if (task.trace.outcome === "completed" && incompleteSteps.length > 0) {
3831
+ missing.push(`completed work plan (${incompleteSteps.map((step) => `${step.id}:${step.status}`).join(", ")})`);
4859
3832
  }
4860
- }
4861
-
4862
- function attachLocalImagesFromText(
4863
- text: string,
4864
- existingImages: unknown[] | undefined,
4865
- cwd: string,
4866
- resolveAccess: () => ChatImageAccessPolicy
4867
- ): ChatImageAttachmentResult | undefined {
4868
- const imagePaths = extractLocalImagePathCandidates(text, cwd);
4869
- if (imagePaths.length === 0) return undefined;
4870
- const access = resolveAccess();
4871
-
4872
- const existing = Array.isArray(existingImages) ? existingImages : [];
4873
- const images: ChatImageAttachmentResult["images"] = [];
4874
- const attached: ChatImageAttachmentResult["attached"] = [];
4875
- const skipped: ChatImageAttachmentResult["skipped"] = [];
4876
- let nextText = text;
4877
-
4878
- for (const imagePath of imagePaths) {
4879
- if (images.length + existing.length >= MAX_CHAT_IMAGE_ATTACHMENTS) {
4880
- skipped.push({ path: imagePath, reason: `attachment limit ${MAX_CHAT_IMAGE_ATTACHMENTS} reached` });
4881
- continue;
4882
- }
4883
- try {
4884
- const resolved = readChatImage(imagePath, cwd, access);
4885
- if (resolved.status === "error") {
4886
- skipped.push({ path: imagePath, reason: resolved.reason });
4887
- continue;
4888
- }
4889
- const marker = `[image${existing.length + images.length + 1}]`;
4890
- images.push({ type: "image", mimeType: resolved.mimeType, data: resolved.bytes.toString("base64") });
4891
- attached.push({ marker, path: imagePath, mimeType: resolved.mimeType, bytes: resolved.bytes.length });
4892
- nextText = nextText.replace(new RegExp(escapeRegExp(imagePath), "g"), marker);
4893
- nextText = nextText.replace(new RegExp(escapeRegExp(`file://${imagePath}`), "g"), marker);
4894
- } catch (error) {
4895
- const message = error instanceof Error ? error.message : String(error);
4896
- skipped.push({ path: imagePath, reason: message });
4897
- }
3833
+ const changedFileEvidence = taskChangedFileEvidence(cwd, task, currentDigests);
3834
+ if (task.changeMode === "source-change" && task.trace.outcome === "completed") {
3835
+ if (task.changedFiles.length === 0) missing.push("changed files");
3836
+ if (changedFileEvidence.undeclared.length > 0) missing.push(`declared observed changes (${changedFileEvidence.undeclared.join(", ")})`);
3837
+ if (changedFileEvidence.unsupportedClaims.length > 0) missing.push(`supported changed-file claims (${changedFileEvidence.unsupportedClaims.join(", ")})`);
3838
+ if (changedFileEvidence.outsideScope.length > 0) missing.push(`changes within task scope (${changedFileEvidence.outsideScope.join(", ")})`);
4898
3839
  }
4899
-
4900
- if (attached.length === 0) {
4901
- return skipped.length > 0 ? { text, images: [], attached, skipped } : undefined;
3840
+ if (task.changeMode === "read-only" && changedFileEvidence.expected.length > 0) {
3841
+ missing.push(`read-only task has observed changes (${changedFileEvidence.expected.join(", ")})`);
4902
3842
  }
4903
-
4904
- const attachmentLines = attached.map((item) => `- ${item.marker}: ${path.basename(item.path)} (${item.mimeType}, ${formatCount(item.bytes)} bytes)`);
4905
- const skippedLines = skipped.map((item) => `- skipped ${path.basename(item.path)}: ${item.reason}`);
4906
- nextText = [
4907
- nextText.trim(),
4908
- "",
4909
- "Attached local image(s):",
4910
- ...attachmentLines,
4911
- ...(skippedLines.length > 0 ? ["", "Skipped local image path(s):", ...skippedLines] : [])
4912
- ].join("\n").trim();
4913
-
4914
- return { text: nextText, images, attached, skipped };
3843
+ return { decision: missing.length === 0 ? "pass" : "fail", missing, missingVerifyCommands, warnings, changedFileEvidence };
4915
3844
  }
4916
3845
 
4917
3846
  function appendTrace(cwd: string, payload: Record<string, unknown>): void {
@@ -5092,116 +4021,6 @@ function checkoutReferenceRepo(repoRef: string, forceUpdate = false): ReferenceR
5092
4021
  return { host, owner, repo, cloneUrl, checkoutPath, commit, fetched };
5093
4022
  }
5094
4023
 
5095
- function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
5096
- const signal = classifyContextTask(prompt);
5097
- const lower = prompt.toLowerCase();
5098
- const groups = new Set<PiagentToolGroup>();
5099
-
5100
- if (signal.workflow === "usage") {
5101
- groups.add("usage");
5102
- return [...groups];
5103
- }
5104
- if (signal.workflow === "onboard") {
5105
- groups.add("governance");
5106
- groups.add("policy");
5107
- groups.add("retrieval");
5108
- groups.add("knowledge");
5109
- groups.add("onboarding");
5110
- return [...groups];
5111
- }
5112
-
5113
- groups.add("intake");
5114
- // Keep the common task surface stable across the first tool call so provider
5115
- // prompt caches do not lose the full system prefix after task_start.
5116
- if (signal.lane !== "tiny") groups.add("task");
5117
- if (
5118
- /\b(context (?:engine|index|search|diagnostic)|source checkout|vendor checkout)\b/.test(lower)
5119
- ) {
5120
- groups.add("retrieval");
5121
- }
5122
- if (
5123
- /\b(document intake|project memory|orchestration policy|source checkout|subagent policy|vendor checkout)\b/.test(lower)
5124
- ) {
5125
- groups.add("knowledge");
5126
- }
5127
- if (/\b(permission|capability|exec policy|tool policy)\b/.test(lower)) groups.add("policy");
5128
- if (/\b(profile|tech stack|onboard|project context)\b/.test(lower)) groups.add("onboarding");
5129
- if (/\b(token|usage|cost)\b/.test(lower)) groups.add("usage");
5130
- if (/\b(?:piagent tool loader|load piagent tools?|piagent tool groups?)\b/.test(lower)) groups.add("loader");
5131
- return [...groups];
5132
- }
5133
-
5134
- const AUTO_INTAKE_MAX_PROMPT_CHARS = 700;
5135
- const AUTO_INTAKE_SNAPSHOT_PATTERNS = [
5136
- "src/**", "app/**", "lib/**", "packages/**", "test/**", "tests/**", "spec/**", "__tests__/**"
5137
- ];
5138
- const AUTO_INTAKE_CHANGE_INTENT = /\b(?:add|build|change|correct|create|fix|implement|modify|refactor|remove|rename|replace|update|write|sua|them|doi|cap nhat|xoa|tao)\b/i;
5139
- const AUTO_INTAKE_READ_ONLY_LEAD = /^\s*\/?(?:analy[sz]e|audit|check|discuss|explain|inspect|plan|research|review|scout|summari[sz]e|why|how|can\s+(?:you|we)|kiem tra|nghien cuu|giai thich|danh gia)\b/i;
5140
- const AUTO_INTAKE_MANUAL_RISK = /\b(?:credential|database|deploy|destructive|encryption|external provider|migration|payment|permission|production|publish|release|secret|schema migration|token rotation)\b/i;
5141
-
5142
- function automaticTaskIntakeEligible(prompt: string, readProtectedPaths: string[]): boolean {
5143
- const text = String(prompt ?? "").trim();
5144
- if (!text || text.length > AUTO_INTAKE_MAX_PROMPT_CHARS) return false;
5145
- const signal = classifyContextTask(text);
5146
- if (signal.workflow !== "task" || !AUTO_INTAKE_CHANGE_INTENT.test(text)) return false;
5147
- if (AUTO_INTAKE_READ_ONLY_LEAD.test(text) || AUTO_INTAKE_MANUAL_RISK.test(text)) return false;
5148
- if (/\bpiagent_task_start\b/i.test(text)) return false;
5149
- return !signal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5150
- }
5151
-
5152
- function manualTaskIntakeEligible(prompt: string, readProtectedPaths: string[]): boolean {
5153
- const text = String(prompt ?? "").trim();
5154
- if (!text || text.length > LONG_INPUT_CHARS) return false;
5155
- const signal = classifyContextTask(text);
5156
- if (signal.workflow !== "task" || !AUTO_INTAKE_CHANGE_INTENT.test(text)) return false;
5157
- if (AUTO_INTAKE_READ_ONLY_LEAD.test(text) || /\bpiagent_task_start\b/i.test(text)) return false;
5158
- if (signal.paths.length === 0) return true;
5159
- return signal.paths.some((candidate) => !matchesProtectedPath(candidate, readProtectedPaths));
5160
- }
5161
-
5162
- function automaticTaskScope(prompt: string, context: Array<{ path: string }>): string[] {
5163
- const signal = classifyContextTask(prompt);
5164
- const explicit = signal.paths.filter((candidate) => candidate && candidate !== "." && !candidate.startsWith(".pi/"));
5165
- const navigated = context
5166
- .map((item) => normalizePathCandidate(item.path))
5167
- .filter((candidate): candidate is string => Boolean(
5168
- candidate
5169
- && candidate !== "."
5170
- && !candidate.startsWith(".pi/")
5171
- && !["AGENTS.md", "README.md", "REVIEW_GUIDELINES.md"].includes(candidate)
5172
- && /^(?:app|lib|packages|spec|src|test|tests|__tests__)\//.test(candidate)
5173
- ))
5174
- .slice(0, 8);
5175
- const scope = uniqueStrings([...explicit, ...navigated]);
5176
- if (scope.length === 0) scope.push("src/**", "app/**", "lib/**");
5177
- if (/\b(?:test|tests|verification|verify|kiem thu)\b/i.test(prompt)) {
5178
- scope.push("test/**", "tests/**", "spec/**", "__tests__/**");
5179
- }
5180
- return uniqueStrings(scope);
5181
- }
5182
-
5183
- function automaticReviewLenses(prompt: string): ReviewLens[] {
5184
- const lenses: ReviewLens[] = ["correctness", "tests", "scope"];
5185
- if (/\b(?:auth|authorization|credential|permission|security|session|secret|xac thuc|phan quyen|bao mat)\b/i.test(prompt)) {
5186
- lenses.push("security");
5187
- }
5188
- return lenses;
5189
- }
5190
-
5191
- function validTaskScopePattern(value: string): boolean {
5192
- const candidate = String(value ?? "").trim().replaceAll("\\", "/");
5193
- if (!candidate || /\s|\0/.test(candidate)) return false;
5194
- if (candidate.startsWith("/") || candidate.startsWith("~/") || /^[A-Za-z]:\//.test(candidate)) return false;
5195
- return !candidate.split("/").includes("..");
5196
- }
5197
-
5198
- function activeTaskToolGroups(task: TaskContract): PiagentToolGroup[] {
5199
- const mode = runtimeLifecycleMode(task);
5200
- if (mode.startsWith("automatic")) return [];
5201
- if (mode.startsWith("assisted")) return ["task"];
5202
- return ["task", "recovery"];
5203
- }
5204
-
5205
4024
  function sessionTaskReference(ctx: ExtensionContext): { taskId?: string; taskRunId?: string } | undefined {
5206
4025
  const entries = ctx.sessionManager.getBranch();
5207
4026
  for (let index = entries.length - 1; index >= 0; index -= 1) {
@@ -5258,48 +4077,6 @@ function environmentFeatureEnabled(name: string, fallback = true): boolean {
5258
4077
  return !["0", "false", "no", "off", "disabled"].includes(value);
5259
4078
  }
5260
4079
 
5261
- function rewriteLegacyProjectInstructions(systemPrompt: string): {
5262
- systemPrompt: string;
5263
- rewritten: boolean;
5264
- } {
5265
- const start = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_START);
5266
- if (start < 0) return { systemPrompt, rewritten: false };
5267
- const endStart = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_END, start);
5268
- if (endStart < 0) return { systemPrompt, rewritten: false };
5269
- const end = endStart + LEGACY_PROJECT_INSTRUCTIONS_END.length;
5270
- return {
5271
- systemPrompt: `${systemPrompt.slice(0, start)}${RUNTIME_MANAGED_PROJECT_INSTRUCTIONS}${systemPrompt.slice(end)}`,
5272
- rewritten: true
5273
- };
5274
- }
5275
-
5276
- function compactManagedProjectInstructions(
5277
- systemPrompt: string,
5278
- mode: "automatic" | "protected"
5279
- ): { systemPrompt: string; compacted: boolean } {
5280
- const concise = mode === "protected"
5281
- ? "Piagent protected-path policy: do not read, disclose, or mutate protected content. Refuse without tool calls."
5282
- : "Piagent runtime task is injected below. Root project instructions are already loaded; do not re-read root AGENTS.md. Use task-relevant source/tests and ordinary tools, run the exact verifier after final edits, and make no task-management calls.";
5283
- const markerStart = "<!-- piagent-managed:start -->";
5284
- const markerEnd = "<!-- piagent-managed:end -->";
5285
- const start = systemPrompt.indexOf(markerStart);
5286
- const endStart = start >= 0 ? systemPrompt.indexOf(markerEnd, start) : -1;
5287
- if (start >= 0 && endStart >= 0) {
5288
- const end = endStart + markerEnd.length;
5289
- return {
5290
- systemPrompt: `${systemPrompt.slice(0, start)}${markerStart}\n${concise}\n${markerEnd}${systemPrompt.slice(end)}`,
5291
- compacted: true
5292
- };
5293
- }
5294
- if (systemPrompt.includes(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS)) {
5295
- return {
5296
- systemPrompt: systemPrompt.replace(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS, concise),
5297
- compacted: true
5298
- };
5299
- }
5300
- return { systemPrompt, compacted: false };
5301
- }
5302
-
5303
4080
  export default function piagentGuard(pi: ExtensionAPI) {
5304
4081
  const extensionDir = path.dirname(fileURLToPath(import.meta.url));
5305
4082
  const policy = loadPolicy(extensionDir);
@@ -5308,90 +4085,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
5308
4085
  const contextTelemetryEnabled = environmentFeatureEnabled("PIAGENT_CONTEXT_TELEMETRY");
5309
4086
  const autoContextEnabled = environmentFeatureEnabled("PIAGENT_AUTO_CONTEXT");
5310
4087
  const autoRecoveryEnabled = environmentFeatureEnabled("PIAGENT_AUTO_RECOVERY");
5311
- // Advisory tool-registry verdicts are surfaced once per tool per session. A
5312
- // notice on every call would be constant noise for a tool used all day, and
5313
- // noise that is always present is read as background rather than as a warning.
5314
- const advisedTools = new Set<string>();
5315
- const seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
5316
- const autoPackedPrompts = new Set<string>();
5317
- const injectedContextPacks = new Map<string, { queryHash: string; confidence: string; estimatedTokens: number; paths: string[] }>();
5318
- const taskIdentityBySession = new Map<string, { taskId: string; taskRunId: string }>();
5319
- const observedContextBySession = new Map<string, Map<string, ObservedTaskContext>>();
5320
- const completionRecoveryAttempts = new Map<string, number>();
5321
- const shellMutationSnapshots = new Map<string, Record<string, string>>();
5322
-
5323
- function sessionCacheKey(ctx: ExtensionContext): string {
5324
- return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}`;
5325
- }
5326
-
5327
- function cacheTaskIdentity(ctx: ExtensionContext, task: TaskContract | undefined): void {
5328
- const key = sessionCacheKey(ctx);
5329
- if (task) taskIdentityBySession.set(key, { taskId: task.taskId, taskRunId: task.taskRunId });
5330
- else taskIdentityBySession.delete(key);
5331
- if (taskIdentityBySession.size > 100) taskIdentityBySession.delete(taskIdentityBySession.keys().next().value as string);
5332
- }
5333
-
5334
- function rememberObservedContext(ctx: ExtensionContext, entry: ObservedTaskContext): void {
5335
- const key = sessionCacheKey(ctx);
5336
- let observed = observedContextBySession.get(key);
5337
- if (!observed) {
5338
- observed = new Map();
5339
- observedContextBySession.set(key, observed);
5340
- }
5341
- if (!observed.has(entry.path)) observed.set(entry.path, entry);
5342
- while (observed.size > contextBudgetConfig(policy).maxManifestFiles) {
5343
- observed.delete(observed.keys().next().value as string);
5344
- }
5345
- if (observedContextBySession.size > 100) observedContextBySession.delete(observedContextBySession.keys().next().value as string);
5346
- }
5347
-
5348
- function observedContext(ctx: ExtensionContext): ObservedTaskContext[] {
5349
- return [...(observedContextBySession.get(sessionCacheKey(ctx))?.values() ?? [])];
5350
- }
5351
-
5352
- function clearObservedContext(ctx: ExtensionContext): void {
5353
- observedContextBySession.delete(sessionCacheKey(ctx));
5354
- }
5355
-
5356
- function shellMutationSnapshotKey(
5357
- ctx: ExtensionContext,
5358
- toolName: string,
5359
- input: unknown
5360
- ): string {
5361
- return `${sessionCacheKey(ctx)}\u0000${toolResultFingerprint(toolName, input, []).key}`;
5362
- }
5363
-
5364
- function rememberShellMutationSnapshot(
5365
- ctx: ExtensionContext,
5366
- toolName: string,
5367
- input: unknown
5368
- ): void {
5369
- shellMutationSnapshots.set(
5370
- shellMutationSnapshotKey(ctx, toolName, input),
5371
- workingTreeSnapshot(ctx.cwd) as Record<string, string>
5372
- );
5373
- if (shellMutationSnapshots.size > 100) {
5374
- shellMutationSnapshots.delete(shellMutationSnapshots.keys().next().value as string);
5375
- }
5376
- }
5377
-
5378
- function consumeShellMutationSnapshot(
5379
- ctx: ExtensionContext,
5380
- toolName: string,
5381
- input: unknown
5382
- ): Record<string, string> | undefined {
5383
- const key = shellMutationSnapshotKey(ctx, toolName, input);
5384
- const snapshot = shellMutationSnapshots.get(key);
5385
- shellMutationSnapshots.delete(key);
5386
- return snapshot;
5387
- }
5388
-
5389
- function clearShellMutationSnapshots(ctx: ExtensionContext): void {
5390
- const prefix = `${sessionCacheKey(ctx)}\u0000`;
5391
- for (const key of shellMutationSnapshots.keys()) {
5392
- if (key.startsWith(prefix)) shellMutationSnapshots.delete(key);
5393
- }
5394
- }
4088
+ const runtimeState = new RuntimeSessionState({
4089
+ maxObservedContext: contextBudgetConfig(policy).maxManifestFiles
4090
+ });
5395
4091
 
5396
4092
  function retrievalKey(ctx: ExtensionContext, query: string): string {
5397
4093
  const signal = classifyContextTask(query);
@@ -5413,11 +4109,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
5413
4109
  if (!contextTelemetryEnabled) return;
5414
4110
  try {
5415
4111
  const safePayload = redactForStorage(payload) as Record<string, unknown>;
5416
- let taskIdentity = taskIdentityBySession.get(sessionCacheKey(ctx));
4112
+ let taskIdentity = runtimeState.taskIdentity(ctx);
5417
4113
  if (!taskIdentity) {
5418
4114
  const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5419
4115
  if (task) {
5420
- cacheTaskIdentity(ctx, task);
4116
+ runtimeState.cacheTaskIdentity(ctx, task);
5421
4117
  taskIdentity = { taskId: task.taskId, taskRunId: task.taskRunId };
5422
4118
  }
5423
4119
  }
@@ -5468,159 +4164,41 @@ export default function piagentGuard(pi: ExtensionAPI) {
5468
4164
  return ordered;
5469
4165
  }
5470
4166
 
5471
- pi.on("session_start", async (_event, ctx) => {
5472
- activateToolGroups(ctx, []);
5473
- const projectTrusted = ctx.isProjectTrusted();
5474
- const explicitProfile = Boolean(process.env.PIAGENT_PROFILE?.trim());
5475
- const profile = loadProfile(ctx.cwd, projectTrusted);
5476
- const name = profile.displayName || profile.projectId || path.basename(ctx.cwd);
5477
- const operatorSessionName = currentSessionName(ctx);
5478
- if (!hasOperatorSessionName(operatorSessionName)) {
5479
- pi.setSessionName(`pi:${name}`);
5480
- }
5481
- const sessionId = ctx.sessionManager.getSessionId();
5482
- const sessionName = currentSessionName(ctx);
5483
- const taskReference = sessionTaskReference(ctx);
5484
- let captureRetention: Record<string, unknown>;
5485
- try {
5486
- captureRetention = pruneCaptureFiles(toolResultCaptureRoot(ctx.cwd), {
5487
- maxFiles: CAPTURE_RETENTION_MAX_FILES,
5488
- maxBytes: CAPTURE_RETENTION_MAX_BYTES,
5489
- maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
5490
- projectRoot: ctx.cwd
5491
- });
5492
- } catch (error) {
5493
- const message = error instanceof Error ? error.message : String(error);
5494
- captureRetention = { removed: 0, kept: 0, bytes: 0, error: message };
5495
- ctx.ui.notify(`Piagent local state needs recovery: ${message}`, "warning");
5496
- }
5497
- const migration = migrateTaskState(ctx.cwd, {
5498
- sessionId,
5499
- sessionName,
5500
- taskId: taskReference?.taskId
5501
- });
5502
- if (migration.warnings.length > 0) {
5503
- ctx.ui.notify(`Piagent task-state migration needs recovery: ${migration.warnings.join("; ")}`, "warning");
5504
- }
5505
- let resumedTask = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
5506
- if (!resumedTask && taskReference) {
5507
- resumedTask = resolveTaskContract(ctx.cwd, taskReference.taskRunId ?? taskReference.taskId, sessionId) as TaskContract | undefined;
5508
- if (resumedTask) bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
5509
- }
5510
- if (resumedTask && resumedTask.sessionName !== sessionName) {
5511
- resumedTask.sessionName = sessionName;
5512
- resumedTask = writeTask(ctx.cwd, resumedTask);
5513
- bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
5514
- }
5515
- cacheTaskIdentity(ctx, resumedTask);
5516
- if (resumedTask?.trace.outcome === "pending") activateToolGroups(ctx, activeTaskToolGroups(resumedTask));
5517
- const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
5518
- ? ""
5519
- : " (run /onboard to select a profile)";
5520
- const snapshot = buildUsageSnapshot(ctx, String(pi.getThinkingLevel()));
5521
- const preflight = buildContextPreflight(snapshot, "task", 0);
5522
- const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted);
5523
- const permissionProfile = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
5524
- const contextHint = preflight.recommendation === "fresh-session"
5525
- ? " Context is high; use /fresh task or /fresh scout for new work."
5526
- : preflight.recommendation === "compact"
5527
- ? " Context is warm; run /task-preflight before large work."
5528
- : "";
5529
- const permissionHint = ` permission=${permissionProfile.mode}`;
5530
- ctx.ui.notify(`Piagent Pi guard loaded: ${name}${profileHint}${permissionHint}${contextHint}`, preflight.recommendation === "fresh-session" ? "warning" : "info");
5531
- if (!capabilityState.ok) ctx.ui.notify(capabilityState.reason ?? "Capability validation failed.", "warning");
5532
- if (capabilityState.repinned) {
5533
- ctx.ui.notify(`Capability lock re-pinned: ${capabilityState.repinned}. The capabilities this project grants are unchanged.`, "info");
5534
- }
5535
- const legacyWarning = legacyProjectStateWarning(ctx.cwd);
5536
- if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
5537
- if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
5538
- if (permissionProfile.mode === "trusted-full-access") {
5539
- ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
5540
- }
5541
- // A server this repository defines and nobody approved is a decision waiting
5542
- // on the operator, so it is a warning and it comes before the release news.
5543
- const mcpNotice = mcpReadinessNotice(ctx.cwd);
5544
- if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
5545
- // Last, so a release announcement never pushes a security warning out of
5546
- // the first thing the operator reads.
5547
- const updateNotice = updateAvailabilityNotice();
5548
- if (updateNotice) ctx.ui.notify(updateNotice, "info");
5549
- let engineStatus: unknown;
5550
- try {
5551
- engineStatus = await contextIndexV2Status(ctx.cwd, {
5552
- excludePatterns: contextIndexExcludePatterns(policy, profile)
5553
- });
5554
- } catch (error) {
5555
- engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
5556
- }
5557
- telemetry(ctx, {
5558
- event: "session_start",
5559
- activeTools: pi.getActiveTools().length,
5560
- index: engineStatus,
5561
- taskMigration: migration,
5562
- taskId: resumedTask?.taskId,
5563
- taskRunId: resumedTask?.taskRunId,
5564
- captureRetention
5565
- });
4167
+ registerSessionStartHook(pi, {
4168
+ state: runtimeState,
4169
+ loadProfile: loadProfileFromContext,
4170
+ projectProfileExists: (cwd) => fs.existsSync(projectProfilePath(cwd)),
4171
+ activateToolGroups,
4172
+ taskReference: sessionTaskReference,
4173
+ activeTask: (cwd, sessionId) => activeSessionTask(cwd, sessionId) as TaskContract | undefined,
4174
+ resolveTask: (cwd, reference, sessionId) => resolveTaskContract(cwd, reference, sessionId) as TaskContract | undefined,
4175
+ bindTask: bindSessionTask,
4176
+ writeTask,
4177
+ capabilityState: (ctx) => verifyProjectCapabilityState(extensionDir, ctx.cwd, ctx.isProjectTrusted()),
4178
+ permissionProfile: (ctx, profile) => resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx)),
4179
+ legacyProjectWarning: legacyProjectStateWarning,
4180
+ mcpReadinessNotice,
4181
+ updateAvailabilityNotice,
4182
+ contextExcludePatterns: (profile) => contextIndexExcludePatterns(policy, profile),
4183
+ telemetry
5566
4184
  });
5567
4185
 
5568
- pi.on("session_info_changed", async (event, ctx) => {
5569
- const sessionId = ctx.sessionManager.getSessionId();
5570
- const task = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
5571
- if (!task) return;
5572
- const sessionName = String(event.name ?? "").trim() || undefined;
5573
- task.sessionName = sessionName;
5574
- const written = writeTask(ctx.cwd, task);
5575
- bindSessionTask(ctx.cwd, sessionId, sessionName, written);
5576
- appendTrace(ctx.cwd, {
5577
- event: "task_session_renamed",
5578
- taskId: written.taskId,
5579
- taskRunId: written.taskRunId,
5580
- sessionId,
5581
- sessionName
5582
- });
4186
+ registerSessionHooks(pi, {
4187
+ state: runtimeState,
4188
+ maxManifestFiles: contextBudgetConfig(policy).maxManifestFiles,
4189
+ telemetry,
4190
+ activeTask: (cwd, sessionId) => activeSessionTask(cwd, sessionId) as TaskContract | undefined,
4191
+ writeTask,
4192
+ bindTask: bindSessionTask,
4193
+ appendTrace,
4194
+ flushObservedTaskContext
5583
4195
  });
5584
4196
 
5585
- pi.on("input", async (event, ctx) => {
5586
- const text = event.text.trim();
5587
- if (!text || isFreshOrUtilityInput(text)) return { action: "continue" };
5588
- if (/^\/piagent-workflow\b/i.test(text)) {
5589
- return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
5590
- }
5591
- const taskSignal = classifyContextTask(text);
5592
- const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5593
- const readProtectedPaths = effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths;
5594
- const protectedTarget = taskSignal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5595
- const protectedOnlyTarget = taskSignal.paths.length > 0
5596
- && taskSignal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5597
- const runtimeIntake = !activeTask && automaticTaskIntakeEligible(text, readProtectedPaths);
5598
- const manualIntake = !activeTask && !runtimeIntake && manualTaskIntakeEligible(text, readProtectedPaths);
5599
- const promptGroups = protectedOnlyTarget
5600
- ? []
5601
- : toolGroupsForPrompt(text).filter((group) => (
5602
- runtimeIntake ? group !== "intake" && group !== "task" : true
5603
- ));
5604
- if (manualIntake && !promptGroups.includes("intake")) promptGroups.push("intake");
5605
- activateToolGroups(ctx, activeTask?.trace.outcome === "pending"
5606
- ? [...promptGroups.filter((group) => group !== "intake"), ...activeTaskToolGroups(activeTask)]
5607
- : promptGroups);
5608
- telemetry(ctx, {
5609
- event: "user_input",
5610
- source: event.source,
5611
- promptHash: taskSignal.promptHash,
5612
- promptChars: taskSignal.promptChars,
5613
- workflow: taskSignal.workflow,
5614
- riskLane: taskSignal.lane,
5615
- intakeMode: runtimeIntake ? "runtime" : "model",
5616
- protectedTarget,
5617
- protectedOnlyTarget,
5618
- manualIntake,
5619
- explicitPathCount: taskSignal.paths.length,
5620
- termCount: taskSignal.terms.length
5621
- });
5622
-
5623
- const imageAttachment = attachLocalImagesFromText(text, event.images, ctx.cwd, () => {
4197
+ registerInputHook(pi, {
4198
+ boilerplateCollapseChars: BOILERPLATE_COLLAPSE_CHARS,
4199
+ activeTask: (ctx) => activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined,
4200
+ readProtectedPaths: (ctx) => effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths,
4201
+ imageAccess: (ctx) => {
5624
4202
  const projectTrusted = ctx.isProjectTrusted();
5625
4203
  const profile = loadProfileFromContext(ctx);
5626
4204
  const permissionProfile = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
@@ -5635,557 +4213,57 @@ export default function piagentGuard(pi: ExtensionAPI) {
5635
4213
  filesystemRead: capabilityState.filesystemRead,
5636
4214
  enforceFilesystemRead: permissionProfile.mode !== "trusted-full-access"
5637
4215
  };
5638
- });
5639
- if (imageAttachment?.attached.length) {
5640
- ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
5641
- } else if (imageAttachment?.skipped.length) {
5642
- ctx.ui.notify(`Piagent image input: skipped ${imageAttachment.skipped.length} local image path(s)`, "warning");
5643
- }
5644
-
5645
- const inputText = imageAttachment?.text ?? text;
5646
- const canRewriteWorkflow = event.source !== "extension";
5647
- const snapshot = buildUsageSnapshot(ctx, String(pi.getThinkingLevel()));
5648
- const preflight = buildContextPreflight(snapshot, chooseFreshWorkflow(inputText, inputText), inputText.length);
5649
- const hasBoilerplate = looksLikeGovernedBoilerplate(inputText);
5650
- const shouldFreshen =
5651
- canRewriteWorkflow &&
5652
- preflight.recommendation === "fresh-session" &&
5653
- (hasBoilerplate || isPiagentWorkflowInput(inputText) || inputText.length >= LONG_INPUT_CHARS);
5654
- const shouldCollapseBoilerplate = canRewriteWorkflow && hasBoilerplate && inputText.length >= BOILERPLATE_COLLAPSE_CHARS;
5655
-
5656
- const outgoingImages = [
5657
- ...(Array.isArray(event.images) ? event.images : []),
5658
- ...(imageAttachment?.images ?? [])
5659
- ];
5660
-
5661
- if (!shouldFreshen && !shouldCollapseBoilerplate) {
5662
- if (imageAttachment?.attached.length) return { action: "transform", text: inputText, images: outgoingImages };
5663
- return { action: "continue" };
5664
- }
5665
-
5666
- const task = extractTaskRequest(inputText);
5667
- const workflow = chooseFreshWorkflow(inputText, task);
5668
- const reason = shouldFreshen
5669
- ? "Current session is near context limits; use a fresh governed session."
5670
- : "Mandatory flow boilerplate is already part of the platform; collapse it to the task request.";
5671
- const command = shouldFreshen
5672
- ? buildFreshCommand(ctx.cwd, workflow, inputText, reason)
5673
- : `/${workflow} ${trimTaskForInline(task)}`;
5674
-
5675
- ctx.ui.notify(`Piagent preflight: ${reason}`, "warning");
5676
- return outgoingImages.length > 0
5677
- ? { action: "transform", text: command, images: outgoingImages }
5678
- : { action: "transform", text: command };
5679
- });
5680
-
5681
- pi.on("before_agent_start", async (event, ctx) => {
5682
- const projectInstructions = rewriteLegacyProjectInstructions(event.systemPrompt);
5683
- const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
5684
- const signal = classifyContextTask(query);
5685
- const profile = loadProfileFromContext(ctx);
5686
- const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
5687
- const protectedOnlyTarget = signal.paths.length > 0
5688
- && signal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5689
- const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5690
- const runtimeIntake = !activeTask && automaticTaskIntakeEligible(query, readProtectedPaths);
5691
- const compactMode = protectedOnlyTarget
5692
- ? "protected"
5693
- : runtimeIntake || activeTask?.intakeMode === "runtime"
5694
- ? "automatic"
5695
- : undefined;
5696
- const compactedInstructions = compactMode
5697
- ? compactManagedProjectInstructions(projectInstructions.systemPrompt, compactMode)
5698
- : { systemPrompt: projectInstructions.systemPrompt, compacted: false };
5699
- const effectiveSystemPrompt = compactedInstructions.systemPrompt;
5700
- const systemPromptUpdate = effectiveSystemPrompt !== event.systemPrompt
5701
- ? { systemPrompt: effectiveSystemPrompt }
5702
- : undefined;
5703
- const active = new Set<string>(pi.getActiveTools() as string[]);
5704
- const toolMetadata = pi.getAllTools()
5705
- .filter((tool) => active.has(tool.name))
5706
- .map((tool) => ({
5707
- name: tool.name,
5708
- description: tool.description,
5709
- parameters: tool.parameters,
5710
- promptGuidelines: tool.promptGuidelines
5711
- }));
5712
- const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
5713
- const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
5714
- const autoPackUseful = activeTask?.trace.outcome !== "pending"
5715
- && (runtimeIntake || signal.paths.length === 0);
5716
- telemetry(ctx, {
5717
- event: "agent_prompt",
5718
- promptHash: signal.promptHash,
5719
- promptChars: signal.promptChars,
5720
- workflow: signal.workflow,
5721
- riskLane: signal.lane,
5722
- activeTools: active.size,
5723
- activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
5724
- toolSchemaTokens,
5725
- systemPromptTokens,
5726
- systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
5727
- legacyProjectInstructionsRewritten: projectInstructions.rewritten,
5728
- managedInstructionsCompacted: compactedInstructions.compacted,
5729
- contextUsage: ctx.getContextUsage()
5730
- });
5731
-
5732
- const finishAgentStart = async (contextMessage?: {
5733
- customType: string;
5734
- content: string;
5735
- details: Record<string, unknown>;
5736
- }) => {
5737
- const intake = await maybeStartAutomaticTask(query, ctx);
5738
- if (!contextMessage && !intake) return systemPromptUpdate;
5739
- const content = [contextMessage?.content, intake?.text].filter(Boolean).join("\n\n");
5740
- return {
5741
- ...(systemPromptUpdate ?? {}),
5742
- message: {
5743
- customType: contextMessage?.customType ?? "piagent-runtime-task-intake",
5744
- content,
5745
- display: false,
5746
- details: {
5747
- ...(contextMessage?.details ?? {}),
5748
- runtimeTask: intake?.task
5749
- ? {
5750
- taskId: intake.task.taskId,
5751
- taskRunId: intake.task.taskRunId,
5752
- scope: intake.task.scope,
5753
- verifyCommands: intake.task.verifyCommands,
5754
- intakeMode: intake.task.intakeMode
5755
- }
5756
- : undefined,
5757
- runtimeIntakeStarted: intake?.started ?? false
5758
- }
5759
- }
5760
- };
5761
- };
5762
-
5763
- if (
5764
- query.length < 20
5765
- || signal.workflow === "usage"
5766
- || !autoContextEnabled
5767
- || !autoPackUseful
5768
- || autoPackedPrompts.has(promptPackKey(ctx, signal.promptHash))
5769
- ) {
5770
- return finishAgentStart();
5771
- }
5772
-
5773
- autoPackedPrompts.add(promptPackKey(ctx, signal.promptHash));
5774
- if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
5775
- try {
5776
- const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
5777
- const ensured = await ensureContextIndexV2(ctx.cwd, {
5778
- excludePatterns,
5779
- rebuildMissing: false
5780
- });
5781
- const status = ensured.status;
5782
- if (!status.exists || status.stale) {
5783
- telemetry(ctx, {
5784
- event: "context_pack",
5785
- queryHash: signal.promptHash,
5786
- confidence: "none",
5787
- candidates: 0,
5788
- selected: 0,
5789
- skipped: status.exists ? "stale-index" : "missing-index"
5790
- });
5791
- return finishAgentStart();
5792
- }
5793
- const budgetTokens = runtimeIntake
5794
- ? 900
5795
- : signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
5796
- const pack = await buildContextPack(ctx.cwd, query, {
5797
- budgetTokens,
5798
- includeCode: runtimeIntake,
5799
- includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
5800
- currentSnapshot: runtimeIntake,
5801
- limit: runtimeIntake ? 8 : 12,
5802
- excludePatterns
5803
- });
5804
- telemetry(ctx, {
5805
- event: "context_pack",
5806
- queryHash: pack.queryHash,
5807
- confidence: pack.confidence,
5808
- candidates: pack.candidates,
5809
- selected: pack.selected.length,
5810
- estimatedTokens: pack.estimatedTokens,
5811
- selectedPaths: pack.selected.map((item) => item.path),
5812
- finderRecommended: pack.finderRecommended,
5813
- currentSnapshot: runtimeIntake
5814
- });
5815
- if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return finishAgentStart();
5816
- injectedContextPacks.set(retrievalKey(ctx, query), {
5817
- queryHash: pack.queryHash,
5818
- confidence: pack.confidence,
5819
- estimatedTokens: pack.estimatedTokens,
5820
- paths: pack.selected.map((item) => item.path)
5821
- });
5822
- for (const selected of pack.selected) {
5823
- rememberObservedContext(ctx, {
5824
- path: selected.path,
5825
- reason: "Runtime injected a bounded Context Engine navigation pack."
5826
- });
5827
- }
5828
- if (injectedContextPacks.size > 50) injectedContextPacks.delete(injectedContextPacks.keys().next().value as string);
5829
- return finishAgentStart({
5830
- customType: "piagent-context-pack-v2",
5831
- content: pack.text,
5832
- details: {
5833
- schemaVersion: 2,
5834
- queryHash: pack.queryHash,
5835
- confidence: pack.confidence,
5836
- estimatedTokens: pack.estimatedTokens,
5837
- paths: pack.selected.map((item) => item.path),
5838
- currentSnapshot: runtimeIntake
5839
- }
5840
- });
5841
- } catch (error) {
5842
- telemetry(ctx, {
5843
- event: "context_pack",
5844
- queryHash: signal.promptHash,
5845
- confidence: "none",
5846
- candidates: 0,
5847
- selected: 0,
5848
- error: error instanceof Error ? error.message : String(error)
5849
- });
5850
- return finishAgentStart();
5851
- }
4216
+ },
4217
+ activateToolGroups,
4218
+ telemetry
5852
4219
  });
5853
4220
 
5854
- pi.on("turn_end", async (event, ctx) => {
5855
- const message = event.message as unknown as { usage?: unknown; stopReason?: unknown; role?: unknown };
5856
- telemetry(ctx, {
5857
- event: "turn_end",
5858
- turnIndex: event.turnIndex,
5859
- toolResults: event.toolResults.length,
5860
- role: message.role,
5861
- stopReason: message.stopReason,
5862
- usage: message.usage,
5863
- contextUsage: ctx.getContextUsage()
5864
- });
4221
+ registerAgentStartHook(pi, {
4222
+ state: runtimeState,
4223
+ autoContextEnabled,
4224
+ activeTask: (ctx) => activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined,
4225
+ readProtectedPaths: (ctx) => effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths,
4226
+ contextExcludePatterns: (ctx) => contextIndexExcludePatterns(policy, loadProfileFromContext(ctx)),
4227
+ promptPackKey,
4228
+ retrievalKey,
4229
+ startAutomaticTask: maybeStartAutomaticTask,
4230
+ telemetry
5865
4231
  });
5866
4232
 
5867
- pi.on("message_end", async (event, ctx) => {
5868
- if (!event.message || event.message.role !== "assistant" || assistantMessageHasToolCall(event.message)) return;
5869
- const budget = contextBudgetConfig(policy);
5870
- let task = flushObservedTaskContext(
5871
- pi,
5872
- ctx,
5873
- observedContext(ctx),
5874
- budget.maxManifestFiles,
5875
- "context_observed_before_handoff"
5876
- ) ?? activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5877
- if (!task) return;
5878
- const text = assistantMessageText(event.message);
5879
- const completionClaim = looksLikeCompletionClaim(text);
5880
- const incompleteHandoff = looksLikeIncompleteHandoff(text);
5881
- if (task.trace.outcome !== "pending") return;
5882
- const readOnlyEvidenceObserved = task.changeMode === "read-only" && task.contextManifest.length > 0;
5883
- const potentiallyFinalEvidence = task.observedChangedFiles.length > 0
5884
- || task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)
5885
- || readOnlyEvidenceObserved;
5886
- if (!completionClaim && (incompleteHandoff || !potentiallyFinalEvidence)) return;
5887
- const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
5888
- const currentDigest = workingTreeEvidenceDigest(currentDigests);
5889
- const currentPassingVerifierObserved = task.verifyEvidence.some((evidence) => (
5890
- evidence.exitCode === 0
5891
- && evidence.observed === true
5892
- && evidence.matchedProfileCommand === true
5893
- && evidence.workingTreeDigest === currentDigest
5894
- ));
5895
- const handoffAttempt = completionClaim || (
5896
- !incompleteHandoff
5897
- && (task.observedChangedFiles.length > 0 || currentPassingVerifierObserved || readOnlyEvidenceObserved)
5898
- );
5899
- let completionGate: ReturnType<typeof evaluateTaskGate> | undefined;
5900
- if (task.trace.outcome === "pending" && handoffAttempt) {
5901
- const projected = completionTaskProjection(ctx.cwd, task, currentDigests);
5902
- const projectedGate = evaluateTaskGate(ctx.cwd, projected, policy, {
5903
- currentDigests,
5904
- currentWorkingTreeDigest: currentDigest
5905
- });
5906
- completionGate = projectedGate;
5907
- if (projectedGate.decision === "pass") {
5908
- task = writeTask(ctx.cwd, projected);
5909
- cacheTaskIdentity(ctx, task);
5910
- completionRecoveryAttempts.delete(task.taskRunId);
5911
- clearObservedContext(ctx);
5912
- activateToolGroups(ctx, []);
5913
- const trace = {
5914
- event: "task_auto_completed",
5915
- taskId: task.taskId,
5916
- taskRunId: task.taskRunId,
5917
- sessionId: task.sessionId,
5918
- changedFiles: task.changedFiles,
5919
- lifecycleMode: runtimeLifecycleMode(task)
5920
- };
5921
- appendTrace(ctx.cwd, trace);
5922
- appendSessionTrace(pi, trace);
5923
- telemetry(ctx, trace);
5924
- return;
5925
- }
5926
- }
5927
- if (!handoffAttempt || resolveRuntimePolicy(loadProfileFromContext(ctx)).finalGate !== "enforce") return;
5928
- const gate = completionGate ?? evaluateTaskGate(ctx.cwd, task, policy, {
4233
+ registerCompletionHook(pi, {
4234
+ state: runtimeState,
4235
+ maxManifestFiles: contextBudgetConfig(policy).maxManifestFiles,
4236
+ autoRecoveryEnabled,
4237
+ activeTask: (ctx) => activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined,
4238
+ flushObservedTaskContext,
4239
+ completionProjection: completionTaskProjection,
4240
+ evaluateGate: (cwd, task, currentDigests, currentDigest) => evaluateTaskGate(cwd, task, policy, {
5929
4241
  currentDigests,
5930
4242
  currentWorkingTreeDigest: currentDigest
5931
- });
5932
- if (gate.decision === "pass") return;
5933
-
5934
- const recoveryAttempt = completionRecoveryAttempts.get(task.taskRunId) ?? 0;
5935
- const lifecycleMode = runtimeLifecycleMode(task);
5936
- if (
5937
- autoRecoveryEnabled
5938
- && handoffAttempt
5939
- && lifecycleMode !== "manual"
5940
- && recoveryAttempt < 1
5941
- ) {
5942
- completionRecoveryAttempts.set(task.taskRunId, recoveryAttempt + 1);
5943
- const recoveryGuidance = task.changeMode === "read-only"
5944
- ? [
5945
- "Continue the same read-only task. Review the cited evidence against the requested scope, state concrete unknowns, and complete the returned review step when required.",
5946
- "Do not mutate project source or repeat diagnostic Piagent tools; use ordinary targeted reads and the active progress tool only."
5947
- ]
5948
- : [
5949
- "Continue the same bounded task. Re-check the requested behavior against current source, make a relevant in-scope change when required, and run every exact configured verifier.",
5950
- ...verifierCommandInstructions(gate.missingVerifyCommands),
5951
- "A pre-existing passing test without a relevant source diff is not completion. Do not repeat diagnostic Piagent tools; use ordinary read/edit/bash work."
5952
- ];
5953
- const recovery = [
5954
- `[Piagent continuation required]`,
5955
- `Task ${task.taskId} cannot finish yet. Missing: ${gate.missing.join(", ")}.`,
5956
- ...recoveryGuidance
5957
- ].join("\n");
5958
- pi.sendMessage(
5959
- { customType: "piagent-completion-recovery", content: recovery, display: false, details: { taskId: task.taskId, missing: gate.missing } },
5960
- { deliverAs: "followUp", triggerTurn: true }
5961
- );
5962
- const recoveryTrace = {
5963
- event: "completion_recovery_scheduled",
5964
- taskId: task.taskId,
5965
- taskRunId: task.taskRunId,
5966
- sessionId: task.sessionId,
5967
- attempt: recoveryAttempt + 1,
5968
- missing: gate.missing
5969
- };
5970
- appendTrace(ctx.cwd, recoveryTrace);
5971
- appendSessionTrace(pi, recoveryTrace);
5972
- telemetry(ctx, recoveryTrace);
5973
- const continuingNotice = [
5974
- "[Piagent completion gate: CONTINUING]",
5975
- `Task ${task.taskId} needs one bounded correction pass before handoff.`,
5976
- ""
5977
- ].join("\n");
5978
- const content = Array.isArray(event.message.content)
5979
- ? [{ type: "text" as const, text: continuingNotice }, ...event.message.content]
5980
- : [{ type: "text" as const, text: `${continuingNotice}${text}` }];
5981
- return { message: { ...event.message, content } };
5982
- }
5983
-
5984
- const notice = [
5985
- `[Piagent completion gate: NOT APPROVED]`,
5986
- `Task ${task.taskId} (${task.taskRunId}) is still open.`,
5987
- `Missing: ${gate.missing.join(", ") || "a completed task trace"}.`,
5988
- ...verifierCommandInstructions(gate.missingVerifyCommands),
5989
- "The response below is preserved as work in progress and must not be treated as a completion report.",
5990
- ""
5991
- ].join("\n");
5992
- const content = Array.isArray(event.message.content)
5993
- ? [{ type: "text" as const, text: notice }, ...event.message.content]
5994
- : [{ type: "text" as const, text: `${notice}${text}` }];
5995
- const trace = {
5996
- event: "completion_claim_blocked",
5997
- taskId: task.taskId,
5998
- taskRunId: task.taskRunId,
5999
- sessionId: task.sessionId,
6000
- missing: gate.missing,
6001
- responseHash: crypto.createHash("sha256").update(text).digest("hex"),
6002
- responseChars: text.length
6003
- };
6004
- appendTrace(ctx.cwd, trace);
6005
- appendSessionTrace(pi, trace);
6006
- telemetry(ctx, trace);
6007
- return { message: { ...event.message, content } };
6008
- });
6009
-
6010
- pi.on("agent_settled", async (_event, ctx) => {
6011
- const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
6012
- telemetry(ctx, {
6013
- event: "agent_settled",
6014
- idle: ctx.isIdle(),
6015
- taskId: task?.taskId,
6016
- taskRunId: task?.taskRunId,
6017
- taskOutcome: task?.trace.outcome
6018
- });
6019
- });
6020
-
6021
- pi.on("session_compact", async (event, ctx) => {
6022
- telemetry(ctx, {
6023
- event: "session_compact",
6024
- reason: event.reason,
6025
- willRetry: event.willRetry,
6026
- fromExtension: event.fromExtension
6027
- });
6028
- });
6029
-
6030
- pi.on("session_shutdown", async (event, ctx) => {
6031
- flushObservedTaskContext(
6032
- pi,
6033
- ctx,
6034
- observedContext(ctx),
6035
- contextBudgetConfig(policy).maxManifestFiles,
6036
- "context_observed_before_shutdown"
6037
- );
6038
- telemetry(ctx, {
6039
- event: "session_shutdown",
6040
- reason: event.reason,
6041
- targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
6042
- });
6043
- clearObservedContext(ctx);
6044
- clearShellMutationSnapshots(ctx);
4243
+ }),
4244
+ writeTask,
4245
+ activateBaseTools: (ctx) => activateToolGroups(ctx, []),
4246
+ appendTrace,
4247
+ appendSessionTrace,
4248
+ telemetry,
4249
+ finalGateMode: (ctx) => resolveRuntimePolicy(loadProfileFromContext(ctx)).finalGate,
4250
+ verifierInstructions: verifierCommandInstructions
6045
4251
  });
6046
4252
 
6047
- pi.on("tool_result", async (event, ctx) => {
6048
- const profile = loadProfileFromContext(ctx);
6049
- const pathPolicy = effectiveProtectedPaths(policy, profile);
6050
-
6051
- const observed = observedBashResultFromToolResultEvent(event, ctx.cwd);
6052
- if (observed) {
6053
- bashResults.record(observed);
6054
- try {
6055
- appendObservedBashResult(observedBashLedgerPath(ctx.cwd), {
6056
- ...observed,
6057
- redactedCommand: redactText(observed.command)
6058
- }, { projectRoot: ctx.cwd });
6059
- } catch (error) {
6060
- const message = error instanceof Error ? error.message : String(error);
6061
- ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
6062
- }
6063
- }
6064
-
6065
- const observedContextEntry = observedTaskContextFromToolResult(ctx.cwd, event, pathPolicy.readProtectedPaths);
6066
- if (observedContextEntry) rememberObservedContext(ctx, observedContextEntry);
6067
- const pendingContext = observedContext(ctx);
6068
- const maxManifestFiles = contextBudgetConfig(policy).maxManifestFiles;
6069
- const shellSnapshotBefore = SHELL_TOOL_NAMES.has(event.toolName)
6070
- ? consumeShellMutationSnapshot(ctx, event.toolName, event.input)
6071
- : undefined;
6072
- recordObservedTaskChanges(pi, ctx, event, pendingContext, maxManifestFiles, shellSnapshotBefore);
6073
- if (observed) recordObservedTaskVerification(pi, ctx, observed, pendingContext, maxManifestFiles);
6074
-
6075
- let resultContent: unknown = event.content;
6076
- let resultDetails: unknown = event.details;
6077
- let resultChanged = false;
6078
-
6079
- if (event.toolName === "grep") {
6080
- const filtered = filterGrepProtectedContent(resultContent, pathPolicy.readProtectedPaths);
6081
- if (filtered.changed) {
6082
- resultContent = filtered.content;
6083
- resultDetails = resultDetails && typeof resultDetails === "object"
6084
- ? { ...resultDetails, protectedMatchesRedacted: filtered.redactedLines }
6085
- : { protectedMatchesRedacted: filtered.redactedLines };
6086
- resultChanged = true;
6087
- }
6088
- }
6089
-
6090
- if (event.toolName === "find" || event.toolName === "ls") {
6091
- const input = event.input && typeof event.input === "object"
6092
- ? event.input as Record<string, unknown>
6093
- : {};
6094
- const basePath = extractLikelyPathFromInput(ctx.cwd, input) || ".";
6095
- const filtered = filterProtectedPathListContent(ctx.cwd, resultContent, pathPolicy.readProtectedPaths, basePath, event.toolName);
6096
- if (filtered.changed) {
6097
- resultContent = filtered.content;
6098
- resultDetails = resultDetails && typeof resultDetails === "object"
6099
- ? { ...resultDetails, protectedPathsRedacted: filtered.redactedLines }
6100
- : { protectedPathsRedacted: filtered.redactedLines };
6101
- resultChanged = true;
6102
- }
6103
- }
6104
-
6105
- const safeContent = redactToolResultTextContent(resultContent);
6106
- const safeDetails = redactForStorage(resultDetails);
6107
- const detailRedactions = countChangedStringLeaves(resultDetails, safeDetails);
6108
- const sensitiveValuesRedacted = safeContent.redacted + detailRedactions;
6109
- if (sensitiveValuesRedacted > 0) {
6110
- resultContent = safeContent.content;
6111
- resultDetails = isPlainRecord(safeDetails)
6112
- ? { ...safeDetails, sensitiveValuesRedacted }
6113
- : safeDetails;
6114
- resultChanged = true;
6115
- }
6116
-
6117
- const fingerprint = toolResultFingerprint(event.toolName, event.input, resultContent);
6118
- const previousFingerprint = seenToolResults.get(fingerprint.key);
6119
- const repeated = previousFingerprint?.outputHash === fingerprint.outputHash;
6120
- seenToolResults.set(fingerprint.key, { outputHash: fingerprint.outputHash, recordedAt: nowIso() });
6121
- if (seenToolResults.size > 500) seenToolResults.delete(seenToolResults.keys().next().value as string);
6122
- if (repeated && ["read", "grep", "find", "ls"].includes(event.toolName) && fingerprint.outputChars > 0) {
6123
- resultContent = [{
6124
- type: "text",
6125
- text: `[Piagent delta: unchanged ${event.toolName} result; ${fingerprint.outputChars} chars / ${fingerprint.outputLines} lines match the previous identical call.]`
6126
- }];
6127
- resultDetails = isPlainRecord(resultDetails)
6128
- ? {
6129
- ...resultDetails,
6130
- piagentDelta: {
6131
- unchanged: true,
6132
- previousRecordedAt: previousFingerprint?.recordedAt,
6133
- outputHash: fingerprint.outputHash,
6134
- originalChars: fingerprint.outputChars,
6135
- originalLines: fingerprint.outputLines
6136
- }
6137
- }
6138
- : {
6139
- value: resultDetails,
6140
- piagentDelta: {
6141
- unchanged: true,
6142
- previousRecordedAt: previousFingerprint?.recordedAt,
6143
- outputHash: fingerprint.outputHash,
6144
- originalChars: fingerprint.outputChars,
6145
- originalLines: fingerprint.outputLines
6146
- }
6147
- };
6148
- resultChanged = true;
6149
- }
6150
-
6151
- const captureCache = new Map<string, ToolResultCaptureSummary>();
6152
- const compactedContent = compactToolResultTextContent(ctx.cwd, event, ctx, resultContent, captureCache);
6153
- const compactionCaptures = [...compactedContent.captures];
6154
- if (compactedContent.captures.length > 0) {
6155
- resultContent = compactedContent.content;
6156
- resultChanged = true;
6157
- }
6158
- if (resultDetails !== undefined) {
6159
- const compactedDetails = compactToolResultDetails(ctx.cwd, event, ctx, resultDetails, captureCache, compactionCaptures);
6160
- if (compactionCaptures.length > compactedContent.captures.length) {
6161
- resultDetails = compactedDetails;
6162
- resultChanged = true;
6163
- }
6164
- }
6165
- if (compactionCaptures.length > 0) {
6166
- resultDetails = attachToolResultCompactionDetails(resultDetails, compactionCaptures);
6167
- }
6168
-
6169
- telemetry(ctx, {
6170
- event: "tool_result",
6171
- toolName: event.toolName,
6172
- inputHash: fingerprint.inputHash,
6173
- outputHash: fingerprint.outputHash,
6174
- outputChars: fingerprint.outputChars,
6175
- outputLines: fingerprint.outputLines,
6176
- repeated,
6177
- compacted: compactionCaptures.length > 0,
6178
- compactedCaptures: compactionCaptures.length,
6179
- sensitiveValuesRedacted,
6180
- isError: event.isError,
6181
- usage: event.usage
6182
- });
6183
-
6184
- if (resultChanged) {
6185
- return resultDetails === undefined
6186
- ? { content: resultContent }
6187
- : { content: resultContent, details: resultDetails };
6188
- }
4253
+ registerToolResultHook(pi, {
4254
+ state: runtimeState,
4255
+ maxManifestFiles: contextBudgetConfig(policy).maxManifestFiles,
4256
+ readProtectedPaths: (ctx) => effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths,
4257
+ recordObservedBash: (observed) => bashResults.record(observed),
4258
+ observedBashLedgerPath,
4259
+ redactText,
4260
+ observedTaskContext: observedTaskContextFromToolResult,
4261
+ recordObservedTaskChanges,
4262
+ recordObservedTaskVerification,
4263
+ extractLikelyPath: extractLikelyPathFromInput,
4264
+ isShellTool: (toolName) => SHELL_TOOL_NAMES.has(toolName),
4265
+ telemetry,
4266
+ now: nowIso
6189
4267
  });
6190
4268
 
6191
4269
  pi.on("tool_call", async (event, ctx) => {
@@ -6218,8 +4296,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
6218
4296
  }
6219
4297
  // An advisory verdict that is computed and discarded makes advisory mode
6220
4298
  // indistinguishable from off, which is not what the mode is for.
6221
- if (toolDecision.decision === "warn" && !advisedTools.has(event.toolName)) {
6222
- advisedTools.add(event.toolName);
4299
+ if (toolDecision.decision === "warn" && !runtimeState.hasAdvisedTool(ctx, event.toolName)) {
4300
+ runtimeState.rememberAdvisedTool(ctx, event.toolName);
6223
4301
  const missing = toolDecision.requiredCapabilities.length > 0
6224
4302
  ? ` Declare ${toolDecision.requiredCapabilities.join(", ")} in profile mcpCapabilities to clear this.`
6225
4303
  : "";
@@ -6412,7 +4490,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
6412
4490
  && sessionTask.changeMode === "source-change"
6413
4491
  && shellProjectMutation
6414
4492
  ) {
6415
- rememberShellMutationSnapshot(ctx, event.toolName, event.input);
4493
+ runtimeState.rememberShellMutationSnapshot(ctx, event.toolName, event.input);
6416
4494
  }
6417
4495
  });
6418
4496
 
@@ -6511,7 +4589,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
6511
4589
  ].join("\n");
6512
4590
  } else if (params.action === "pack") {
6513
4591
  if (!params.query?.trim()) throw new Error("query is required for a context pack");
6514
- const injected = injectedContextPacks.get(retrievalKey(ctx, params.query));
4592
+ const injected = runtimeState.injectedContextPack(ctx, retrievalKey(ctx, params.query));
6515
4593
  if (injected && params.refresh !== true) {
6516
4594
  result = { reusedInjectedPack: true, ...injected };
6517
4595
  text = [
@@ -7565,7 +5643,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
7565
5643
  const reviewLenses = normalizeReviewLenses(params.reviewLenses, orchestration.defaultReviewLenses);
7566
5644
  const providedWorkPlan = normalizeWorkPlanSteps(params.workPlan);
7567
5645
  const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane, changeMode);
7568
- const seededContext = observedContext(ctx).slice(0, contextBudgetConfig(policy).maxManifestFiles);
5646
+ const seededContext = runtimeState.observedContext(ctx).slice(0, contextBudgetConfig(policy).maxManifestFiles);
7569
5647
  const workPlanError = validateNewWorkPlan(workPlan);
7570
5648
  if (workPlanError) {
7571
5649
  return { content: [{ type: "text", text: `Task start refused: ${workPlanError}.` }], details: workPlan, isError: true };
@@ -7627,7 +5705,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
7627
5705
  }
7628
5706
  const written = writeTask(ctx.cwd, task);
7629
5707
  bindSessionTask(ctx.cwd, sessionId, sessionName, written);
7630
- cacheTaskIdentity(ctx, written);
5708
+ runtimeState.cacheTaskIdentity(ctx, written);
7631
5709
  if (written.intakeMode !== "runtime") {
7632
5710
  // Intake classification defines the cache-stable surface for this agent turn.
7633
5711
  // A model may choose a narrower lane than the prompt classifier; never remove
@@ -7675,7 +5753,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
7675
5753
 
7676
5754
  const summary = redactText(prompt).replace(/\s+/g, " ").trim().slice(0, 320);
7677
5755
  const sessionName = currentSessionName(ctx);
7678
- const scope = automaticTaskScope(prompt, observedContext(ctx));
5756
+ const scope = automaticTaskScope(prompt, runtimeState.observedContext(ctx));
7679
5757
  const started = await taskStartTool.execute(
7680
5758
  `runtime-intake-${ctx.sessionManager.getSessionId()}`,
7681
5759
  {
@@ -9590,7 +7668,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9590
7668
  `cwd: ${redactText(snapshot.cwd)}`,
9591
7669
  `model: ${snapshot.model}; thinking: ${snapshot.thinkingLevel}`,
9592
7670
  `entries: ${formatCount(snapshot.entries.branch)} active / ${formatCount(snapshot.entries.total)} total`,
9593
- "name: /name <task name>",
7671
+ "name: Pi native /name <task name>",
9594
7672
  "resume: use Pi native /resume or /session"
9595
7673
  ].join("\n"), snapshot, { message: `Piagent session: ${snapshot.sessionName ?? "unnamed"}`, level: "info" });
9596
7674
  }
@@ -9613,7 +7691,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9613
7691
  if (!action) {
9614
7692
  const chosen = await selectRuntimeAction(ctx, "Piagent session", [
9615
7693
  { value: "current", label: "Current session", description: "Name, id, file, model", recommended: true },
9616
- { value: "name", label: "Set name", description: "Use /name <task name>" },
7694
+ { value: "name", label: "Set name", description: "Use Pi native /name <task name>" },
9617
7695
  { value: "resume", label: "Resume help", description: "Commands for continuing old sessions" },
9618
7696
  { value: "fresh-task", label: "Fresh task", description: "Use /fresh task <request>" },
9619
7697
  { value: "fresh-scout", label: "Fresh scout", description: "Use /fresh scout <request>" },
@@ -9635,7 +7713,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9635
7713
  emitRuntimeMessage(ctx, "piagent-session-help", [
9636
7714
  "session helpers:",
9637
7715
  "current: /usage live or Pi native /session",
9638
- "name: /name <task/session name>",
7716
+ "name: Pi native /name <task/session name>",
9639
7717
  "fresh: /fresh task|scout|be-to-fe <request>",
9640
7718
  "resume: Pi native /resume or /session",
9641
7719
  "legacy: /piagent-session | /setname | /fresh-task | /fresh-scout | /fresh-be-to-fe"
@@ -9680,7 +7758,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9680
7758
  emitRuntimeMessage(ctx, "piagent-session-help", [
9681
7759
  "session helpers:",
9682
7760
  "/usage live",
9683
- "/name ABC-123 Short task name",
7761
+ "Pi native: /name ABC-123 Short task name",
9684
7762
  "/fresh task Implement <request>",
9685
7763
  "native: /session | /resume"
9686
7764
  ].join("\n"));
@@ -9728,7 +7806,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9728
7806
  });
9729
7807
 
9730
7808
  pi.registerCommand("piagent-session", {
9731
- description: "Legacy session helper namespace; prefer /usage, /name, and /fresh",
7809
+ description: "Legacy session helper namespace; prefer /usage, Pi native /name, and /fresh",
9732
7810
  getArgumentCompletions: (prefix: string) => {
9733
7811
  const actions = ["current", "name", "resume", "fresh", "usage", "help"];
9734
7812
  const typed = String(prefix ?? "").trim().toLowerCase();
@@ -9739,15 +7817,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
9739
7817
  }
9740
7818
  });
9741
7819
 
9742
- pi.registerCommand("name", {
9743
- description: "Set the current session name for Agent Watch/report mapping",
9744
- handler: async (args, ctx) => {
9745
- setSessionNameFromCommand(ctx, String(args ?? ""), "/name <task/session name>");
9746
- }
9747
- });
9748
-
9749
7820
  pi.registerCommand("setname", {
9750
- description: "Legacy alias for /name",
7821
+ description: "Compatibility alias for Pi native /name",
9751
7822
  handler: async (args, ctx) => {
9752
7823
  setSessionNameFromCommand(ctx, String(args ?? ""), "/setname <task/session name>");
9753
7824
  }
@@ -9901,8 +7972,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
9901
7972
  const sections: Record<string, string[]> = {
9902
7973
  overview: [
9903
7974
  "Piagent command surface:",
9904
- "runtime: /workflow | /usage | /context | /permission | /commands | /profile | /memory | /onboard | /name | /fresh",
9905
- "native: /model | /session | /resume | /compact | /mcp",
7975
+ "runtime: /workflow | /usage | /context | /permission | /commands | /profile | /memory | /onboard | /fresh",
7976
+ "native: /model | /name | /session | /resume | /compact | /mcp",
9906
7977
  "workflow: /workflow task|scout|be-to-fe|review|commit|pr <request>",
9907
7978
  "mcp: /mcp is Pi native; governed MCP checks stay at /piagent-mcp to avoid collision",
9908
7979
  "legacy: /piagent-* commands still work where they existed",
@@ -9920,7 +7991,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
9920
7991
  "Usage/session:",
9921
7992
  "/usage",
9922
7993
  "/usage history",
9923
- "/name <task name>",
7994
+ "Pi native: /name <task name>",
9924
7995
  "/fresh task|scout|be-to-fe <request>",
9925
7996
  "native: /session | /resume"
9926
7997
  ],