@piagent/platform 1.2.10 → 1.2.11

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 (105) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +27 -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/catalog/capabilities.json +10 -10
  39. package/docs/command-reference-vietnamese.md +10 -6
  40. package/docs/context-engine.md +33 -11
  41. package/docs/distribution-standard.md +11 -8
  42. package/docs/mcp-and-tools.md +13 -7
  43. package/docs/model-options.md +7 -10
  44. package/docs/operator-manual-vietnamese.md +82 -33
  45. package/docs/package-architecture-notes.md +3 -1
  46. package/docs/publishing-for-teams.md +4 -0
  47. package/docs/quality-benchmark.md +225 -58
  48. package/docs/quickstart-vietnamese.md +5 -0
  49. package/docs/release-install-policy.md +41 -7
  50. package/docs/runtime-harness-standard.md +47 -29
  51. package/docs/runtime-policy-design.md +23 -12
  52. package/docs/runtime-quality-baseline.md +31 -17
  53. package/docs/security-threat-model.md +1 -1
  54. package/docs/task-implementation-contract.md +113 -177
  55. package/docs/task-lifecycle-vietnamese.md +129 -125
  56. package/docs/team-onboarding.md +12 -4
  57. package/docs/usage-observability.md +15 -8
  58. package/docs-site/architecture.html +1 -1
  59. package/docs-site/commands.html +1 -1
  60. package/docs-site/content/runtime.html +10 -0
  61. package/docs-site/context-engine.html +1 -1
  62. package/docs-site/index.html +1 -1
  63. package/docs-site/mcp-approval.html +1 -1
  64. package/docs-site/mcp-auth.html +1 -1
  65. package/docs-site/mcp-servers.html +1 -1
  66. package/docs-site/mcp.html +1 -1
  67. package/docs-site/permissions.html +1 -1
  68. package/docs-site/profiles.html +1 -1
  69. package/docs-site/quickstart.html +1 -1
  70. package/docs-site/runtime.html +11 -1
  71. package/docs-site/scope.html +1 -1
  72. package/docs-site/security.html +1 -1
  73. package/docs-site/team.html +1 -1
  74. package/docs-site/workflows.html +1 -1
  75. package/evals/fixtures/task-contract.invalid.json +15 -1
  76. package/evals/fixtures/task-contract.valid.json +18 -2
  77. package/package.json +3 -2
  78. package/packages/piagent-core/README.md +37 -3
  79. package/packages/piagent-core/benchmark/benchmark-core.js +691 -0
  80. package/packages/piagent-core/extensions/context-engine.js +81 -57
  81. package/packages/piagent-core/extensions/guard-types.ts +35 -3
  82. package/packages/piagent-core/extensions/local-state-path.js +59 -0
  83. package/packages/piagent-core/extensions/piagent-guard.ts +1865 -171
  84. package/packages/piagent-core/extensions/policy-core.js +11 -1
  85. package/packages/piagent-core/extensions/runtime-evidence.js +39 -19
  86. package/packages/piagent-core/extensions/state-retention.js +218 -0
  87. package/packages/piagent-core/extensions/task-lifecycle.js +138 -0
  88. package/packages/piagent-core/extensions/task-state.js +630 -0
  89. package/packages/piagent-core/package.json +1 -1
  90. package/packages/piagent-core/policies/base-policy.json +1 -0
  91. package/packages/piagent-core/prompts/be-to-fe.md +14 -22
  92. package/packages/piagent-core/prompts/platform-improve.md +12 -17
  93. package/packages/piagent-core/prompts/review.md +7 -11
  94. package/packages/piagent-core/prompts/scout.md +6 -10
  95. package/packages/piagent-core/prompts/task.md +11 -17
  96. package/packages/piagent-core/skills/piagent-ops/SKILL.md +12 -15
  97. package/schemas/task-contract.schema.json +267 -104
  98. package/scripts/benchmark-runner.mjs +1172 -0
  99. package/scripts/install-global.sh +1 -1
  100. package/scripts/migrate-project-state.mjs +94 -3
  101. package/scripts/piagent-cli.mjs +1 -1
  102. package/scripts/update-global.mjs +36 -3
  103. package/scripts/verify-local.sh +15 -0
  104. package/templates/project/.pi/task-contract.template.json +19 -2
  105. 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,21 +3900,78 @@ 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 evaluateTaskGate(
3963
+ cwd: string,
3964
+ task: TaskContract | undefined,
3965
+ policy: BasePolicy,
3966
+ options: {
3967
+ currentDigests?: Record<string, string>;
3968
+ currentWorkingTreeDigest?: string;
3969
+ } = {}
3970
+ ): {
3406
3971
  decision: "pass" | "fail";
3407
3972
  missing: string[];
3408
3973
  warnings: string[];
3974
+ changedFileEvidence?: ReturnType<typeof taskChangedFileEvidence>;
3409
3975
  } {
3410
3976
  const finalGate = finalGateConfig(policy);
3411
3977
  const missing: string[] = [];
@@ -3413,20 +3979,90 @@ function evaluateTaskGate(task: TaskContract | undefined, policy: BasePolicy): {
3413
3979
  if (!task) {
3414
3980
  return { decision: "fail", missing: ["task contract"], warnings };
3415
3981
  }
3982
+ const currentDigests = options.currentDigests ?? workingTreeSnapshot(cwd) as Record<string, string>;
3983
+ const currentWorkingTreeDigest = options.currentWorkingTreeDigest ?? workingTreeEvidenceDigest(currentDigests);
3984
+ if (task.schemaVersion !== 2 || !task.taskRunId || !task.sessionId) missing.push("session-bound task contract v2");
3985
+ if (task.attempt > task.maxAttempts) missing.push(`attempt within maxAttempts (${task.attempt}/${task.maxAttempts})`);
3986
+ const plannedVerifyCommands = meaningfulVerifyCommands(task.verifyCommands);
3987
+ if (task.changeMode === "source-change" && plannedVerifyCommands.length === 0) missing.push("meaningful verify command");
3416
3988
  if (finalGate.requireContextManifest && task.contextManifest.length === 0) missing.push("context manifest");
3417
- if (finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
3989
+ if (task.changeMode === "source-change" && finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
3418
3990
  if (task.verifyEvidence.some((evidence) => evidence.observed !== true)) {
3419
3991
  warnings.push("Unobserved verify evidence is ignored by the passing verify gate.");
3420
3992
  }
3421
3993
  if (task.verifyEvidence.some((evidence) => evidence.observed === true && evidence.matchedProfileCommand !== true)) {
3422
3994
  warnings.push("Observed verify evidence that does not exactly match task verifyCommands is advisory only.");
3423
3995
  }
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 };
3996
+ if (task.verifyEvidence.some((evidence) => (
3997
+ evidence.observed === true
3998
+ && evidence.matchedProfileCommand === true
3999
+ && evidence.workingTreeDigest !== currentWorkingTreeDigest
4000
+ ))) {
4001
+ warnings.push("Verification evidence from a different working-tree snapshot is stale and is ignored.");
4002
+ }
4003
+ if (task.changeMode === "source-change" && finalGate.requirePassingVerify && plannedVerifyCommands.length > 0) {
4004
+ const passingCommands = new Set(task.verifyEvidence
4005
+ .filter((evidence) => (
4006
+ evidence.exitCode === 0
4007
+ && evidence.observed === true
4008
+ && evidence.matchedProfileCommand === true
4009
+ && evidence.workingTreeDigest === currentWorkingTreeDigest
4010
+ ))
4011
+ .map((evidence) => evidence.command.trim()));
4012
+ const missingCommands = plannedVerifyCommands.filter((command) => !passingCommands.has(command.trim()));
4013
+ if (missingCommands.length > 0) missing.push(`observed passing verify evidence for every command (${missingCommands.join(" | ")})`);
4014
+ }
4015
+ if (finalGate.requireTrace && task.trace.outcome !== "completed") missing.push("completed final trace");
4016
+ const incompleteSteps = (task.workPlan ?? []).filter((step) => step.status === "pending" || step.status === "in-progress" || step.status === "failed");
4017
+ if (task.trace.outcome === "completed" && incompleteSteps.length > 0) {
4018
+ missing.push(`completed work plan (${incompleteSteps.map((step) => `${step.id}:${step.status}`).join(", ")})`);
4019
+ }
4020
+ const changedFileEvidence = taskChangedFileEvidence(cwd, task, currentDigests);
4021
+ if (task.changeMode === "source-change" && task.trace.outcome === "completed") {
4022
+ if (task.changedFiles.length === 0) missing.push("changed files");
4023
+ if (changedFileEvidence.undeclared.length > 0) missing.push(`declared observed changes (${changedFileEvidence.undeclared.join(", ")})`);
4024
+ if (changedFileEvidence.unsupportedClaims.length > 0) missing.push(`supported changed-file claims (${changedFileEvidence.unsupportedClaims.join(", ")})`);
4025
+ if (changedFileEvidence.outsideScope.length > 0) missing.push(`changes within task scope (${changedFileEvidence.outsideScope.join(", ")})`);
4026
+ }
4027
+ if (task.changeMode === "read-only" && changedFileEvidence.expected.length > 0) {
4028
+ missing.push(`read-only task has observed changes (${changedFileEvidence.expected.join(", ")})`);
4029
+ }
4030
+ return { decision: missing.length === 0 ? "pass" : "fail", missing, warnings, changedFileEvidence };
4031
+ }
4032
+
4033
+ function assistantMessageText(message: unknown): string {
4034
+ if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return "";
4035
+ const content = (message as { content?: unknown }).content;
4036
+ if (!Array.isArray(content)) return "";
4037
+ return content
4038
+ .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"))
4039
+ .map((item) => item.text)
4040
+ .join("\n");
4041
+ }
4042
+
4043
+ function assistantMessageHasToolCall(message: unknown): boolean {
4044
+ const content = message && typeof message === "object" ? (message as { content?: unknown }).content : undefined;
4045
+ return Array.isArray(content) && content.some((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "toolCall");
4046
+ }
4047
+
4048
+ function normalizeLanguageSignal(text: string): string {
4049
+ return text
4050
+ .normalize("NFKD")
4051
+ .replace(/\p{M}/gu, "")
4052
+ .toLowerCase()
4053
+ .replaceAll("đ", "d");
4054
+ }
4055
+
4056
+ function looksLikeIncompleteHandoff(text: string): boolean {
4057
+ const normalized = normalizeLanguageSignal(text);
4058
+ 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);
4059
+ }
4060
+
4061
+ function looksLikeCompletionClaim(text: string): boolean {
4062
+ const normalized = normalizeLanguageSignal(text);
4063
+ if (!normalized.trim() || normalized.includes("[piagent completion gate:")) return false;
4064
+ if (looksLikeIncompleteHandoff(text)) return false;
4065
+ 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
4066
  }
3431
4067
 
3432
4068
  function formatCount(value: number | null | undefined): string {
@@ -3620,14 +4256,27 @@ function writeToolResultCapture(
3620
4256
  };
3621
4257
 
3622
4258
  try {
3623
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3624
- fs.writeFileSync(absolutePath, [
4259
+ ensurePrivateStateDirectory(cwd, path.dirname(absolutePath), "Tool-result capture directory");
4260
+ const capturePath = resolveLocalStatePath(cwd, absolutePath, { label: "Tool-result capture" });
4261
+ fs.writeFileSync(capturePath, [
3625
4262
  "# Piagent compacted tool result capture",
3626
4263
  JSON.stringify(metadata),
3627
4264
  "---",
3628
4265
  capture.text
3629
- ].join("\n"));
3630
- fs.appendFileSync(toolResultCaptureIndexPath(cwd), `${JSON.stringify(metadata)}\n`);
4266
+ ].join("\n"), { mode: 0o600 });
4267
+ appendJsonlBounded(toolResultCaptureIndexPath(cwd), metadata, { maxBytes: CAPTURE_INDEX_MAX_BYTES, mode: 0o600, projectRoot: cwd });
4268
+ const writes = (CAPTURE_WRITES_SINCE_PRUNE.get(cwd) ?? 0) + 1;
4269
+ if (writes >= 25) {
4270
+ pruneCaptureFiles(toolResultCaptureRoot(cwd), {
4271
+ maxFiles: CAPTURE_RETENTION_MAX_FILES,
4272
+ maxBytes: CAPTURE_RETENTION_MAX_BYTES,
4273
+ maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
4274
+ projectRoot: cwd
4275
+ });
4276
+ CAPTURE_WRITES_SINCE_PRUNE.set(cwd, 0);
4277
+ } else {
4278
+ CAPTURE_WRITES_SINCE_PRUNE.set(cwd, writes);
4279
+ }
3631
4280
  const summary = {
3632
4281
  path: relativePath,
3633
4282
  source,
@@ -3731,16 +4380,9 @@ function attachToolResultCompactionDetails(details: unknown, captures: ToolResul
3731
4380
 
3732
4381
  function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
3733
4382
  const indexPath = toolResultCaptureIndexPath(cwd);
3734
- if (!fs.existsSync(indexPath)) return [];
3735
- const lines = fs.readFileSync(indexPath, "utf8").trim().split(/\n/).filter(Boolean);
3736
4383
  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
- }
4384
+ for (const parsed of readJsonlTail(indexPath, { limit: Math.max(limit * 3, limit), maxBytes: CAPTURE_INDEX_MAX_BYTES, projectRoot: cwd })) {
4385
+ if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
3744
4386
  }
3745
4387
  return captures.slice(-limit);
3746
4388
  }
@@ -4269,7 +4911,7 @@ function attachLocalImagesFromText(
4269
4911
  function appendTrace(cwd: string, payload: Record<string, unknown>): void {
4270
4912
  ensureStateDirs(cwd);
4271
4913
  const safePayload = redactForStorage(payload) as Record<string, unknown>;
4272
- fs.appendFileSync(traceFilePath(cwd), `${JSON.stringify({ recordedAt: nowIso(), ...safePayload })}\n`);
4914
+ appendJsonlBounded(traceFilePath(cwd), { recordedAt: nowIso(), ...safePayload }, { maxBytes: TRACE_MAX_BYTES, mode: 0o600, projectRoot: cwd });
4273
4915
  }
4274
4916
 
4275
4917
  function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>): void {
@@ -4281,8 +4923,51 @@ function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>):
4281
4923
  });
4282
4924
  }
4283
4925
 
4284
- function flattenVerifyCommands(profile: ProjectProfile): string[] {
4285
- return Object.values(profile.verifyCommands ?? {}).flat();
4926
+ function selectVerifyPlan(
4927
+ profile: ProjectProfile,
4928
+ requestedGroup: string | undefined,
4929
+ changeMode: TaskContract["changeMode"],
4930
+ cwd?: string
4931
+ ): { group?: string; commands: string[]; error?: string } {
4932
+ if (changeMode === "read-only") return { commands: [] };
4933
+ const groups = profile.verifyCommands ?? {};
4934
+ const names = Object.keys(groups);
4935
+ const requested = requestedGroup?.trim();
4936
+ if (requested && !Object.hasOwn(groups, requested)) {
4937
+ return { commands: [], error: `Unknown verify group ${requested}. Available groups: ${names.join(", ") || "none"}.` };
4938
+ }
4939
+ const group = requested
4940
+ ?? (Object.hasOwn(groups, "source") ? "source" : undefined)
4941
+ ?? (Object.hasOwn(groups, "frontendSource") ? "frontendSource" : undefined)
4942
+ ?? names.find((name) => !/docs|runtime/i.test(name));
4943
+ let commands = group ? uniqueStrings(groups[group] ?? []) : [];
4944
+ if (
4945
+ cwd
4946
+ && commands.length === 1
4947
+ && commands[0].includes("No Node source verifier is configured")
4948
+ ) {
4949
+ try {
4950
+ const manifestPath = path.join(cwd, "package.json");
4951
+ if (!fs.lstatSync(manifestPath).isSymbolicLink()) {
4952
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { scripts?: Record<string, unknown> };
4953
+ const scripts = manifest.scripts && typeof manifest.scripts === "object" ? manifest.scripts : {};
4954
+ const selected = ["type-check", "typecheck", "lint", "test"].filter((name) => typeof scripts[name] === "string");
4955
+ if (selected.length > 0) {
4956
+ commands = selected.map((name) => name === "test" ? "npm test" : `npm run ${name}`);
4957
+ }
4958
+ }
4959
+ } catch {
4960
+ // Preserve the profile's fail-closed verifier when package metadata is absent or invalid.
4961
+ }
4962
+ }
4963
+ if (meaningfulVerifyCommands(commands).length === 0) {
4964
+ return {
4965
+ group,
4966
+ commands,
4967
+ error: `Verify group ${group ?? "(none)"} has no meaningful command. Configure .pi/piagent-profile.json before source changes.`
4968
+ };
4969
+ }
4970
+ return { group, commands };
4286
4971
  }
4287
4972
 
4288
4973
  function parseReferenceRepoRef(input: string): { host: string; owner: string; repo: string } {
@@ -4404,7 +5089,7 @@ function checkoutReferenceRepo(repoRef: string, forceUpdate = false): ReferenceR
4404
5089
  function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
4405
5090
  const signal = classifyContextTask(prompt);
4406
5091
  const lower = prompt.toLowerCase();
4407
- const groups = new Set<PiagentToolGroup>(["loader"]);
5092
+ const groups = new Set<PiagentToolGroup>();
4408
5093
 
4409
5094
  if (signal.workflow === "usage") {
4410
5095
  groups.add("usage");
@@ -4419,45 +5104,110 @@ function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
4419
5104
  return [...groups];
4420
5105
  }
4421
5106
 
4422
- groups.add("governance");
4423
- groups.add("policy");
5107
+ groups.add("intake");
5108
+ // Keep the common task surface stable across the first tool call so provider
5109
+ // prompt caches do not lose the full system prefix after task_start.
5110
+ if (signal.lane !== "tiny") groups.add("task");
4424
5111
  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)
5112
+ /\b(context (?:engine|index|search|diagnostic)|source checkout|vendor checkout)\b/.test(lower)
4428
5113
  ) {
4429
5114
  groups.add("retrieval");
4430
5115
  }
4431
5116
  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)
