@piagent/platform 1.2.9 → 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.
- package/CHANGELOG.md +179 -0
- package/README.md +29 -6
- package/adapters/backend-api/profile.json +1 -1
- package/adapters/be-readonly-fe/profile.json +2 -2
- package/adapters/data/profile.json +1 -1
- package/adapters/devops/profile.json +1 -3
- package/adapters/docs/profile.json +1 -1
- package/adapters/fullstack/profile.json +2 -4
- package/adapters/generic/profile.json +1 -1
- package/adapters/mobile/profile.json +1 -2
- package/adapters/node-typescript/profile.json +1 -3
- package/benchmarks/core-v1/suite.json +51 -0
- package/benchmarks/core-v1/tasks/discount-order/grade.mjs +17 -0
- package/benchmarks/core-v1/tasks/discount-order/project/package.json +8 -0
- package/benchmarks/core-v1/tasks/discount-order/project/src/discount.js +3 -0
- package/benchmarks/core-v1/tasks/discount-order/project/src/order.js +6 -0
- package/benchmarks/core-v1/tasks/discount-order/project/test/order.test.js +7 -0
- package/benchmarks/core-v1/tasks/discount-order/prompt.md +4 -0
- package/benchmarks/core-v1/tasks/invoice-quantity/grade.mjs +17 -0
- package/benchmarks/core-v1/tasks/invoice-quantity/project/package.json +8 -0
- package/benchmarks/core-v1/tasks/invoice-quantity/project/src/invoice.js +3 -0
- package/benchmarks/core-v1/tasks/invoice-quantity/project/test/invoice.test.js +7 -0
- package/benchmarks/core-v1/tasks/invoice-quantity/prompt.md +3 -0
- package/benchmarks/core-v1/tasks/protected-env-refusal/grade.mjs +11 -0
- package/benchmarks/core-v1/tasks/protected-env-refusal/project/README.md +4 -0
- package/benchmarks/core-v1/tasks/protected-env-refusal/project/package.json +8 -0
- package/benchmarks/core-v1/tasks/protected-env-refusal/project/test/smoke.test.js +4 -0
- package/benchmarks/core-v1/tasks/protected-env-refusal/prompt.md +4 -0
- package/benchmarks/core-v1/tasks/targeted-session/grade.mjs +14 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/package.json +8 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/src/auth/session.js +3 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/src/billing/currency.js +3 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/src/catalog/search.js +3 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/src/notifications/channel.js +3 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/src/search/tokenize.js +3 -0
- package/benchmarks/core-v1/tasks/targeted-session/project/test/session.test.js +7 -0
- package/benchmarks/core-v1/tasks/targeted-session/prompt.md +4 -0
- package/catalog/capabilities.json +12 -12
- package/docs/command-reference-vietnamese.md +15 -8
- package/docs/context-engine.md +56 -15
- package/docs/distribution-standard.md +11 -8
- package/docs/mcp-and-tools.md +20 -7
- package/docs/model-options.md +7 -10
- package/docs/operator-manual-vietnamese.md +102 -37
- package/docs/package-architecture-notes.md +3 -1
- package/docs/publishing-for-teams.md +5 -0
- package/docs/quality-benchmark.md +225 -58
- package/docs/quickstart-vietnamese.md +5 -0
- package/docs/release-install-policy.md +41 -7
- package/docs/runtime-harness-standard.md +47 -29
- package/docs/runtime-policy-design.md +23 -12
- package/docs/runtime-quality-baseline.md +31 -17
- package/docs/security-threat-model.md +7 -2
- package/docs/subagents-and-multiagent.md +1 -1
- package/docs/task-implementation-contract.md +113 -177
- package/docs/task-lifecycle-vietnamese.md +129 -125
- package/docs/team-onboarding.md +12 -4
- package/docs/usage-observability.md +15 -8
- package/docs-site/architecture.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/context-engine.html +8 -1
- package/docs-site/content/runtime.html +10 -0
- package/docs-site/content/security.html +1 -0
- package/docs-site/context-engine.html +9 -2
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +11 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +2 -1
- package/docs-site/team.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/task-contract.invalid.json +15 -1
- package/evals/fixtures/task-contract.valid.json +18 -2
- package/package.json +3 -2
- package/packages/piagent-core/README.md +43 -4
- package/packages/piagent-core/benchmark/benchmark-core.js +691 -0
- package/packages/piagent-core/extensions/context-engine.js +209 -120
- package/packages/piagent-core/extensions/context-index-policy.js +1 -1
- package/packages/piagent-core/extensions/guard-types.ts +35 -3
- package/packages/piagent-core/extensions/local-state-path.js +59 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +2073 -222
- package/packages/piagent-core/extensions/policy-core.js +11 -1
- package/packages/piagent-core/extensions/runtime-evidence.js +39 -19
- package/packages/piagent-core/extensions/state-retention.js +218 -0
- package/packages/piagent-core/extensions/task-lifecycle.js +138 -0
- package/packages/piagent-core/extensions/task-state.js +630 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +52 -6
- package/packages/piagent-core/mcp/mcp-tool-naming.js +12 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +1 -0
- package/packages/piagent-core/prompts/be-to-fe.md +14 -22
- package/packages/piagent-core/prompts/platform-improve.md +12 -17
- package/packages/piagent-core/prompts/review.md +7 -11
- package/packages/piagent-core/prompts/scout.md +6 -10
- package/packages/piagent-core/prompts/task.md +11 -17
- package/packages/piagent-core/skills/piagent-ops/SKILL.md +12 -15
- package/packages/piagent-core/subagents/piagent-reviewer.md +3 -3
- package/schemas/task-contract.schema.json +267 -104
- package/scripts/benchmark-runner.mjs +1172 -0
- package/scripts/install-global.sh +1 -1
- package/scripts/migrate-project-state.mjs +94 -3
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/update-global.mjs +36 -3
- package/scripts/verify-local.sh +15 -0
- package/templates/project/.pi/task-contract.template.json +19 -2
- 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
|
-
|
|
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",
|
|
@@ -263,6 +334,14 @@ type ChatImageAttachmentResult = {
|
|
|
263
334
|
skipped: Array<{ path: string; reason: string }>;
|
|
264
335
|
};
|
|
265
336
|
|
|
337
|
+
type ChatImageAccessPolicy = {
|
|
338
|
+
roots: ReturnType<typeof resolveDocumentRoots>;
|
|
339
|
+
readProtectedPaths: string[];
|
|
340
|
+
filesystemRead?: string[];
|
|
341
|
+
enforceFilesystemRead: boolean;
|
|
342
|
+
onImageInspected?: (path: string) => void;
|
|
343
|
+
};
|
|
344
|
+
|
|
266
345
|
const DEFAULT_MEMORY_SETTINGS: Required<MemorySettings> = {
|
|
267
346
|
enabled: true,
|
|
268
347
|
mode: "manual",
|
|
@@ -412,6 +491,7 @@ const DEFAULT_POLICY: BasePolicy = {
|
|
|
412
491
|
"piagent_profile_tech_context_record",
|
|
413
492
|
"piagent_project_onboarding_record",
|
|
414
493
|
"piagent_task_start",
|
|
494
|
+
"piagent_task_progress",
|
|
415
495
|
"piagent_source_checkout",
|
|
416
496
|
"piagent_context_record",
|
|
417
497
|
"piagent_verify_record",
|
|
@@ -1538,10 +1618,6 @@ function stateRoot(cwd: string): string {
|
|
|
1538
1618
|
return path.join(cwd, ".pi", "piagent-state");
|
|
1539
1619
|
}
|
|
1540
1620
|
|
|
1541
|
-
function taskFilePath(cwd: string, taskId: string): string {
|
|
1542
|
-
return path.join(stateRoot(cwd), "tasks", `${safeTaskId(taskId)}.json`);
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
1621
|
function projectContextFilePath(cwd: string): string {
|
|
1546
1622
|
return path.join(cwd, ".pi", "project-context.md");
|
|
1547
1623
|
}
|
|
@@ -1566,21 +1642,18 @@ function toolResultCaptureIndexPath(cwd: string): string {
|
|
|
1566
1642
|
return path.join(toolResultCaptureRoot(cwd), "index.jsonl");
|
|
1567
1643
|
}
|
|
1568
1644
|
|
|
1569
|
-
function safeTaskId(taskId: string): string {
|
|
1570
|
-
return taskId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "task";
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
1645
|
function nowIso(): string {
|
|
1574
1646
|
return new Date().toISOString();
|
|
1575
1647
|
}
|
|
1576
1648
|
|
|
1577
1649
|
function ensureStateDirs(cwd: string): void {
|
|
1578
|
-
|
|
1650
|
+
ensurePrivateStateDirectory(cwd, stateRoot(cwd), "Piagent state directory");
|
|
1651
|
+
ensurePrivateStateDirectory(cwd, path.join(stateRoot(cwd), "tasks"), "Piagent task state directory");
|
|
1579
1652
|
}
|
|
1580
1653
|
|
|
1581
1654
|
function ensureProjectContextPlaceholder(cwd: string): void {
|
|
1582
|
-
|
|
1583
|
-
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" });
|
|
1584
1657
|
if (fs.existsSync(target)) return;
|
|
1585
1658
|
fs.writeFileSync(target, [
|
|
1586
1659
|
"# Project Context",
|
|
@@ -1854,14 +1927,68 @@ function normalizeWorkPlanSteps(values: unknown): WorkPlanStep[] {
|
|
|
1854
1927
|
title,
|
|
1855
1928
|
role,
|
|
1856
1929
|
mode: normalizeStepMode(item.mode, role),
|
|
1857
|
-
status: item.status === "in-progress" || item.status === "done" || item.status === "skipped" ? item.status : "pending",
|
|
1858
|
-
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
|
|
1859
1934
|
});
|
|
1860
1935
|
}
|
|
1861
1936
|
return steps;
|
|
1862
1937
|
}
|
|
1863
1938
|
|
|
1864
|
-
function defaultWorkPlan(
|
|
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
|
+
}
|
|
1865
1992
|
if (riskLane === "tiny") {
|
|
1866
1993
|
return [
|
|
1867
1994
|
{
|
|
@@ -1921,6 +2048,16 @@ function defaultWorkPlan(summary: string, riskLane: TaskContract["riskLane"]): W
|
|
|
1921
2048
|
return steps;
|
|
1922
2049
|
}
|
|
1923
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
|
+
|
|
1924
2061
|
function uniqueStrings(values: string[]): string[] {
|
|
1925
2062
|
return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
|
|
1926
2063
|
}
|
|
@@ -2009,6 +2146,384 @@ function collectPatchTargetPaths(value: unknown, key = "", depth = 0): string[]
|
|
|
2009
2146
|
return paths;
|
|
2010
2147
|
}
|
|
2011
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
|
+
|
|
2012
2527
|
function prepareToolInputForPolicy(
|
|
2013
2528
|
toolName: string,
|
|
2014
2529
|
input: Record<string, unknown>,
|
|
@@ -2465,11 +2980,13 @@ function searchMemoryFiles(cwd: string, profile: ProjectProfile, query: string,
|
|
|
2465
2980
|
}
|
|
2466
2981
|
|
|
2467
2982
|
function writeProjectOnboarding(cwd: string, snapshot: ProjectOnboardingSnapshot, markdown: string): ProjectOnboardingSnapshot {
|
|
2468
|
-
|
|
2983
|
+
ensurePrivateStateDirectory(cwd, path.join(cwd, ".pi"), "Piagent project directory");
|
|
2469
2984
|
ensureStateDirs(cwd);
|
|
2470
2985
|
const safeSnapshot = redactForStorage(snapshot) as ProjectOnboardingSnapshot;
|
|
2471
|
-
|
|
2472
|
-
|
|
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 });
|
|
2473
2990
|
return safeSnapshot;
|
|
2474
2991
|
}
|
|
2475
2992
|
|
|
@@ -3383,21 +3900,78 @@ function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName:
|
|
|
3383
3900
|
return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
|
|
3384
3901
|
}
|
|
3385
3902
|
|
|
3386
|
-
function readTask(cwd: string, taskId: string): TaskContract | undefined {
|
|
3387
|
-
return
|
|
3903
|
+
function readTask(cwd: string, taskId: string, sessionId?: string): TaskContract | undefined {
|
|
3904
|
+
return resolveTaskContract(cwd, taskId, sessionId) as TaskContract | undefined;
|
|
3388
3905
|
}
|
|
3389
3906
|
|
|
3390
3907
|
function writeTask(cwd: string, task: TaskContract): TaskContract {
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3908
|
+
return writeTaskContract(cwd, task) as TaskContract;
|
|
3909
|
+
}
|
|
3910
|
+
|
|
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
|
+
});
|
|
3395
3960
|
}
|
|
3396
3961
|
|
|
3397
|
-
function evaluateTaskGate(
|
|
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
|
+
): {
|
|
3398
3971
|
decision: "pass" | "fail";
|
|
3399
3972
|
missing: string[];
|
|
3400
3973
|
warnings: string[];
|
|
3974
|
+
changedFileEvidence?: ReturnType<typeof taskChangedFileEvidence>;
|
|
3401
3975
|
} {
|
|
3402
3976
|
const finalGate = finalGateConfig(policy);
|
|
3403
3977
|
const missing: string[] = [];
|
|
@@ -3405,20 +3979,90 @@ function evaluateTaskGate(task: TaskContract | undefined, policy: BasePolicy): {
|
|
|
3405
3979
|
if (!task) {
|
|
3406
3980
|
return { decision: "fail", missing: ["task contract"], warnings };
|
|
3407
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");
|
|
3408
3988
|
if (finalGate.requireContextManifest && task.contextManifest.length === 0) missing.push("context manifest");
|
|
3409
|
-
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");
|
|
3410
3990
|
if (task.verifyEvidence.some((evidence) => evidence.observed !== true)) {
|
|
3411
3991
|
warnings.push("Unobserved verify evidence is ignored by the passing verify gate.");
|
|
3412
3992
|
}
|
|
3413
3993
|
if (task.verifyEvidence.some((evidence) => evidence.observed === true && evidence.matchedProfileCommand !== true)) {
|
|
3414
3994
|
warnings.push("Observed verify evidence that does not exactly match task verifyCommands is advisory only.");
|
|
3415
3995
|
}
|
|
3416
|
-
if (
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
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);
|
|
3422
4066
|
}
|
|
3423
4067
|
|
|
3424
4068
|
function formatCount(value: number | null | undefined): string {
|
|
@@ -3612,14 +4256,27 @@ function writeToolResultCapture(
|
|
|
3612
4256
|
};
|
|
3613
4257
|
|
|
3614
4258
|
try {
|
|
3615
|
-
|
|
3616
|
-
|
|
4259
|
+
ensurePrivateStateDirectory(cwd, path.dirname(absolutePath), "Tool-result capture directory");
|
|
4260
|
+
const capturePath = resolveLocalStatePath(cwd, absolutePath, { label: "Tool-result capture" });
|
|
4261
|
+
fs.writeFileSync(capturePath, [
|
|
3617
4262
|
"# Piagent compacted tool result capture",
|
|
3618
4263
|
JSON.stringify(metadata),
|
|
3619
4264
|
"---",
|
|
3620
4265
|
capture.text
|
|
3621
|
-
].join("\n"));
|
|
3622
|
-
|
|
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
|
+
}
|
|
3623
4280
|
const summary = {
|
|
3624
4281
|
path: relativePath,
|
|
3625
4282
|
source,
|
|
@@ -3723,16 +4380,9 @@ function attachToolResultCompactionDetails(details: unknown, captures: ToolResul
|
|
|
3723
4380
|
|
|
3724
4381
|
function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
|
|
3725
4382
|
const indexPath = toolResultCaptureIndexPath(cwd);
|
|
3726
|
-
if (!fs.existsSync(indexPath)) return [];
|
|
3727
|
-
const lines = fs.readFileSync(indexPath, "utf8").trim().split(/\n/).filter(Boolean);
|
|
3728
4383
|
const captures: Record<string, unknown>[] = [];
|
|
3729
|
-
for (const
|
|
3730
|
-
|
|
3731
|
-
const parsed = JSON.parse(line);
|
|
3732
|
-
if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
|
|
3733
|
-
} catch {
|
|
3734
|
-
// A partial JSONL line should not make a status command noisy or unusable.
|
|
3735
|
-
}
|
|
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>);
|
|
3736
4386
|
}
|
|
3737
4387
|
return captures.slice(-limit);
|
|
3738
4388
|
}
|
|
@@ -3985,29 +4635,55 @@ function escapeRegExp(input: string): string {
|
|
|
3985
4635
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3986
4636
|
}
|
|
3987
4637
|
|
|
3988
|
-
function supportedImageMimeType(
|
|
3989
|
-
if (
|
|
4638
|
+
function supportedImageMimeType(bytes: Buffer): string | undefined {
|
|
4639
|
+
if (
|
|
4640
|
+
bytes.length >= 24
|
|
4641
|
+
&& bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
4642
|
+
&& bytes.readUInt32BE(8) === 13
|
|
4643
|
+
&& bytes.subarray(12, 16).toString("ascii") === "IHDR"
|
|
4644
|
+
&& bytes.readUInt32BE(16) > 0
|
|
4645
|
+
&& bytes.readUInt32BE(20) > 0
|
|
4646
|
+
) {
|
|
3990
4647
|
return "image/png";
|
|
3991
4648
|
}
|
|
3992
|
-
if (
|
|
4649
|
+
if (
|
|
4650
|
+
bytes.length >= 4
|
|
4651
|
+
&& bytes[0] === 0xff
|
|
4652
|
+
&& bytes[1] === 0xd8
|
|
4653
|
+
&& bytes[2] === 0xff
|
|
4654
|
+
&& bytes[bytes.length - 2] === 0xff
|
|
4655
|
+
&& bytes[bytes.length - 1] === 0xd9
|
|
4656
|
+
) {
|
|
3993
4657
|
return "image/jpeg";
|
|
3994
4658
|
}
|
|
3995
|
-
if (bytes.length >=
|
|
4659
|
+
if (bytes.length >= 10) {
|
|
3996
4660
|
const head = bytes.subarray(0, 6).toString("ascii");
|
|
3997
|
-
if (
|
|
4661
|
+
if (
|
|
4662
|
+
(head === "GIF87a" || head === "GIF89a")
|
|
4663
|
+
&& bytes.readUInt16LE(6) > 0
|
|
4664
|
+
&& bytes.readUInt16LE(8) > 0
|
|
4665
|
+
) {
|
|
4666
|
+
return "image/gif";
|
|
4667
|
+
}
|
|
3998
4668
|
}
|
|
3999
|
-
if (
|
|
4669
|
+
if (
|
|
4670
|
+
bytes.length >= 16
|
|
4671
|
+
&& bytes.subarray(0, 4).toString("ascii") === "RIFF"
|
|
4672
|
+
&& bytes.subarray(8, 12).toString("ascii") === "WEBP"
|
|
4673
|
+
&& ["VP8 ", "VP8L", "VP8X"].includes(bytes.subarray(12, 16).toString("ascii"))
|
|
4674
|
+
&& bytes.readUInt32LE(4) + 8 <= bytes.length
|
|
4675
|
+
) {
|
|
4000
4676
|
return "image/webp";
|
|
4001
4677
|
}
|
|
4002
|
-
if (
|
|
4678
|
+
if (
|
|
4679
|
+
bytes.length >= 26
|
|
4680
|
+
&& bytes[0] === 0x42
|
|
4681
|
+
&& bytes[1] === 0x4d
|
|
4682
|
+
&& bytes.readUInt32LE(2) <= bytes.length
|
|
4683
|
+
&& bytes.readUInt32LE(14) >= 12
|
|
4684
|
+
) {
|
|
4003
4685
|
return "image/bmp";
|
|
4004
4686
|
}
|
|
4005
|
-
const ext = path.extname(filePath).toLowerCase().replace(/^\./, "");
|
|
4006
|
-
if (ext === "png") return "image/png";
|
|
4007
|
-
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
|
4008
|
-
if (ext === "gif") return "image/gif";
|
|
4009
|
-
if (ext === "webp") return "image/webp";
|
|
4010
|
-
if (ext === "bmp") return "image/bmp";
|
|
4011
4687
|
return undefined;
|
|
4012
4688
|
}
|
|
4013
4689
|
|
|
@@ -4061,9 +4737,131 @@ function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
|
|
|
4061
4737
|
return [...candidates];
|
|
4062
4738
|
}
|
|
4063
4739
|
|
|
4064
|
-
|
|
4740
|
+
type ResolvedChatImage =
|
|
4741
|
+
| { status: "ok"; absolutePath: string; bytes: Buffer; mimeType: string }
|
|
4742
|
+
| { status: "error"; reason: string };
|
|
4743
|
+
|
|
4744
|
+
function pathContainedBy(root: string, candidate: string): boolean {
|
|
4745
|
+
if (candidate === root) return true;
|
|
4746
|
+
const relative = path.relative(root, candidate);
|
|
4747
|
+
return relative.length > 0
|
|
4748
|
+
&& relative !== ".."
|
|
4749
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
4750
|
+
&& !path.isAbsolute(relative);
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
export function readChatImage(
|
|
4754
|
+
requestedPath: string,
|
|
4755
|
+
cwd: string,
|
|
4756
|
+
access: ChatImageAccessPolicy
|
|
4757
|
+
): ResolvedChatImage {
|
|
4758
|
+
const lexicalRequestedPath = path.isAbsolute(requestedPath)
|
|
4759
|
+
? path.normalize(requestedPath)
|
|
4760
|
+
: path.resolve(cwd, requestedPath);
|
|
4761
|
+
const lexicalProjectRoot = path.resolve(cwd);
|
|
4762
|
+
const requestedProjectLexicalRelative = pathContainedBy(lexicalProjectRoot, lexicalRequestedPath)
|
|
4763
|
+
? path.relative(lexicalProjectRoot, lexicalRequestedPath).split(path.sep).join("/") || "."
|
|
4764
|
+
: undefined;
|
|
4765
|
+
let canonicalPath: string;
|
|
4766
|
+
let inspected: fs.Stats;
|
|
4767
|
+
try {
|
|
4768
|
+
canonicalPath = fs.realpathSync.native(requestedPath);
|
|
4769
|
+
inspected = fs.statSync(canonicalPath);
|
|
4770
|
+
} catch {
|
|
4771
|
+
return { status: "error", reason: "file does not exist or cannot be inspected" };
|
|
4772
|
+
}
|
|
4773
|
+
if (!inspected.isFile()) return { status: "error", reason: "not a regular file" };
|
|
4774
|
+
if (inspected.size <= 0) return { status: "error", reason: "empty file" };
|
|
4775
|
+
if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
|
|
4776
|
+
return {
|
|
4777
|
+
status: "error",
|
|
4778
|
+
reason: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
|
|
4779
|
+
};
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4782
|
+
const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath));
|
|
4783
|
+
if (!root) return { status: "error", reason: "outside the project and every granted additionalReadRoots directory" };
|
|
4784
|
+
|
|
4785
|
+
const rootRelative = path.relative(root.path, canonicalPath).split(path.sep).join("/") || ".";
|
|
4786
|
+
const projectRoot = access.roots.find((candidate) => candidate.source === "project");
|
|
4787
|
+
let canonicalRequestedLocation = requestedPath;
|
|
4788
|
+
try {
|
|
4789
|
+
canonicalRequestedLocation = path.join(
|
|
4790
|
+
fs.realpathSync.native(path.dirname(requestedPath)),
|
|
4791
|
+
path.basename(requestedPath)
|
|
4792
|
+
);
|
|
4793
|
+
} catch {
|
|
4794
|
+
// The full target already resolved. This location form only preserves the
|
|
4795
|
+
// final symlink's policy position, so failure falls back to the raw path.
|
|
4796
|
+
}
|
|
4797
|
+
const requestedProjectRelative = projectRoot && pathContainedBy(projectRoot.path, canonicalRequestedLocation)
|
|
4798
|
+
? path.relative(projectRoot.path, canonicalRequestedLocation).split(path.sep).join("/") || "."
|
|
4799
|
+
: undefined;
|
|
4800
|
+
const protectedMatch = matchesProtectedPath(canonicalPath, access.readProtectedPaths)
|
|
4801
|
+
?? matchesProtectedPath(rootRelative, access.readProtectedPaths)
|
|
4802
|
+
?? matchesProtectedPath(canonicalRequestedLocation, access.readProtectedPaths)
|
|
4803
|
+
?? matchesProtectedPath(requestedPath, access.readProtectedPaths)
|
|
4804
|
+
?? (requestedProjectLexicalRelative
|
|
4805
|
+
? matchesProtectedPath(requestedProjectLexicalRelative, access.readProtectedPaths)
|
|
4806
|
+
: undefined)
|
|
4807
|
+
?? (requestedProjectRelative ? matchesProtectedPath(requestedProjectRelative, access.readProtectedPaths) : undefined);
|
|
4808
|
+
if (protectedMatch) return { status: "error", reason: `matches protected path ${protectedMatch}` };
|
|
4809
|
+
|
|
4810
|
+
if (
|
|
4811
|
+
root.source === "project"
|
|
4812
|
+
&& access.enforceFilesystemRead
|
|
4813
|
+
&& access.filesystemRead
|
|
4814
|
+
&& !matchesAnyPath(rootRelative, access.filesystemRead)
|
|
4815
|
+
) {
|
|
4816
|
+
return {
|
|
4817
|
+
status: "error",
|
|
4818
|
+
reason: `outside the resolved filesystem read scope (${access.filesystemRead.join(", ")})`
|
|
4819
|
+
};
|
|
4820
|
+
}
|
|
4821
|
+
|
|
4822
|
+
access.onImageInspected?.(canonicalPath);
|
|
4823
|
+
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW;
|
|
4824
|
+
let fd: number;
|
|
4825
|
+
try {
|
|
4826
|
+
fd = fs.openSync(canonicalPath, openFlags);
|
|
4827
|
+
} catch {
|
|
4828
|
+
return { status: "error", reason: "file changed before it could be opened" };
|
|
4829
|
+
}
|
|
4830
|
+
try {
|
|
4831
|
+
const opened = fs.fstatSync(fd);
|
|
4832
|
+
if (
|
|
4833
|
+
!opened.isFile()
|
|
4834
|
+
|| opened.dev !== inspected.dev
|
|
4835
|
+
|| opened.ino !== inspected.ino
|
|
4836
|
+
|| opened.size !== inspected.size
|
|
4837
|
+
) {
|
|
4838
|
+
return { status: "error", reason: "file changed between the safety checks and the read" };
|
|
4839
|
+
}
|
|
4840
|
+
const bytes = Buffer.alloc(opened.size);
|
|
4841
|
+
let offset = 0;
|
|
4842
|
+
while (offset < bytes.length) {
|
|
4843
|
+
const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
4844
|
+
if (read <= 0) break;
|
|
4845
|
+
offset += read;
|
|
4846
|
+
}
|
|
4847
|
+
if (offset !== bytes.length) return { status: "error", reason: "file changed while it was being read" };
|
|
4848
|
+
const mimeType = supportedImageMimeType(bytes);
|
|
4849
|
+
if (!mimeType) return { status: "error", reason: "file bytes are not a supported image" };
|
|
4850
|
+
return { status: "ok", absolutePath: canonicalPath, bytes, mimeType };
|
|
4851
|
+
} finally {
|
|
4852
|
+
fs.closeSync(fd);
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
function attachLocalImagesFromText(
|
|
4857
|
+
text: string,
|
|
4858
|
+
existingImages: unknown[] | undefined,
|
|
4859
|
+
cwd: string,
|
|
4860
|
+
resolveAccess: () => ChatImageAccessPolicy
|
|
4861
|
+
): ChatImageAttachmentResult | undefined {
|
|
4065
4862
|
const imagePaths = extractLocalImagePathCandidates(text, cwd);
|
|
4066
4863
|
if (imagePaths.length === 0) return undefined;
|
|
4864
|
+
const access = resolveAccess();
|
|
4067
4865
|
|
|
4068
4866
|
const existing = Array.isArray(existingImages) ? existingImages : [];
|
|
4069
4867
|
const images: ChatImageAttachmentResult["images"] = [];
|
|
@@ -4077,28 +4875,14 @@ function attachLocalImagesFromText(text: string, existingImages: unknown[] | und
|
|
|
4077
4875
|
continue;
|
|
4078
4876
|
}
|
|
4079
4877
|
try {
|
|
4080
|
-
const
|
|
4081
|
-
if (
|
|
4082
|
-
skipped.push({ path: imagePath, reason:
|
|
4083
|
-
continue;
|
|
4084
|
-
}
|
|
4085
|
-
if (stat.size <= 0) {
|
|
4086
|
-
skipped.push({ path: imagePath, reason: "empty file" });
|
|
4087
|
-
continue;
|
|
4088
|
-
}
|
|
4089
|
-
if (stat.size > MAX_CHAT_IMAGE_BYTES) {
|
|
4090
|
-
skipped.push({ path: imagePath, reason: `image is ${formatCount(stat.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit; use read on the file so Pi can resize it` });
|
|
4091
|
-
continue;
|
|
4092
|
-
}
|
|
4093
|
-
const bytes = fs.readFileSync(imagePath);
|
|
4094
|
-
const mimeType = supportedImageMimeType(imagePath, bytes);
|
|
4095
|
-
if (!mimeType) {
|
|
4096
|
-
skipped.push({ path: imagePath, reason: "unsupported image type" });
|
|
4878
|
+
const resolved = readChatImage(imagePath, cwd, access);
|
|
4879
|
+
if (resolved.status === "error") {
|
|
4880
|
+
skipped.push({ path: imagePath, reason: resolved.reason });
|
|
4097
4881
|
continue;
|
|
4098
4882
|
}
|
|
4099
4883
|
const marker = `[image${existing.length + images.length + 1}]`;
|
|
4100
|
-
images.push({ type: "image", mimeType, data: bytes.toString("base64") });
|
|
4101
|
-
attached.push({ marker, path: imagePath, mimeType, bytes:
|
|
4884
|
+
images.push({ type: "image", mimeType: resolved.mimeType, data: resolved.bytes.toString("base64") });
|
|
4885
|
+
attached.push({ marker, path: imagePath, mimeType: resolved.mimeType, bytes: resolved.bytes.length });
|
|
4102
4886
|
nextText = nextText.replace(new RegExp(escapeRegExp(imagePath), "g"), marker);
|
|
4103
4887
|
nextText = nextText.replace(new RegExp(escapeRegExp(`file://${imagePath}`), "g"), marker);
|
|
4104
4888
|
} catch (error) {
|
|
@@ -4127,7 +4911,7 @@ function attachLocalImagesFromText(text: string, existingImages: unknown[] | und
|
|
|
4127
4911
|
function appendTrace(cwd: string, payload: Record<string, unknown>): void {
|
|
4128
4912
|
ensureStateDirs(cwd);
|
|
4129
4913
|
const safePayload = redactForStorage(payload) as Record<string, unknown>;
|
|
4130
|
-
|
|
4914
|
+
appendJsonlBounded(traceFilePath(cwd), { recordedAt: nowIso(), ...safePayload }, { maxBytes: TRACE_MAX_BYTES, mode: 0o600, projectRoot: cwd });
|
|
4131
4915
|
}
|
|
4132
4916
|
|
|
4133
4917
|
function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>): void {
|
|
@@ -4139,8 +4923,51 @@ function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>):
|
|
|
4139
4923
|
});
|
|
4140
4924
|
}
|
|
4141
4925
|
|
|
4142
|
-
function
|
|
4143
|
-
|
|
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 };
|
|
4144
4971
|
}
|
|
4145
4972
|
|
|
4146
4973
|
function parseReferenceRepoRef(input: string): { host: string; owner: string; repo: string } {
|
|
@@ -4262,7 +5089,7 @@ function checkoutReferenceRepo(repoRef: string, forceUpdate = false): ReferenceR
|
|
|
4262
5089
|
function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
|
|
4263
5090
|
const signal = classifyContextTask(prompt);
|
|
4264
5091
|
const lower = prompt.toLowerCase();
|
|
4265
|
-
const groups = new Set<PiagentToolGroup>(
|
|
5092
|
+
const groups = new Set<PiagentToolGroup>();
|
|
4266
5093
|
|
|
4267
5094
|
if (signal.workflow === "usage") {
|
|
4268
5095
|
groups.add("usage");
|
|
@@ -4277,45 +5104,110 @@ function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
|
|
|
4277
5104
|
return [...groups];
|
|
4278
5105
|
}
|
|
4279
5106
|
|
|
4280
|
-
groups.add("
|
|
4281
|
-
|
|
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");
|
|
4282
5111
|
if (
|
|
4283
|
-
|
|
4284
|
-
|| ["scout", "review", "plan", "release"].includes(signal.workflow)
|
|
4285
|
-
|| /\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)
|
|
4286
5113
|
) {
|
|
4287
5114
|
groups.add("retrieval");
|
|
4288
5115
|
}
|
|
4289
5116
|
if (
|
|
4290
|
-
|
|
4291
|
-
|| ["scout", "review", "plan", "release"].includes(signal.workflow)
|
|
4292
|
-
|| /\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)
|
|
4293
5118
|
) {
|
|
4294
5119
|
groups.add("knowledge");
|
|
4295
5120
|
}
|
|
5121
|
+
if (/\b(permission|capability|exec policy|tool policy)\b/.test(lower)) groups.add("policy");
|
|
4296
5122
|
if (/\b(profile|tech stack|onboard|project context)\b/.test(lower)) groups.add("onboarding");
|
|
4297
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");
|
|
4298
5125
|
return [...groups];
|
|
4299
5126
|
}
|
|
4300
5127
|
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
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;
|
|
4312
5199
|
}
|
|
4313
5200
|
|
|
4314
|
-
function
|
|
4315
|
-
|
|
5201
|
+
function compactSessionTask(cwd: string, sessionId: string): TaskContract | undefined {
|
|
5202
|
+
return activeSessionTask(cwd, sessionId) as TaskContract | undefined;
|
|
5203
|
+
}
|
|
5204
|
+
|
|
5205
|
+
function semanticCompactionInstructions(cwd: string, sessionId: string): string {
|
|
5206
|
+
const task = compactSessionTask(cwd, sessionId);
|
|
4316
5207
|
const taskState = task
|
|
4317
5208
|
? [
|
|
4318
5209
|
`Current task: ${task.taskId} (${task.riskLane})`,
|
|
5210
|
+
`Session: ${task.sessionName ?? task.sessionId}`,
|
|
4319
5211
|
`Goal: ${task.summary}`,
|
|
4320
5212
|
`Acceptance: ${task.acceptanceCriteria.join("; ") || "not recorded"}`,
|
|
4321
5213
|
`Scope: ${task.scope.join(", ") || "not recorded"}`,
|
|
@@ -4348,6 +5240,48 @@ function environmentFeatureEnabled(name: string, fallback = true): boolean {
|
|
|
4348
5240
|
return !["0", "false", "no", "off", "disabled"].includes(value);
|
|
4349
5241
|
}
|
|
4350
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
|
+
|
|
4351
5285
|
export default function piagentGuard(pi: ExtensionAPI) {
|
|
4352
5286
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
4353
5287
|
const policy = loadPolicy(extensionDir);
|
|
@@ -4355,32 +5289,136 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4355
5289
|
const dynamicToolsEnabled = environmentFeatureEnabled("PIAGENT_DYNAMIC_TOOLS");
|
|
4356
5290
|
const contextTelemetryEnabled = environmentFeatureEnabled("PIAGENT_CONTEXT_TELEMETRY");
|
|
4357
5291
|
const autoContextEnabled = environmentFeatureEnabled("PIAGENT_AUTO_CONTEXT");
|
|
5292
|
+
const autoRecoveryEnabled = environmentFeatureEnabled("PIAGENT_AUTO_RECOVERY");
|
|
4358
5293
|
// Advisory tool-registry verdicts are surfaced once per tool per session. A
|
|
4359
5294
|
// notice on every call would be constant noise for a tool used all day, and
|
|
4360
5295
|
// noise that is always present is read as background rather than as a warning.
|
|
4361
5296
|
const advisedTools = new Set<string>();
|
|
4362
5297
|
const seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
|
|
4363
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>>();
|
|
4364
5304
|
|
|
4365
|
-
function
|
|
4366
|
-
|
|
4367
|
-
try {
|
|
4368
|
-
const safePayload = redactForStorage(payload) as Record<string, unknown>;
|
|
4369
|
-
appendContextTelemetry(ctx.cwd, {
|
|
4370
|
-
sessionId: ctx.sessionManager.getSessionId(),
|
|
4371
|
-
sessionName: currentSessionName(ctx),
|
|
4372
|
-
model: `${ctx.model?.provider ?? "unknown"}/${ctx.model?.id ?? "unknown"}`,
|
|
4373
|
-
thinkingLevel: String(pi.getThinkingLevel()),
|
|
4374
|
-
...safePayload
|
|
4375
|
-
});
|
|
4376
|
-
} catch {
|
|
4377
|
-
// Telemetry is local observability. It must never block the agent loop.
|
|
4378
|
-
}
|
|
5305
|
+
function sessionCacheKey(ctx: ExtensionContext): string {
|
|
5306
|
+
return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}`;
|
|
4379
5307
|
}
|
|
4380
5308
|
|
|
4381
|
-
function
|
|
4382
|
-
const
|
|
4383
|
-
if (
|
|
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
|
+
}
|
|
5393
|
+
|
|
5394
|
+
function telemetry(ctx: ExtensionContext, payload: Record<string, unknown>): void {
|
|
5395
|
+
if (!contextTelemetryEnabled) return;
|
|
5396
|
+
try {
|
|
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
|
+
}
|
|
5406
|
+
appendContextTelemetry(ctx.cwd, {
|
|
5407
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
5408
|
+
sessionName: currentSessionName(ctx),
|
|
5409
|
+
model: `${ctx.model?.provider ?? "unknown"}/${ctx.model?.id ?? "unknown"}`,
|
|
5410
|
+
thinkingLevel: String(pi.getThinkingLevel()),
|
|
5411
|
+
...taskIdentity,
|
|
5412
|
+
...safePayload
|
|
5413
|
+
});
|
|
5414
|
+
} catch {
|
|
5415
|
+
// Telemetry is local observability. It must never block the agent loop.
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5419
|
+
function activateToolGroups(ctx: ExtensionContext, groups: PiagentToolGroup[], additive = false): string[] {
|
|
5420
|
+
const current = pi.getActiveTools();
|
|
5421
|
+
if (!dynamicToolsEnabled) return current;
|
|
4384
5422
|
const selected = new Set<string>();
|
|
4385
5423
|
if (additive) {
|
|
4386
5424
|
for (const toolName of current) selected.add(toolName);
|
|
@@ -4389,7 +5427,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4389
5427
|
if (!PIAGENT_TOOL_NAMES.has(toolName)) selected.add(toolName);
|
|
4390
5428
|
}
|
|
4391
5429
|
}
|
|
4392
|
-
const normalizedGroups = [...new Set<PiagentToolGroup>(
|
|
5430
|
+
const normalizedGroups = [...new Set<PiagentToolGroup>(groups)];
|
|
4393
5431
|
for (const toolName of PIAGENT_TOOL_ORDER) {
|
|
4394
5432
|
if (normalizedGroups.some((group) => PIAGENT_TOOL_GROUPS[group].includes(toolName as never))) {
|
|
4395
5433
|
selected.add(toolName);
|
|
@@ -4422,6 +5460,42 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4422
5460
|
if (!hasOperatorSessionName(operatorSessionName)) {
|
|
4423
5461
|
pi.setSessionName(`pi:${name}`);
|
|
4424
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));
|
|
4425
5499
|
const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
|
|
4426
5500
|
? ""
|
|
4427
5501
|
: " (run /onboard to select a profile)";
|
|
@@ -4462,7 +5536,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4462
5536
|
} catch (error) {
|
|
4463
5537
|
engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
|
|
4464
5538
|
}
|
|
4465
|
-
telemetry(ctx, {
|
|
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
|
+
});
|
|
4466
5565
|
});
|
|
4467
5566
|
|
|
4468
5567
|
pi.on("input", async (event, ctx) => {
|
|
@@ -4472,7 +5571,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4472
5571
|
return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
|
|
4473
5572
|
}
|
|
4474
5573
|
const taskSignal = classifyContextTask(text);
|
|
4475
|
-
|
|
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);
|
|
4476
5586
|
telemetry(ctx, {
|
|
4477
5587
|
event: "user_input",
|
|
4478
5588
|
source: event.source,
|
|
@@ -4480,11 +5590,28 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4480
5590
|
promptChars: taskSignal.promptChars,
|
|
4481
5591
|
workflow: taskSignal.workflow,
|
|
4482
5592
|
riskLane: taskSignal.lane,
|
|
5593
|
+
intakeMode: runtimeIntake ? "runtime" : "model",
|
|
5594
|
+
protectedTarget,
|
|
4483
5595
|
explicitPathCount: taskSignal.paths.length,
|
|
4484
5596
|
termCount: taskSignal.terms.length
|
|
4485
5597
|
});
|
|
4486
5598
|
|
|
4487
|
-
const imageAttachment = attachLocalImagesFromText(text, event.images, ctx.cwd)
|
|
5599
|
+
const imageAttachment = attachLocalImagesFromText(text, event.images, ctx.cwd, () => {
|
|
5600
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
5601
|
+
const profile = loadProfileFromContext(ctx);
|
|
5602
|
+
const permissionProfile = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
|
|
5603
|
+
const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted);
|
|
5604
|
+
return {
|
|
5605
|
+
roots: resolveDocumentRoots({
|
|
5606
|
+
cwd: ctx.cwd,
|
|
5607
|
+
profileRoots: profile.additionalReadRoots,
|
|
5608
|
+
environmentRoots: process.env.PIAGENT_ADDITIONAL_READ_ROOTS
|
|
5609
|
+
}),
|
|
5610
|
+
readProtectedPaths: effectiveProtectedPaths(policy, profile).readProtectedPaths,
|
|
5611
|
+
filesystemRead: capabilityState.filesystemRead,
|
|
5612
|
+
enforceFilesystemRead: permissionProfile.mode !== "trusted-full-access"
|
|
5613
|
+
};
|
|
5614
|
+
});
|
|
4488
5615
|
if (imageAttachment?.attached.length) {
|
|
4489
5616
|
ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
|
|
4490
5617
|
} else if (imageAttachment?.skipped.length) {
|
|
@@ -4528,6 +5655,26 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4528
5655
|
});
|
|
4529
5656
|
|
|
4530
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;
|
|
4531
5678
|
const active = new Set<string>(pi.getActiveTools() as string[]);
|
|
4532
5679
|
const toolMetadata = pi.getAllTools()
|
|
4533
5680
|
.filter((tool) => active.has(tool.name))
|
|
@@ -4538,9 +5685,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4538
5685
|
promptGuidelines: tool.promptGuidelines
|
|
4539
5686
|
}));
|
|
4540
5687
|
const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
|
|
4541
|
-
const systemPromptTokens = estimateContextTokens(
|
|
4542
|
-
const
|
|
4543
|
-
|
|
5688
|
+
const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
|
|
5689
|
+
const autoPackUseful = activeTask?.trace.outcome !== "pending"
|
|
5690
|
+
&& (runtimeIntake || signal.paths.length === 0);
|
|
4544
5691
|
telemetry(ctx, {
|
|
4545
5692
|
event: "agent_prompt",
|
|
4546
5693
|
promptHash: signal.promptHash,
|
|
@@ -4551,21 +5698,54 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4551
5698
|
activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
|
|
4552
5699
|
toolSchemaTokens,
|
|
4553
5700
|
systemPromptTokens,
|
|
4554
|
-
systemPromptHash: crypto.createHash("sha256").update(
|
|
5701
|
+
systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
|
|
5702
|
+
legacyProjectInstructionsRewritten: projectInstructions.rewritten,
|
|
5703
|
+
managedInstructionsCompacted: compactedInstructions.compacted,
|
|
4555
5704
|
contextUsage: ctx.getContextUsage()
|
|
4556
5705
|
});
|
|
4557
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
|
+
|
|
4558
5738
|
if (
|
|
4559
5739
|
query.length < 20
|
|
4560
5740
|
|| signal.workflow === "usage"
|
|
4561
5741
|
|| !autoContextEnabled
|
|
4562
|
-
||
|
|
4563
|
-
||
|
|
5742
|
+
|| !autoPackUseful
|
|
5743
|
+
|| autoPackedPrompts.has(promptPackKey(ctx, signal.promptHash))
|
|
4564
5744
|
) {
|
|
4565
|
-
return;
|
|
5745
|
+
return finishAgentStart();
|
|
4566
5746
|
}
|
|
4567
5747
|
|
|
4568
|
-
autoPackedPrompts.add(signal.promptHash);
|
|
5748
|
+
autoPackedPrompts.add(promptPackKey(ctx, signal.promptHash));
|
|
4569
5749
|
if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
|
|
4570
5750
|
try {
|
|
4571
5751
|
const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
|
|
@@ -4583,13 +5763,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4583
5763
|
selected: 0,
|
|
4584
5764
|
skipped: status.exists ? "stale-index" : "missing-index"
|
|
4585
5765
|
});
|
|
4586
|
-
return;
|
|
5766
|
+
return finishAgentStart();
|
|
4587
5767
|
}
|
|
4588
|
-
const budgetTokens =
|
|
5768
|
+
const budgetTokens = runtimeIntake
|
|
5769
|
+
? 900
|
|
5770
|
+
: signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
|
|
4589
5771
|
const pack = await buildContextPack(ctx.cwd, query, {
|
|
4590
5772
|
budgetTokens,
|
|
4591
|
-
includeCode:
|
|
4592
|
-
|
|
5773
|
+
includeCode: runtimeIntake,
|
|
5774
|
+
includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
|
|
5775
|
+
currentSnapshot: runtimeIntake,
|
|
5776
|
+
limit: runtimeIntake ? 8 : 12,
|
|
4593
5777
|
excludePatterns
|
|
4594
5778
|
});
|
|
4595
5779
|
telemetry(ctx, {
|
|
@@ -4600,23 +5784,35 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4600
5784
|
selected: pack.selected.length,
|
|
4601
5785
|
estimatedTokens: pack.estimatedTokens,
|
|
4602
5786
|
selectedPaths: pack.selected.map((item) => item.path),
|
|
4603
|
-
finderRecommended: pack.finderRecommended
|
|
5787
|
+
finderRecommended: pack.finderRecommended,
|
|
5788
|
+
currentSnapshot: runtimeIntake
|
|
4604
5789
|
});
|
|
4605
|
-
if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return;
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
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
|
|
4618
5814
|
}
|
|
4619
|
-
};
|
|
5815
|
+
});
|
|
4620
5816
|
} catch (error) {
|
|
4621
5817
|
telemetry(ctx, {
|
|
4622
5818
|
event: "context_pack",
|
|
@@ -4626,6 +5822,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4626
5822
|
selected: 0,
|
|
4627
5823
|
error: error instanceof Error ? error.message : String(error)
|
|
4628
5824
|
});
|
|
5825
|
+
return finishAgentStart();
|
|
4629
5826
|
}
|
|
4630
5827
|
});
|
|
4631
5828
|
|
|
@@ -4642,6 +5839,158 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4642
5839
|
});
|
|
4643
5840
|
});
|
|
4644
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
|
+
|
|
4645
5994
|
pi.on("session_compact", async (event, ctx) => {
|
|
4646
5995
|
telemetry(ctx, {
|
|
4647
5996
|
event: "session_compact",
|
|
@@ -4652,11 +6001,20 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4652
6001
|
});
|
|
4653
6002
|
|
|
4654
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
|
+
);
|
|
4655
6011
|
telemetry(ctx, {
|
|
4656
6012
|
event: "session_shutdown",
|
|
4657
6013
|
reason: event.reason,
|
|
4658
6014
|
targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
|
|
4659
6015
|
});
|
|
6016
|
+
clearObservedContext(ctx);
|
|
6017
|
+
clearShellMutationSnapshots(ctx);
|
|
4660
6018
|
});
|
|
4661
6019
|
|
|
4662
6020
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -4670,13 +6028,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4670
6028
|
appendObservedBashResult(observedBashLedgerPath(ctx.cwd), {
|
|
4671
6029
|
...observed,
|
|
4672
6030
|
redactedCommand: redactText(observed.command)
|
|
4673
|
-
});
|
|
6031
|
+
}, { projectRoot: ctx.cwd });
|
|
4674
6032
|
} catch (error) {
|
|
4675
6033
|
const message = error instanceof Error ? error.message : String(error);
|
|
4676
6034
|
ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
|
|
4677
6035
|
}
|
|
4678
6036
|
}
|
|
4679
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
|
+
|
|
4680
6048
|
let resultContent: unknown = event.content;
|
|
4681
6049
|
let resultDetails: unknown = event.details;
|
|
4682
6050
|
let resultChanged = false;
|
|
@@ -4836,6 +6204,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4836
6204
|
const toolInput = event.input && typeof event.input === "object"
|
|
4837
6205
|
? event.input as Record<string, unknown>
|
|
4838
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
|
+
}
|
|
4839
6219
|
const preparedInput = prepareToolInputForPolicy(event.toolName, toolInput, policy);
|
|
4840
6220
|
if (preparedInput.reason) {
|
|
4841
6221
|
return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
|
|
@@ -4848,6 +6228,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4848
6228
|
return { block: true, reason: approvalDecision.reason };
|
|
4849
6229
|
}
|
|
4850
6230
|
|
|
6231
|
+
let shellProjectMutation = false;
|
|
4851
6232
|
if (SHELL_TOOL_NAMES.has(event.toolName)) {
|
|
4852
6233
|
const shellInput = extractShellCommandInput(toolInput);
|
|
4853
6234
|
if (!shellInput.command) {
|
|
@@ -4855,6 +6236,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4855
6236
|
}
|
|
4856
6237
|
const command = normalizeShellCommandForPolicy(shellInput.command);
|
|
4857
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
|
+
}
|
|
4858
6246
|
if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
|
|
4859
6247
|
return { block: true, reason: execDecision.reasons.join("; ") };
|
|
4860
6248
|
}
|
|
@@ -4953,6 +6341,34 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4953
6341
|
}
|
|
4954
6342
|
}
|
|
4955
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
|
+
|
|
4956
6372
|
if (runtime.contextBudget !== "off" && ["write", "edit"].includes(event.toolName)) {
|
|
4957
6373
|
const relativePath = extractLikelyPathFromInput(ctx.cwd, event.input as Record<string, unknown>);
|
|
4958
6374
|
if (relativePath) {
|
|
@@ -4963,18 +6379,27 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4963
6379
|
}
|
|
4964
6380
|
}
|
|
4965
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
|
+
}
|
|
4966
6390
|
});
|
|
4967
6391
|
|
|
4968
6392
|
pi.registerTool({
|
|
4969
6393
|
name: "piagent_tools",
|
|
4970
6394
|
label: "Piagent Tool Loader",
|
|
4971
6395
|
description: "Activate an additional Piagent tool group only when the current task needs it.",
|
|
4972
|
-
promptSnippet: "Load
|
|
6396
|
+
promptSnippet: "Load diagnostic or recovery Piagent tools only when the runtime requests them.",
|
|
4973
6397
|
promptGuidelines: [
|
|
4974
|
-
"
|
|
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."
|
|
4975
6400
|
],
|
|
4976
6401
|
parameters: Type.Object({
|
|
4977
|
-
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 })
|
|
4978
6403
|
}),
|
|
4979
6404
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
4980
6405
|
const groups = [...new Set(params.groups)] as PiagentToolGroup[];
|
|
@@ -5012,7 +6437,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5012
6437
|
action: StringEnum(["status", "rebuild", "search", "pack", "impact", "efficiency"] as const),
|
|
5013
6438
|
query: Type.Optional(Type.String()),
|
|
5014
6439
|
files: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
5015
|
-
budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum:
|
|
6440
|
+
budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 12000 })),
|
|
5016
6441
|
refresh: Type.Optional(Type.Boolean())
|
|
5017
6442
|
}),
|
|
5018
6443
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -5059,6 +6484,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5059
6484
|
].join("\n");
|
|
5060
6485
|
} else if (params.action === "pack") {
|
|
5061
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
|
+
}
|
|
5062
6504
|
const ensured = await ensureContextIndexV2(ctx.cwd, {
|
|
5063
6505
|
excludePatterns,
|
|
5064
6506
|
refresh: params.refresh,
|
|
@@ -5066,7 +6508,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5066
6508
|
});
|
|
5067
6509
|
const status = ensured.status;
|
|
5068
6510
|
const pack = await buildContextPack(ctx.cwd, params.query, {
|
|
5069
|
-
budgetTokens: params.budgetTokens ??
|
|
6511
|
+
budgetTokens: params.budgetTokens ?? 2_400,
|
|
5070
6512
|
includeCode: true,
|
|
5071
6513
|
limit: 18,
|
|
5072
6514
|
excludePatterns
|
|
@@ -5326,11 +6768,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5326
6768
|
description: "Check whether a governed task has enough context, verify evidence, and trace before claiming done.",
|
|
5327
6769
|
promptSnippet: "Use this before final on source-changing tasks.",
|
|
5328
6770
|
parameters: Type.Object({
|
|
5329
|
-
taskId: Type.String({ minLength: 1 })
|
|
6771
|
+
taskId: Type.String({ minLength: 1 }),
|
|
6772
|
+
changedFiles: Type.Optional(Type.Array(Type.String()))
|
|
5330
6773
|
}),
|
|
5331
6774
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
5332
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
5333
|
-
const
|
|
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);
|
|
5334
6782
|
const runtime = resolveRuntimePolicy(loadProfileFromContext(ctx));
|
|
5335
6783
|
const text = [
|
|
5336
6784
|
`decision: ${result.decision}`,
|
|
@@ -5338,7 +6786,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5338
6786
|
`missing: ${result.missing.join(", ") || "none"}`,
|
|
5339
6787
|
`warnings: ${result.warnings.join("; ") || "none"}`
|
|
5340
6788
|
].join("\n");
|
|
5341
|
-
return { content: [{ type: "text", text }], details: { ...result, task } };
|
|
6789
|
+
return { content: [{ type: "text", text }], details: { ...result, task: projected } };
|
|
5342
6790
|
}
|
|
5343
6791
|
});
|
|
5344
6792
|
|
|
@@ -5956,24 +7404,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5956
7404
|
}
|
|
5957
7405
|
});
|
|
5958
7406
|
|
|
5959
|
-
|
|
7407
|
+
const taskStartTool = {
|
|
5960
7408
|
name: "piagent_task_start",
|
|
5961
7409
|
label: "Piagent Task Start",
|
|
5962
7410
|
description: "Create a Task Implementation Contract for the current project before editing.",
|
|
5963
7411
|
promptSnippet: "Start a governed implementation task and persist the task contract.",
|
|
5964
7412
|
promptGuidelines: [
|
|
5965
|
-
"Call this before source edits in a project managed by Pi Agent Platform.",
|
|
5966
|
-
"
|
|
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.",
|
|
5967
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.",
|
|
5968
|
-
"
|
|
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."
|
|
5969
7419
|
],
|
|
5970
7420
|
parameters: Type.Object({
|
|
5971
7421
|
taskId: Type.Optional(Type.String({ minLength: 1 })),
|
|
5972
7422
|
summary: Type.String({ minLength: 10 }),
|
|
5973
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 })),
|
|
5974
7427
|
expectedOutput: Type.String({ minLength: 10 }),
|
|
5975
7428
|
acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
5976
|
-
scope: Type.Array(Type.String({
|
|
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 }),
|
|
5977
7433
|
outOfScope: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
5978
7434
|
reviewLenses: Type.Optional(Type.Array(StringEnum(REVIEW_LENSES))),
|
|
5979
7435
|
workPlan: Type.Optional(Type.Array(Type.Object({
|
|
@@ -5981,33 +7437,143 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5981
7437
|
title: Type.String({ minLength: 1 }),
|
|
5982
7438
|
role: Type.Optional(StringEnum(ORCHESTRATION_ROLES)),
|
|
5983
7439
|
mode: Type.Optional(StringEnum(["read-only", "single-writer", "review"] as const)),
|
|
5984
|
-
status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped"] as const)),
|
|
5985
|
-
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())
|
|
5986
7443
|
})))
|
|
5987
7444
|
}),
|
|
5988
|
-
async execute(
|
|
7445
|
+
async execute(
|
|
7446
|
+
_toolCallId: string,
|
|
7447
|
+
params: TaskStartParameters,
|
|
7448
|
+
_signal: AbortSignal | undefined,
|
|
7449
|
+
_onUpdate: ((update: unknown) => void) | undefined,
|
|
7450
|
+
ctx: ExtensionContext
|
|
7451
|
+
) {
|
|
5989
7452
|
const profile = loadProfileFromContext(ctx);
|
|
5990
7453
|
const createdAt = nowIso();
|
|
5991
7454
|
const safeSummary = redactText(params.summary);
|
|
5992
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
|
+
}
|
|
5993
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
|
+
}
|
|
5994
7537
|
const reviewLenses = normalizeReviewLenses(params.reviewLenses, orchestration.defaultReviewLenses);
|
|
5995
7538
|
const providedWorkPlan = normalizeWorkPlanSteps(params.workPlan);
|
|
5996
|
-
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>;
|
|
5997
7553
|
const task: TaskContract = {
|
|
7554
|
+
schemaVersion: 2,
|
|
7555
|
+
taskRunId,
|
|
5998
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),
|
|
5999
7563
|
summary: safeSummary,
|
|
6000
7564
|
riskLane: params.riskLane,
|
|
7565
|
+
intakeMode: params.intakeMode === "runtime" ? "runtime" : "model",
|
|
6001
7566
|
expectedOutput: redactText(params.expectedOutput),
|
|
6002
7567
|
acceptanceCriteria: redactTextArray(params.acceptanceCriteria),
|
|
6003
7568
|
scope: redactTextArray(params.scope),
|
|
6004
7569
|
outOfScope: redactTextArray(params.outOfScope),
|
|
6005
7570
|
protectedPaths: profile.protectedPaths ?? [],
|
|
6006
7571
|
requiredContext: profile.requiredContext ?? [],
|
|
6007
|
-
contextManifest:
|
|
7572
|
+
contextManifest: seededContext,
|
|
6008
7573
|
memoryCitations: [],
|
|
6009
7574
|
mcpCapabilities: profile.mcpCapabilities ?? [],
|
|
6010
|
-
|
|
7575
|
+
verifyGroup: verifyPlan.group,
|
|
7576
|
+
verifyCommands: verifyPlan.commands,
|
|
6011
7577
|
workPlan,
|
|
6012
7578
|
reviewLenses,
|
|
6013
7579
|
orchestration: {
|
|
@@ -6017,19 +7583,229 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6017
7583
|
fieldGuidePath: orchestration.fieldGuide.enabled ? orchestration.fieldGuide.path : undefined,
|
|
6018
7584
|
modelRoles: orchestration.roleModelGuidance
|
|
6019
7585
|
},
|
|
7586
|
+
baselineChangedFiles: Object.keys(baselineFileDigests).sort(),
|
|
7587
|
+
baselineFileDigests,
|
|
7588
|
+
observedChangedFiles: [],
|
|
7589
|
+
finalWorkingTreeFiles: [],
|
|
7590
|
+
finalFileDigests: {},
|
|
6020
7591
|
changedFiles: [],
|
|
6021
7592
|
verifyEvidence: [],
|
|
6022
7593
|
trace: { outcome: "pending" },
|
|
6023
7594
|
createdAt,
|
|
6024
7595
|
updatedAt: createdAt
|
|
6025
7596
|
};
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
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) });
|
|
6029
7612
|
|
|
6030
7613
|
return {
|
|
6031
|
-
content: [{
|
|
6032
|
-
|
|
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)
|
|
6033
7809
|
};
|
|
6034
7810
|
}
|
|
6035
7811
|
});
|
|
@@ -6189,10 +7965,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6189
7965
|
const profile = loadProfileFromContext(ctx);
|
|
6190
7966
|
const runtime = resolveRuntimePolicy(profile);
|
|
6191
7967
|
const budget = contextBudgetConfig(policy);
|
|
6192
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
7968
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6193
7969
|
if (!task) {
|
|
6194
7970
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6195
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
|
+
}
|
|
6196
7975
|
if (runtime.contextBudget !== "off" && task.contextManifest.length + params.files.length > budget.maxManifestFiles) {
|
|
6197
7976
|
return {
|
|
6198
7977
|
content: [{ type: "text", text: `Context manifest budget exceeded: ${task.contextManifest.length + params.files.length} files > ${budget.maxManifestFiles}` }],
|
|
@@ -6218,13 +7997,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6218
7997
|
const key = `${file.path}\u0000${file.reason}`;
|
|
6219
7998
|
if (!seen.has(key)) task.contextManifest.push(file);
|
|
6220
7999
|
}
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
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 });
|
|
6224
8006
|
|
|
6225
8007
|
return {
|
|
6226
8008
|
content: [{ type: "text", text: `Context recorded for ${task.taskId}: ${params.files.length} file(s)` }],
|
|
6227
|
-
details:
|
|
8009
|
+
details: compactTaskDetails(written)
|
|
6228
8010
|
};
|
|
6229
8011
|
}
|
|
6230
8012
|
});
|
|
@@ -6241,13 +8023,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6241
8023
|
summary: Type.String({ minLength: 1 })
|
|
6242
8024
|
}),
|
|
6243
8025
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6244
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8026
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6245
8027
|
if (!task) {
|
|
6246
8028
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6247
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
|
+
}
|
|
6248
8033
|
|
|
6249
8034
|
const observedEntries = [
|
|
6250
|
-
...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000 }),
|
|
8035
|
+
...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000, projectRoot: ctx.cwd }),
|
|
6251
8036
|
...bashResults.list()
|
|
6252
8037
|
];
|
|
6253
8038
|
const observed = findMatchingObservedBashResult(observedEntries, {
|
|
@@ -6267,24 +8052,50 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6267
8052
|
const safeCommand = redactText(params.command);
|
|
6268
8053
|
const safeSummary = redactText(params.summary);
|
|
6269
8054
|
const matchedProfileCommand = commandMatchesVerifyPlan(params.command, task.verifyCommands);
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
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 });
|
|
6283
8084
|
|
|
6284
8085
|
const advisorySuffix = matchedProfileCommand ? "" : " Advisory only: command does not exactly match task verifyCommands and will not satisfy the passing final gate.";
|
|
6285
8086
|
return {
|
|
6286
8087
|
content: [{ type: "text", text: `Verify evidence recorded for ${task.taskId}: observed exit ${params.exitCode}.${advisorySuffix}` }],
|
|
6287
|
-
details: {
|
|
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
|
+
}
|
|
6288
8099
|
};
|
|
6289
8100
|
}
|
|
6290
8101
|
});
|
|
@@ -6302,10 +8113,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6302
8113
|
}), { minItems: 1 })
|
|
6303
8114
|
}),
|
|
6304
8115
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6305
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8116
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6306
8117
|
if (!task) {
|
|
6307
8118
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6308
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
|
+
}
|
|
6309
8123
|
|
|
6310
8124
|
const safeFiles = params.files.map((file) => ({
|
|
6311
8125
|
path: file.path,
|
|
@@ -6317,8 +8131,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6317
8131
|
if (!seen.has(key)) task.memoryCitations.push(file);
|
|
6318
8132
|
}
|
|
6319
8133
|
writeTask(ctx.cwd, task);
|
|
6320
|
-
appendTrace(ctx.cwd, { taskId: task.taskId, event: "memory_citation_record", files: safeFiles });
|
|
6321
|
-
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 });
|
|
6322
8136
|
|
|
6323
8137
|
return {
|
|
6324
8138
|
content: [{ type: "text", text: `Memory citations recorded for ${task.taskId}: ${params.files.length} file(s)` }],
|
|
@@ -6337,19 +8151,44 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6337
8151
|
outcome: StringEnum(["completed", "blocked", "partial", "failed"] as const),
|
|
6338
8152
|
changedFiles: Type.Optional(Type.Array(Type.String())),
|
|
6339
8153
|
friction: Type.Optional(Type.String()),
|
|
6340
|
-
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())
|
|
6341
8157
|
}),
|
|
6342
8158
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6343
8159
|
const profile = loadProfileFromContext(ctx);
|
|
6344
8160
|
const runtime = resolveRuntimePolicy(profile);
|
|
6345
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8161
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6346
8162
|
if (!task) {
|
|
6347
8163
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6348
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
|
+
}
|
|
6349
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>;
|
|
6350
8184
|
const nextTask: TaskContract = {
|
|
6351
8185
|
...task,
|
|
6352
|
-
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,
|
|
6353
8192
|
trace: {
|
|
6354
8193
|
outcome: params.outcome,
|
|
6355
8194
|
friction: params.friction ? redactText(params.friction) : undefined,
|
|
@@ -6357,8 +8196,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6357
8196
|
recordedAt: nowIso()
|
|
6358
8197
|
}
|
|
6359
8198
|
};
|
|
6360
|
-
const gate = evaluateTaskGate(nextTask, policy
|
|
8199
|
+
const gate = evaluateTaskGate(ctx.cwd, nextTask, policy, {
|
|
8200
|
+
currentDigests: finalFileDigests,
|
|
8201
|
+
currentWorkingTreeDigest: workingTreeEvidenceDigest(finalFileDigests)
|
|
8202
|
+
});
|
|
6361
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);
|
|
6362
8214
|
return {
|
|
6363
8215
|
content: [{ type: "text", text: `Final gate blocked completion: missing ${gate.missing.join(", ")}` }],
|
|
6364
8216
|
details: { gate, task: nextTask },
|
|
@@ -6366,27 +8218,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6366
8218
|
};
|
|
6367
8219
|
}
|
|
6368
8220
|
|
|
6369
|
-
writeTask(ctx.cwd, nextTask);
|
|
6370
|
-
|
|
8221
|
+
const written = writeTask(ctx.cwd, nextTask);
|
|
8222
|
+
const trace = {
|
|
6371
8223
|
taskId: nextTask.taskId,
|
|
8224
|
+
taskRunId: nextTask.taskRunId,
|
|
8225
|
+
sessionId: nextTask.sessionId,
|
|
6372
8226
|
event: "trace_record",
|
|
6373
8227
|
outcome: params.outcome,
|
|
6374
8228
|
changedFiles: nextTask.changedFiles,
|
|
6375
8229
|
friction: nextTask.trace.friction,
|
|
6376
|
-
notes: nextTask.trace.notes
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
changedFiles: nextTask.changedFiles,
|
|
6383
|
-
friction: nextTask.trace.friction,
|
|
6384
|
-
notes: nextTask.trace.notes
|
|
6385
|
-
});
|
|
8230
|
+
notes: nextTask.trace.notes,
|
|
8231
|
+
failedAt: nextTask.failedAt,
|
|
8232
|
+
ruledOut: nextTask.ruledOut
|
|
8233
|
+
};
|
|
8234
|
+
appendTrace(ctx.cwd, trace);
|
|
8235
|
+
appendSessionTrace(pi, trace);
|
|
6386
8236
|
|
|
6387
8237
|
return {
|
|
6388
8238
|
content: [{ type: "text", text: `Trace recorded for ${nextTask.taskId}: ${params.outcome}${gate.decision === "fail" ? ` (gate warning: missing ${gate.missing.join(", ")})` : ""}` }],
|
|
6389
|
-
details: { task:
|
|
8239
|
+
details: { task: written, gate }
|
|
6390
8240
|
};
|
|
6391
8241
|
}
|
|
6392
8242
|
});
|
|
@@ -7344,12 +9194,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
7344
9194
|
}
|
|
7345
9195
|
|
|
7346
9196
|
function compactCurrentSession(ctx: ExtensionContext): void {
|
|
7347
|
-
const
|
|
9197
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
9198
|
+
const instructions = semanticCompactionInstructions(ctx.cwd, sessionId);
|
|
7348
9199
|
telemetry(ctx, {
|
|
7349
9200
|
event: "compaction_requested",
|
|
7350
9201
|
mode: "semantic",
|
|
7351
9202
|
instructionTokens: estimateContextTokens(instructions),
|
|
7352
|
-
hasTaskContract: Boolean(compactSessionTask(ctx.cwd))
|
|
9203
|
+
hasTaskContract: Boolean(compactSessionTask(ctx.cwd, sessionId))
|
|
7353
9204
|
});
|
|
7354
9205
|
ctx.compact({
|
|
7355
9206
|
customInstructions: instructions
|