@piagent/platform 1.2.10 → 1.2.12

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 (149) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +29 -4
  3. package/adapters/backend-api/profile.json +1 -1
  4. package/adapters/be-readonly-fe/profile.json +2 -2
  5. package/adapters/data/profile.json +1 -1
  6. package/adapters/devops/profile.json +1 -3
  7. package/adapters/docs/profile.json +1 -1
  8. package/adapters/fullstack/profile.json +2 -4
  9. package/adapters/generic/profile.json +1 -1
  10. package/adapters/mobile/profile.json +1 -2
  11. package/adapters/node-typescript/profile.json +1 -3
  12. package/benchmarks/core-v1/suite.json +51 -0
  13. package/benchmarks/core-v1/tasks/discount-order/grade.mjs +17 -0
  14. package/benchmarks/core-v1/tasks/discount-order/project/package.json +8 -0
  15. package/benchmarks/core-v1/tasks/discount-order/project/src/discount.js +3 -0
  16. package/benchmarks/core-v1/tasks/discount-order/project/src/order.js +6 -0
  17. package/benchmarks/core-v1/tasks/discount-order/project/test/order.test.js +7 -0
  18. package/benchmarks/core-v1/tasks/discount-order/prompt.md +4 -0
  19. package/benchmarks/core-v1/tasks/invoice-quantity/grade.mjs +17 -0
  20. package/benchmarks/core-v1/tasks/invoice-quantity/project/package.json +8 -0
  21. package/benchmarks/core-v1/tasks/invoice-quantity/project/src/invoice.js +3 -0
  22. package/benchmarks/core-v1/tasks/invoice-quantity/project/test/invoice.test.js +7 -0
  23. package/benchmarks/core-v1/tasks/invoice-quantity/prompt.md +3 -0
  24. package/benchmarks/core-v1/tasks/protected-env-refusal/grade.mjs +11 -0
  25. package/benchmarks/core-v1/tasks/protected-env-refusal/project/README.md +4 -0
  26. package/benchmarks/core-v1/tasks/protected-env-refusal/project/package.json +8 -0
  27. package/benchmarks/core-v1/tasks/protected-env-refusal/project/test/smoke.test.js +4 -0
  28. package/benchmarks/core-v1/tasks/protected-env-refusal/prompt.md +4 -0
  29. package/benchmarks/core-v1/tasks/targeted-session/grade.mjs +14 -0
  30. package/benchmarks/core-v1/tasks/targeted-session/project/package.json +8 -0
  31. package/benchmarks/core-v1/tasks/targeted-session/project/src/auth/session.js +3 -0
  32. package/benchmarks/core-v1/tasks/targeted-session/project/src/billing/currency.js +3 -0
  33. package/benchmarks/core-v1/tasks/targeted-session/project/src/catalog/search.js +3 -0
  34. package/benchmarks/core-v1/tasks/targeted-session/project/src/notifications/channel.js +3 -0
  35. package/benchmarks/core-v1/tasks/targeted-session/project/src/search/tokenize.js +3 -0
  36. package/benchmarks/core-v1/tasks/targeted-session/project/test/session.test.js +7 -0
  37. package/benchmarks/core-v1/tasks/targeted-session/prompt.md +4 -0
  38. package/benchmarks/production-v1/grade.mjs +220 -0
  39. package/benchmarks/production-v1/project/README.md +7 -0
  40. package/benchmarks/production-v1/project/config/service.json +4 -0
  41. package/benchmarks/production-v1/project/data/audit-ledger.jsonl +1 -0
  42. package/benchmarks/production-v1/project/docs/ops.md +3 -0
  43. package/benchmarks/production-v1/project/logs/incident.log +2 -0
  44. package/benchmarks/production-v1/project/package.json +11 -0
  45. package/benchmarks/production-v1/project/scripts/check.mjs +21 -0
  46. package/benchmarks/production-v1/project/src/backend/auth.js +3 -0
  47. package/benchmarks/production-v1/project/src/backend/cache.js +15 -0
  48. package/benchmarks/production-v1/project/src/backend/invoice.js +4 -0
  49. package/benchmarks/production-v1/project/src/data/csv.js +3 -0
  50. package/benchmarks/production-v1/project/src/data/dedup.js +5 -0
  51. package/benchmarks/production-v1/project/src/data/migration.js +9 -0
  52. package/benchmarks/production-v1/project/src/frontend/pagination.js +8 -0
  53. package/benchmarks/production-v1/project/src/frontend/search-state.js +8 -0
  54. package/benchmarks/production-v1/project/src/frontend/unicode-search.js +7 -0
  55. package/benchmarks/production-v1/project/src/platform/args.js +10 -0
  56. package/benchmarks/production-v1/project/src/platform/config.js +7 -0
  57. package/benchmarks/production-v1/project/src/platform/workspace.js +13 -0
  58. package/benchmarks/production-v1/project/src/reliability/expiry.js +5 -0
  59. package/benchmarks/production-v1/project/src/reliability/retry.js +15 -0
  60. package/benchmarks/production-v1/project/test/smoke.test.js +12 -0
  61. package/benchmarks/production-v1/project/vendor/README.md +1 -0
  62. package/benchmarks/production-v1/prompts/bounded-retry.md +7 -0
  63. package/benchmarks/production-v1/prompts/cli-double-dash.md +7 -0
  64. package/benchmarks/production-v1/prompts/config-precedence.md +6 -0
  65. package/benchmarks/production-v1/prompts/destructive-history-refusal.md +3 -0
  66. package/benchmarks/production-v1/prompts/expiry-boundary.md +7 -0
  67. package/benchmarks/production-v1/prompts/incident-diagnosis.md +4 -0
  68. package/benchmarks/production-v1/prompts/invoice-rounding.md +9 -0
  69. package/benchmarks/production-v1/prompts/pagination-boundary.md +7 -0
  70. package/benchmarks/production-v1/prompts/protected-env-refusal.md +2 -0
  71. package/benchmarks/production-v1/prompts/quoted-csv.md +6 -0
  72. package/benchmarks/production-v1/prompts/repository-prompt-injection.md +5 -0
  73. package/benchmarks/production-v1/prompts/schema-migration.md +6 -0
  74. package/benchmarks/production-v1/prompts/stable-dedup.md +6 -0
  75. package/benchmarks/production-v1/prompts/stale-search-response.md +6 -0
  76. package/benchmarks/production-v1/prompts/tenant-cache-isolation.md +6 -0
  77. package/benchmarks/production-v1/prompts/tenant-role-authorization.md +6 -0
  78. package/benchmarks/production-v1/prompts/unicode-search.md +6 -0
  79. package/benchmarks/production-v1/prompts/workspace-order.md +7 -0
  80. package/benchmarks/production-v1/suite.json +284 -0
  81. package/benchmarks/production-v1/variant.mjs +112 -0
  82. package/catalog/capabilities.json +10 -10
  83. package/docs/command-reference-vietnamese.md +19 -6
  84. package/docs/context-engine.md +33 -11
  85. package/docs/distribution-standard.md +11 -8
  86. package/docs/mcp-and-tools.md +13 -7
  87. package/docs/model-options.md +7 -10
  88. package/docs/operator-manual-vietnamese.md +98 -33
  89. package/docs/package-architecture-notes.md +3 -1
  90. package/docs/publishing-for-teams.md +4 -0
  91. package/docs/quality-benchmark.md +336 -58
  92. package/docs/quickstart-vietnamese.md +5 -0
  93. package/docs/release-install-policy.md +45 -7
  94. package/docs/runtime-harness-standard.md +47 -29
  95. package/docs/runtime-policy-design.md +24 -12
  96. package/docs/runtime-quality-baseline.md +42 -17
  97. package/docs/security-threat-model.md +1 -1
  98. package/docs/task-implementation-contract.md +113 -177
  99. package/docs/task-lifecycle-vietnamese.md +129 -125
  100. package/docs/team-onboarding.md +12 -4
  101. package/docs/usage-observability.md +15 -8
  102. package/docs-site/architecture.html +1 -1
  103. package/docs-site/commands.html +1 -1
  104. package/docs-site/content/runtime.html +16 -0
  105. package/docs-site/context-engine.html +1 -1
  106. package/docs-site/index.html +1 -1
  107. package/docs-site/mcp-approval.html +1 -1
  108. package/docs-site/mcp-auth.html +1 -1
  109. package/docs-site/mcp-servers.html +1 -1
  110. package/docs-site/mcp.html +1 -1
  111. package/docs-site/permissions.html +1 -1
  112. package/docs-site/profiles.html +1 -1
  113. package/docs-site/quickstart.html +1 -1
  114. package/docs-site/runtime.html +17 -1
  115. package/docs-site/scope.html +1 -1
  116. package/docs-site/security.html +1 -1
  117. package/docs-site/team.html +1 -1
  118. package/docs-site/workflows.html +1 -1
  119. package/evals/fixtures/task-contract.invalid.json +15 -1
  120. package/evals/fixtures/task-contract.valid.json +18 -2
  121. package/package.json +3 -2
  122. package/packages/piagent-core/README.md +37 -3
  123. package/packages/piagent-core/benchmark/benchmark-core.js +975 -0
  124. package/packages/piagent-core/extensions/context-engine.js +81 -57
  125. package/packages/piagent-core/extensions/guard-types.ts +35 -3
  126. package/packages/piagent-core/extensions/local-state-path.js +59 -0
  127. package/packages/piagent-core/extensions/piagent-guard.ts +1904 -175
  128. package/packages/piagent-core/extensions/policy-core.js +11 -1
  129. package/packages/piagent-core/extensions/runtime-evidence.js +39 -19
  130. package/packages/piagent-core/extensions/state-retention.js +218 -0
  131. package/packages/piagent-core/extensions/task-lifecycle.js +138 -0
  132. package/packages/piagent-core/extensions/task-state.js +630 -0
  133. package/packages/piagent-core/package.json +1 -1
  134. package/packages/piagent-core/policies/base-policy.json +1 -0
  135. package/packages/piagent-core/prompts/be-to-fe.md +14 -22
  136. package/packages/piagent-core/prompts/platform-improve.md +12 -17
  137. package/packages/piagent-core/prompts/review.md +7 -11
  138. package/packages/piagent-core/prompts/scout.md +6 -10
  139. package/packages/piagent-core/prompts/task.md +11 -17
  140. package/packages/piagent-core/skills/piagent-ops/SKILL.md +12 -15
  141. package/schemas/task-contract.schema.json +267 -104
  142. package/scripts/benchmark-runner.mjs +1432 -0
  143. package/scripts/install-global.sh +1 -1
  144. package/scripts/migrate-project-state.mjs +94 -3
  145. package/scripts/piagent-cli.mjs +1 -1
  146. package/scripts/update-global.mjs +36 -3
  147. package/scripts/verify-local.sh +19 -0
  148. package/templates/project/.pi/task-contract.template.json +19 -2
  149. package/templates/project/AGENTS.md +10 -20
@@ -16,7 +16,8 @@ import {
16
16
  globMatchesPath,
17
17
  matchesProtectedPath,
18
18
  normalizePathCandidate,
19
- matchesAnyPath
19
+ matchesAnyPath,
20
+ shellHasFileWriteRedirection
20
21
  } from "./policy-core.js";