5117
+ /\b(document intake|project memory|orchestration policy|source checkout|subagent policy|vendor checkout)\b/.test(lower)
4435
5118
  ) {
4436
5119
  groups.add("knowledge");
4437
5120
  }
5121
+ if (/\b(permission|capability|exec policy|tool policy)\b/.test(lower)) groups.add("policy");
4438
5122
  if (/\b(profile|tech stack|onboard|project context)\b/.test(lower)) groups.add("onboarding");
4439
5123
  if (/\b(token|usage|cost)\b/.test(lower)) groups.add("usage");
5124
+ if (/\b(?:piagent tool loader|load piagent tools?|piagent tool groups?)\b/.test(lower)) groups.add("loader");
4440
5125
  return [...groups];
4441
5126
  }
4442
5127
 
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;
5128
+ const AUTO_INTAKE_MAX_PROMPT_CHARS = 700;
5129
+ const AUTO_INTAKE_SNAPSHOT_PATTERNS = [
5130
+ "src/**", "app/**", "lib/**", "packages/**", "test/**", "tests/**", "spec/**", "__tests__/**"
5131
+ ];
5132
+ 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;
5133
+ 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;
5134
+ 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;
5135
+
5136
+ function automaticTaskIntakeEligible(prompt: string, readProtectedPaths: string[]): boolean {
5137
+ const text = String(prompt ?? "").trim();
5138
+ if (!text || text.length > AUTO_INTAKE_MAX_PROMPT_CHARS) return false;
5139
+ const signal = classifyContextTask(text);
5140
+ if (signal.workflow !== "task" || !AUTO_INTAKE_CHANGE_INTENT.test(text)) return false;
5141
+ if (AUTO_INTAKE_READ_ONLY_LEAD.test(text) || AUTO_INTAKE_MANUAL_RISK.test(text)) return false;
5142
+ if (/\bpiagent_task_start\b/i.test(text)) return false;
5143
+ return !signal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5144
+ }
5145
+
5146
+ function automaticTaskScope(prompt: string, context: Array<{ path: string }>): string[] {
5147
+ const signal = classifyContextTask(prompt);
5148
+ const explicit = signal.paths.filter((candidate) => candidate && candidate !== "." && !candidate.startsWith(".pi/"));
5149
+ const navigated = context
5150
+ .map((item) => normalizePathCandidate(item.path))
5151
+ .filter((candidate): candidate is string => Boolean(
5152
+ candidate
5153
+ && candidate !== "."
5154
+ && !candidate.startsWith(".pi/")
5155
+ && !["AGENTS.md", "README.md", "REVIEW_GUIDELINES.md"].includes(candidate)
5156
+ && /^(?:app|lib|packages|spec|src|test|tests|__tests__)\//.test(candidate)
5157
+ ))
5158
+ .slice(0, 8);
5159
+ const scope = uniqueStrings([...explicit, ...navigated]);
5160
+ if (scope.length === 0) scope.push("src/**", "app/**", "lib/**");
5161
+ if (/\b(?:test|tests|verification|verify|kiem thu)\b/i.test(prompt)) {
5162
+ scope.push("test/**", "tests/**", "spec/**", "__tests__/**");
5163
+ }
5164
+ return uniqueStrings(scope);
5165
+ }
5166
+
5167
+ function automaticReviewLenses(prompt: string): ReviewLens[] {
5168
+ const lenses: ReviewLens[] = ["correctness", "tests", "scope"];
5169
+ if (/\b(?:auth|authorization|credential|permission|security|session|secret|xac thuc|phan quyen|bao mat)\b/i.test(prompt)) {
5170
+ lenses.push("security");
5171
+ }
5172
+ return lenses;
5173
+ }
5174
+
5175
+ function validTaskScopePattern(value: string): boolean {
5176
+ const candidate = String(value ?? "").trim().replaceAll("\\", "/");
5177
+ if (!candidate || /\s|\0/.test(candidate)) return false;
5178
+ if (candidate.startsWith("/") || candidate.startsWith("~/") || /^[A-Za-z]:\//.test(candidate)) return false;
5179
+ return !candidate.split("/").includes("..");
5180
+ }
5181
+
5182
+ function activeTaskToolGroups(task: TaskContract): PiagentToolGroup[] {
5183
+ const mode = runtimeLifecycleMode(task);
5184
+ if (mode.startsWith("automatic")) return [];
5185
+ if (mode.startsWith("assisted")) return ["task"];
5186
+ return ["task", "recovery"];
5187
+ }
5188
+
5189
+ function sessionTaskReference(ctx: ExtensionContext): { taskId?: string; taskRunId?: string } | undefined {
5190
+ const entries = ctx.sessionManager.getBranch();
5191
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
5192
+ const entry = entries[index] as unknown as { type?: string; customType?: string; data?: Record<string, unknown> };
5193
+ if (entry.type !== "custom" || entry.customType !== PIAGENT_TRACE_STATE_TYPE || !entry.data) continue;
5194
+ const taskId = typeof entry.data.taskId === "string" ? entry.data.taskId : undefined;
5195
+ const taskRunId = typeof entry.data.taskRunId === "string" ? entry.data.taskRunId : undefined;
5196
+ if (taskId || taskRunId) return { taskId, taskRunId };
5197
+ }
5198
+ return undefined;
5199
+ }
5200
+
5201
+ function compactSessionTask(cwd: string, sessionId: string): TaskContract | undefined {
5202
+ return activeSessionTask(cwd, sessionId) as TaskContract | undefined;
4454
5203
  }
4455
5204
 