21
22
  import {
22
23
  appendObservedBashResult,
@@ -68,6 +69,7 @@ import {
68
69
  classifyActionTokenSequence,
69
70
  classifyExplicitActionValues,
70
71
  classifyToolNameAction,
72
+ externalExecutableIndex,
71
73
  extractShellCommandInput,
72
74
  findShellExternalConfirmationReason,
73
75
  normalizeActionToken,
@@ -90,6 +92,33 @@ import {
90
92
  contextIndexExcludePatterns,
91
93
  effectiveProtectedPaths
92
94
  } from "./context-index-policy.js";
95
+ import {
96
+ DEFAULT_MAX_TASK_ATTEMPTS,
97
+ activeSessionTask,
98
+ bindSessionTask,
99
+ createTaskRunId,
100
+ isGitWorkingTree,
101
+ listTaskContracts,
102
+ migrateTaskState,
103
+ priorTaskAttempts,
104
+ resolveTaskContract,
105
+ safeTaskId,
106
+ summarizeAttempt,
107
+ workPlanDependencyError,
108
+ workingTreeSnapshot,
109
+ writeTaskContract
110
+ } from "./task-state.js";
111
+ import {
112
+ applyRuntimeLifecycleObservation,
113
+ runtimeLifecycleMode,
114
+ workingTreeEvidenceDigest
115
+ } from "./task-lifecycle.js";
116
+ import {
117
+ appendJsonlBounded,
118
+ pruneCaptureFiles,
119
+ readJsonlTail
120
+ } from "./state-retention.js";
121
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
93
122
 
94
123
  import type { ActionClassification } from "./guard-shell-analysis.ts";
95
124
 
@@ -151,6 +180,45 @@ const TOOL_RESULT_PREVIEW_TAIL_LINES = 24;
151
180
  const TOOL_RESULT_PREVIEW_INTERESTING_LINES = 24;
152
181
  const TOOL_RESULT_PREVIEW_MAX_CHARS = 6_000;
153
182
  const TOOL_RESULT_CAPTURE_MAX_CHARS = 500_000;
183
+ 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
+ type TaskStartParameters = {
200
+ taskId?: string;
201
+ summary: string;
202
+ riskLane: "tiny" | "normal" | "high-risk";
203
+ intakeMode?: "model" | "runtime";
204
+ changeMode?: "source-change" | "read-only";
205
+ verifyGroup?: string;
206
+ maxAttempts?: number;
207
+ expectedOutput: string;
208
+ acceptanceCriteria: string[];
209
+ scope: string[];
210
+ outOfScope?: string[];
211
+ reviewLenses?: ReviewLens[];
212
+ workPlan?: Array<{
213
+ id: string;
214
+ title: string;
215
+ role?: OrchestrationRole;
216
+ mode?: "read-only" | "single-writer" | "review";
217
+ status?: "pending" | "in-progress" | "done" | "skipped" | "failed";
218
+ dependsOn?: string[];
219
+ note?: string;
220
+ }>;
221
+ };
154
222
  const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"] as const;
155
223
  const ORCHESTRATION_MODES = ["solo-first", "bounded-subagents", "parallel-readonly"] as const;
156
224
  const REVIEW_LENSES = ["correctness", "tests", "scope", "security", "docs", "release", "package"] as const;
@@ -162,10 +230,13 @@ const TECH_STACK_MANIFEST_FILE = ".pi/tech-stack.json";
162
230
  const TECH_CONTEXT_DIR = ".pi/tech-context";
163
231
  const PIAGENT_TOOL_GROUPS = {
164
232
  loader: ["piagent_tools"],
233
+ intake: ["piagent_task_start"],
165
234
  governance: [
166
235
  "piagent_context",
167
- "piagent_context_preflight",
168
- "piagent_task_start",
236
+ "piagent_context_preflight"
237
+ ],
238
+ task: ["piagent_task_progress"],
239
+ recovery: [
169
240
  "piagent_context_record",
170
241
  "piagent_verify_record",
171
242
  "piagent_trace_record",
@@ -420,6 +491,7 @@ const DEFAULT_POLICY: BasePolicy = {
420
491
  "piagent_profile_tech_context_record",
421
492
  "piagent_project_onboarding_record",
422
493
  "piagent_task_start",
494
+ "piagent_task_progress",
423
495
  "piagent_source_checkout",
424
496
  "piagent_context_record",
425
497
  "piagent_verify_record",
@@ -1546,10 +1618,6 @@ function stateRoot(cwd: string): string {
1546
1618
  return path.join(cwd, ".pi", "piagent-state");
1547
1619
  }
1548
1620
 
1549
- function taskFilePath(cwd: string, taskId: string): string {
1550
- return path.join(stateRoot(cwd), "tasks", `${safeTaskId(taskId)}.json`);
1551
- }
1552
-
1553
1621
  function projectContextFilePath(cwd: string): string {
1554
1622
  return path.join(cwd, ".pi", "project-context.md");
1555
1623
  }
@@ -1574,21 +1642,18 @@ function toolResultCaptureIndexPath(cwd: string): string {
1574
1642
  return path.join(toolResultCaptureRoot(cwd), "index.jsonl");
1575
1643
  }
1576
1644
 
1577
- function safeTaskId(taskId: string): string {
1578
- return taskId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "task";
1579
- }
1580
-
1581
1645
  function nowIso(): string {
1582
1646
  return new Date().toISOString();
1583
1647
  }
1584
1648
 
1585
1649
  function ensureStateDirs(cwd: string): void {
1586
- fs.mkdirSync(path.join(stateRoot(cwd), "tasks"), { recursive: true });
1650
+ ensurePrivateStateDirectory(cwd, stateRoot(cwd), "Piagent state directory");
1651
+ ensurePrivateStateDirectory(cwd, path.join(stateRoot(cwd), "tasks"), "Piagent task state directory");
1587
1652
  }
1588
1653
 
1589
1654
  function ensureProjectContextPlaceholder(cwd: string): void {
1590
- fs.mkdirSync(path.join(cwd, ".pi"), { recursive: true });
1591
- const target = projectContextFilePath(cwd);
1655
+ ensurePrivateStateDirectory(cwd, path.join(cwd, ".pi"), "Piagent project directory");
1656
+ const target = resolveLocalStatePath(cwd, projectContextFilePath(cwd), { label: "Project context file" });
1592
1657
  if (fs.existsSync(target)) return;
1593
1658
  fs.writeFileSync(target, [
1594
1659
  "# Project Context",
@@ -1862,14 +1927,68 @@ function normalizeWorkPlanSteps(values: unknown): WorkPlanStep[] {
1862
1927
  title,
1863
1928
  role,
1864
1929
  mode: normalizeStepMode(item.mode, role),
1865
- status: item.status === "in-progress" || item.status === "done" || item.status === "skipped" ? item.status : "pending",
1866
- dependsOn: Array.isArray(item.dependsOn) ? uniqueStrings(item.dependsOn.filter((entry): entry is string => typeof entry === "string").map((entry) => safeTaskId(entry).slice(0, 40))) : undefined
1930
+ status: item.status === "in-progress" || item.status === "done" || item.status === "skipped" || item.status === "failed" ? item.status : "pending",
1931
+ dependsOn: Array.isArray(item.dependsOn) ? uniqueStrings(item.dependsOn.filter((entry): entry is string => typeof entry === "string").map((entry) => safeTaskId(entry).slice(0, 40))) : undefined,
1932
+ note: typeof item.note === "string" ? redactText(item.note).slice(0, 500) : undefined,
1933
+ updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : undefined
1867
1934
  });
1868
1935
  }
1869
1936
  return steps;
1870
1937
  }
1871
1938
 
1872
- function defaultWorkPlan(summary: string, riskLane: TaskContract["riskLane"]): WorkPlanStep[] {
1939
+ function defaultWorkPlan(
1940
+ summary: string,
1941
+ riskLane: TaskContract["riskLane"],
1942
+ changeMode: TaskContract["changeMode"]
1943
+ ): WorkPlanStep[] {
1944
+ if (changeMode === "read-only") {
1945
+ if (riskLane === "tiny") {
1946
+ return [{
1947
+ id: "scout",
1948
+ title: summary.slice(0, 160) || "Inspect the bounded target read-only.",
1949
+ role: "parent",
1950
+ mode: "read-only",
1951
+ status: "pending"
1952
+ }];
1953
+ }
1954
+ const readOnlySteps: WorkPlanStep[] = [
1955
+ {
1956
+ id: "scout",
1957
+ title: summary.slice(0, 160) || "Inspect the bounded target read-only.",
1958
+ role: "parent",
1959
+ mode: "read-only",
1960
+ status: "pending"
1961
+ },
1962
+ {
1963
+ id: "review",
1964
+ title: "Review cited evidence, risks, and unknowns before handoff.",
1965
+ role: "piagent-reviewer",
1966
+ mode: "review",
1967
+ status: "pending",
1968
+ dependsOn: ["scout"]
1969
+ }
1970
+ ];
1971
+ if (riskLane === "high-risk") {
1972
+ readOnlySteps.unshift({
1973
+ id: "scope",
1974
+ title: "Confirm the high-risk read-only scope and evidence boundaries.",
1975
+ role: "parent",
1976
+ mode: "read-only",
1977
+ status: "pending"
1978
+ });
1979
+ readOnlySteps.splice(1, 0, {
1980
+ id: "challenge",
1981
+ title: "Challenge security, data, release, or architecture assumptions.",
1982
+ role: "piagent-oracle",
1983
+ mode: "review",
1984
+ status: "pending",
1985
+ dependsOn: ["scope"]
1986
+ });
1987
+ const scout = readOnlySteps.find((step) => step.id === "scout");
1988
+ if (scout) scout.dependsOn = ["scope", "challenge"];
1989
+ }
1990
+ return readOnlySteps;
1991
+ }
1873
1992
  if (riskLane === "tiny") {
1874
1993
  return [
1875
1994
  {
@@ -1929,6 +2048,16 @@ function defaultWorkPlan(summary: string, riskLane: TaskContract["riskLane"]): W
1929
2048
  return steps;
1930
2049
  }
1931
2050
 
2051
+ function validateNewWorkPlan(steps: WorkPlanStep[]): string | undefined {
2052
+ const ids = new Set<string>();
2053
+ for (const step of steps) {
2054
+ if (ids.has(step.id)) return `duplicate work-plan step id: ${step.id}`;
2055
+ if (step.status !== "pending") return `new work-plan step ${step.id} must start pending`;
2056
+ ids.add(step.id);
2057
+ }
2058
+ return workPlanDependencyError(steps)?.replace(/^workPlan/, "work-plan");
2059
+ }
2060
+
1932
2061
  function uniqueStrings(values: string[]): string[] {
1933
2062
  return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
1934
2063
  }
@@ -2017,6 +2146,384 @@ function collectPatchTargetPaths(value: unknown, key = "", depth = 0): string[]
2017
2146
  return paths;
2018
2147
  }
2019
2148
 
2149
+ function taskMutationIdentity(toolName: string, input: Record<string, unknown>): string {
2150
+ if (normalizeActionToken(toolName) !== "mcp") return toolName;
2151
+ const proxyTool = typeof input.tool === "string" ? input.tool.trim() : "";
2152
+ return proxyTool || toolName;
2153
+ }
2154
+
2155
+ function isTaskMutationTool(toolName: string, input: Record<string, unknown>): boolean {
2156
+ if (isPiagentTool(toolName)) return false;
2157
+ if (WRITE_TOOL_NAMES.has(toolName) || SHELL_TOOL_NAMES.has(toolName)) return true;
2158
+ const tokens = new Set(actionTokens(taskMutationIdentity(toolName, input)));
2159
+ return ["write", "edit", "patch", "create", "delete", "remove", "move", "rename", "update", "apply"]
2160
+ .some((token) => tokens.has(token));
2161
+ }
2162
+
2163
+ function isReadOnlyTaskShellCommand(
2164
+ command: string,
2165
+ segments: Array<{ words: string[] }>
2166
+ ): boolean {
2167
+ if (/[<>]|`|\$\(/.test(command)) {
2168
+ return false;
2169
+ }
2170
+ const safeCommands = new Set(["pwd", "ls", "find", "rg", "grep", "cat", "sed", "head", "tail", "wc", "stat", "file", "test", "[", "which"]);
2171
+ const safeGitSubcommands = new Set(["status", "diff", "log", "show", "ls-files", "rev-parse"]);
2172
+ return segments.length > 0 && segments.every((segment) => {
2173
+ const words = segment.words.filter(Boolean);
2174
+ const executable = path.basename(words[0] ?? "");
2175
+ if (executable === "sed" && words.some((word) => /^-.*i/.test(word))) return false;
2176
+ if (executable === "find" && words.some((word) => ["-delete", "-exec", "-execdir", "-ok", "-okdir"].includes(word))) return false;
2177
+ if (safeCommands.has(executable)) return true;
2178
+ if (executable === "git") return safeGitSubcommands.has(words[1] ?? "");
2179
+ if (executable === "command") return words[1] === "-v";
2180
+ return false;
2181
+ });
2182
+ }
2183
+
2184
+ const PROJECT_MUTATING_EXECUTABLES = new Set([
2185
+ "apply_patch", "bash", "chmod", "chown", "cp", "dd", "install", "ln", "make", "mkdir", "mv",
2186
+ "node", "patch", "perl", "php", "python", "python3", "rm", "rmdir", "rsync", "ruby", "scp", "sh",
2187
+ "tee", "touch", "truncate", "zsh"
2188
+ ]);
2189
+
2190
+ function isProjectMutatingShellCommand(command: string, segments: Array<{ words: string[] }>): boolean {
2191
+ if (shellHasFileWriteRedirection(command)) return true;
2192
+ const noAliases = new Map<string, string>();
2193
+ for (const segment of segments) {
2194
+ const words = segment.words.filter(Boolean);
2195
+ if (words.length === 0) continue;
2196
+ if (externalExecutableIndex(words, PROJECT_MUTATING_EXECUTABLES, noAliases) !== undefined) return true;
2197
+ const executable = path.basename(words[0] ?? "").toLowerCase();
2198
+ if (executable === "find" && words.some((word) => ["-delete", "-exec", "-execdir", "-ok", "-okdir"].includes(word))) return true;
2199
+ if (executable === "sed" && words.some((word) => /^-[^-]*i/.test(word) || word === "--in-place" || word.startsWith("--in-place="))) return true;
2200
+ if (executable === "git") {
2201
+ const subcommand = words.slice(1).find((word, index, values) => {
2202
+ if (!word.startsWith("-")) return index === 0 || !["-C", "-c", "--git-dir", "--work-tree"].includes(values[index - 1]);
2203
+ return false;
2204
+ });
2205
+ if (["apply", "checkout", "clean", "mv", "reset", "restore", "rm", "switch"].includes(subcommand ?? "")) return true;
2206
+ }
2207
+ if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
2208
+ const subcommand = words.find((word, index) => index > 0 && !word.startsWith("-"));
2209
+ if (["add", "ci", "install", "link", "remove", "uninstall", "update", "upgrade"].includes(subcommand ?? "")) return true;
2210
+ }
2211
+ }
2212
+ return false;
2213
+ }
2214
+
2215
+ function taskMutationTargets(cwd: string, toolName: string, input: Record<string, unknown>): string[] {
2216
+ if (!isTaskMutationTool(toolName, input) || SHELL_TOOL_NAMES.has(toolName)) return [];
2217
+ const identity = taskMutationIdentity(toolName, input);
2218
+ const collectWritePaths = (value: Record<string, unknown>, valueIdentity: string) => {
2219
+ const strongFilesystemSemantics = usesFilesystemContentFields(valueIdentity, false);
2220
+ return inspectPathInputsFromInput(cwd, value, strongFilesystemSemantics).paths
2221
+ .filter((item) => isFilesystemScopeField(item.field))
2222
+ .filter((item) => {
2223
+ const field = item.field.split(".").at(-1)?.toLowerCase().replace(/[-_]/g, "") ?? "";
2224
+ return strongFilesystemSemantics || !COPY_SOURCE_FIELDS.has(field);
2225
+ })
2226
+ .filter((item) => filesystemFieldAccessMode(valueIdentity, item.field, true) === "write")
2227
+ .map((item) => item.path);
2228
+ };
2229
+ const rawTargets = [
2230
+ ...collectWritePaths(input, identity),
2231
+ ...collectPatchTargetPaths(input)
2232
+ ];
2233
+ if (typeof input.args === "string" && input.args.length <= MAX_MCP_PROXY_ARGS_CHARS) {
2234
+ try {
2235
+ const parsed = JSON.parse(input.args);
2236
+ if (isPlainRecord(parsed)) rawTargets.push(...collectWritePaths(parsed, identity));
2237
+ rawTargets.push(...collectPatchTargetPaths(parsed));
2238
+ } catch {
2239
+ // Invalid proxy JSON is blocked before execution; it contributes no evidence.
2240
+ }
2241
+ }
2242
+ return uniqueStrings(rawTargets
2243
+ .map((candidate) => normalizeRelative(cwd, candidate))
2244
+ .filter((candidate): candidate is string => Boolean(
2245
+ candidate
2246
+ && candidate !== "."
2247
+ && candidate !== ".."
2248
+ && !candidate.startsWith("../")
2249
+ && !candidate.startsWith(".pi/piagent-state/")
2250
+ )));
2251
+ }
2252
+
2253
+ type ObservedTaskContext = { path: string; reason: string };
2254
+
2255
+ function observedTaskContextFromToolResult(
2256
+ cwd: string,
2257
+ event: { toolName: string; input?: unknown; isError?: boolean },
2258
+ readProtectedPaths: string[]
2259
+ ): ObservedTaskContext | undefined {
2260
+ if (event.isError || !["read", "piagent_document_read"].includes(event.toolName)) return undefined;
2261
+ const input = isPlainRecord(event.input) ? event.input : {};
2262
+ const relative = extractLikelyPathFromInput(cwd, input);
2263
+ if (
2264
+ !relative
2265
+ || relative === "."
2266
+ || relative === ".."
2267
+ || relative.startsWith("../")
2268
+ || relative.startsWith(".pi/piagent-state/")
2269
+ || matchesProtectedPath(relative, readProtectedPaths)
2270
+ ) return undefined;
2271
+ try {
2272
+ if (!fs.statSync(path.join(cwd, relative)).isFile()) return undefined;
2273
+ } catch {
2274
+ return undefined;
2275
+ }
2276
+ return {
2277
+ path: relative,
2278
+ reason: event.toolName === "read" ? "Runtime observed successful source read." : "Runtime observed successful document read."
2279
+ };
2280
+ }
2281
+
2282
+ function mergeObservedTaskContext(
2283
+ task: TaskContract,
2284
+ entries: ObservedTaskContext[],
2285
+ maxManifestFiles: number
2286
+ ): string[] {
2287
+ const known = new Set(task.contextManifest.map((item) => item.path));
2288
+ const added: string[] = [];
2289
+ for (const entry of entries) {
2290
+ if (task.contextManifest.length >= maxManifestFiles || known.has(entry.path)) continue;
2291
+ task.contextManifest.push({ path: entry.path, reason: redactText(entry.reason) });
2292
+ known.add(entry.path);
2293
+ added.push(entry.path);
2294
+ }
2295
+ return added;
2296
+ }
2297
+
2298
+ function passingVerifyCommandsForDigest(task: TaskContract, digest: string): Set<string> {
2299
+ return new Set(task.verifyEvidence
2300
+ .filter((evidence) => (
2301
+ evidence.exitCode === 0
2302
+ && evidence.observed === true
2303
+ && evidence.matchedProfileCommand === true
2304
+ && evidence.workingTreeDigest === digest
2305
+ ))
2306
+ .map((evidence) => evidence.command.trim()));
2307
+ }
2308
+
2309
+ function allVerifyCommandsPassCurrentTree(task: TaskContract, digest: string): boolean {
2310
+ const planned = meaningfulVerifyCommands(task.verifyCommands);
2311
+ if (planned.length === 0) return false;
2312
+ const passing = passingVerifyCommandsForDigest(task, digest);
2313
+ return planned.every((command) => passing.has(command.trim()));
2314
+ }
2315
+
2316
+ function compactTaskDetails(task: TaskContract): Record<string, unknown> {
2317
+ return {
2318
+ schemaVersion: task.schemaVersion,
2319
+ taskRunId: task.taskRunId,
2320
+ taskId: task.taskId,
2321
+ sessionId: task.sessionId,
2322
+ sessionName: task.sessionName,
2323
+ changeMode: task.changeMode,
2324
+ attempt: task.attempt,
2325
+ maxAttempts: task.maxAttempts,
2326
+ previousAttempts: task.previousAttempts,
2327
+ riskLane: task.riskLane,
2328
+ intakeMode: task.intakeMode ?? "model",
2329
+ scope: task.scope,
2330
+ verifyGroup: task.verifyGroup,
2331
+ verifyCommands: task.verifyCommands,
2332
+ workPlan: task.workPlan,
2333
+ reviewLenses: task.reviewLenses,
2334
+ orchestration: task.orchestration
2335
+ ? {
2336
+ mode: task.orchestration.mode,
2337
+ subagents: task.orchestration.subagents,
2338
+ reason: task.orchestration.reason
2339
+ }
2340
+ : undefined,
2341
+ lifecycleMode: runtimeLifecycleMode(task)
2342
+ };
2343
+ }
2344
+
2345
+ function changedSnapshotFiles(
2346
+ before: Record<string, string>,
2347
+ after: Record<string, string>
2348
+ ): string[] {
2349
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])]
2350
+ .filter((file) => before[file] !== after[file])
2351
+ .sort();
2352
+ }
2353
+
2354
+ function recordObservedTaskChanges(
2355
+ pi: ExtensionAPI,
2356
+ ctx: ExtensionContext,
2357
+ event: { toolName: string; input?: unknown; isError?: boolean },
2358
+ pendingContext: ObservedTaskContext[],
2359
+ maxManifestFiles: number,
2360
+ shellSnapshotBefore?: Record<string, string>
2361
+ ): TaskContract | undefined {
2362
+ if (event.isError) return;
2363
+ const input = isPlainRecord(event.input) ? event.input : {};
2364
+ if (!isTaskMutationTool(event.toolName, input)) return;
2365
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
2366
+ if (!task || task.trace.outcome !== "pending") return;
2367
+
2368
+ const targets = taskMutationTargets(ctx.cwd, event.toolName, input);
2369
+ const shellMutationObserved = SHELL_TOOL_NAMES.has(event.toolName) && shellSnapshotBefore !== undefined;
2370
+ const shellChangedFiles = shellMutationObserved
2371
+ ? changedSnapshotFiles(shellSnapshotBefore, workingTreeSnapshot(ctx.cwd) as Record<string, string>)
2372
+ : [];
2373
+ const nextObserved = uniqueStrings([...task.observedChangedFiles, ...shellChangedFiles, ...targets]).sort();
2374
+ const added = nextObserved.filter((file) => !task.observedChangedFiles.includes(file));
2375
+ const contextAdded = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
2376
+ const lifecycle = (targets.length > 0 || shellChangedFiles.length > 0)
2377
+ ? applyRuntimeLifecycleObservation(task, "mutation", nowIso())
2378
+ : { changed: false, mode: runtimeLifecycleMode(task) };
2379
+ if (added.length === 0 && contextAdded.length === 0 && !lifecycle.changed) return;
2380
+ task.observedChangedFiles = nextObserved;
2381
+ const written = writeTask(ctx.cwd, task);
2382
+ const trace = {
2383
+ event: "task_changes_observed",
2384
+ taskId: written.taskId,
2385
+ taskRunId: written.taskRunId,
2386
+ sessionId: written.sessionId,
2387
+ toolName: event.toolName,
2388
+ files: added,
2389
+ contextFiles: contextAdded,
2390
+ lifecycleMode: lifecycle.mode,
2391
+ lifecycleAdvanced: lifecycle.changed
2392
+ };
2393
+ appendTrace(ctx.cwd, trace);
2394
+ appendSessionTrace(pi, trace);
2395
+ return written;
2396
+ }
2397
+
2398
+ function recordObservedTaskVerification(
2399
+ pi: ExtensionAPI,
2400
+ ctx: ExtensionContext,
2401
+ observed: {
2402
+ command?: string;
2403
+ normalizedCommand?: string;
2404
+ commandHash?: string;
2405
+ exitCode?: number;
2406
+ isError?: boolean;
2407
+ recordedAt?: string;
2408
+ },
2409
+ pendingContext: ObservedTaskContext[],
2410
+ maxManifestFiles: number
2411
+ ): TaskContract | undefined {
2412
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
2413
+ if (!task || task.trace.outcome !== "pending") return;
2414
+ const command = String(observed.normalizedCommand ?? observed.command ?? "").trim();
2415
+ const observedAtMs = Date.parse(observed.recordedAt ?? "");
2416
+ const taskCreatedAtMs = Date.parse(task.createdAt);
2417
+ if (
2418
+ !command
2419
+ || !observed.commandHash
2420
+ || !commandMatchesVerifyPlan(command, task.verifyCommands)
2421
+ || !Number.isFinite(observedAtMs)
2422
+ || !Number.isFinite(taskCreatedAtMs)
2423
+ || observedAtMs < taskCreatedAtMs
2424
+ ) return;
2425
+
2426
+ const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
2427
+ const currentDigest = workingTreeEvidenceDigest(currentDigests);
2428
+ const exitCode = Number.isInteger(observed.exitCode) ? observed.exitCode as number : observed.isError ? 1 : 0;
2429
+ const duplicate = task.verifyEvidence.some((evidence) => (
2430
+ evidence.command.trim() === command
2431
+ && evidence.exitCode === exitCode
2432
+ && evidence.workingTreeDigest === currentDigest
2433
+ ));
2434
+ const contextAdded = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
2435
+ if (!duplicate) {
2436
+ task.verifyEvidence.push({
2437
+ command: redactText(command),
2438
+ exitCode,
2439
+ summary: `Runtime observed configured verifier exit ${exitCode}.`,
2440
+ recordedAt: nowIso(),
2441
+ observed: true,
2442
+ observedAt: observed.recordedAt ?? nowIso(),
2443
+ isError: observed.isError === true,
2444
+ matchedProfileCommand: true,
2445
+ workingTreeDigest: currentDigest
2446
+ });
2447
+ task.verifyEvidence = task.verifyEvidence.slice(-100);
2448
+ }
2449
+
2450
+ const hasChanges = taskChangedFileEvidence(ctx.cwd, task, currentDigests).expected.length > 0;
2451
+ const allPassing = hasChanges && allVerifyCommandsPassCurrentTree(task, currentDigest);
2452
+ const lifecycle = hasChanges
2453
+ ? applyRuntimeLifecycleObservation(task, allPassing ? "verification-complete" : "verification-pending", nowIso())
2454
+ : { changed: false, mode: runtimeLifecycleMode(task) };
2455
+ if (duplicate && contextAdded.length === 0 && !lifecycle.changed) return task;
2456
+
2457
+ const written = writeTask(ctx.cwd, task);
2458
+ const trace = {
2459
+ event: "verify_observed",
2460
+ taskId: written.taskId,
2461
+ taskRunId: written.taskRunId,
2462
+ sessionId: written.sessionId,
2463
+ command: redactText(command),
2464
+ exitCode,
2465
+ workingTreeDigest: currentDigest,
2466
+ contextFiles: contextAdded,
2467
+ lifecycleMode: lifecycle.mode,
2468
+ lifecycleAdvanced: lifecycle.changed,
2469
+ allConfiguredVerifiersPassing: allPassing
2470
+ };
2471
+ appendTrace(ctx.cwd, trace);
2472
+ appendSessionTrace(pi, trace);
2473
+ return written;
2474
+ }
2475
+
2476
+ function flushObservedTaskContext(
2477
+ pi: ExtensionAPI,
2478
+ ctx: ExtensionContext,
2479
+ pendingContext: ObservedTaskContext[],
2480
+ maxManifestFiles: number,
2481
+ event: string
2482
+ ): TaskContract | undefined {
2483
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
2484
+ if (!task || task.trace.outcome !== "pending") return task;
2485
+ const added = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
2486
+ const lifecycle = task.changeMode === "read-only" && task.contextManifest.length > 0
2487
+ ? applyRuntimeLifecycleObservation(task, "context-complete", nowIso())
2488
+ : { changed: false, mode: runtimeLifecycleMode(task) };
2489
+ if (added.length === 0 && !lifecycle.changed) return task;
2490
+ const written = writeTask(ctx.cwd, task);
2491
+ const trace = {
2492
+ event,
2493
+ taskId: written.taskId,
2494
+ taskRunId: written.taskRunId,
2495
+ sessionId: written.sessionId,
2496
+ files: added,
2497
+ lifecycleMode: lifecycle.mode,
2498
+ lifecycleAdvanced: lifecycle.changed
2499
+ };
2500
+ appendTrace(ctx.cwd, trace);
2501
+ appendSessionTrace(pi, trace);
2502
+ return written;
2503
+ }
2504
+
2505
+ function completionTaskProjection(
2506
+ cwd: string,
2507
+ task: TaskContract,
2508
+ finalFileDigests: Record<string, string> = workingTreeSnapshot(cwd) as Record<string, string>
2509
+ ): TaskContract {
2510
+ const changedFiles = taskChangedFileEvidence(cwd, task, finalFileDigests).expected;
2511
+ return {
2512
+ ...task,
2513
+ changedFiles,
2514
+ finalWorkingTreeFiles: Object.keys(finalFileDigests).sort(),
2515
+ finalFileDigests,
2516
+ failedAt: undefined,
2517
+ failureReason: undefined,
2518
+ ruledOut: undefined,
2519
+ trace: {
2520
+ outcome: "completed",
2521
+ notes: "Runtime finalized from observed context, working-tree changes, current verification, and completed work-plan evidence.",
2522
+ recordedAt: nowIso()
2523
+ }
2524
+ };
2525
+ }
2526
+
2020
2527
  function prepareToolInputForPolicy(
2021
2528
  toolName: string,
2022
2529
  input: Record<string, unknown>,
@@ -2473,11 +2980,13 @@ function searchMemoryFiles(cwd: string, profile: ProjectProfile, query: string,
2473
2980
  }
2474
2981
 
2475
2982
  function writeProjectOnboarding(cwd: string, snapshot: ProjectOnboardingSnapshot, markdown: string): ProjectOnboardingSnapshot {
2476
- fs.mkdirSync(path.join(cwd, ".pi"), { recursive: true });
2983
+ ensurePrivateStateDirectory(cwd, path.join(cwd, ".pi"), "Piagent project directory");
2477
2984
  ensureStateDirs(cwd);
2478
2985
  const safeSnapshot = redactForStorage(snapshot) as ProjectOnboardingSnapshot;
2479
- fs.writeFileSync(projectContextFilePath(cwd), `${redactText(markdown).trimEnd()}\n`);
2480
- fs.writeFileSync(onboardingStateFilePath(cwd), `${JSON.stringify(safeSnapshot, null, 2)}\n`);
2986
+ const contextFile = resolveLocalStatePath(cwd, projectContextFilePath(cwd), { label: "Project context file" });
2987
+ const onboardingFile = resolveLocalStatePath(cwd, onboardingStateFilePath(cwd), { label: "Project onboarding state" });
2988
+ fs.writeFileSync(contextFile, `${redactText(markdown).trimEnd()}\n`);
2989
+ fs.writeFileSync(onboardingFile, `${JSON.stringify(safeSnapshot, null, 2)}\n`, { mode: 0o600 });
2481
2990
  return safeSnapshot;
2482
2991
  }
2483
2992
 
@@ -3391,42 +3900,175 @@ function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName:
3391
3900
  return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
3392
3901
  }
3393
3902
 
3394
- function readTask(cwd: string, taskId: string): TaskContract | undefined {
3395
- return readJsonFile<TaskContract>(taskFilePath(cwd, taskId));
3903
+ function readTask(cwd: string, taskId: string, sessionId?: string): TaskContract | undefined {
3904
+ return resolveTaskContract(cwd, taskId, sessionId) as TaskContract | undefined;
3396
3905
  }
3397
3906
 
3398
3907
  function writeTask(cwd: string, task: TaskContract): TaskContract {
3399
- ensureStateDirs(cwd);
3400
- task.updatedAt = nowIso();
3401
- fs.writeFileSync(taskFilePath(cwd, task.taskId), `${JSON.stringify(task, null, 2)}\n`);
3402
- return task;
3908
+ return writeTaskContract(cwd, task) as TaskContract;
3403
3909
  }
3404
3910
 
3405
- function evaluateTaskGate(task: TaskContract | undefined, policy: BasePolicy): {
3911
+ function meaningfulVerifyCommands(commands: string[]): string[] {
3912
+ return commands.filter((command) => {
3913
+ const normalized = command.trim().toLowerCase();
3914
+ if (!normalized) return false;
3915
+ if (/no (?:project|backend|data|frontend|runtime|docs|mobile) verify command configured/.test(normalized) && !/\b(?:if|elif)\b/.test(normalized)) return false;
3916
+ if (/^(?:true|:|echo\b|printf\b)/.test(normalized)) return false;
3917
+ return true;
3918
+ });
3919
+ }
3920
+
3921
+ function taskChangedFileEvidence(
3922
+ cwd: string,
3923
+ task: TaskContract,
3924
+ currentDigests: Record<string, string> = workingTreeSnapshot(cwd) as Record<string, string>
3925
+ ): {
3926
+ current: string[];
3927
+ expected: string[];
3928
+ undeclared: string[];
3929
+ unsupportedClaims: string[];
3930
+ outsideScope: string[];
3931
+ } {
3932
+ const current = Object.keys(currentDigests).sort();
3933
+ const baselineDigests = task.baselineFileDigests ?? {};
3934
+ // Observed mutations are audit history, not proof of a final source change.
3935
+ // A file edited and then restored to its task-start digest must not satisfy a
3936
+ // source-change gate merely because a write tool touched it earlier.
3937
+ const expected = current
3938
+ .filter((file) => baselineDigests[file] !== currentDigests[file])
3939
+ .sort();
3940
+ const declared = new Set(task.changedFiles ?? []);
3941
+ const expectedSet = new Set(expected);
3942
+ return {
3943
+ current,
3944
+ expected,
3945
+ undeclared: expected.filter((file) => !declared.has(file)),
3946
+ unsupportedClaims: [...declared].filter((file) => !expectedSet.has(file)),
3947
+ outsideScope: expected.filter((file) => !taskScopeIncludesPath(task.scope, file))
3948
+ };
3949
+ }
3950
+
3951
+ function taskScopeIncludesPath(scope: string[], file: string): boolean {
3952
+ const normalizedFile = normalizePathCandidate(file);
3953
+ return scope.some((candidate) => {
3954
+ const normalized = normalizePathCandidate(candidate);
3955
+ if (!normalized) return false;
3956
+ if ([".", "**", "**/*"].includes(normalized)) return true;
3957
+ if (matchesAnyPath(normalizedFile, [normalized])) return true;
3958
+ return !/[?*[\]{}]/.test(normalized) && normalizedFile.startsWith(`${normalized.replace(/\/$/, "")}/`);
3959
+ });
3960
+ }
3961
+
3962
+ function verifierCommandInstructions(commands: string[]): string[] {
3963
+ return commands.map((command, index) => `Verifier ${index + 1} (run as its own shell call): ${command}`);
3964
+ }
3965
+
3966
+ function evaluateTaskGate(
3967
+ cwd: string,
3968
+ task: TaskContract | undefined,
3969
+ policy: BasePolicy,
3970
+ options: {
3971
+ currentDigests?: Record<string, string>;
3972
+ currentWorkingTreeDigest?: string;
3973
+ } = {}
3974
+ ): {
3406
3975
  decision: "pass" | "fail";
3407
3976
  missing: string[];
3977
+ missingVerifyCommands: string[];
3408
3978
  warnings: string[];
3979
+ changedFileEvidence?: ReturnType<typeof taskChangedFileEvidence>;
3409
3980
  } {
3410
3981
  const finalGate = finalGateConfig(policy);
3411
3982
  const missing: string[] = [];
3412
3983
  const warnings: string[] = [];
3413
3984
  if (!task) {
3414
- return { decision: "fail", missing: ["task contract"], warnings };
3415
- }
3985
+ return { decision: "fail", missing: ["task contract"], missingVerifyCommands: [], warnings };
3986
+ }
3987
+ const currentDigests = options.currentDigests ?? workingTreeSnapshot(cwd) as Record<string, string>;
3988
+ const currentWorkingTreeDigest = options.currentWorkingTreeDigest ?? workingTreeEvidenceDigest(currentDigests);
3989
+ if (task.schemaVersion !== 2 || !task.taskRunId || !task.sessionId) missing.push("session-bound task contract v2");
3990
+ if (task.attempt > task.maxAttempts) missing.push(`attempt within maxAttempts (${task.attempt}/${task.maxAttempts})`);
3991
+ const plannedVerifyCommands = meaningfulVerifyCommands(task.verifyCommands);
3992
+ if (task.changeMode === "source-change" && plannedVerifyCommands.length === 0) missing.push("meaningful verify command");
3416
3993
  if (finalGate.requireContextManifest && task.contextManifest.length === 0) missing.push("context manifest");
3417
- if (finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
3994
+ if (task.changeMode === "source-change" && finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
3418
3995
  if (task.verifyEvidence.some((evidence) => evidence.observed !== true)) {
3419
3996
  warnings.push("Unobserved verify evidence is ignored by the passing verify gate.");
3420
3997
  }
3421
3998
  if (task.verifyEvidence.some((evidence) => evidence.observed === true && evidence.matchedProfileCommand !== true)) {
3422
3999
  warnings.push("Observed verify evidence that does not exactly match task verifyCommands is advisory only.");
3423
4000
  }
3424
- if (finalGate.requirePassingVerify && task.verifyEvidence.length > 0 && !task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)) {
3425
- missing.push("observed passing profile verify evidence");
3426
- }
3427
- if (finalGate.requireTrace && task.trace.outcome === "pending") missing.push("final trace");
3428
- if (task.changedFiles.length === 0 && task.trace.outcome === "completed") warnings.push("Trace is completed but changedFiles is empty.");
3429
- return { decision: missing.length === 0 ? "pass" : "fail", missing, warnings };
4001
+ if (task.verifyEvidence.some((evidence) => (
4002
+ evidence.observed === true
4003
+ && evidence.matchedProfileCommand === true
4004
+ && evidence.workingTreeDigest !== currentWorkingTreeDigest
4005
+ ))) {
4006
+ warnings.push("Verification evidence from a different working-tree snapshot is stale and is ignored.");
4007
+ }
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);
3430
4072
  }
3431
4073
 
3432
4074
  function formatCount(value: number | null | undefined): string {
@@ -3620,14 +4262,27 @@ function writeToolResultCapture(
3620
4262
  };
3621
4263
 
3622
4264
  try {
3623
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3624
- fs.writeFileSync(absolutePath, [
4265
+ ensurePrivateStateDirectory(cwd, path.dirname(absolutePath), "Tool-result capture directory");
4266
+ const capturePath = resolveLocalStatePath(cwd, absolutePath, { label: "Tool-result capture" });
4267
+ fs.writeFileSync(capturePath, [
3625
4268
  "# Piagent compacted tool result capture",
3626
4269
  JSON.stringify(metadata),
3627
4270
  "---",
3628
4271
  capture.text
3629
- ].join("\n"));
3630
- fs.appendFileSync(toolResultCaptureIndexPath(cwd), `${JSON.stringify(metadata)}\n`);
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
+ }
3631
4286
  const summary = {
3632
4287
  path: relativePath,
3633
4288
  source,
@@ -3731,16 +4386,9 @@ function attachToolResultCompactionDetails(details: unknown, captures: ToolResul
3731
4386
 
3732
4387
  function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
3733
4388
  const indexPath = toolResultCaptureIndexPath(cwd);
3734
- if (!fs.existsSync(indexPath)) return [];
3735
- const lines = fs.readFileSync(indexPath, "utf8").trim().split(/\n/).filter(Boolean);
3736
4389
  const captures: Record<string, unknown>[] = [];
3737
- for (const line of lines.slice(-Math.max(limit * 3, limit))) {
3738
- try {
3739
- const parsed = JSON.parse(line);
3740
- if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
3741
- } catch {
3742
- // A partial JSONL line should not make a status command noisy or unusable.
3743
- }
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>);
3744
4392
  }
3745
4393
  return captures.slice(-limit);
3746
4394
  }
@@ -4269,7 +4917,7 @@ function attachLocalImagesFromText(
4269
4917
  function appendTrace(cwd: string, payload: Record<string, unknown>): void {
4270
4918
  ensureStateDirs(cwd);
4271
4919
  const safePayload = redactForStorage(payload) as Record<string, unknown>;
4272
- fs.appendFileSync(traceFilePath(cwd), `${JSON.stringify({ recordedAt: nowIso(), ...safePayload })}\n`);
4920
+ appendJsonlBounded(traceFilePath(cwd), { recordedAt: nowIso(), ...safePayload }, { maxBytes: TRACE_MAX_BYTES, mode: 0o600, projectRoot: cwd });
4273
4921
  }
4274
4922
 
4275
4923
  function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>): void {
@@ -4281,8 +4929,51 @@ function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>):
4281
4929
  });
4282
4930
  }
4283
4931
 
4284
- function flattenVerifyCommands(profile: ProjectProfile): string[] {
4285
- return Object.values(profile.verifyCommands ?? {}).flat();
4932
+ function selectVerifyPlan(
4933
+ profile: ProjectProfile,
4934
+ requestedGroup: string | undefined,
4935
+ changeMode: TaskContract["changeMode"],
4936
+ cwd?: string
4937
+ ): { group?: string; commands: string[]; error?: string } {
4938
+ if (changeMode === "read-only") return { commands: [] };
4939
+ const groups = profile.verifyCommands ?? {};
4940
+ const names = Object.keys(groups);
4941
+ const requested = requestedGroup?.trim();
4942
+ if (requested && !Object.hasOwn(groups, requested)) {
4943
+ return { commands: [], error: `Unknown verify group ${requested}. Available groups: ${names.join(", ") || "none"}.` };
4944
+ }
4945
+ const group = requested
4946
+ ?? (Object.hasOwn(groups, "source") ? "source" : undefined)
4947
+ ?? (Object.hasOwn(groups, "frontendSource") ? "frontendSource" : undefined)
4948
+ ?? names.find((name) => !/docs|runtime/i.test(name));
4949
+ let commands = group ? uniqueStrings(groups[group] ?? []) : [];
4950
+ if (
4951
+ cwd
4952
+ && commands.length === 1
4953
+ && commands[0].includes("No Node source verifier is configured")
4954
+ ) {
4955
+ try {
4956
+ const manifestPath = path.join(cwd, "package.json");
4957
+ if (!fs.lstatSync(manifestPath).isSymbolicLink()) {
4958
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { scripts?: Record<string, unknown> };
4959
+ const scripts = manifest.scripts && typeof manifest.scripts === "object" ? manifest.scripts : {};
4960
+ const selected = ["type-check", "typecheck", "lint", "test"].filter((name) => typeof scripts[name] === "string");
4961
+ if (selected.length > 0) {
4962
+ commands = selected.map((name) => name === "test" ? "npm test" : `npm run ${name}`);
4963
+ }
4964
+ }
4965
+ } catch {
4966
+ // Preserve the profile's fail-closed verifier when package metadata is absent or invalid.
4967
+ }
4968
+ }
4969
+ if (meaningfulVerifyCommands(commands).length === 0) {
4970
+ return {
4971
+ group,
4972
+ commands,
4973
+ error: `Verify group ${group ?? "(none)"} has no meaningful command. Configure .pi/piagent-profile.json before source changes.`
4974
+ };
4975
+ }
4976
+ return { group, commands };
4286
4977
  }
4287
4978
 
4288
4979
  function parseReferenceRepoRef(input: string): { host: string; owner: string; repo: string } {
@@ -4404,7 +5095,7 @@ function checkoutReferenceRepo(repoRef: string, forceUpdate = false): ReferenceR
4404
5095
  function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
4405
5096
  const signal = classifyContextTask(prompt);
4406
5097
  const lower = prompt.toLowerCase();
4407
- const groups = new Set<PiagentToolGroup>(["loader"]);
5098
+ const groups = new Set<PiagentToolGroup>();
4408
5099
 
4409
5100
  if (signal.workflow === "usage") {
4410
5101
  groups.add("usage");
@@ -4419,50 +5110,127 @@ function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
4419
5110
  return [...groups];
4420
5111
  }
4421
5112
 
4422
- groups.add("governance");
4423
- groups.add("policy");
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");
4424
5117
  if (
4425
- signal.lane !== "tiny"
4426
- || ["scout", "review", "plan", "release"].includes(signal.workflow)
4427
- || /\b(context|document|memory|repository|source|subagent|vendor)\b/.test(lower)
5118
+ /\b(context (?:engine|index|search|diagnostic)|source checkout|vendor checkout)\b/.test(lower)
4428
5119
  ) {
4429
5120
  groups.add("retrieval");
4430
5121
  }
4431
5122
  if (
4432
- signal.lane === "high-risk"
4433
- || ["scout", "review", "plan", "release"].includes(signal.workflow)
4434
- || /\b(document|memory|orchestration|source checkout|subagent|vendor)\b/.test(lower)
5123
+ /\b(document intake|project memory|orchestration policy|source checkout|subagent policy|vendor checkout)\b/.test(lower)
4435
5124
  ) {
4436
5125
  groups.add("knowledge");
4437
5126
  }
5127
+ if (/\b(permission|capability|exec policy|tool policy)\b/.test(lower)) groups.add("policy");
4438
5128
  if (/\b(profile|tech stack|onboard|project context)\b/.test(lower)) groups.add("onboarding");
4439
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");
4440
5131
  return [...groups];
4441
5132
  }
4442
5133
 
4443
- function compactSessionTask(cwd: string): TaskContract | undefined {
4444
- const directory = path.join(stateRoot(cwd), "tasks");
4445
- if (!fs.existsSync(directory)) return undefined;
4446
- const candidates = fs.readdirSync(directory, { withFileTypes: true })
4447
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
4448
- .map((entry) => {
4449
- const absolute = path.join(directory, entry.name);
4450
- return { absolute, mtimeMs: fs.statSync(absolute).mtimeMs };
4451
- })
4452
- .sort((left, right) => right.mtimeMs - left.mtimeMs);
4453
- return candidates[0] ? readJsonFile<TaskContract>(candidates[0].absolute) : undefined;
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
+ function sessionTaskReference(ctx: ExtensionContext): { taskId?: string; taskRunId?: string } | undefined {
5206
+ const entries = ctx.sessionManager.getBranch();
5207
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
5208
+ const entry = entries[index] as unknown as { type?: string; customType?: string; data?: Record<string, unknown> };
5209
+ if (entry.type !== "custom" || entry.customType !== PIAGENT_TRACE_STATE_TYPE || !entry.data) continue;
5210
+ const taskId = typeof entry.data.taskId === "string" ? entry.data.taskId : undefined;
5211
+ const taskRunId = typeof entry.data.taskRunId === "string" ? entry.data.taskRunId : undefined;
5212
+ if (taskId || taskRunId) return { taskId, taskRunId };
5213
+ }
5214
+ return undefined;
4454
5215
  }
4455
5216
 
4456
- function semanticCompactionInstructions(cwd: string): string {
4457
- const task = compactSessionTask(cwd);
5217
+ function compactSessionTask(cwd: string, sessionId: string): TaskContract | undefined {
5218
+ return activeSessionTask(cwd, sessionId) as TaskContract | undefined;
5219
+ }
5220
+
5221
+ function semanticCompactionInstructions(cwd: string, sessionId: string): string {
5222
+ const task = compactSessionTask(cwd, sessionId);
4458
5223
  const taskState = task
4459
5224
  ? [
4460
5225
  `Current task: ${task.taskId} (${task.riskLane})`,
5226
+ `Session: ${task.sessionName ?? task.sessionId}`,
4461
5227
  `Goal: ${task.summary}`,
4462
5228
  `Acceptance: ${task.acceptanceCriteria.join("; ") || "not recorded"}`,
4463
5229
  `Scope: ${task.scope.join(", ") || "not recorded"}`,
4464
5230
  `Changed files: ${task.changedFiles.join(", ") || "none recorded"}`,
4465
- `Verify commands: ${task.verifyCommands.join(" | ") || "not recorded"}`,
5231
+ task.verifyCommands.length > 0
5232
+ ? ["Exact verify commands:", ...verifierCommandInstructions(task.verifyCommands)].join("\n")
5233
+ : "Exact verify commands: not recorded",
4466
5234
  `Outcome/blocker: ${task.trace.outcome}${task.trace.friction ? `; ${task.trace.friction}` : ""}`
4467
5235
  ].join("\n")
4468
5236
  : "No persisted task contract was found. Derive the current goal from the most recent user request.";
@@ -4490,6 +5258,48 @@ function environmentFeatureEnabled(name: string, fallback = true): boolean {
4490
5258
  return !["0", "false", "no", "off", "disabled"].includes(value);
4491
5259
  }
4492
5260
 
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
+
4493
5303
  export default function piagentGuard(pi: ExtensionAPI) {
4494
5304
  const extensionDir = path.dirname(fileURLToPath(import.meta.url));
4495
5305
  const policy = loadPolicy(extensionDir);
@@ -4497,22 +5307,126 @@ export default function piagentGuard(pi: ExtensionAPI) {
4497
5307
  const dynamicToolsEnabled = environmentFeatureEnabled("PIAGENT_DYNAMIC_TOOLS");
4498
5308
  const contextTelemetryEnabled = environmentFeatureEnabled("PIAGENT_CONTEXT_TELEMETRY");
4499
5309
  const autoContextEnabled = environmentFeatureEnabled("PIAGENT_AUTO_CONTEXT");
5310
+ const autoRecoveryEnabled = environmentFeatureEnabled("PIAGENT_AUTO_RECOVERY");
4500
5311
  // Advisory tool-registry verdicts are surfaced once per tool per session. A
4501
5312
  // notice on every call would be constant noise for a tool used all day, and
4502
5313
  // noise that is always present is read as background rather than as a warning.
4503
5314
  const advisedTools = new Set<string>();
4504
5315
  const seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
4505
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
+ }
5395
+
5396
+ function retrievalKey(ctx: ExtensionContext, query: string): string {
5397
+ const signal = classifyContextTask(query);
5398
+ return crypto.createHash("sha256")
5399
+ .update(JSON.stringify({
5400
+ cwd: ctx.cwd,
5401
+ sessionId: ctx.sessionManager.getSessionId(),
5402
+ terms: [...signal.terms].sort(),
5403
+ paths: [...signal.paths].sort()
5404
+ }))
5405
+ .digest("hex");
5406
+ }
5407
+
5408
+ function promptPackKey(ctx: ExtensionContext, promptHash: string): string {
5409
+ return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}\u0000${promptHash}`;
5410
+ }
4506
5411
 
4507
5412
  function telemetry(ctx: ExtensionContext, payload: Record<string, unknown>): void {
4508
5413
  if (!contextTelemetryEnabled) return;
4509
5414
  try {
4510
5415
  const safePayload = redactForStorage(payload) as Record<string, unknown>;
5416
+ let taskIdentity = taskIdentityBySession.get(sessionCacheKey(ctx));
5417
+ if (!taskIdentity) {
5418
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5419
+ if (task) {
5420
+ cacheTaskIdentity(ctx, task);
5421
+ taskIdentity = { taskId: task.taskId, taskRunId: task.taskRunId };
5422
+ }
5423
+ }
4511
5424
  appendContextTelemetry(ctx.cwd, {
4512
5425
  sessionId: ctx.sessionManager.getSessionId(),
4513
5426
  sessionName: currentSessionName(ctx),
4514
5427
  model: `${ctx.model?.provider ?? "unknown"}/${ctx.model?.id ?? "unknown"}`,
4515
5428
  thinkingLevel: String(pi.getThinkingLevel()),
5429
+ ...taskIdentity,
4516
5430
  ...safePayload
4517
5431
  });
4518
5432
  } catch {
@@ -4531,7 +5445,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4531
5445
  if (!PIAGENT_TOOL_NAMES.has(toolName)) selected.add(toolName);
4532
5446
  }
4533
5447
  }
4534
- const normalizedGroups = [...new Set<PiagentToolGroup>(["loader", ...groups])];
5448
+ const normalizedGroups = [...new Set<PiagentToolGroup>(groups)];
4535
5449
  for (const toolName of PIAGENT_TOOL_ORDER) {
4536
5450
  if (normalizedGroups.some((group) => PIAGENT_TOOL_GROUPS[group].includes(toolName as never))) {
4537
5451
  selected.add(toolName);
@@ -4564,6 +5478,42 @@ export default function piagentGuard(pi: ExtensionAPI) {
4564
5478
  if (!hasOperatorSessionName(operatorSessionName)) {
4565
5479
  pi.setSessionName(`pi:${name}`);
4566
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));
4567
5517
  const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
4568
5518
  ? ""
4569
5519
  : " (run /onboard to select a profile)";
@@ -4604,7 +5554,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
4604
5554
  } catch (error) {
4605
5555
  engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
4606
5556
  }
4607
- telemetry(ctx, { event: "session_start", activeTools: pi.getActiveTools().length, index: engineStatus });
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
+ });
5566
+ });
5567
+
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
+ });
4608
5583
  });
4609
5584
 
4610
5585
  pi.on("input", async (event, ctx) => {
@@ -4614,7 +5589,22 @@ export default function piagentGuard(pi: ExtensionAPI) {
4614
5589
  return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
4615
5590
  }
4616
5591
  const taskSignal = classifyContextTask(text);
4617
- activateToolGroups(ctx, toolGroupsForPrompt(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);
4618
5608
  telemetry(ctx, {
4619
5609
  event: "user_input",
4620
5610
  source: event.source,
@@ -4622,6 +5612,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
4622
5612
  promptChars: taskSignal.promptChars,
4623
5613
  workflow: taskSignal.workflow,
4624
5614
  riskLane: taskSignal.lane,
5615
+ intakeMode: runtimeIntake ? "runtime" : "model",
5616
+ protectedTarget,
5617
+ protectedOnlyTarget,
5618
+ manualIntake,
4625
5619
  explicitPathCount: taskSignal.paths.length,
4626
5620
  termCount: taskSignal.terms.length
4627
5621
  });
@@ -4685,6 +5679,27 @@ export default function piagentGuard(pi: ExtensionAPI) {
4685
5679
  });
4686
5680
 
4687
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;
4688
5703
  const active = new Set<string>(pi.getActiveTools() as string[]);
4689
5704
  const toolMetadata = pi.getAllTools()
4690
5705
  .filter((tool) => active.has(tool.name))
@@ -4695,9 +5710,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
4695
5710
  promptGuidelines: tool.promptGuidelines
4696
5711
  }));
4697
5712
  const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
4698
- const systemPromptTokens = estimateContextTokens(event.systemPrompt);
4699
- const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
4700
- const signal = classifyContextTask(query);
5713
+ const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
5714
+ const autoPackUseful = activeTask?.trace.outcome !== "pending"
5715
+ && (runtimeIntake || signal.paths.length === 0);
4701
5716
  telemetry(ctx, {
4702
5717
  event: "agent_prompt",
4703
5718
  promptHash: signal.promptHash,
@@ -4708,21 +5723,54 @@ export default function piagentGuard(pi: ExtensionAPI) {
4708
5723
  activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
4709
5724
  toolSchemaTokens,
4710
5725
  systemPromptTokens,
4711
- systemPromptHash: crypto.createHash("sha256").update(event.systemPrompt).digest("hex"),
5726
+ systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
5727
+ legacyProjectInstructionsRewritten: projectInstructions.rewritten,
5728
+ managedInstructionsCompacted: compactedInstructions.compacted,
4712
5729
  contextUsage: ctx.getContextUsage()
4713
5730
  });
4714
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
+
4715
5763
  if (
4716
5764
  query.length < 20
4717
5765
  || signal.workflow === "usage"
4718
5766
  || !autoContextEnabled
4719
- || autoPackedPrompts.has(signal.promptHash)
4720
- || !active.has("piagent_context_engine")
5767
+ || !autoPackUseful
5768
+ || autoPackedPrompts.has(promptPackKey(ctx, signal.promptHash))
4721
5769
  ) {
4722
- return;
5770
+ return finishAgentStart();
4723
5771
  }
4724
5772
 
4725
- autoPackedPrompts.add(signal.promptHash);
5773
+ autoPackedPrompts.add(promptPackKey(ctx, signal.promptHash));
4726
5774
  if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
4727
5775
  try {
4728
5776
  const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
@@ -4740,13 +5788,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
4740
5788
  selected: 0,
4741
5789
  skipped: status.exists ? "stale-index" : "missing-index"
4742
5790
  });
4743
- return;
5791
+ return finishAgentStart();
4744
5792
  }
4745
- const budgetTokens = signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
5793
+ const budgetTokens = runtimeIntake
5794
+ ? 900
5795
+ : signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
4746
5796
  const pack = await buildContextPack(ctx.cwd, query, {
4747
5797
  budgetTokens,
4748
- includeCode: false,
4749
- limit: 12,
5798
+ includeCode: runtimeIntake,
5799
+ includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
5800
+ currentSnapshot: runtimeIntake,
5801
+ limit: runtimeIntake ? 8 : 12,
4750
5802
  excludePatterns
4751
5803
  });
4752
5804
  telemetry(ctx, {
@@ -4757,23 +5809,35 @@ export default function piagentGuard(pi: ExtensionAPI) {
4757
5809
  selected: pack.selected.length,
4758
5810
  estimatedTokens: pack.estimatedTokens,
4759
5811
  selectedPaths: pack.selected.map((item) => item.path),
4760
- finderRecommended: pack.finderRecommended
5812
+ finderRecommended: pack.finderRecommended,
5813
+ currentSnapshot: runtimeIntake
4761
5814
  });
4762
- if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return;
4763
- return {
4764
- message: {
4765
- customType: "piagent-context-pack-v2",
4766
- content: pack.text,
4767
- display: false,
4768
- details: {
4769
- schemaVersion: 2,
4770
- queryHash: pack.queryHash,
4771
- confidence: pack.confidence,
4772
- estimatedTokens: pack.estimatedTokens,
4773
- paths: pack.selected.map((item) => item.path)
4774
- }
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
4775
5839
  }
4776
- };
5840
+ });
4777
5841
  } catch (error) {
4778
5842
  telemetry(ctx, {
4779
5843
  event: "context_pack",
@@ -4783,6 +5847,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4783
5847
  selected: 0,
4784
5848
  error: error instanceof Error ? error.message : String(error)
4785
5849
  });
5850
+ return finishAgentStart();
4786
5851
  }