4456
- function semanticCompactionInstructions(cwd: string): string {
4457
- const task = compactSessionTask(cwd);
5205
+ function semanticCompactionInstructions(cwd: string, sessionId: string): string {
5206
+ const task = compactSessionTask(cwd, sessionId);
4458
5207
  const taskState = task
4459
5208
  ? [
4460
5209
  `Current task: ${task.taskId} (${task.riskLane})`,
5210
+ `Session: ${task.sessionName ?? task.sessionId}`,
4461
5211
  `Goal: ${task.summary}`,
4462
5212
  `Acceptance: ${task.acceptanceCriteria.join("; ") || "not recorded"}`,
4463
5213
  `Scope: ${task.scope.join(", ") || "not recorded"}`,
@@ -4490,6 +5240,48 @@ function environmentFeatureEnabled(name: string, fallback = true): boolean {
4490
5240
  return !["0", "false", "no", "off", "disabled"].includes(value);
4491
5241
  }
4492
5242
 
5243
+ function rewriteLegacyProjectInstructions(systemPrompt: string): {
5244
+ systemPrompt: string;
5245
+ rewritten: boolean;
5246
+ } {
5247
+ const start = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_START);
5248
+ if (start < 0) return { systemPrompt, rewritten: false };
5249
+ const endStart = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_END, start);
5250
+ if (endStart < 0) return { systemPrompt, rewritten: false };
5251
+ const end = endStart + LEGACY_PROJECT_INSTRUCTIONS_END.length;
5252
+ return {
5253
+ systemPrompt: `${systemPrompt.slice(0, start)}${RUNTIME_MANAGED_PROJECT_INSTRUCTIONS}${systemPrompt.slice(end)}`,
5254
+ rewritten: true
5255
+ };
5256
+ }
5257
+
5258
+ function compactManagedProjectInstructions(
5259
+ systemPrompt: string,
5260
+ mode: "automatic" | "protected"
5261
+ ): { systemPrompt: string; compacted: boolean } {
5262
+ const concise = mode === "protected"
5263
+ ? "Piagent protected-path policy: do not read, disclose, or mutate protected content. Refuse without tool calls."
5264
+ : "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.";
5265
+ const markerStart = "<!-- piagent-managed:start -->";
5266
+ const markerEnd = "<!-- piagent-managed:end -->";
5267
+ const start = systemPrompt.indexOf(markerStart);
5268
+ const endStart = start >= 0 ? systemPrompt.indexOf(markerEnd, start) : -1;
5269
+ if (start >= 0 && endStart >= 0) {
5270
+ const end = endStart + markerEnd.length;
5271
+ return {
5272
+ systemPrompt: `${systemPrompt.slice(0, start)}${markerStart}\n${concise}\n${markerEnd}${systemPrompt.slice(end)}`,
5273
+ compacted: true
5274
+ };
5275
+ }
5276
+ if (systemPrompt.includes(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS)) {
5277
+ return {
5278
+ systemPrompt: systemPrompt.replace(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS, concise),
5279
+ compacted: true
5280
+ };
5281
+ }
5282
+ return { systemPrompt, compacted: false };
5283
+ }
5284
+
4493
5285
  export default function piagentGuard(pi: ExtensionAPI) {
4494
5286
  const extensionDir = path.dirname(fileURLToPath(import.meta.url));
4495
5287
  const policy = loadPolicy(extensionDir);
@@ -4497,22 +5289,126 @@ export default function piagentGuard(pi: ExtensionAPI) {
4497
5289
  const dynamicToolsEnabled = environmentFeatureEnabled("PIAGENT_DYNAMIC_TOOLS");
4498
5290
  const contextTelemetryEnabled = environmentFeatureEnabled("PIAGENT_CONTEXT_TELEMETRY");
4499
5291
  const autoContextEnabled = environmentFeatureEnabled("PIAGENT_AUTO_CONTEXT");
5292
+ const autoRecoveryEnabled = environmentFeatureEnabled("PIAGENT_AUTO_RECOVERY");
4500
5293
  // Advisory tool-registry verdicts are surfaced once per tool per session. A
4501
5294
  // notice on every call would be constant noise for a tool used all day, and
4502
5295
  // noise that is always present is read as background rather than as a warning.
4503
5296
  const advisedTools = new Set<string>();
4504
5297
  const seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
4505
5298
  const autoPackedPrompts = new Set<string>();
5299
+ const injectedContextPacks = new Map<string, { queryHash: string; confidence: string; estimatedTokens: number; paths: string[] }>();
5300
+ const taskIdentityBySession = new Map<string, { taskId: string; taskRunId: string }>();
5301
+ const observedContextBySession = new Map<string, Map<string, ObservedTaskContext>>();
5302
+ const completionRecoveryAttempts = new Map<string, number>();
5303
+ const shellMutationSnapshots = new Map<string, Record<string, string>>();
5304
+
5305
+ function sessionCacheKey(ctx: ExtensionContext): string {
5306
+ return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}`;
5307
+ }
5308
+
5309
+ function cacheTaskIdentity(ctx: ExtensionContext, task: TaskContract | undefined): void {
5310
+ const key = sessionCacheKey(ctx);
5311
+ if (task) taskIdentityBySession.set(key, { taskId: task.taskId, taskRunId: task.taskRunId });
5312
+ else taskIdentityBySession.delete(key);
5313
+ if (taskIdentityBySession.size > 100) taskIdentityBySession.delete(taskIdentityBySession.keys().next().value as string);
5314
+ }
5315
+
5316
+ function rememberObservedContext(ctx: ExtensionContext, entry: ObservedTaskContext): void {
5317
+ const key = sessionCacheKey(ctx);
5318
+ let observed = observedContextBySession.get(key);
5319
+ if (!observed) {
5320
+ observed = new Map();
5321
+ observedContextBySession.set(key, observed);
5322
+ }
5323
+ if (!observed.has(entry.path)) observed.set(entry.path, entry);
5324
+ while (observed.size > contextBudgetConfig(policy).maxManifestFiles) {
5325
+ observed.delete(observed.keys().next().value as string);
5326
+ }
5327
+ if (observedContextBySession.size > 100) observedContextBySession.delete(observedContextBySession.keys().next().value as string);
5328
+ }
5329
+
5330
+ function observedContext(ctx: ExtensionContext): ObservedTaskContext[] {
5331
+ return [...(observedContextBySession.get(sessionCacheKey(ctx))?.values() ?? [])];
5332
+ }
5333
+
5334
+ function clearObservedContext(ctx: ExtensionContext): void {
5335
+ observedContextBySession.delete(sessionCacheKey(ctx));
5336
+ }
5337
+
5338
+ function shellMutationSnapshotKey(
5339
+ ctx: ExtensionContext,
5340
+ toolName: string,
5341
+ input: unknown
5342
+ ): string {
5343
+ return `${sessionCacheKey(ctx)}\u0000${toolResultFingerprint(toolName, input, []).key}`;
5344
+ }
5345
+
5346
+ function rememberShellMutationSnapshot(
5347
+ ctx: ExtensionContext,
5348
+ toolName: string,
5349
+ input: unknown
5350
+ ): void {
5351
+ shellMutationSnapshots.set(
5352
+ shellMutationSnapshotKey(ctx, toolName, input),
5353
+ workingTreeSnapshot(ctx.cwd) as Record<string, string>
5354
+ );
5355
+ if (shellMutationSnapshots.size > 100) {
5356
+ shellMutationSnapshots.delete(shellMutationSnapshots.keys().next().value as string);
5357
+ }
5358
+ }
5359
+
5360
+ function consumeShellMutationSnapshot(
5361
+ ctx: ExtensionContext,
5362
+ toolName: string,
5363
+ input: unknown
5364
+ ): Record<string, string> | undefined {
5365
+ const key = shellMutationSnapshotKey(ctx, toolName, input);
5366
+ const snapshot = shellMutationSnapshots.get(key);
5367
+ shellMutationSnapshots.delete(key);
5368
+ return snapshot;
5369
+ }
5370
+
5371
+ function clearShellMutationSnapshots(ctx: ExtensionContext): void {
5372
+ const prefix = `${sessionCacheKey(ctx)}\u0000`;
5373
+ for (const key of shellMutationSnapshots.keys()) {
5374
+ if (key.startsWith(prefix)) shellMutationSnapshots.delete(key);
5375
+ }
5376
+ }
5377
+
5378
+ function retrievalKey(ctx: ExtensionContext, query: string): string {
5379
+ const signal = classifyContextTask(query);
5380
+ return crypto.createHash("sha256")
5381
+ .update(JSON.stringify({
5382
+ cwd: ctx.cwd,
5383
+ sessionId: ctx.sessionManager.getSessionId(),
5384
+ terms: [...signal.terms].sort(),
5385
+ paths: [...signal.paths].sort()
5386
+ }))
5387
+ .digest("hex");
5388
+ }
5389
+
5390
+ function promptPackKey(ctx: ExtensionContext, promptHash: string): string {
5391
+ return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}\u0000${promptHash}`;
5392
+ }
4506
5393
 
4507
5394
  function telemetry(ctx: ExtensionContext, payload: Record<string, unknown>): void {
4508
5395
  if (!contextTelemetryEnabled) return;
4509
5396
  try {
4510
5397
  const safePayload = redactForStorage(payload) as Record<string, unknown>;
5398
+ let taskIdentity = taskIdentityBySession.get(sessionCacheKey(ctx));
5399
+ if (!taskIdentity) {
5400
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5401
+ if (task) {
5402
+ cacheTaskIdentity(ctx, task);
5403
+ taskIdentity = { taskId: task.taskId, taskRunId: task.taskRunId };
5404
+ }
5405
+ }
4511
5406
  appendContextTelemetry(ctx.cwd, {
4512
5407
  sessionId: ctx.sessionManager.getSessionId(),
4513
5408
  sessionName: currentSessionName(ctx),
4514
5409
  model: `${ctx.model?.provider ?? "unknown"}/${ctx.model?.id ?? "unknown"}`,
4515
5410
  thinkingLevel: String(pi.getThinkingLevel()),
5411
+ ...taskIdentity,
4516
5412
  ...safePayload
4517
5413
  });
4518
5414
  } catch {
@@ -4531,7 +5427,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4531
5427
  if (!PIAGENT_TOOL_NAMES.has(toolName)) selected.add(toolName);
4532
5428
  }
4533
5429
  }
4534
- const normalizedGroups = [...new Set<PiagentToolGroup>(["loader", ...groups])];
5430
+ const normalizedGroups = [...new Set<PiagentToolGroup>(groups)];
4535
5431
  for (const toolName of PIAGENT_TOOL_ORDER) {
4536
5432
  if (normalizedGroups.some((group) => PIAGENT_TOOL_GROUPS[group].includes(toolName as never))) {
4537
5433
  selected.add(toolName);
@@ -4564,6 +5460,42 @@ export default function piagentGuard(pi: ExtensionAPI) {
4564
5460
  if (!hasOperatorSessionName(operatorSessionName)) {
4565
5461
  pi.setSessionName(`pi:${name}`);
4566
5462
  }
5463
+ const sessionId = ctx.sessionManager.getSessionId();
5464
+ const sessionName = currentSessionName(ctx);
5465
+ const taskReference = sessionTaskReference(ctx);
5466
+ let captureRetention: Record<string, unknown>;
5467
+ try {
5468
+ captureRetention = pruneCaptureFiles(toolResultCaptureRoot(ctx.cwd), {
5469
+ maxFiles: CAPTURE_RETENTION_MAX_FILES,
5470
+ maxBytes: CAPTURE_RETENTION_MAX_BYTES,
5471
+ maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
5472
+ projectRoot: ctx.cwd
5473
+ });
5474
+ } catch (error) {
5475
+ const message = error instanceof Error ? error.message : String(error);
5476
+ captureRetention = { removed: 0, kept: 0, bytes: 0, error: message };
5477
+ ctx.ui.notify(`Piagent local state needs recovery: ${message}`, "warning");
5478
+ }
5479
+ const migration = migrateTaskState(ctx.cwd, {
5480
+ sessionId,
5481
+ sessionName,
5482
+ taskId: taskReference?.taskId
5483
+ });
5484
+ if (migration.warnings.length > 0) {
5485
+ ctx.ui.notify(`Piagent task-state migration needs recovery: ${migration.warnings.join("; ")}`, "warning");
5486
+ }
5487
+ let resumedTask = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
5488
+ if (!resumedTask && taskReference) {
5489
+ resumedTask = resolveTaskContract(ctx.cwd, taskReference.taskRunId ?? taskReference.taskId, sessionId) as TaskContract | undefined;
5490
+ if (resumedTask) bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
5491
+ }
5492
+ if (resumedTask && resumedTask.sessionName !== sessionName) {
5493
+ resumedTask.sessionName = sessionName;
5494
+ resumedTask = writeTask(ctx.cwd, resumedTask);
5495
+ bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
5496
+ }
5497
+ cacheTaskIdentity(ctx, resumedTask);
5498
+ if (resumedTask?.trace.outcome === "pending") activateToolGroups(ctx, activeTaskToolGroups(resumedTask));
4567
5499
  const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
4568
5500
  ? ""
4569
5501
  : " (run /onboard to select a profile)";
@@ -4604,7 +5536,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
4604
5536
  } catch (error) {
4605
5537
  engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
4606
5538
  }
4607
- telemetry(ctx, { event: "session_start", activeTools: pi.getActiveTools().length, index: engineStatus });
5539
+ telemetry(ctx, {
5540
+ event: "session_start",
5541
+ activeTools: pi.getActiveTools().length,
5542
+ index: engineStatus,
5543
+ taskMigration: migration,
5544
+ taskId: resumedTask?.taskId,
5545
+ taskRunId: resumedTask?.taskRunId,
5546
+ captureRetention
5547
+ });
5548
+ });
5549
+
5550
+ pi.on("session_info_changed", async (event, ctx) => {
5551
+ const sessionId = ctx.sessionManager.getSessionId();
5552
+ const task = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
5553
+ if (!task) return;
5554
+ const sessionName = String(event.name ?? "").trim() || undefined;
5555
+ task.sessionName = sessionName;
5556
+ const written = writeTask(ctx.cwd, task);
5557
+ bindSessionTask(ctx.cwd, sessionId, sessionName, written);
5558
+ appendTrace(ctx.cwd, {
5559
+ event: "task_session_renamed",
5560
+ taskId: written.taskId,
5561
+ taskRunId: written.taskRunId,
5562
+ sessionId,
5563
+ sessionName
5564
+ });
4608
5565
  });
4609
5566
 
4610
5567
  pi.on("input", async (event, ctx) => {
@@ -4614,7 +5571,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
4614
5571
  return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
4615
5572
  }
4616
5573
  const taskSignal = classifyContextTask(text);
4617
- activateToolGroups(ctx, toolGroupsForPrompt(text));
5574
+ const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5575
+ const readProtectedPaths = effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths;
5576
+ const protectedTarget = taskSignal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5577
+ const runtimeIntake = !activeTask && automaticTaskIntakeEligible(text, readProtectedPaths);
5578
+ const promptGroups = protectedTarget
5579
+ ? []
5580
+ : toolGroupsForPrompt(text).filter((group) => (
5581
+ runtimeIntake ? group !== "intake" && group !== "task" : true
5582
+ ));
5583
+ activateToolGroups(ctx, activeTask?.trace.outcome === "pending"
5584
+ ? [...promptGroups.filter((group) => group !== "intake"), ...activeTaskToolGroups(activeTask)]
5585
+ : promptGroups);
4618
5586
  telemetry(ctx, {
4619
5587
  event: "user_input",
4620
5588
  source: event.source,
@@ -4622,6 +5590,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
4622
5590
  promptChars: taskSignal.promptChars,
4623
5591
  workflow: taskSignal.workflow,
4624
5592
  riskLane: taskSignal.lane,
5593
+ intakeMode: runtimeIntake ? "runtime" : "model",
5594
+ protectedTarget,
4625
5595
  explicitPathCount: taskSignal.paths.length,
4626
5596
  termCount: taskSignal.terms.length
4627
5597
  });
@@ -4685,6 +5655,26 @@ export default function piagentGuard(pi: ExtensionAPI) {
4685
5655
  });
4686
5656
 
4687
5657
  pi.on("before_agent_start", async (event, ctx) => {
5658
+ const projectInstructions = rewriteLegacyProjectInstructions(event.systemPrompt);
5659
+ const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
5660
+ const signal = classifyContextTask(query);
5661
+ const profile = loadProfileFromContext(ctx);
5662
+ const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
5663
+ const protectedTarget = signal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
5664
+ const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5665
+ const runtimeIntake = !activeTask && automaticTaskIntakeEligible(query, readProtectedPaths);
5666
+ const compactMode = protectedTarget
5667
+ ? "protected"
5668
+ : runtimeIntake || activeTask?.intakeMode === "runtime"
5669
+ ? "automatic"
5670
+ : undefined;
5671
+ const compactedInstructions = compactMode
5672
+ ? compactManagedProjectInstructions(projectInstructions.systemPrompt, compactMode)
5673
+ : { systemPrompt: projectInstructions.systemPrompt, compacted: false };
5674
+ const effectiveSystemPrompt = compactedInstructions.systemPrompt;
5675
+ const systemPromptUpdate = effectiveSystemPrompt !== event.systemPrompt
5676
+ ? { systemPrompt: effectiveSystemPrompt }
5677
+ : undefined;
4688
5678
  const active = new Set<string>(pi.getActiveTools() as string[]);
4689
5679
  const toolMetadata = pi.getAllTools()
4690
5680
  .filter((tool) => active.has(tool.name))
@@ -4695,9 +5685,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
4695
5685
  promptGuidelines: tool.promptGuidelines
4696
5686
  }));
4697
5687
  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);