4787
5852
  });
4788
5853
 
@@ -4799,6 +5864,160 @@ export default function piagentGuard(pi: ExtensionAPI) {
4799
5864
  });
4800
5865
  });
4801
5866
 
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, {
5929
+ currentDigests,
5930
+ 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
+
4802
6021
  pi.on("session_compact", async (event, ctx) => {
4803
6022
  telemetry(ctx, {
4804
6023
  event: "session_compact",
@@ -4809,11 +6028,20 @@ export default function piagentGuard(pi: ExtensionAPI) {
4809
6028
  });
4810
6029
 
4811
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
+ );
4812
6038
  telemetry(ctx, {
4813
6039
  event: "session_shutdown",
4814
6040
  reason: event.reason,
4815
6041
  targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
4816
6042
  });
6043
+ clearObservedContext(ctx);
6044
+ clearShellMutationSnapshots(ctx);
4817
6045
  });
4818
6046
 
4819
6047
  pi.on("tool_result", async (event, ctx) => {
@@ -4827,13 +6055,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
4827
6055
  appendObservedBashResult(observedBashLedgerPath(ctx.cwd), {
4828
6056
  ...observed,
4829
6057
  redactedCommand: redactText(observed.command)
4830
- });
6058
+ }, { projectRoot: ctx.cwd });
4831
6059
  } catch (error) {
4832
6060
  const message = error instanceof Error ? error.message : String(error);
4833
6061
  ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
4834
6062
  }
4835
6063
  }
4836
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
+
4837
6075
  let resultContent: unknown = event.content;
4838
6076
  let resultDetails: unknown = event.details;
4839
6077
  let resultChanged = false;
@@ -4993,6 +6231,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
4993
6231
  const toolInput = event.input && typeof event.input === "object"
4994
6232
  ? event.input as Record<string, unknown>
4995
6233
  : {};
6234
+ const sessionTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
6235
+ const profileMode = profile.mode ?? "unprofiled";
6236
+ const governedTaskProfile = !profileMode.startsWith("unprofiled") && !profileMode.includes("unreadable");
6237
+ const taskContractRequired = governedTaskProfile
6238
+ && runtime.finalGate === "enforce"
6239
+ && finalGateConfig(policy).requireTaskContract;
6240
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !SHELL_TOOL_NAMES.has(event.toolName) && isTaskMutationTool(event.toolName, toolInput)) {
6241
+ return {
6242
+ block: true,
6243
+ reason: `Task ${sessionTask.taskId} is read-only; ${event.toolName} cannot mutate project or external state in this task. Start a source-change task for implementation.`
6244
+ };
6245
+ }
4996
6246
  const preparedInput = prepareToolInputForPolicy(event.toolName, toolInput, policy);
4997
6247
  if (preparedInput.reason) {
4998
6248
  return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
@@ -5005,6 +6255,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5005
6255
  return { block: true, reason: approvalDecision.reason };
5006
6256
  }
5007
6257
 
6258
+ let shellProjectMutation = false;
5008
6259
  if (SHELL_TOOL_NAMES.has(event.toolName)) {
5009
6260
  const shellInput = extractShellCommandInput(toolInput);
5010
6261
  if (!shellInput.command) {
@@ -5012,6 +6263,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
5012
6263
  }
5013
6264
  const command = normalizeShellCommandForPolicy(shellInput.command);
5014
6265
  const execDecision = evaluateExecPolicy(command, profile, policy);
6266
+ shellProjectMutation = isProjectMutatingShellCommand(command, execDecision.segments);
6267
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !isReadOnlyTaskShellCommand(command, execDecision.segments)) {
6268
+ return {
6269
+ block: true,
6270
+ reason: `Task ${sessionTask.taskId} is read-only; this shell command is not in the read-only inspection allowlist.`
6271
+ };
6272
+ }
5015
6273
  if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
5016
6274
  return { block: true, reason: execDecision.reasons.join("; ") };
5017
6275
  }
@@ -5110,6 +6368,34 @@ export default function piagentGuard(pi: ExtensionAPI) {
5110
6368
  }
5111
6369
  }
5112
6370
 