5688
+ const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
5689
+ const autoPackUseful = activeTask?.trace.outcome !== "pending"
5690
+ && (runtimeIntake || signal.paths.length === 0);
4701
5691
  telemetry(ctx, {
4702
5692
  event: "agent_prompt",
4703
5693
  promptHash: signal.promptHash,
@@ -4708,21 +5698,54 @@ export default function piagentGuard(pi: ExtensionAPI) {
4708
5698
  activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
4709
5699
  toolSchemaTokens,
4710
5700
  systemPromptTokens,
4711
- systemPromptHash: crypto.createHash("sha256").update(event.systemPrompt).digest("hex"),
5701
+ systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
5702
+ legacyProjectInstructionsRewritten: projectInstructions.rewritten,
5703
+ managedInstructionsCompacted: compactedInstructions.compacted,
4712
5704
  contextUsage: ctx.getContextUsage()
4713
5705
  });
4714
5706
 
5707
+ const finishAgentStart = async (contextMessage?: {
5708
+ customType: string;
5709
+ content: string;
5710
+ details: Record<string, unknown>;
5711
+ }) => {
5712
+ const intake = await maybeStartAutomaticTask(query, ctx);
5713
+ if (!contextMessage && !intake) return systemPromptUpdate;
5714
+ const content = [contextMessage?.content, intake?.text].filter(Boolean).join("\n\n");
5715
+ return {
5716
+ ...(systemPromptUpdate ?? {}),
5717
+ message: {
5718
+ customType: contextMessage?.customType ?? "piagent-runtime-task-intake",
5719
+ content,
5720
+ display: false,
5721
+ details: {
5722
+ ...(contextMessage?.details ?? {}),
5723
+ runtimeTask: intake?.task
5724
+ ? {
5725
+ taskId: intake.task.taskId,
5726
+ taskRunId: intake.task.taskRunId,
5727
+ scope: intake.task.scope,
5728
+ verifyCommands: intake.task.verifyCommands,
5729
+ intakeMode: intake.task.intakeMode
5730
+ }
5731
+ : undefined,
5732
+ runtimeIntakeStarted: intake?.started ?? false
5733
+ }
5734
+ }
5735
+ };
5736
+ };
5737
+
4715
5738
  if (
4716
5739
  query.length < 20
4717
5740
  || signal.workflow === "usage"
4718
5741
  || !autoContextEnabled
4719
- || autoPackedPrompts.has(signal.promptHash)
4720
- || !active.has("piagent_context_engine")
5742
+ || !autoPackUseful
5743
+ || autoPackedPrompts.has(promptPackKey(ctx, signal.promptHash))
4721
5744
  ) {
4722
- return;
5745
+ return finishAgentStart();
4723
5746
  }
4724
5747
 
4725
- autoPackedPrompts.add(signal.promptHash);
5748
+ autoPackedPrompts.add(promptPackKey(ctx, signal.promptHash));
4726
5749
  if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
4727
5750
  try {
4728
5751
  const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
@@ -4740,13 +5763,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
4740
5763
  selected: 0,
4741
5764
  skipped: status.exists ? "stale-index" : "missing-index"
4742
5765
  });
4743
- return;
5766
+ return finishAgentStart();
4744
5767
  }
4745
- const budgetTokens = signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
5768
+ const budgetTokens = runtimeIntake
5769
+ ? 900
5770
+ : signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
4746
5771
  const pack = await buildContextPack(ctx.cwd, query, {
4747
5772
  budgetTokens,
4748
- includeCode: false,
4749
- limit: 12,
5773
+ includeCode: runtimeIntake,
5774
+ includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
5775
+ currentSnapshot: runtimeIntake,
5776
+ limit: runtimeIntake ? 8 : 12,
4750
5777
  excludePatterns
4751
5778
  });
4752
5779
  telemetry(ctx, {
@@ -4757,23 +5784,35 @@ export default function piagentGuard(pi: ExtensionAPI) {
4757
5784
  selected: pack.selected.length,
4758
5785
  estimatedTokens: pack.estimatedTokens,
4759
5786
  selectedPaths: pack.selected.map((item) => item.path),
4760
- finderRecommended: pack.finderRecommended
5787
+ finderRecommended: pack.finderRecommended,
5788
+ currentSnapshot: runtimeIntake
4761
5789
  });
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
- }
5790
+ if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return finishAgentStart();
5791
+ injectedContextPacks.set(retrievalKey(ctx, query), {
5792
+ queryHash: pack.queryHash,
5793
+ confidence: pack.confidence,
5794
+ estimatedTokens: pack.estimatedTokens,
5795
+ paths: pack.selected.map((item) => item.path)
5796
+ });
5797
+ for (const selected of pack.selected) {
5798
+ rememberObservedContext(ctx, {
5799
+ path: selected.path,
5800
+ reason: "Runtime injected a bounded Context Engine navigation pack."
5801
+ });
5802
+ }
5803
+ if (injectedContextPacks.size > 50) injectedContextPacks.delete(injectedContextPacks.keys().next().value as string);
5804
+ return finishAgentStart({
5805
+ customType: "piagent-context-pack-v2",
5806
+ content: pack.text,
5807
+ details: {
5808
+ schemaVersion: 2,
5809
+ queryHash: pack.queryHash,
5810
+ confidence: pack.confidence,
5811
+ estimatedTokens: pack.estimatedTokens,
5812
+ paths: pack.selected.map((item) => item.path),
5813
+ currentSnapshot: runtimeIntake
4775
5814
  }
4776
- };
5815
+ });
4777
5816
  } catch (error) {
4778
5817
  telemetry(ctx, {
4779
5818
  event: "context_pack",
@@ -4783,6 +5822,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4783
5822
  selected: 0,
4784
5823
  error: error instanceof Error ? error.message : String(error)
4785
5824
  });
5825
+ return finishAgentStart();
4786
5826
  }
4787
5827
  });
4788
5828
 
@@ -4799,6 +5839,158 @@ export default function piagentGuard(pi: ExtensionAPI) {
4799
5839
  });
4800
5840
  });
4801
5841
 