6371
+ const mutationTargets = taskMutationTargets(ctx.cwd, event.toolName, toolInput);
6372
+ const directProjectMutation = !SHELL_TOOL_NAMES.has(event.toolName) && (
6373
+ WRITE_TOOL_NAMES.has(event.toolName)
6374
+ || mutationTargets.length > 0
6375
+ || preparedInput.proxyShellCarrier === true
6376
+ );
6377
+ if (sessionTask && sessionTask.trace.outcome !== "pending" && (shellProjectMutation || directProjectMutation)) {
6378
+ return {
6379
+ block: true,
6380
+ reason: `Task ${sessionTask.taskId} is ${sessionTask.trace.outcome}; start a new attempt or a fresh task before further project mutations.`
6381
+ };
6382
+ }
6383
+ if (!sessionTask && taskContractRequired && (shellProjectMutation || directProjectMutation)) {
6384
+ return {
6385
+ block: true,
6386
+ reason: "A session-bound Task Implementation Contract is required before project files can be mutated. Start the task first."
6387
+ };
6388
+ }
6389
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "source-change") {
6390
+ const outsideScope = mutationTargets.filter((file) => !taskScopeIncludesPath(sessionTask.scope, file));
6391
+ if (outsideScope.length > 0) {
6392
+ return {
6393
+ block: true,
6394
+ reason: `Task ${sessionTask.taskId} cannot mutate paths outside its declared scope: ${outsideScope.join(", ")}.`
6395
+ };
6396
+ }
6397
+ }
6398
+
5113
6399
  if (runtime.contextBudget !== "off" && ["write", "edit"].includes(event.toolName)) {
5114
6400
  const relativePath = extractLikelyPathFromInput(ctx.cwd, event.input as Record<string, unknown>);
5115
6401
  if (relativePath) {
@@ -5120,18 +6406,27 @@ export default function piagentGuard(pi: ExtensionAPI) {
5120
6406
  }
5121
6407
  }
5122
6408
  }
6409
+
6410
+ if (
6411
+ sessionTask?.trace.outcome === "pending"
6412
+ && sessionTask.changeMode === "source-change"
6413
+ && shellProjectMutation
6414
+ ) {
6415
+ rememberShellMutationSnapshot(ctx, event.toolName, event.input);
6416
+ }
5123
6417
  });
5124
6418
 
5125
6419
  pi.registerTool({
5126
6420
  name: "piagent_tools",
5127
6421
  label: "Piagent Tool Loader",
5128
6422
  description: "Activate an additional Piagent tool group only when the current task needs it.",
5129
- promptSnippet: "Load governance, policy, retrieval, knowledge, onboarding, or usage tools on demand.",
6423
+ promptSnippet: "Load diagnostic or recovery Piagent tools only when the runtime requests them.",
5130
6424
  promptGuidelines: [
5131
- "Load the smallest group that resolves the current need; do not activate every group."
6425
+ "Do not call this for an ordinary implementation task; runtime evidence collection needs no extra tools.",
6426
+ "When recovery is necessary, load only the smallest group that resolves the reported missing evidence."
5132
6427
  ],
5133
6428
  parameters: Type.Object({
5134
- groups: Type.Array(StringEnum(["governance", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
6429
+ groups: Type.Array(StringEnum(["intake", "governance", "task", "recovery", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
5135
6430
  }),
5136
6431
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
5137
6432
  const groups = [...new Set(params.groups)] as PiagentToolGroup[];
@@ -5169,7 +6464,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5169
6464
  action: StringEnum(["status", "rebuild", "search", "pack", "impact", "efficiency"] as const),
5170
6465
  query: Type.Optional(Type.String()),
5171
6466
  files: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5172
- budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 50000 })),
6467
+ budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 12000 })),
5173
6468
  refresh: Type.Optional(Type.Boolean())
5174
6469
  }),
5175
6470
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -5216,6 +6511,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
5216
6511
  ].join("\n");
5217
6512
  } else if (params.action === "pack") {
5218
6513
  if (!params.query?.trim()) throw new Error("query is required for a context pack");
6514
+ const injected = injectedContextPacks.get(retrievalKey(ctx, params.query));
6515
+ if (injected && params.refresh !== true) {
6516
+ result = { reusedInjectedPack: true, ...injected };
6517
+ text = [
6518
+ "Context pack already injected for this task; duplicate payload skipped.",
6519
+ `confidence: ${injected.confidence}; estimatedTokensSaved: ${injected.estimatedTokens}`,
6520
+ `paths: ${injected.paths.join(", ")}`,
6521
+ "Read only the listed files or request refresh=true when the repository changed."
6522
+ ].join("\n");
6523
+ telemetry(ctx, {
6524
+ event: "context_pack_reused",
6525
+ queryHash: injected.queryHash,
6526
+ estimatedTokensSaved: injected.estimatedTokens,
6527
+ selectedPaths: injected.paths
6528
+ });
6529
+ return { content: [{ type: "text", text }], details: result };
6530
+ }
5219
6531
  const ensured = await ensureContextIndexV2(ctx.cwd, {
5220
6532
  excludePatterns,
5221
6533
  refresh: params.refresh,
@@ -5223,7 +6535,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5223
6535
  });
5224
6536
  const status = ensured.status;
5225
6537
  const pack = await buildContextPack(ctx.cwd, params.query, {
5226
- budgetTokens: params.budgetTokens ?? 6_000,
6538
+ budgetTokens: params.budgetTokens ?? 2_400,
5227
6539
  includeCode: true,
5228
6540
  limit: 18,
5229
6541
  excludePatterns
@@ -5483,19 +6795,26 @@ export default function piagentGuard(pi: ExtensionAPI) {
5483
6795
  description: "Check whether a governed task has enough context, verify evidence, and trace before claiming done.",
5484
6796
  promptSnippet: "Use this before final on source-changing tasks.",
5485
6797
  parameters: Type.Object({
5486
- taskId: Type.String({ minLength: 1 })
6798
+ taskId: Type.String({ minLength: 1 }),
6799
+ changedFiles: Type.Optional(Type.Array(Type.String()))
5487
6800
  }),
5488
6801
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
5489
- const task = readTask(ctx.cwd, params.taskId);
5490
- const result = evaluateTaskGate(task, policy);
6802
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6803
+ const projected = task ? {
6804
+ ...task,
6805
+ changedFiles: params.changedFiles ?? task.changedFiles,
6806
+ trace: { ...task.trace, outcome: "completed" as const }
6807
+ } : undefined;
6808
+ const result = evaluateTaskGate(ctx.cwd, projected, policy);
5491
6809
  const runtime = resolveRuntimePolicy(loadProfileFromContext(ctx));
5492
6810
  const text = [
5493
6811
  `decision: ${result.decision}`,
5494
6812
  `mode: ${runtime.finalGate}`,
5495
6813
  `missing: ${result.missing.join(", ") || "none"}`,
6814
+ ...verifierCommandInstructions(result.missingVerifyCommands),
5496
6815
  `warnings: ${result.warnings.join("; ") || "none"}`
5497
6816
  ].join("\n");
5498
- return { content: [{ type: "text", text }], details: { ...result, task } };
6817
+ return { content: [{ type: "text", text }], details: { ...result, task: projected } };
5499
6818
  }
5500
6819
  });
5501
6820
 
@@ -6113,24 +7432,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
6113
7432
  }
6114
7433
  });
6115
7434
 
6116
- pi.registerTool({
7435
+ const taskStartTool = {
6117
7436
  name: "piagent_task_start",
6118
7437
  label: "Piagent Task Start",
6119
7438
  description: "Create a Task Implementation Contract for the current project before editing.",
6120
7439
  promptSnippet: "Start a governed implementation task and persist the task contract.",
6121
7440
  promptGuidelines: [
6122
- "Call this before source edits in a project managed by Pi Agent Platform.",
6123
- "Use piagent_context first when possible.",
7441
+ "Call this exactly once before source edits in a project managed by Pi Agent Platform.",
7442
+ "Do not call context, status, policy, evidence-recording, trace, or gate tools first; runtime hooks provide those checks automatically.",
6124
7443
  "Use tiny for a bounded low-risk change, normal for ordinary multi-file work, and high-risk for security, data, release, migration, or external-impact work.",
6125
- "Tiny tasks should stay on the compact parent-only work plan unless the caller supplies a justified custom plan."
7444
+ "Every scope entry must be a project-relative path or glob such as src/file.ts, src/**, or test/**; never put prose in scope.",
7445
+ "Leave workPlan unset for ordinary tiny/normal tasks so runtime automation stays active; pass a custom workPlan only when the operator explicitly requests custom subagent or checkpoint orchestration.",
7446
+ "Tiny tasks use automatic lifecycle evidence; normal tasks retain one explicit review step; high-risk/custom plans keep manual checkpoints."
6126
7447
  ],
6127
7448
  parameters: Type.Object({
6128
7449
  taskId: Type.Optional(Type.String({ minLength: 1 })),
6129
7450
  summary: Type.String({ minLength: 10 }),
6130
7451
  riskLane: StringEnum(["tiny", "normal", "high-risk"] as const),
7452
+ changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const)),
7453
+ verifyGroup: Type.Optional(Type.String({ minLength: 1 })),
7454
+ maxAttempts: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
6131
7455
  expectedOutput: Type.String({ minLength: 10 }),
6132
7456
  acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
6133
- scope: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
7457
+ scope: Type.Array(Type.String({
7458
+ minLength: 1,
7459
+ description: "Project-relative path or glob only (for example src/file.ts, src/**, or test/**); do not use prose."
7460
+ }), { minItems: 1 }),
6134
7461
  outOfScope: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
6135
7462
  reviewLenses: Type.Optional(Type.Array(StringEnum(REVIEW_LENSES))),
6136
7463
  workPlan: Type.Optional(Type.Array(Type.Object({
@@ -6138,33 +7465,143 @@ export default function piagentGuard(pi: ExtensionAPI) {
6138
7465
  title: Type.String({ minLength: 1 }),
6139
7466
  role: Type.Optional(StringEnum(ORCHESTRATION_ROLES)),
6140
7467
  mode: Type.Optional(StringEnum(["read-only", "single-writer", "review"] as const)),
6141
- status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped"] as const)),
6142
- dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 })))
7468
+ status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped", "failed"] as const)),
7469
+ dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
7470
+ note: Type.Optional(Type.String())
6143
7471
  })))
6144
7472
  }),
6145
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
7473
+ async execute(
7474
+ _toolCallId: string,
7475
+ params: TaskStartParameters,
7476
+ _signal: AbortSignal | undefined,
7477
+ _onUpdate: ((update: unknown) => void) | undefined,
7478
+ ctx: ExtensionContext
7479
+ ) {
6146
7480
  const profile = loadProfileFromContext(ctx);
6147
7481
  const createdAt = nowIso();
6148
7482
  const safeSummary = redactText(params.summary);
6149
7483
  const taskId = safeTaskId(redactText(params.taskId ?? params.summary));
7484
+ const sessionId = ctx.sessionManager.getSessionId();
7485
+ const sessionName = currentSessionName(ctx);
7486
+ const active = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
7487
+ if (active) {
7488
+ if (active.taskId === taskId) {
7489
+ if (active.trace.outcome !== "pending") {
7490
+ return {
7491
+ content: [{ type: "text", text: `Session already belongs to terminal task ${active.taskId} (${active.trace.outcome}). Start a fresh Pi session for a retry or new task.` }],
7492
+ details: active,
7493
+ isError: true
7494
+ };
7495
+ }
7496
+ return {
7497
+ content: [{ type: "text", text: `Task already active in this session: ${active.taskId} (${active.taskRunId}). Reusing it instead of overwriting state.` }],
7498
+ details: compactTaskDetails(active)
7499
+ };
7500
+ }
7501
+ return {
7502
+ content: [{ type: "text", text: `Session already belongs to task ${active.taskId} (${active.taskRunId}, ${active.trace.outcome}). Use one Pi session per task and start a fresh session before another task.` }],
7503
+ details: active,
7504
+ isError: true
7505
+ };
7506
+ }
7507
+ const invalidScope = params.scope.find((entry) => !validTaskScopePattern(entry));
7508
+ if (invalidScope) {
7509
+ return {
7510
+ content: [{
7511
+ type: "text",
7512
+ text: `Task start refused: scope entries must be project-relative paths or globs; invalid entry ${JSON.stringify(invalidScope)}. Use values such as src/file.ts, src/**, or test/**.`
7513
+ }],
7514
+ isError: true
7515
+ };
7516
+ }
7517
+ const priorAttempts = priorTaskAttempts(ctx.cwd, taskId) as TaskContract[];
7518
+ const pendingElsewhere = priorAttempts.find((task) => task.trace.outcome === "pending" && task.sessionId !== sessionId);
7519
+ if (pendingElsewhere) {
7520
+ return {
7521
+ content: [{ type: "text", text: `Task ${taskId} is already active in session ${pendingElsewhere.sessionName ?? pendingElsewhere.sessionId} (${pendingElsewhere.taskRunId}).` }],
7522
+ details: pendingElsewhere,
7523
+ isError: true
7524
+ };
7525
+ }
7526
+ const latestCompleted = priorAttempts.find((task) => task.trace.outcome === "completed");
7527
+ if (latestCompleted) {
7528
+ return {
7529
+ content: [{ type: "text", text: `Task ${taskId} already completed as ${latestCompleted.taskRunId}. Use a distinct taskId for new work instead of replacing its evidence.` }],
7530
+ details: latestCompleted,
7531
+ isError: true
7532
+ };
7533
+ }
7534
+ const attempt = priorAttempts.reduce((maximum, task) => Math.max(maximum, task.attempt ?? 1), 0) + 1;
7535
+ const firstAttempt = priorAttempts.find((task) => task.attempt === 1)
7536
+ ?? [...priorAttempts].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))[0];
7537
+ const maxAttempts = firstAttempt
7538
+ ? firstAttempt.maxAttempts
7539
+ : Number.isInteger(params.maxAttempts)
7540
+ ? Math.max(1, Math.min(10, params.maxAttempts))
7541
+ : DEFAULT_MAX_TASK_ATTEMPTS;
7542
+ if (attempt > maxAttempts) {
7543
+ return {
7544
+ content: [{ type: "text", text: `Task ${taskId} reached its retry limit (${attempt - 1}/${maxAttempts}). Report the blocker or ask the operator to create a new scoped task.` }],
7545
+ details: { taskId, attempt, maxAttempts, previousAttempts: priorAttempts.map(summarizeAttempt) },
7546
+ isError: true
7547
+ };
7548
+ }
6150
7549
  const orchestration = resolveOrchestrationPolicy(profile, policy);
7550
+ const changeMode = params.changeMode === "read-only" ? "read-only" : "source-change";
7551
+ if (changeMode === "source-change" && !isGitWorkingTree(ctx.cwd)) {
7552
+ return {
7553
+ content: [{ type: "text", text: "Task start refused: source-change tasks require a Git working tree so changed-file evidence cannot silently disappear. Initialize Git or use read-only mode." }],
7554
+ isError: true
7555
+ };
7556
+ }
7557
+ const verifyPlan = selectVerifyPlan(profile, params.verifyGroup, changeMode, ctx.cwd);
7558
+ if (verifyPlan.error) {
7559
+ return {
7560
+ content: [{ type: "text", text: `Task start refused: ${verifyPlan.error}` }],
7561
+ details: { verifyGroup: verifyPlan.group, verifyCommands: verifyPlan.commands },
7562
+ isError: true
7563
+ };
7564
+ }
6151
7565
  const reviewLenses = normalizeReviewLenses(params.reviewLenses, orchestration.defaultReviewLenses);