5842
+ pi.on("message_end", async (event, ctx) => {
5843
+ if (!event.message || event.message.role !== "assistant" || assistantMessageHasToolCall(event.message)) return;
5844
+ const budget = contextBudgetConfig(policy);
5845
+ let task = flushObservedTaskContext(
5846
+ pi,
5847
+ ctx,
5848
+ observedContext(ctx),
5849
+ budget.maxManifestFiles,
5850
+ "context_observed_before_handoff"
5851
+ ) ?? activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5852
+ if (!task) return;
5853
+ const text = assistantMessageText(event.message);
5854
+ const completionClaim = looksLikeCompletionClaim(text);
5855
+ const incompleteHandoff = looksLikeIncompleteHandoff(text);
5856
+ if (task.trace.outcome !== "pending") return;
5857
+ const readOnlyEvidenceObserved = task.changeMode === "read-only" && task.contextManifest.length > 0;
5858
+ const potentiallyFinalEvidence = task.observedChangedFiles.length > 0
5859
+ || task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)
5860
+ || readOnlyEvidenceObserved;
5861
+ if (!completionClaim && (incompleteHandoff || !potentiallyFinalEvidence)) return;
5862
+ const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
5863
+ const currentDigest = workingTreeEvidenceDigest(currentDigests);
5864
+ const currentPassingVerifierObserved = task.verifyEvidence.some((evidence) => (
5865
+ evidence.exitCode === 0
5866
+ && evidence.observed === true
5867
+ && evidence.matchedProfileCommand === true
5868
+ && evidence.workingTreeDigest === currentDigest
5869
+ ));
5870
+ const handoffAttempt = completionClaim || (
5871
+ !incompleteHandoff
5872
+ && (task.observedChangedFiles.length > 0 || currentPassingVerifierObserved || readOnlyEvidenceObserved)
5873
+ );
5874
+ let completionGate: ReturnType<typeof evaluateTaskGate> | undefined;
5875
+ if (task.trace.outcome === "pending" && handoffAttempt) {
5876
+ const projected = completionTaskProjection(ctx.cwd, task, currentDigests);
5877
+ const projectedGate = evaluateTaskGate(ctx.cwd, projected, policy, {
5878
+ currentDigests,
5879
+ currentWorkingTreeDigest: currentDigest
5880
+ });
5881
+ completionGate = projectedGate;
5882
+ if (projectedGate.decision === "pass") {
5883
+ task = writeTask(ctx.cwd, projected);
5884
+ cacheTaskIdentity(ctx, task);
5885
+ completionRecoveryAttempts.delete(task.taskRunId);
5886
+ clearObservedContext(ctx);
5887
+ activateToolGroups(ctx, []);
5888
+ const trace = {
5889
+ event: "task_auto_completed",
5890
+ taskId: task.taskId,
5891
+ taskRunId: task.taskRunId,
5892
+ sessionId: task.sessionId,
5893
+ changedFiles: task.changedFiles,
5894
+ lifecycleMode: runtimeLifecycleMode(task)
5895
+ };
5896
+ appendTrace(ctx.cwd, trace);
5897
+ appendSessionTrace(pi, trace);
5898
+ telemetry(ctx, trace);
5899
+ return;
5900
+ }
5901
+ }
5902
+ if (!handoffAttempt || resolveRuntimePolicy(loadProfileFromContext(ctx)).finalGate !== "enforce") return;
5903
+ const gate = completionGate ?? evaluateTaskGate(ctx.cwd, task, policy, {
5904
+ currentDigests,
5905
+ currentWorkingTreeDigest: currentDigest
5906
+ });
5907
+ if (gate.decision === "pass") return;
5908
+
5909
+ const recoveryAttempt = completionRecoveryAttempts.get(task.taskRunId) ?? 0;
5910
+ const lifecycleMode = runtimeLifecycleMode(task);
5911
+ if (
5912
+ autoRecoveryEnabled
5913
+ && handoffAttempt
5914
+ && lifecycleMode !== "manual"
5915
+ && recoveryAttempt < 1
5916
+ ) {
5917
+ completionRecoveryAttempts.set(task.taskRunId, recoveryAttempt + 1);
5918
+ const recoveryGuidance = task.changeMode === "read-only"
5919
+ ? [
5920
+ "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.",
5921
+ "Do not mutate project source or repeat diagnostic Piagent tools; use ordinary targeted reads and the active progress tool only."
5922
+ ]
5923
+ : [
5924
+ "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.",
5925
+ "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."
5926
+ ];
5927
+ const recovery = [
5928
+ `[Piagent continuation required]`,
5929
+ `Task ${task.taskId} cannot finish yet. Missing: ${gate.missing.join(", ")}.`,
5930
+ ...recoveryGuidance
5931
+ ].join("\n");
5932
+ pi.sendMessage(
5933
+ { customType: "piagent-completion-recovery", content: recovery, display: false, details: { taskId: task.taskId, missing: gate.missing } },
5934
+ { deliverAs: "followUp", triggerTurn: true }
5935
+ );
5936
+ const recoveryTrace = {
5937
+ event: "completion_recovery_scheduled",
5938
+ taskId: task.taskId,
5939
+ taskRunId: task.taskRunId,
5940
+ sessionId: task.sessionId,
5941
+ attempt: recoveryAttempt + 1,
5942
+ missing: gate.missing
5943
+ };
5944
+ appendTrace(ctx.cwd, recoveryTrace);
5945
+ appendSessionTrace(pi, recoveryTrace);
5946
+ telemetry(ctx, recoveryTrace);
5947
+ const continuingNotice = [
5948
+ "[Piagent completion gate: CONTINUING]",
5949
+ `Task ${task.taskId} needs one bounded correction pass before handoff.`,
5950
+ ""
5951
+ ].join("\n");
5952
+ const content = Array.isArray(event.message.content)
5953
+ ? [{ type: "text" as const, text: continuingNotice }, ...event.message.content]
5954
+ : [{ type: "text" as const, text: `${continuingNotice}${text}` }];
5955
+ return { message: { ...event.message, content } };
5956
+ }
5957
+
5958
+ const notice = [
5959
+ `[Piagent completion gate: NOT APPROVED]`,
5960
+ `Task ${task.taskId} (${task.taskRunId}) is still open.`,
5961
+ `Missing: ${gate.missing.join(", ") || "a completed task trace"}.`,
5962
+ "The response below is preserved as work in progress and must not be treated as a completion report.",
5963
+ ""
5964
+ ].join("\n");
5965
+ const content = Array.isArray(event.message.content)
5966
+ ? [{ type: "text" as const, text: notice }, ...event.message.content]
5967
+ : [{ type: "text" as const, text: `${notice}${text}` }];
5968
+ const trace = {
5969
+ event: "completion_claim_blocked",
5970
+ taskId: task.taskId,
5971
+ taskRunId: task.taskRunId,
5972
+ sessionId: task.sessionId,
5973
+ missing: gate.missing,
5974
+ responseHash: crypto.createHash("sha256").update(text).digest("hex"),
5975
+ responseChars: text.length
5976
+ };
5977
+ appendTrace(ctx.cwd, trace);
5978
+ appendSessionTrace(pi, trace);
5979
+ telemetry(ctx, trace);
5980
+ return { message: { ...event.message, content } };
5981
+ });
5982
+
5983
+ pi.on("agent_settled", async (_event, ctx) => {
5984
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
5985
+ telemetry(ctx, {
5986
+ event: "agent_settled",
5987
+ idle: ctx.isIdle(),
5988
+ taskId: task?.taskId,
5989
+ taskRunId: task?.taskRunId,
5990
+ taskOutcome: task?.trace.outcome
5991
+ });
5992
+ });
5993
+
4802
5994
  pi.on("session_compact", async (event, ctx) => {
4803
5995
  telemetry(ctx, {
4804
5996
  event: "session_compact",
@@ -4809,11 +6001,20 @@ export default function piagentGuard(pi: ExtensionAPI) {
4809
6001
  });
4810
6002
 
4811
6003
  pi.on("session_shutdown", async (event, ctx) => {
6004
+ flushObservedTaskContext(
6005
+ pi,
6006
+ ctx,
6007
+ observedContext(ctx),
6008
+ contextBudgetConfig(policy).maxManifestFiles,
6009
+ "context_observed_before_shutdown"
6010
+ );
4812
6011
  telemetry(ctx, {
4813
6012
  event: "session_shutdown",
4814
6013
  reason: event.reason,
4815
6014
  targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
4816
6015
  });
6016
+ clearObservedContext(ctx);
6017
+ clearShellMutationSnapshots(ctx);
4817
6018
  });
4818
6019
 
4819
6020
  pi.on("tool_result", async (event, ctx) => {
@@ -4827,13 +6028,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
4827
6028
  appendObservedBashResult(observedBashLedgerPath(ctx.cwd), {
4828
6029
  ...observed,
4829
6030
  redactedCommand: redactText(observed.command)
4830
- });
6031
+ }, { projectRoot: ctx.cwd });
4831
6032
  } catch (error) {
4832
6033
  const message = error instanceof Error ? error.message : String(error);
4833
6034
  ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
4834
6035
  }
4835
6036
  }
4836
6037
 
6038
+ const observedContextEntry = observedTaskContextFromToolResult(ctx.cwd, event, pathPolicy.readProtectedPaths);
6039
+ if (observedContextEntry) rememberObservedContext(ctx, observedContextEntry);
6040
+ const pendingContext = observedContext(ctx);
6041
+ const maxManifestFiles = contextBudgetConfig(policy).maxManifestFiles;
6042
+ const shellSnapshotBefore = SHELL_TOOL_NAMES.has(event.toolName)
6043
+ ? consumeShellMutationSnapshot(ctx, event.toolName, event.input)
6044
+ : undefined;
6045
+ recordObservedTaskChanges(pi, ctx, event, pendingContext, maxManifestFiles, shellSnapshotBefore);
6046
+ if (observed) recordObservedTaskVerification(pi, ctx, observed, pendingContext, maxManifestFiles);
6047
+
4837
6048
  let resultContent: unknown = event.content;
4838
6049
  let resultDetails: unknown = event.details;
4839
6050
  let resultChanged = false;
@@ -4993,6 +6204,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
4993
6204
  const toolInput = event.input && typeof event.input === "object"
4994
6205
  ? event.input as Record<string, unknown>
4995
6206
  : {};
6207
+ const sessionTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
6208
+ const profileMode = profile.mode ?? "unprofiled";
6209
+ const governedTaskProfile = !profileMode.startsWith("unprofiled") && !profileMode.includes("unreadable");
6210
+ const taskContractRequired = governedTaskProfile
6211
+ && runtime.finalGate === "enforce"
6212
+ && finalGateConfig(policy).requireTaskContract;
6213
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !SHELL_TOOL_NAMES.has(event.toolName) && isTaskMutationTool(event.toolName, toolInput)) {
6214
+ return {
6215
+ block: true,
6216
+ 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.`
6217
+ };
6218
+ }
4996
6219
  const preparedInput = prepareToolInputForPolicy(event.toolName, toolInput, policy);
4997
6220
  if (preparedInput.reason) {
4998
6221
  return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
@@ -5005,6 +6228,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5005
6228
  return { block: true, reason: approvalDecision.reason };
5006
6229
  }
5007
6230
 
6231
+ let shellProjectMutation = false;
5008
6232
  if (SHELL_TOOL_NAMES.has(event.toolName)) {
5009
6233
  const shellInput = extractShellCommandInput(toolInput);
5010
6234
  if (!shellInput.command) {
@@ -5012,6 +6236,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
5012
6236
  }
5013
6237
  const command = normalizeShellCommandForPolicy(shellInput.command);
5014
6238
  const execDecision = evaluateExecPolicy(command, profile, policy);
6239
+ shellProjectMutation = isProjectMutatingShellCommand(command, execDecision.segments);
6240
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !isReadOnlyTaskShellCommand(command, execDecision.segments)) {
6241
+ return {
6242
+ block: true,
6243
+ reason: `Task ${sessionTask.taskId} is read-only; this shell command is not in the read-only inspection allowlist.`
6244
+ };
6245
+ }
5015
6246
  if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
5016
6247
  return { block: true, reason: execDecision.reasons.join("; ") };
5017
6248
  }
@@ -5110,6 +6341,34 @@ export default function piagentGuard(pi: ExtensionAPI) {
5110
6341
  }
5111
6342
  }
5112
6343
 
6344
+ const mutationTargets = taskMutationTargets(ctx.cwd, event.toolName, toolInput);
6345
+ const directProjectMutation = !SHELL_TOOL_NAMES.has(event.toolName) && (
6346
+ WRITE_TOOL_NAMES.has(event.toolName)
6347
+ || mutationTargets.length > 0
6348
+ || preparedInput.proxyShellCarrier === true
6349
+ );
6350
+ if (sessionTask && sessionTask.trace.outcome !== "pending" && (shellProjectMutation || directProjectMutation)) {
6351
+ return {
6352
+ block: true,
6353
+ reason: `Task ${sessionTask.taskId} is ${sessionTask.trace.outcome}; start a new attempt or a fresh task before further project mutations.`
6354
+ };
6355
+ }
6356
+ if (!sessionTask && taskContractRequired && (shellProjectMutation || directProjectMutation)) {
6357
+ return {
6358
+ block: true,
6359
+ reason: "A session-bound Task Implementation Contract is required before project files can be mutated. Start the task first."
6360
+ };
6361
+ }
6362
+ if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "source-change") {
6363
+ const outsideScope = mutationTargets.filter((file) => !taskScopeIncludesPath(sessionTask.scope, file));
6364
+ if (outsideScope.length > 0) {
6365
+ return {
6366
+ block: true,
6367
+ reason: `Task ${sessionTask.taskId} cannot mutate paths outside its declared scope: ${outsideScope.join(", ")}.`
6368
+ };
6369
+ }
6370
+ }
6371
+
5113
6372
  if (runtime.contextBudget !== "off" && ["write", "edit"].includes(event.toolName)) {
5114
6373
  const relativePath = extractLikelyPathFromInput(ctx.cwd, event.input as Record<string, unknown>);
5115
6374
  if (relativePath) {
@@ -5120,18 +6379,27 @@ export default function piagentGuard(pi: ExtensionAPI) {
5120
6379
  }
5121
6380
  }
5122
6381
  }
6382
+
6383
+ if (
6384
+ sessionTask?.trace.outcome === "pending"
6385
+ && sessionTask.changeMode === "source-change"
6386
+ && shellProjectMutation
6387
+ ) {
6388
+ rememberShellMutationSnapshot(ctx, event.toolName, event.input);
6389
+ }
5123
6390
  });
5124
6391
 
5125
6392
  pi.registerTool({
5126
6393
  name: "piagent_tools",
5127
6394
  label: "Piagent Tool Loader",
5128
6395
  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.",
6396
+ promptSnippet: "Load diagnostic or recovery Piagent tools only when the runtime requests them.",
5130
6397
  promptGuidelines: [
5131
- "Load the smallest group that resolves the current need; do not activate every group."
6398
+ "Do not call this for an ordinary implementation task; runtime evidence collection needs no extra tools.",
6399
+ "When recovery is necessary, load only the smallest group that resolves the reported missing evidence."
5132
6400
  ],
5133
6401
  parameters: Type.Object({
5134
- groups: Type.Array(StringEnum(["governance", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
6402
+ groups: Type.Array(StringEnum(["intake", "governance", "task", "recovery", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
5135
6403
  }),
5136
6404
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
5137
6405
  const groups = [...new Set(params.groups)] as PiagentToolGroup[];
@@ -5169,7 +6437,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5169
6437
  action: StringEnum(["status", "rebuild", "search", "pack", "impact", "efficiency"] as const),
5170
6438
  query: Type.Optional(Type.String()),
5171
6439
  files: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5172
- budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 50000 })),
6440
+ budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 12000 })),
5173
6441
  refresh: Type.Optional(Type.Boolean())
5174
6442
  }),
5175
6443
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -5216,6 +6484,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
5216
6484
  ].join("\n");
5217
6485
  } else if (params.action === "pack") {
5218
6486
  if (!params.query?.trim()) throw new Error("query is required for a context pack");
6487
+ const injected = injectedContextPacks.get(retrievalKey(ctx, params.query));
6488
+ if (injected && params.refresh !== true) {
6489
+ result = { reusedInjectedPack: true, ...injected };
6490
+ text = [
6491
+ "Context pack already injected for this task; duplicate payload skipped.",
6492
+ `confidence: ${injected.confidence}; estimatedTokensSaved: ${injected.estimatedTokens}`,
6493
+ `paths: ${injected.paths.join(", ")}`,
6494
+ "Read only the listed files or request refresh=true when the repository changed."
6495
+ ].join("\n");
6496
+ telemetry(ctx, {
6497
+ event: "context_pack_reused",
6498
+ queryHash: injected.queryHash,
6499
+ estimatedTokensSaved: injected.estimatedTokens,
6500
+ selectedPaths: injected.paths
6501
+ });
6502
+ return { content: [{ type: "text", text }], details: result };
6503
+ }
5219
6504
  const ensured = await ensureContextIndexV2(ctx.cwd, {
5220
6505
  excludePatterns,
5221
6506
  refresh: params.refresh,
@@ -5223,7 +6508,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5223
6508
  });
5224
6509
  const status = ensured.status;
5225
6510
  const pack = await buildContextPack(ctx.cwd, params.query, {
5226
- budgetTokens: params.budgetTokens ?? 6_000,
6511
+ budgetTokens: params.budgetTokens ?? 2_400,
5227
6512
  includeCode: true,
5228
6513
  limit: 18,
5229
6514
  excludePatterns
@@ -5483,11 +6768,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
5483
6768
  description: "Check whether a governed task has enough context, verify evidence, and trace before claiming done.",
5484
6769
  promptSnippet: "Use this before final on source-changing tasks.",
5485
6770
  parameters: Type.Object({
5486
- taskId: Type.String({ minLength: 1 })
6771
+ taskId: Type.String({ minLength: 1 }),
6772
+ changedFiles: Type.Optional(Type.Array(Type.String()))
5487
6773
  }),
5488
6774
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
5489
- const task = readTask(ctx.cwd, params.taskId);
5490
- const result = evaluateTaskGate(task, policy);
6775
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6776
+ const projected = task ? {
6777
+ ...task,
6778
+ changedFiles: params.changedFiles ?? task.changedFiles,
6779
+ trace: { ...task.trace, outcome: "completed" as const }
6780
+ } : undefined;
6781
+ const result = evaluateTaskGate(ctx.cwd, projected, policy);
5491
6782
  const runtime = resolveRuntimePolicy(loadProfileFromContext(ctx));
5492
6783
  const text = [
5493
6784
  `decision: ${result.decision}`,
@@ -5495,7 +6786,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
5495
6786
  `missing: ${result.missing.join(", ") || "none"}`,
5496
6787
  `warnings: ${result.warnings.join("; ") || "none"}`
5497
6788
  ].join("\n");
5498
- return { content: [{ type: "text", text }], details: { ...result, task } };
6789
+ return { content: [{ type: "text", text }], details: { ...result, task: projected } };
5499
6790
  }
5500
6791
  });
5501
6792
 
@@ -6113,24 +7404,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
6113
7404
  }
6114
7405
  });
6115
7406
 
6116
- pi.registerTool({
7407
+ const taskStartTool = {
6117
7408
  name: "piagent_task_start",
6118
7409
  label: "Piagent Task Start",
6119
7410
  description: "Create a Task Implementation Contract for the current project before editing.",
6120
7411
  promptSnippet: "Start a governed implementation task and persist the task contract.",
6121
7412
  promptGuidelines: [
6122
- "Call this before source edits in a project managed by Pi Agent Platform.",
6123
- "Use piagent_context first when possible.",
7413
+ "Call this exactly once before source edits in a project managed by Pi Agent Platform.",
7414
+ "Do not call context, status, policy, evidence-recording, trace, or gate tools first; runtime hooks provide those checks automatically.",
6124
7415
  "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."
7416
+ "Every scope entry must be a project-relative path or glob such as src/file.ts, src/**, or test/**; never put prose in scope.",
7417
+ "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.",
7418
+ "Tiny tasks use automatic lifecycle evidence; normal tasks retain one explicit review step; high-risk/custom plans keep manual checkpoints."
6126
7419
  ],
6127
7420
  parameters: Type.Object({
6128
7421
  taskId: Type.Optional(Type.String({ minLength: 1 })),
6129
7422
  summary: Type.String({ minLength: 10 }),
6130
7423
  riskLane: StringEnum(["tiny", "normal", "high-risk"] as const),
7424
+ changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const)),
7425
+ verifyGroup: Type.Optional(Type.String({ minLength: 1 })),
7426
+ maxAttempts: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
6131
7427
  expectedOutput: Type.String({ minLength: 10 }),
6132
7428
  acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
6133
- scope: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
7429
+ scope: Type.Array(Type.String({
7430
+ minLength: 1,
7431
+ description: "Project-relative path or glob only (for example src/file.ts, src/**, or test/**); do not use prose."
7432
+ }), { minItems: 1 }),
6134
7433
  outOfScope: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
6135
7434
  reviewLenses: Type.Optional(Type.Array(StringEnum(REVIEW_LENSES))),
6136
7435
  workPlan: Type.Optional(Type.Array(Type.Object({
@@ -6138,33 +7437,143 @@ export default function piagentGuard(pi: ExtensionAPI) {
6138
7437
  title: Type.String({ minLength: 1 }),
6139
7438
  role: Type.Optional(StringEnum(ORCHESTRATION_ROLES)),
6140
7439
  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 })))
7440
+ status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped", "failed"] as const)),
7441
+ dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
7442
+ note: Type.Optional(Type.String())
6143
7443
  })))
6144
7444
  }),
6145
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
7445
+ async execute(
7446
+ _toolCallId: string,
7447
+ params: TaskStartParameters,
7448
+ _signal: AbortSignal | undefined,
7449
+ _onUpdate: ((update: unknown) => void) | undefined,
7450
+ ctx: ExtensionContext
7451
+ ) {
6146
7452
  const profile = loadProfileFromContext(ctx);
6147
7453
  const createdAt = nowIso();
6148
7454
  const safeSummary = redactText(params.summary);
6149
7455
  const taskId = safeTaskId(redactText(params.taskId ?? params.summary));
7456
+ const sessionId = ctx.sessionManager.getSessionId();
7457
+ const sessionName = currentSessionName(ctx);
7458
+ const active = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
7459
+ if (active) {
7460
+ if (active.taskId === taskId) {
7461
+ if (active.trace.outcome !== "pending") {
7462
+ return {
7463
+ 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.` }],
7464
+ details: active,
7465
+ isError: true
7466
+ };
7467
+ }
7468
+ return {
7469
+ content: [{ type: "text", text: `Task already active in this session: ${active.taskId} (${active.taskRunId}). Reusing it instead of overwriting state.` }],
7470
+ details: compactTaskDetails(active)
7471
+ };
7472
+ }
7473
+ return {
7474
+ 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.` }],
7475
+ details: active,
7476
+ isError: true
7477
+ };
7478
+ }
7479
+ const invalidScope = params.scope.find((entry) => !validTaskScopePattern(entry));
7480
+ if (invalidScope) {
7481
+ return {
7482
+ content: [{
7483
+ type: "text",
7484
+ 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/**.`
7485
+ }],
7486
+ isError: true
7487
+ };
7488
+ }
7489
+ const priorAttempts = priorTaskAttempts(ctx.cwd, taskId) as TaskContract[];
7490
+ const pendingElsewhere = priorAttempts.find((task) => task.trace.outcome === "pending" && task.sessionId !== sessionId);
7491
+ if (pendingElsewhere) {
7492
+ return {
7493
+ content: [{ type: "text", text: `Task ${taskId} is already active in session ${pendingElsewhere.sessionName ?? pendingElsewhere.sessionId} (${pendingElsewhere.taskRunId}).` }],
7494
+ details: pendingElsewhere,
7495
+ isError: true
7496
+ };
7497
+ }
7498
+ const latestCompleted = priorAttempts.find((task) => task.trace.outcome === "completed");
7499
+ if (latestCompleted) {
7500
+ return {
7501
+ content: [{ type: "text", text: `Task ${taskId} already completed as ${latestCompleted.taskRunId}. Use a distinct taskId for new work instead of replacing its evidence.` }],
7502
+ details: latestCompleted,
7503
+ isError: true
7504
+ };
7505
+ }
7506
+ const attempt = priorAttempts.reduce((maximum, task) => Math.max(maximum, task.attempt ?? 1), 0) + 1;
7507
+ const firstAttempt = priorAttempts.find((task) => task.attempt === 1)
7508
+ ?? [...priorAttempts].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))[0];
7509
+ const maxAttempts = firstAttempt
7510
+ ? firstAttempt.maxAttempts
7511
+ : Number.isInteger(params.maxAttempts)
7512
+ ? Math.max(1, Math.min(10, params.maxAttempts))
7513
+ : DEFAULT_MAX_TASK_ATTEMPTS;
7514
+ if (attempt > maxAttempts) {
7515
+ return {
7516
+ 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.` }],
7517
+ details: { taskId, attempt, maxAttempts, previousAttempts: priorAttempts.map(summarizeAttempt) },
7518
+ isError: true
7519
+ };
7520
+ }
6150
7521
  const orchestration = resolveOrchestrationPolicy(profile, policy);
7522
+ const changeMode = params.changeMode === "read-only" ? "read-only" : "source-change";
7523
+ if (changeMode === "source-change" && !isGitWorkingTree(ctx.cwd)) {
7524
+ return {
7525
+ 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." }],
7526
+ isError: true
7527
+ };
7528
+ }
7529
+ const verifyPlan = selectVerifyPlan(profile, params.verifyGroup, changeMode, ctx.cwd);
7530
+ if (verifyPlan.error) {
7531
+ return {
7532
+ content: [{ type: "text", text: `Task start refused: ${verifyPlan.error}` }],
7533
+ details: { verifyGroup: verifyPlan.group, verifyCommands: verifyPlan.commands },
7534
+ isError: true
7535
+ };
7536
+ }
6151
7537
  const reviewLenses = normalizeReviewLenses(params.reviewLenses, orchestration.defaultReviewLenses);
6152
7538
  const providedWorkPlan = normalizeWorkPlanSteps(params.workPlan);
6153
- const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane);
7539
+ const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane, changeMode);
7540
+ const seededContext = observedContext(ctx).slice(0, contextBudgetConfig(policy).maxManifestFiles);
7541
+ const workPlanError = validateNewWorkPlan(workPlan);
7542
+ if (workPlanError) {
7543
+ return { content: [{ type: "text", text: `Task start refused: ${workPlanError}.` }], details: workPlan, isError: true };
7544
+ }
7545
+ const firstReady = workPlan.find((step) => (step.dependsOn ?? []).length === 0);
7546
+ if (!firstReady) {
7547
+ return { content: [{ type: "text", text: "Task start refused: work plan has no dependency-ready first step." }], details: workPlan, isError: true };
7548
+ }
7549
+ firstReady.status = "in-progress";
7550
+ firstReady.updatedAt = createdAt;
7551
+ const taskRunId = createTaskRunId(taskId, sessionId, createdAt);
7552
+ const baselineFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
6154
7553
  const task: TaskContract = {
7554
+ schemaVersion: 2,
7555
+ taskRunId,
6155
7556
  taskId,
7557
+ sessionId,
7558
+ sessionName,
7559
+ changeMode,
7560
+ attempt,
7561
+ maxAttempts,
7562
+ previousAttempts: priorAttempts.filter((task) => task.trace.outcome !== "pending").slice(0, 10).reverse().map(summarizeAttempt),
6156
7563
  summary: safeSummary,
6157
7564
  riskLane: params.riskLane,
7565
+ intakeMode: params.intakeMode === "runtime" ? "runtime" : "model",
6158
7566
  expectedOutput: redactText(params.expectedOutput),
6159
7567
  acceptanceCriteria: redactTextArray(params.acceptanceCriteria),
6160
7568
  scope: redactTextArray(params.scope),
6161
7569
  outOfScope: redactTextArray(params.outOfScope),
6162
7570
  protectedPaths: profile.protectedPaths ?? [],
6163
7571
  requiredContext: profile.requiredContext ?? [],
6164
- contextManifest: [],
7572
+ contextManifest: seededContext,
6165
7573
  memoryCitations: [],
6166
7574
  mcpCapabilities: profile.mcpCapabilities ?? [],
6167
- verifyCommands: flattenVerifyCommands(profile),
7575
+ verifyGroup: verifyPlan.group,
7576
+ verifyCommands: verifyPlan.commands,
6168
7577
  workPlan,
6169
7578
  reviewLenses,
6170
7579
  orchestration: {
@@ -6174,19 +7583,229 @@ export default function piagentGuard(pi: ExtensionAPI) {
6174
7583
  fieldGuidePath: orchestration.fieldGuide.enabled ? orchestration.fieldGuide.path : undefined,
6175
7584
  modelRoles: orchestration.roleModelGuidance
6176
7585
  },
7586
+ baselineChangedFiles: Object.keys(baselineFileDigests).sort(),
7587
+ baselineFileDigests,
7588
+ observedChangedFiles: [],
7589
+ finalWorkingTreeFiles: [],
7590
+ finalFileDigests: {},
6177
7591
  changedFiles: [],
6178
7592
  verifyEvidence: [],
6179
7593
  trace: { outcome: "pending" },
6180
7594
  createdAt,
6181
7595
  updatedAt: createdAt
6182
7596
  };
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 });
7597
+ if (changeMode === "read-only" && seededContext.length > 0) {
7598
+ applyRuntimeLifecycleObservation(task, "context-complete", createdAt);
7599
+ }
7600
+ const written = writeTask(ctx.cwd, task);
7601
+ bindSessionTask(ctx.cwd, sessionId, sessionName, written);
7602
+ cacheTaskIdentity(ctx, written);
7603
+ if (written.intakeMode !== "runtime") {
7604
+ // Intake classification defines the cache-stable surface for this agent turn.
7605
+ // A model may choose a narrower lane than the prompt classifier; never remove
7606
+ // schemas mid-turn, but add recovery tools when the chosen lane requires them.
7607
+ activateToolGroups(ctx, activeTaskToolGroups(written), true);
7608
+ }
7609
+ const lifecycleMode = runtimeLifecycleMode(written);
7610
+ 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) });
7611
+ 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
7612
 
6187
7613
  return {
6188
- content: [{ type: "text", text: `Task contract created: .pi/piagent-state/tasks/${taskId}.json` }],
6189
- details: task
7614
+ content: [{
7615
+ type: "text",
7616
+ text: [
7617
+ `Task ${taskId} started (${params.riskLane}, ${lifecycleMode}; attempt ${attempt}/${maxAttempts}).`,
7618
+ written.verifyCommands.length > 0
7619
+ ? `Verify: ${written.verifyCommands.join(" | ")}.`
7620
+ : "Verify: none (read-only).",
7621
+ lifecycleMode === "automatic-readonly"
7622
+ ? "Runtime records targeted reads and final completion automatically. Stay read-only and report cited evidence."
7623
+ : lifecycleMode === "assisted-readonly"
7624
+ ? "Runtime records read-only evidence automatically; complete only the explicit evidence-review step before handoff."
7625
+ : lifecycleMode === "automatic"
7626
+ ? "Runtime will record reads, changes, exact verifier results, and final completion automatically. Continue with ordinary read/edit/bash work."
7627
+ : lifecycleMode === "assisted"
7628
+ ? "Runtime records objective evidence automatically; complete only the explicit review step before handoff."
7629
+ : "Use the active progress/recovery tools for the custom or high-risk checkpoints."
7630
+ ].join("\n")
7631
+ }],
7632
+ details: compactTaskDetails(written)
7633
+ };
7634
+ }
7635
+ };
7636
+ pi.registerTool(taskStartTool);
7637
+
7638
+ async function maybeStartAutomaticTask(
7639
+ prompt: string,
7640
+ ctx: ExtensionContext
7641
+ ): Promise<{ started: boolean; text: string; task?: TaskContract } | undefined> {
7642
+ const profile = loadProfileFromContext(ctx);
7643
+ const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
7644
+ if (!automaticTaskIntakeEligible(prompt, readProtectedPaths)) return undefined;
7645
+ const active = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
7646
+ if (active?.trace.outcome === "pending") return undefined;
7647
+
7648
+ const summary = redactText(prompt).replace(/\s+/g, " ").trim().slice(0, 320);
7649
+ const sessionName = currentSessionName(ctx);
7650
+ const scope = automaticTaskScope(prompt, observedContext(ctx));
7651
+ const started = await taskStartTool.execute(
7652
+ `runtime-intake-${ctx.sessionManager.getSessionId()}`,
7653
+ {
7654
+ taskId: hasOperatorSessionName(sessionName) ? sessionName : summary,
7655
+ summary,
7656
+ riskLane: "tiny",
7657
+ intakeMode: "runtime",
7658
+ changeMode: "source-change",
7659
+ expectedOutput: "The requested bounded change is implemented and passes the configured verification.",
7660
+ acceptanceCriteria: [
7661
+ "The requested behavior is implemented without changing unrelated behavior.",
7662
+ "Changes stay within the runtime-derived task scope.",
7663
+ "The configured verification command passes after the final mutation."
7664
+ ],
7665
+ scope,
7666
+ outOfScope: ["Unrelated files and behavior outside the operator request."],
7667
+ reviewLenses: automaticReviewLenses(prompt)
7668
+ },
7669
+ undefined,
7670
+ undefined,
7671
+ ctx
7672
+ );
7673
+ if (started.isError) {
7674
+ activateToolGroups(ctx, ["intake"], true);
7675
+ const reason = started.content?.[0]?.text ?? "runtime intake could not create a task contract";
7676
+ return {
7677
+ started: false,
7678
+ text: `Piagent runtime intake paused: ${reason}\nUse piagent_task_start once with explicit project-relative scope before mutation.`
7679
+ };
7680
+ }
7681
+ const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
7682
+ if (!task) {
7683
+ activateToolGroups(ctx, ["intake"], true);
7684
+ return {
7685
+ started: false,
7686
+ text: "Piagent runtime intake did not persist a task contract. Use piagent_task_start once before mutation."
7687
+ };
7688
+ }
7689
+ const verify = task.verifyCommands.length > 0 ? task.verifyCommands.join(" | ") : "none";
7690
+ return {
7691
+ started: true,
7692
+ task,
7693
+ text: [
7694
+ `Piagent runtime task: ${task.taskId}; scope: ${task.scope.join(", ")}; verify: ${verify}.`,
7695
+ "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.",
7696
+ "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."
7697
+ ].join("\n")
7698
+ };
7699
+ }
7700
+
7701
+ pi.registerTool({
7702
+ name: "piagent_task_progress",
7703
+ label: "Piagent Task Progress",
7704
+ description: "Advance or fail one dependency-aware work-plan step in the current session task.",
7705
+ promptSnippet: "Record durable task progress as each planned phase starts, completes, skips, or fails.",
7706
+ promptGuidelines: [
7707
+ "Do not mark a step done until its work and evidence are actually complete.",
7708
+ "A failed step requires a concrete note and records where the attempt failed.",
7709
+ "Completing a step automatically starts the next dependency-ready pending step."
7710
+ ],
7711
+ parameters: Type.Object({
7712
+ taskId: Type.String({ minLength: 1 }),
7713
+ stepId: Type.String({ minLength: 1 }),
7714
+ status: StringEnum(["in-progress", "done", "skipped", "failed"] as const),
7715
+ note: Type.Optional(Type.String()),
7716
+ failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
7717
+ ruledOut: Type.Optional(Type.String())
7718
+ }),
7719
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
7720
+ const sessionId = ctx.sessionManager.getSessionId();
7721
+ const task = readTask(ctx.cwd, params.taskId, sessionId);
7722
+ if (!task) {
7723
+ return { content: [{ type: "text", text: `Task not found in this session: ${params.taskId}` }], isError: true };
7724
+ }
7725
+ if (task.trace.outcome !== "pending") {
7726
+ 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 };
7727
+ }
7728
+ const stepId = safeTaskId(params.stepId).slice(0, 40);
7729
+ const step = task.workPlan.find((item) => item.id === stepId);
7730
+ if (!step) {
7731
+ return { content: [{ type: "text", text: `Work-plan step not found: ${stepId}` }], details: task.workPlan, isError: true };
7732
+ }
7733
+ const dependencies = step.dependsOn ?? [];
7734
+ const unresolved = dependencies.filter((dependency) => {
7735
+ const required = task.workPlan.find((item) => item.id === dependency);
7736
+ return !required || (required.status !== "done" && required.status !== "skipped");
7737
+ });
7738
+ if ((params.status === "in-progress" || params.status === "done") && unresolved.length > 0) {
7739
+ return {
7740
+ content: [{ type: "text", text: `Step ${stepId} is blocked by unfinished dependencies: ${unresolved.join(", ")}` }],
7741
+ details: { step, unresolved },
7742
+ isError: true
7743
+ };
7744
+ }
7745
+ const note = params.note ? redactText(params.note).slice(0, 500) : undefined;
7746
+ if (params.status === "failed" && !note) {
7747
+ return { content: [{ type: "text", text: `A concrete note is required when step ${stepId} fails.` }], isError: true };
7748
+ }
7749
+ if ((step.status === "done" || step.status === "skipped") && params.status !== step.status) {
7750
+ 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 };
7751
+ }
7752
+ if (step.status === params.status) {
7753
+ return { content: [{ type: "text", text: `Work-plan step ${stepId} is already ${params.status}; no state change was recorded.` }], details: step, isError: true };
7754
+ }
7755
+ if (step.status === "failed" && params.status === "in-progress" && !note) {
7756
+ return { content: [{ type: "text", text: `A concrete note is required to reopen failed step ${stepId} within this attempt.` }], details: step, isError: true };
7757
+ }
7758
+
7759
+ const recordedAt = nowIso();
7760
+ step.status = params.status;
7761
+ step.note = note;
7762
+ step.updatedAt = recordedAt;
7763
+ if (params.status === "failed") {
7764
+ task.failedAt = params.failedAt ?? (step.mode === "review" ? "review" : step.id === "plan" ? "plan" : "execute");
7765
+ task.failureReason = note;
7766
+ task.ruledOut = params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : undefined;
7767
+ } else if (task.failureReason && task.workPlan.every((item) => item.status !== "failed")) {
7768
+ task.failedAt = undefined;
7769
+ task.failureReason = undefined;
7770
+ task.ruledOut = undefined;
7771
+ }
7772
+
7773
+ let startedStep: WorkPlanStep | undefined;
7774
+ if (params.status === "done" || params.status === "skipped") {
7775
+ startedStep = task.workPlan.find((candidate) => {
7776
+ if (candidate.status !== "pending") return false;
7777
+ return (candidate.dependsOn ?? []).every((dependency) => {
7778
+ const required = task.workPlan.find((item) => item.id === dependency);
7779
+ return required?.status === "done" || required?.status === "skipped";
7780
+ });
7781
+ });
7782
+ if (startedStep) {
7783
+ startedStep.status = "in-progress";
7784
+ startedStep.updatedAt = recordedAt;
7785
+ }
7786
+ }
7787
+
7788
+ const written = writeTask(ctx.cwd, task);
7789
+ const trace = {
7790
+ taskId: written.taskId,
7791
+ taskRunId: written.taskRunId,
7792
+ sessionId,
7793
+ event: "task_progress",
7794
+ stepId,
7795
+ status: params.status,
7796
+ note,
7797
+ startedStep: startedStep?.id,
7798
+ failedAt: written.failedAt,
7799
+ ruledOut: written.ruledOut
7800
+ };
7801
+ appendTrace(ctx.cwd, trace);
7802
+ appendSessionTrace(pi, trace);
7803
+ return {
7804
+ content: [{
7805
+ type: "text",
7806
+ text: `Task ${written.taskId}: ${stepId} -> ${params.status}${startedStep ? `; started ${startedStep.id}` : ""}`
7807
+ }],
7808
+ details: compactTaskDetails(written)
6190
7809
  };
6191
7810
  }
6192
7811
  });
@@ -6346,10 +7965,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
6346
7965
  const profile = loadProfileFromContext(ctx);
6347
7966
  const runtime = resolveRuntimePolicy(profile);
6348
7967
  const budget = contextBudgetConfig(policy);
6349
- const task = readTask(ctx.cwd, params.taskId);
7968
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6350
7969
  if (!task) {
6351
7970
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6352
7971
  }
7972
+ if (task.trace.outcome !== "pending") {
7973
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; context evidence was not changed.` }], details: task, isError: true };
7974
+ }
6353
7975
  if (runtime.contextBudget !== "off" && task.contextManifest.length + params.files.length > budget.maxManifestFiles) {
6354
7976
  return {
6355
7977
  content: [{ type: "text", text: `Context manifest budget exceeded: ${task.contextManifest.length + params.files.length} files > ${budget.maxManifestFiles}` }],
@@ -6375,13 +7997,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
6375
7997
  const key = `${file.path}\u0000${file.reason}`;
6376
7998
  if (!seen.has(key)) task.contextManifest.push(file);
6377
7999
  }
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 });
8000
+ const lifecycle = task.changeMode === "read-only"
8001
+ ? applyRuntimeLifecycleObservation(task, "context-complete", nowIso())
8002
+ : { changed: false, mode: runtimeLifecycleMode(task) };
8003
+ const written = writeTask(ctx.cwd, task);
8004
+ appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
8005
+ appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
6381
8006
 
6382
8007
  return {
6383
8008
  content: [{ type: "text", text: `Context recorded for ${task.taskId}: ${params.files.length} file(s)` }],
6384
- details: task
8009
+ details: compactTaskDetails(written)
6385
8010
  };
6386
8011
  }
6387
8012
  });
@@ -6398,13 +8023,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
6398
8023
  summary: Type.String({ minLength: 1 })
6399
8024
  }),
6400
8025
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6401
- const task = readTask(ctx.cwd, params.taskId);
8026
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6402
8027
  if (!task) {
6403
8028
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6404
8029
  }
8030
+ if (task.trace.outcome !== "pending") {
8031
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; verify evidence was not changed.` }], details: task, isError: true };
8032
+ }
6405
8033
 
6406
8034
  const observedEntries = [
6407
- ...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000 }),
8035
+ ...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000, projectRoot: ctx.cwd }),
6408
8036
  ...bashResults.list()
6409
8037
  ];
6410
8038
  const observed = findMatchingObservedBashResult(observedEntries, {
@@ -6424,24 +8052,50 @@ export default function piagentGuard(pi: ExtensionAPI) {
6424
8052
  const safeCommand = redactText(params.command);
6425
8053
  const safeSummary = redactText(params.summary);
6426
8054
  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 });
8055
+ const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
8056
+ const workingTreeDigest = workingTreeEvidenceDigest(currentDigests);
8057
+ const duplicate = task.verifyEvidence.some((evidence) => (
8058
+ evidence.command.trim() === safeCommand.trim()
8059
+ && evidence.exitCode === params.exitCode
8060
+ && evidence.workingTreeDigest === workingTreeDigest
8061
+ ));
8062
+ if (!duplicate) {
8063
+ task.verifyEvidence.push({
8064
+ command: safeCommand,
8065
+ exitCode: params.exitCode,
8066
+ summary: safeSummary,
8067
+ recordedAt: nowIso(),
8068
+ observed: true,
8069
+ observedAt: observed.entry.recordedAt,
8070
+ isError: observed.entry.isError,
8071
+ matchedProfileCommand,
8072
+ workingTreeDigest
8073
+ });
8074
+ task.verifyEvidence = task.verifyEvidence.slice(-100);
8075
+ }
8076
+ const hasChanges = taskChangedFileEvidence(ctx.cwd, task, currentDigests).expected.length > 0;
8077
+ const allPassing = matchedProfileCommand && hasChanges && allVerifyCommandsPassCurrentTree(task, workingTreeDigest);
8078
+ if (matchedProfileCommand && hasChanges) {
8079
+ applyRuntimeLifecycleObservation(task, allPassing ? "verification-complete" : "verification-pending", nowIso());
8080
+ }
8081
+ const written = writeTask(ctx.cwd, task);
8082
+ 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 });
8083
+ 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
8084
 
6441
8085
  const advisorySuffix = matchedProfileCommand ? "" : " Advisory only: command does not exactly match task verifyCommands and will not satisfy the passing final gate.";
6442
8086
  return {
6443
8087
  content: [{ type: "text", text: `Verify evidence recorded for ${task.taskId}: observed exit ${params.exitCode}.${advisorySuffix}` }],
6444
- details: { task, observation: redactForStorage(observed.entry), matchedProfileCommand }
8088
+ details: {
8089
+ task: compactTaskDetails(written),
8090
+ evidence: {
8091
+ command: safeCommand,
8092
+ exitCode: params.exitCode,
8093
+ observed: true,
8094
+ matchedProfileCommand,
8095
+ workingTreeDigest
8096
+ },
8097
+ duplicate
8098
+ }
6445
8099
  };
6446
8100
  }
6447
8101
  });
@@ -6459,10 +8113,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
6459
8113
  }), { minItems: 1 })
6460
8114
  }),
6461
8115
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6462
- const task = readTask(ctx.cwd, params.taskId);
8116
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6463
8117
  if (!task) {
6464
8118
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6465
8119
  }
8120
+ if (task.trace.outcome !== "pending") {
8121
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; memory evidence was not changed.` }], details: task, isError: true };
8122
+ }
6466
8123
 