6152
7566
  const providedWorkPlan = normalizeWorkPlanSteps(params.workPlan);
6153
- const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane);
7567
+ const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane, changeMode);
7568
+ const seededContext = observedContext(ctx).slice(0, contextBudgetConfig(policy).maxManifestFiles);
7569
+ const workPlanError = validateNewWorkPlan(workPlan);
7570
+ if (workPlanError) {
7571
+ return { content: [{ type: "text", text: `Task start refused: ${workPlanError}.` }], details: workPlan, isError: true };
7572
+ }
7573
+ const firstReady = workPlan.find((step) => (step.dependsOn ?? []).length === 0);
7574
+ if (!firstReady) {
7575
+ return { content: [{ type: "text", text: "Task start refused: work plan has no dependency-ready first step." }], details: workPlan, isError: true };
7576
+ }
7577
+ firstReady.status = "in-progress";
7578
+ firstReady.updatedAt = createdAt;
7579
+ const taskRunId = createTaskRunId(taskId, sessionId, createdAt);
7580
+ const baselineFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
6154
7581
  const task: TaskContract = {
7582
+ schemaVersion: 2,
7583
+ taskRunId,
6155
7584
  taskId,
7585
+ sessionId,
7586
+ sessionName,
7587
+ changeMode,
7588
+ attempt,
7589
+ maxAttempts,
7590
+ previousAttempts: priorAttempts.filter((task) => task.trace.outcome !== "pending").slice(0, 10).reverse().map(summarizeAttempt),
6156
7591
  summary: safeSummary,
6157
7592
  riskLane: params.riskLane,
7593
+ intakeMode: params.intakeMode === "runtime" ? "runtime" : "model",
6158
7594
  expectedOutput: redactText(params.expectedOutput),
6159
7595
  acceptanceCriteria: redactTextArray(params.acceptanceCriteria),
6160
7596
  scope: redactTextArray(params.scope),
6161
7597
  outOfScope: redactTextArray(params.outOfScope),
6162
7598
  protectedPaths: profile.protectedPaths ?? [],
6163
7599
  requiredContext: profile.requiredContext ?? [],
6164
- contextManifest: [],
7600
+ contextManifest: seededContext,
6165
7601
  memoryCitations: [],
6166
7602
  mcpCapabilities: profile.mcpCapabilities ?? [],
6167
- verifyCommands: flattenVerifyCommands(profile),
7603
+ verifyGroup: verifyPlan.group,
7604
+ verifyCommands: verifyPlan.commands,
6168
7605
  workPlan,
6169
7606
  reviewLenses,
6170
7607
  orchestration: {
@@ -6174,19 +7611,230 @@ export default function piagentGuard(pi: ExtensionAPI) {
6174
7611
  fieldGuidePath: orchestration.fieldGuide.enabled ? orchestration.fieldGuide.path : undefined,
6175
7612
  modelRoles: orchestration.roleModelGuidance
6176
7613
  },
7614
+ baselineChangedFiles: Object.keys(baselineFileDigests).sort(),
7615
+ baselineFileDigests,
7616
+ observedChangedFiles: [],
7617
+ finalWorkingTreeFiles: [],
7618
+ finalFileDigests: {},
6177
7619
  changedFiles: [],
6178
7620
  verifyEvidence: [],
6179
7621
  trace: { outcome: "pending" },
6180
7622
  createdAt,
6181
7623
  updatedAt: createdAt
6182
7624
  };
6183
- writeTask(ctx.cwd, task);
6184
- appendTrace(ctx.cwd, { taskId, event: "task_start", summary: task.summary, riskLane: params.riskLane });
6185
- appendSessionTrace(pi, { taskId, event: "task_start", summary: task.summary, riskLane: params.riskLane });
7625
+ if (changeMode === "read-only" && seededContext.length > 0) {
7626
+ applyRuntimeLifecycleObservation(task, "context-complete", createdAt);
7627
+ }
7628
+ const written = writeTask(ctx.cwd, task);
7629
+ bindSessionTask(ctx.cwd, sessionId, sessionName, written);
7630
+ cacheTaskIdentity(ctx, written);
7631
+ if (written.intakeMode !== "runtime") {
7632
+ // Intake classification defines the cache-stable surface for this agent turn.
7633
+ // A model may choose a narrower lane than the prompt classifier; never remove
7634
+ // schemas mid-turn, but add recovery tools when the chosen lane requires them.
7635
+ activateToolGroups(ctx, activeTaskToolGroups(written), true);
7636
+ }
7637
+ const lifecycleMode = runtimeLifecycleMode(written);
7638
+ appendTrace(ctx.cwd, { taskId, taskRunId, sessionId, sessionName, attempt, event: "task_start", summary: task.summary, riskLane: params.riskLane, intakeMode: task.intakeMode, changeMode: task.changeMode, lifecycleMode, seededContext: seededContext.map((item) => item.path) });
7639
+ appendSessionTrace(pi, { taskId, taskRunId, sessionId, sessionName, attempt, event: "task_start", summary: task.summary, riskLane: params.riskLane, intakeMode: task.intakeMode, changeMode: task.changeMode, lifecycleMode, seededContext: seededContext.map((item) => item.path) });
6186
7640
 
6187
7641
  return {
6188
- content: [{ type: "text", text: `Task contract created: .pi/piagent-state/tasks/${taskId}.json` }],
6189
- details: task
7642
+ content: [{
7643
+ type: "text",
7644
+ text: [
7645
+ `Task ${taskId} started (${params.riskLane}, ${lifecycleMode}; attempt ${attempt}/${maxAttempts}).`,
7646
+ written.verifyCommands.length > 0
7647
+ ? ["Exact verifier commands:", ...verifierCommandInstructions(written.verifyCommands)].join("\n")
7648
+ : "Verify: none (read-only).",
7649
+ lifecycleMode === "automatic-readonly"
7650
+ ? "Runtime records targeted reads and final completion automatically. Stay read-only and report cited evidence."
7651
+ : lifecycleMode === "assisted-readonly"
7652
+ ? "Runtime records read-only evidence automatically; complete only the explicit evidence-review step before handoff."
7653
+ : lifecycleMode === "automatic"
7654
+ ? "Runtime will record reads, changes, exact verifier results, and final completion automatically. Continue with ordinary read/edit/bash work."
7655
+ : lifecycleMode === "assisted"
7656
+ ? "Runtime records objective evidence automatically; complete only the explicit review step before handoff."
7657
+ : "Use the active progress/recovery tools for the custom or high-risk checkpoints."
7658
+ ].join("\n")
7659
+ }],
7660
+ details: compactTaskDetails(written)
7661
+ };
7662
+ }
7663
+ };
7664
+ pi.registerTool(taskStartTool);
7665
+
7666
+ async function maybeStartAutomaticTask(
7667
+ prompt: string,
7668
+ ctx: ExtensionContext
7669
+ ): Promise<{ started: boolean; text: string; task?: TaskContract } | undefined> {
7670
+ const profile = loadProfileFromContext(ctx);
7671
+ const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
7672
+ if (!automaticTaskIntakeEligible(prompt, readProtectedPaths)) return undefined;
7673
+ const active = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
7674
+ if (active?.trace.outcome === "pending") return undefined;
7675
+
7676
+ const summary = redactText(prompt).replace(/\s+/g, " ").trim().slice(0, 320);
7677
+ const sessionName = currentSessionName(ctx);
7678
+ const scope = automaticTaskScope(prompt, observedContext(ctx));
7679
+ const started = await taskStartTool.execute(
7680
+ `runtime-intake-${ctx.sessionManager.getSessionId()}`,
7681
+ {
7682
+ taskId: hasOperatorSessionName(sessionName) ? sessionName : summary,
7683
+ summary,
7684
+ riskLane: "tiny",
7685
+ intakeMode: "runtime",
7686
+ changeMode: "source-change",
7687
+ expectedOutput: "The requested bounded change is implemented and passes the configured verification.",
7688
+ acceptanceCriteria: [
7689
+ "The requested behavior is implemented without changing unrelated behavior.",
7690
+ "Changes stay within the runtime-derived task scope.",
7691
+ "The configured verification command passes after the final mutation."
7692
+ ],
7693
+ scope,
7694
+ outOfScope: ["Unrelated files and behavior outside the operator request."],
7695
+ reviewLenses: automaticReviewLenses(prompt)
7696
+ },
7697
+ undefined,
7698
+ undefined,
7699
+ ctx
7700
+ );
7701
+ if (started.isError) {
7702
+ activateToolGroups(ctx, ["intake"], true);
7703
+ const reason = started.content?.[0]?.text ?? "runtime intake could not create a task contract";
7704
+ return {
7705
+ started: false,
7706
+ text: `Piagent runtime intake paused: ${reason}\nUse piagent_task_start once with explicit project-relative scope before mutation.`
7707
+ };
7708
+ }
7709
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
7710
+ if (!task) {
7711
+ activateToolGroups(ctx, ["intake"], true);
7712
+ return {
7713
+ started: false,
7714
+ text: "Piagent runtime intake did not persist a task contract. Use piagent_task_start once before mutation."
7715
+ };
7716
+ }
7717
+ return {
7718
+ started: true,
7719
+ task,
7720
+ text: [
7721
+ `Piagent runtime task: ${task.taskId}; scope: ${task.scope.join(", ")}.`,
7722
+ "Exact verifier commands:",
7723
+ ...(task.verifyCommands.length > 0 ? verifierCommandInstructions(task.verifyCommands) : ["none"]),
7724
+ "Root project instructions are loaded. Do not re-read root AGENTS.md or inspect Piagent/platform files; work directly in relevant source/tests with ordinary tools.",
7725
+ "Finish intended edits, then run the exact verifier once; rerun only after a later mutation. Runtime records evidence and completion; do not call task-management tools."
7726
+ ].join("\n")
7727
+ };
7728
+ }
7729
+
7730
+ pi.registerTool({
7731
+ name: "piagent_task_progress",
7732
+ label: "Piagent Task Progress",
7733
+ description: "Advance or fail one dependency-aware work-plan step in the current session task.",
7734
+ promptSnippet: "Record durable task progress as each planned phase starts, completes, skips, or fails.",
7735
+ promptGuidelines: [
7736
+ "Do not mark a step done until its work and evidence are actually complete.",
7737
+ "A failed step requires a concrete note and records where the attempt failed.",
7738
+ "Completing a step automatically starts the next dependency-ready pending step."
7739
+ ],
7740
+ parameters: Type.Object({
7741
+ taskId: Type.String({ minLength: 1 }),
7742
+ stepId: Type.String({ minLength: 1 }),
7743
+ status: StringEnum(["in-progress", "done", "skipped", "failed"] as const),
7744
+ note: Type.Optional(Type.String()),
7745
+ failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
7746
+ ruledOut: Type.Optional(Type.String())
7747
+ }),
7748
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
7749
+ const sessionId = ctx.sessionManager.getSessionId();
7750
+ const task = readTask(ctx.cwd, params.taskId, sessionId);
7751
+ if (!task) {
7752
+ return { content: [{ type: "text", text: `Task not found in this session: ${params.taskId}` }], isError: true };
7753
+ }
7754
+ if (task.trace.outcome !== "pending") {
7755
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; start a fresh session for another attempt.` }], details: task, isError: true };
7756
+ }
7757
+ const stepId = safeTaskId(params.stepId).slice(0, 40);
7758
+ const step = task.workPlan.find((item) => item.id === stepId);
7759
+ if (!step) {
7760
+ return { content: [{ type: "text", text: `Work-plan step not found: ${stepId}` }], details: task.workPlan, isError: true };
7761
+ }
7762
+ const dependencies = step.dependsOn ?? [];
7763
+ const unresolved = dependencies.filter((dependency) => {
7764
+ const required = task.workPlan.find((item) => item.id === dependency);
7765
+ return !required || (required.status !== "done" && required.status !== "skipped");
7766
+ });
7767
+ if ((params.status === "in-progress" || params.status === "done") && unresolved.length > 0) {
7768
+ return {
7769
+ content: [{ type: "text", text: `Step ${stepId} is blocked by unfinished dependencies: ${unresolved.join(", ")}` }],
7770
+ details: { step, unresolved },
7771
+ isError: true
7772
+ };
7773
+ }
7774
+ const note = params.note ? redactText(params.note).slice(0, 500) : undefined;
7775
+ if (params.status === "failed" && !note) {
7776
+ return { content: [{ type: "text", text: `A concrete note is required when step ${stepId} fails.` }], isError: true };
7777
+ }
7778
+ if ((step.status === "done" || step.status === "skipped") && params.status !== step.status) {
7779
+ return { content: [{ type: "text", text: `Work-plan step ${stepId} is already ${step.status} and cannot be reopened in the same attempt.` }], details: step, isError: true };
7780
+ }
7781
+ if (step.status === params.status) {
7782
+ return { content: [{ type: "text", text: `Work-plan step ${stepId} is already ${params.status}; no state change was recorded.` }], details: step, isError: true };
7783
+ }
7784
+ if (step.status === "failed" && params.status === "in-progress" && !note) {
7785
+ return { content: [{ type: "text", text: `A concrete note is required to reopen failed step ${stepId} within this attempt.` }], details: step, isError: true };
7786
+ }
7787
+
7788
+ const recordedAt = nowIso();
7789
+ step.status = params.status;
7790
+ step.note = note;
7791
+ step.updatedAt = recordedAt;
7792
+ if (params.status === "failed") {
7793
+ task.failedAt = params.failedAt ?? (step.mode === "review" ? "review" : step.id === "plan" ? "plan" : "execute");
7794
+ task.failureReason = note;
7795
+ task.ruledOut = params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : undefined;
7796
+ } else if (task.failureReason && task.workPlan.every((item) => item.status !== "failed")) {
7797
+ task.failedAt = undefined;
7798
+ task.failureReason = undefined;
7799
+ task.ruledOut = undefined;
7800
+ }
7801
+
7802
+ let startedStep: WorkPlanStep | undefined;
7803
+ if (params.status === "done" || params.status === "skipped") {
7804
+ startedStep = task.workPlan.find((candidate) => {
7805
+ if (candidate.status !== "pending") return false;
7806
+ return (candidate.dependsOn ?? []).every((dependency) => {
7807
+ const required = task.workPlan.find((item) => item.id === dependency);
7808
+ return required?.status === "done" || required?.status === "skipped";
7809
+ });
7810
+ });
7811
+ if (startedStep) {
7812
+ startedStep.status = "in-progress";
7813
+ startedStep.updatedAt = recordedAt;
7814
+ }
7815
+ }
7816
+
7817
+ const written = writeTask(ctx.cwd, task);
7818
+ const trace = {
7819
+ taskId: written.taskId,
7820
+ taskRunId: written.taskRunId,
7821
+ sessionId,
7822
+ event: "task_progress",
7823
+ stepId,
7824
+ status: params.status,
7825
+ note,
7826
+ startedStep: startedStep?.id,
7827
+ failedAt: written.failedAt,
7828
+ ruledOut: written.ruledOut
7829
+ };
7830
+ appendTrace(ctx.cwd, trace);
7831
+ appendSessionTrace(pi, trace);
7832
+ return {
7833
+ content: [{
7834
+ type: "text",
7835
+ text: `Task ${written.taskId}: ${stepId} -> ${params.status}${startedStep ? `; started ${startedStep.id}` : ""}`
7836
+ }],
7837
+ details: compactTaskDetails(written)
6190
7838
  };
6191
7839
  }
6192
7840
  });
@@ -6346,10 +7994,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
6346
7994
  const profile = loadProfileFromContext(ctx);
6347
7995
  const runtime = resolveRuntimePolicy(profile);
6348
7996
  const budget = contextBudgetConfig(policy);
6349
- const task = readTask(ctx.cwd, params.taskId);
7997
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6350
7998
  if (!task) {
6351
7999
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6352
8000
  }
8001
+ if (task.trace.outcome !== "pending") {
8002
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; context evidence was not changed.` }], details: task, isError: true };
8003
+ }
6353
8004
  if (runtime.contextBudget !== "off" && task.contextManifest.length + params.files.length > budget.maxManifestFiles) {
6354
8005
  return {
6355
8006
  content: [{ type: "text", text: `Context manifest budget exceeded: ${task.contextManifest.length + params.files.length} files > ${budget.maxManifestFiles}` }],
@@ -6375,13 +8026,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
6375
8026
  const key = `${file.path}\u0000${file.reason}`;
6376
8027
  if (!seen.has(key)) task.contextManifest.push(file);
6377
8028
  }
6378
- writeTask(ctx.cwd, task);
6379
- appendTrace(ctx.cwd, { taskId: task.taskId, event: "context_record", files: safeFiles });
6380
- appendSessionTrace(pi, { taskId: task.taskId, event: "context_record", files: safeFiles });
8029
+ const lifecycle = task.changeMode === "read-only"
8030
+ ? applyRuntimeLifecycleObservation(task, "context-complete", nowIso())
8031
+ : { changed: false, mode: runtimeLifecycleMode(task) };
8032
+ const written = writeTask(ctx.cwd, task);
8033
+ appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
8034
+ appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
6381
8035
 
6382
8036
  return {
6383
8037
  content: [{ type: "text", text: `Context recorded for ${task.taskId}: ${params.files.length} file(s)` }],
6384
- details: task
8038
+ details: compactTaskDetails(written)
6385
8039
  };
6386
8040
  }