6467
8124
  const safeFiles = params.files.map((file) => ({
6468
8125
  path: file.path,
@@ -6474,8 +8131,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
6474
8131
  if (!seen.has(key)) task.memoryCitations.push(file);
6475
8132
  }
6476
8133
  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 });
8134
+ appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
8135
+ appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
6479
8136
 
6480
8137
  return {
6481
8138
  content: [{ type: "text", text: `Memory citations recorded for ${task.taskId}: ${params.files.length} file(s)` }],
@@ -6494,19 +8151,44 @@ export default function piagentGuard(pi: ExtensionAPI) {
6494
8151
  outcome: StringEnum(["completed", "blocked", "partial", "failed"] as const),
6495
8152
  changedFiles: Type.Optional(Type.Array(Type.String())),
6496
8153
  friction: Type.Optional(Type.String()),
6497
- notes: Type.Optional(Type.String())
8154
+ notes: Type.Optional(Type.String()),
8155
+ failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
8156
+ ruledOut: Type.Optional(Type.String())
6498
8157
  }),
6499
8158
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
6500
8159
  const profile = loadProfileFromContext(ctx);
6501
8160
  const runtime = resolveRuntimePolicy(profile);
6502
- const task = readTask(ctx.cwd, params.taskId);
8161
+ const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
6503
8162
  if (!task) {
6504
8163
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
6505
8164
  }