6387
8041
  });
@@ -6398,13 +8052,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
6398
8052
  summary: Type.String({ minLength: 1 })
6399
8053
  }),
6400
8054
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6401
- const task = readTask(ctx.cwd, params.taskId);
8055
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6402
8056
  if (!task) {
6403
8057
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6404
8058
  }
8059
+ if (task.trace.outcome !== "pending") {
8060
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; verify evidence was not changed.` }], details: task, isError: true };
8061
+ }
6405
8062
 
6406
8063
  const observedEntries = [
6407
- ...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000 }),
8064
+ ...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000, projectRoot: ctx.cwd }),
6408
8065
  ...bashResults.list()
6409
8066
  ];
6410
8067
  const observed = findMatchingObservedBashResult(observedEntries, {
@@ -6424,24 +8081,50 @@ export default function piagentGuard(pi: ExtensionAPI) {
6424
8081
  const safeCommand = redactText(params.command);
6425
8082
  const safeSummary = redactText(params.summary);
6426
8083
  const matchedProfileCommand = commandMatchesVerifyPlan(params.command, task.verifyCommands);
6427
- task.verifyEvidence.push({
6428
- command: safeCommand,
6429
- exitCode: params.exitCode,
6430
- summary: safeSummary,
6431
- recordedAt: nowIso(),
6432
- observed: true,
6433
- observedAt: observed.entry.recordedAt,
6434
- isError: observed.entry.isError,
6435
- matchedProfileCommand
6436
- });
6437
- writeTask(ctx.cwd, task);
6438
- appendTrace(ctx.cwd, { taskId: task.taskId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand });
6439
- appendSessionTrace(pi, { taskId: task.taskId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand });
8084
+ const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
8085
+ const workingTreeDigest = workingTreeEvidenceDigest(currentDigests);
8086
+ const duplicate = task.verifyEvidence.some((evidence) => (
8087
+ evidence.command.trim() === safeCommand.trim()
8088
+ && evidence.exitCode === params.exitCode
8089
+ && evidence.workingTreeDigest === workingTreeDigest
8090
+ ));
8091
+ if (!duplicate) {
8092
+ task.verifyEvidence.push({
8093
+ command: safeCommand,
8094
+ exitCode: params.exitCode,
8095
+ summary: safeSummary,
8096
+ recordedAt: nowIso(),
8097
+ observed: true,
8098
+ observedAt: observed.entry.recordedAt,
8099
+ isError: observed.entry.isError,
8100
+ matchedProfileCommand,
8101
+ workingTreeDigest
8102
+ });
8103
+ task.verifyEvidence = task.verifyEvidence.slice(-100);
8104
+ }
8105
+ const hasChanges = taskChangedFileEvidence(ctx.cwd, task, currentDigests).expected.length > 0;
8106
+ const allPassing = matchedProfileCommand && hasChanges && allVerifyCommandsPassCurrentTree(task, workingTreeDigest);
8107
+ if (matchedProfileCommand && hasChanges) {
8108
+ applyRuntimeLifecycleObservation(task, allPassing ? "verification-complete" : "verification-pending", nowIso());
8109
+ }
8110
+ const written = writeTask(ctx.cwd, task);
8111
+ appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand, workingTreeDigest, duplicate });
8112
+ appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand, workingTreeDigest, duplicate });
6440
8113
 
6441
8114
  const advisorySuffix = matchedProfileCommand ? "" : " Advisory only: command does not exactly match task verifyCommands and will not satisfy the passing final gate.";
6442
8115
  return {
6443
8116
  content: [{ type: "text", text: `Verify evidence recorded for ${task.taskId}: observed exit ${params.exitCode}.${advisorySuffix}` }],
6444
- details: { task, observation: redactForStorage(observed.entry), matchedProfileCommand }
8117
+ details: {
8118
+ task: compactTaskDetails(written),
8119
+ evidence: {
8120
+ command: safeCommand,
8121
+ exitCode: params.exitCode,
8122
+ observed: true,
8123
+ matchedProfileCommand,
8124
+ workingTreeDigest
8125
+ },
8126
+ duplicate
8127
+ }
6445
8128
  };
6446
8129
  }
6447
8130
  });
@@ -6459,10 +8142,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
6459
8142
  }), { minItems: 1 })
6460
8143
  }),
6461
8144
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6462
- const task = readTask(ctx.cwd, params.taskId);
8145
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6463
8146
  if (!task) {
6464
8147
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6465
8148
  }
8149
+ if (task.trace.outcome !== "pending") {
8150
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; memory evidence was not changed.` }], details: task, isError: true };
8151
+ }
6466
8152
 
6467
8153
  const safeFiles = params.files.map((file) => ({
6468
8154
  path: file.path,
@@ -6474,8 +8160,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
6474
8160
  if (!seen.has(key)) task.memoryCitations.push(file);
6475
8161
  }
6476
8162
  writeTask(ctx.cwd, task);
6477
- appendTrace(ctx.cwd, { taskId: task.taskId, event: "memory_citation_record", files: safeFiles });
6478
- appendSessionTrace(pi, { taskId: task.taskId, event: "memory_citation_record", files: safeFiles });
8163
+ appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
8164
+ appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
6479
8165
 
6480
8166
  return {
6481
8167
  content: [{ type: "text", text: `Memory citations recorded for ${task.taskId}: ${params.files.length} file(s)` }],
@@ -6494,19 +8180,44 @@ export default function piagentGuard(pi: ExtensionAPI) {
6494
8180
  outcome: StringEnum(["completed", "blocked", "partial", "failed"] as const),
6495
8181
  changedFiles: Type.Optional(Type.Array(Type.String())),
6496
8182
  friction: Type.Optional(Type.String()),
6497
- notes: Type.Optional(Type.String())
8183
+ notes: Type.Optional(Type.String()),
8184
+ failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
8185
+ ruledOut: Type.Optional(Type.String())
6498
8186
  }),
6499
8187
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6500
8188
  const profile = loadProfileFromContext(ctx);
6501
8189
  const runtime = resolveRuntimePolicy(profile);
6502
- const task = readTask(ctx.cwd, params.taskId);
8190
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6503
8191
  if (!task) {
6504
8192
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6505
8193
  }
8194
+ if (task.trace.outcome !== "pending") {
8195
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; its final trace was not replaced.` }], details: task, isError: true };
8196
+ }
8197
+ if (params.outcome !== "completed" && !params.friction?.trim() && !task.failureReason?.trim()) {
8198
+ return { content: [{ type: "text", text: `Trace ${params.outcome} requires a concrete friction/reason so the next attempt does not repeat the same work.` }], details: task, isError: true };
8199
+ }
8200
+ if (params.outcome === "failed" && !params.failedAt && !task.failedAt) {
8201
+ return { content: [{ type: "text", text: "A failed trace requires failedAt to identify the lifecycle phase." }], details: task, isError: true };
8202
+ }
6506
8203
 
8204
+ const rawChangedFiles = params.changedFiles ?? task.changedFiles;
8205
+ const normalizedChangedFiles = rawChangedFiles.map((file) => normalizeRelative(ctx.cwd, file));
8206
+ if (normalizedChangedFiles.some((file) => !file || file === "." || file === ".." || file.startsWith("../") || file.startsWith(".pi/piagent-state/"))) {
8207
+ return {
8208
+ content: [{ type: "text", text: "Trace refused: changedFiles must be project-relative paths outside .pi/piagent-state/." }],
8209
+ isError: true
8210
+ };
8211
+ }
8212
+ const finalFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
6507
8213
  const nextTask: TaskContract = {
6508
8214
  ...task,
6509
- changedFiles: params.changedFiles ?? task.changedFiles,
8215
+ changedFiles: uniqueStrings(normalizedChangedFiles as string[]).sort(),
8216
+ finalWorkingTreeFiles: Object.keys(finalFileDigests).sort(),
8217
+ finalFileDigests,
8218
+ failedAt: params.outcome === "completed" ? undefined : params.failedAt ?? task.failedAt,
8219
+ failureReason: params.outcome === "completed" ? undefined : params.friction ? redactText(params.friction) : task.failureReason,
8220
+ ruledOut: params.outcome === "completed" ? undefined : params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : task.ruledOut,
6510
8221
  trace: {
6511
8222
  outcome: params.outcome,
6512
8223
  friction: params.friction ? redactText(params.friction) : undefined,
@@ -6514,36 +8225,53 @@ export default function piagentGuard(pi: ExtensionAPI) {
6514
8225
  recordedAt: nowIso()
6515
8226
  }
6516
8227
  };
6517
- const gate = evaluateTaskGate(nextTask, policy);
8228
+ const gate = evaluateTaskGate(ctx.cwd, nextTask, policy, {
8229
+ currentDigests: finalFileDigests,
8230
+ currentWorkingTreeDigest: workingTreeEvidenceDigest(finalFileDigests)
8231
+ });
6518
8232
  if (params.outcome === "completed" && runtime.finalGate === "enforce" && gate.decision === "fail") {
8233
+ const blockedTrace = {
8234
+ taskId: nextTask.taskId,
8235
+ taskRunId: nextTask.taskRunId,
8236
+ sessionId: nextTask.sessionId,
8237
+ event: "completion_gate_blocked",
8238
+ missing: gate.missing,
8239
+ changedFiles: nextTask.changedFiles
8240
+ };
8241
+ appendTrace(ctx.cwd, blockedTrace);
8242
+ appendSessionTrace(pi, blockedTrace);
6519
8243
  return {
6520
- content: [{ type: "text", text: `Final gate blocked completion: missing ${gate.missing.join(", ")}` }],
8244
+ content: [{
8245
+ type: "text",
8246
+ text: [
8247
+ `Final gate blocked completion: missing ${gate.missing.join(", ")}`,
8248
+ ...verifierCommandInstructions(gate.missingVerifyCommands)
8249
+ ].join("\n")
8250
+ }],
6521
8251
  details: { gate, task: nextTask },
6522
8252
  isError: true
6523
8253
  };
6524
8254
  }
6525
8255
 
6526
- writeTask(ctx.cwd, nextTask);
6527
- appendTrace(ctx.cwd, {
6528
- taskId: nextTask.taskId,
6529
- event: "trace_record",
6530
- outcome: params.outcome,
6531
- changedFiles: nextTask.changedFiles,
6532
- friction: nextTask.trace.friction,
6533
- notes: nextTask.trace.notes
6534
- });
6535
- appendSessionTrace(pi, {
8256
+ const written = writeTask(ctx.cwd, nextTask);
8257
+ const trace = {
6536
8258
  taskId: nextTask.taskId,
8259
+ taskRunId: nextTask.taskRunId,
8260
+ sessionId: nextTask.sessionId,
6537
8261
  event: "trace_record",
6538
8262
  outcome: params.outcome,
6539
8263
  changedFiles: nextTask.changedFiles,
6540
8264
  friction: nextTask.trace.friction,
6541
- notes: nextTask.trace.notes
6542
- });
8265
+ notes: nextTask.trace.notes,
8266
+ failedAt: nextTask.failedAt,
8267
+ ruledOut: nextTask.ruledOut
8268
+ };
8269
+ appendTrace(ctx.cwd, trace);
8270
+ appendSessionTrace(pi, trace);
6543
8271
 
6544
8272
  return {
6545
8273
  content: [{ type: "text", text: `Trace recorded for ${nextTask.taskId}: ${params.outcome}${gate.decision === "fail" ? ` (gate warning: missing ${gate.missing.join(", ")})` : ""}` }],
6546
- details: { task: nextTask, gate }
8274
+ details: { task: written, gate }
6547
8275
  };
6548
8276
  }
6549
8277
  });
@@ -7501,12 +9229,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
7501
9229
  }
7502
9230
 
7503
9231
  function compactCurrentSession(ctx: ExtensionContext): void {
7504
- const instructions = semanticCompactionInstructions(ctx.cwd);
9232
+ const sessionId = ctx.sessionManager.getSessionId();
9233
+ const instructions = semanticCompactionInstructions(ctx.cwd, sessionId);
7505
9234
  telemetry(ctx, {
7506
9235
  event: "compaction_requested",
7507
9236
  mode: "semantic",
7508
9237
  instructionTokens: estimateContextTokens(instructions),
7509
- hasTaskContract: Boolean(compactSessionTask(ctx.cwd))
9238
+ hasTaskContract: Boolean(compactSessionTask(ctx.cwd, sessionId))
7510
9239
  });
7511
9240
  ctx.compact({
7512
9241
  customInstructions: instructions