8165
+ if (task.trace.outcome !== "pending") {
8166
+ return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; its final trace was not replaced.` }], details: task, isError: true };
8167
+ }
8168
+ if (params.outcome !== "completed" && !params.friction?.trim() && !task.failureReason?.trim()) {
8169
+ 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 };
8170
+ }
8171
+ if (params.outcome === "failed" && !params.failedAt && !task.failedAt) {
8172
+ return { content: [{ type: "text", text: "A failed trace requires failedAt to identify the lifecycle phase." }], details: task, isError: true };
8173
+ }
6506
8174
 
8175
+ const rawChangedFiles = params.changedFiles ?? task.changedFiles;
8176
+ const normalizedChangedFiles = rawChangedFiles.map((file) => normalizeRelative(ctx.cwd, file));
8177
+ if (normalizedChangedFiles.some((file) => !file || file === "." || file === ".." || file.startsWith("../") || file.startsWith(".pi/piagent-state/"))) {
8178
+ return {
8179
+ content: [{ type: "text", text: "Trace refused: changedFiles must be project-relative paths outside .pi/piagent-state/." }],
8180
+ isError: true
8181
+ };
8182
+ }
8183
+ const finalFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
6507
8184
  const nextTask: TaskContract = {
6508
8185
  ...task,
6509
- changedFiles: params.changedFiles ?? task.changedFiles,
8186
+ changedFiles: uniqueStrings(normalizedChangedFiles as string[]).sort(),
8187
+ finalWorkingTreeFiles: Object.keys(finalFileDigests).sort(),
8188
+ finalFileDigests,
8189
+ failedAt: params.outcome === "completed" ? undefined : params.failedAt ?? task.failedAt,
8190
+ failureReason: params.outcome === "completed" ? undefined : params.friction ? redactText(params.friction) : task.failureReason,
8191
+ ruledOut: params.outcome === "completed" ? undefined : params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : task.ruledOut,
6510
8192
  trace: {
6511
8193
  outcome: params.outcome,
6512
8194
  friction: params.friction ? redactText(params.friction) : undefined,
@@ -6514,8 +8196,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
6514
8196
  recordedAt: nowIso()
6515
8197
  }
6516
8198
  };
6517
- const gate = evaluateTaskGate(nextTask, policy);
8199
+ const gate = evaluateTaskGate(ctx.cwd, nextTask, policy, {
8200
+ currentDigests: finalFileDigests,
8201
+ currentWorkingTreeDigest: workingTreeEvidenceDigest(finalFileDigests)
8202
+ });
6518
8203
  if (params.outcome === "completed" && runtime.finalGate === "enforce" && gate.decision === "fail") {
8204
+ const blockedTrace = {
8205
+ taskId: nextTask.taskId,
8206
+ taskRunId: nextTask.taskRunId,
8207
+ sessionId: nextTask.sessionId,
8208
+ event: "completion_gate_blocked",
8209
+ missing: gate.missing,
8210
+ changedFiles: nextTask.changedFiles
8211
+ };
8212
+ appendTrace(ctx.cwd, blockedTrace);
8213
+ appendSessionTrace(pi, blockedTrace);
6519
8214
  return {
6520
8215
  content: [{ type: "text", text: `Final gate blocked completion: missing ${gate.missing.join(", ")}` }],
6521
8216
  details: { gate, task: nextTask },
@@ -6523,27 +8218,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
6523
8218
  };
6524
8219
  }
6525
8220
 
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, {
8221
+ const written = writeTask(ctx.cwd, nextTask);
8222
+ const trace = {
6536
8223
  taskId: nextTask.taskId,
8224
+ taskRunId: nextTask.taskRunId,
8225
+ sessionId: nextTask.sessionId,
6537
8226
  event: "trace_record",
6538
8227
  outcome: params.outcome,
6539
8228
  changedFiles: nextTask.changedFiles,
6540
8229
  friction: nextTask.trace.friction,
6541
- notes: nextTask.trace.notes
6542
- });
8230
+ notes: nextTask.trace.notes,
8231
+ failedAt: nextTask.failedAt,
8232
+ ruledOut: nextTask.ruledOut
8233
+ };
8234
+ appendTrace(ctx.cwd, trace);
8235
+ appendSessionTrace(pi, trace);
6543
8236
 
6544
8237
  return {
6545
8238
  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 }
8239
+ details: { task: written, gate }
6547
8240
  };
6548
8241
  }
6549
8242
  });
@@ -7501,12 +9194,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
7501
9194
  }
7502
9195
 
7503
9196
  function compactCurrentSession(ctx: ExtensionContext): void {
7504
- const instructions = semanticCompactionInstructions(ctx.cwd);
9197
+ const sessionId = ctx.sessionManager.getSessionId();
9198
+ const instructions = semanticCompactionInstructions(ctx.cwd, sessionId);
7505
9199
  telemetry(ctx, {
7506
9200
  event: "compaction_requested",
7507
9201
  mode: "semantic",
7508
9202
  instructionTokens: estimateContextTokens(instructions),
7509
- hasTaskContract: Boolean(compactSessionTask(ctx.cwd))
9203
+ hasTaskContract: Boolean(compactSessionTask(ctx.cwd, sessionId))
7510
9204
  });
7511
9205
  ctx.compact({
7512
9206
  customInstructions: instructions