@piagent/platform 1.2.10 → 1.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/README.md +27 -4
- 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 +10 -10
- package/docs/command-reference-vietnamese.md +10 -6
- package/docs/context-engine.md +33 -11
- package/docs/distribution-standard.md +11 -8
- package/docs/mcp-and-tools.md +13 -7
- package/docs/model-options.md +7 -10
- package/docs/operator-manual-vietnamese.md +82 -33
- package/docs/package-architecture-notes.md +3 -1
- package/docs/publishing-for-teams.md +4 -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 +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/runtime.html +10 -0
- package/docs-site/context-engine.html +1 -1
- 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 +1 -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 +37 -3
- package/packages/piagent-core/benchmark/benchmark-core.js +691 -0
- package/packages/piagent-core/extensions/context-engine.js +81 -57
- 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 +1865 -171
- 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/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/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",
|
|
@@ -420,6 +491,7 @@ const DEFAULT_POLICY: BasePolicy = {
|
|
|
420
491
|
"piagent_profile_tech_context_record",
|
|
421
492
|
"piagent_project_onboarding_record",
|
|
422
493
|
"piagent_task_start",
|
|
494
|
+
"piagent_task_progress",
|
|
423
495
|
"piagent_source_checkout",
|
|
424
496
|
"piagent_context_record",
|
|
425
497
|
"piagent_verify_record",
|
|
@@ -1546,10 +1618,6 @@ function stateRoot(cwd: string): string {
|
|
|
1546
1618
|
return path.join(cwd, ".pi", "piagent-state");
|
|
1547
1619
|
}
|
|
1548
1620
|
|
|
1549
|
-
function taskFilePath(cwd: string, taskId: string): string {
|
|
1550
|
-
return path.join(stateRoot(cwd), "tasks", `${safeTaskId(taskId)}.json`);
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
1621
|
function projectContextFilePath(cwd: string): string {
|
|
1554
1622
|
return path.join(cwd, ".pi", "project-context.md");
|
|
1555
1623
|
}
|
|
@@ -1574,21 +1642,18 @@ function toolResultCaptureIndexPath(cwd: string): string {
|
|
|
1574
1642
|
return path.join(toolResultCaptureRoot(cwd), "index.jsonl");
|
|
1575
1643
|
}
|
|
1576
1644
|
|
|
1577
|
-
function safeTaskId(taskId: string): string {
|
|
1578
|
-
return taskId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "task";
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
1645
|
function nowIso(): string {
|
|
1582
1646
|
return new Date().toISOString();
|
|
1583
1647
|
}
|
|
1584
1648
|
|
|
1585
1649
|
function ensureStateDirs(cwd: string): void {
|
|
1586
|
-
|
|
1650
|
+
ensurePrivateStateDirectory(cwd, stateRoot(cwd), "Piagent state directory");
|
|
1651
|
+
ensurePrivateStateDirectory(cwd, path.join(stateRoot(cwd), "tasks"), "Piagent task state directory");
|
|
1587
1652
|
}
|
|
1588
1653
|
|
|
1589
1654
|
function ensureProjectContextPlaceholder(cwd: string): void {
|
|
1590
|
-
|
|
1591
|
-
const target = projectContextFilePath(cwd);
|
|
1655
|
+
ensurePrivateStateDirectory(cwd, path.join(cwd, ".pi"), "Piagent project directory");
|
|
1656
|
+
const target = resolveLocalStatePath(cwd, projectContextFilePath(cwd), { label: "Project context file" });
|
|
1592
1657
|
if (fs.existsSync(target)) return;
|
|
1593
1658
|
fs.writeFileSync(target, [
|
|
1594
1659
|
"# Project Context",
|
|
@@ -1862,14 +1927,68 @@ function normalizeWorkPlanSteps(values: unknown): WorkPlanStep[] {
|
|
|
1862
1927
|
title,
|
|
1863
1928
|
role,
|
|
1864
1929
|
mode: normalizeStepMode(item.mode, role),
|
|
1865
|
-
status: item.status === "in-progress" || item.status === "done" || item.status === "skipped" ? item.status : "pending",
|
|
1866
|
-
dependsOn: Array.isArray(item.dependsOn) ? uniqueStrings(item.dependsOn.filter((entry): entry is string => typeof entry === "string").map((entry) => safeTaskId(entry).slice(0, 40))) : undefined
|
|
1930
|
+
status: item.status === "in-progress" || item.status === "done" || item.status === "skipped" || item.status === "failed" ? item.status : "pending",
|
|
1931
|
+
dependsOn: Array.isArray(item.dependsOn) ? uniqueStrings(item.dependsOn.filter((entry): entry is string => typeof entry === "string").map((entry) => safeTaskId(entry).slice(0, 40))) : undefined,
|
|
1932
|
+
note: typeof item.note === "string" ? redactText(item.note).slice(0, 500) : undefined,
|
|
1933
|
+
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : undefined
|
|
1867
1934
|
});
|
|
1868
1935
|
}
|
|
1869
1936
|
return steps;
|
|
1870
1937
|
}
|
|
1871
1938
|
|
|
1872
|
-
function defaultWorkPlan(
|
|
1939
|
+
function defaultWorkPlan(
|
|
1940
|
+
summary: string,
|
|
1941
|
+
riskLane: TaskContract["riskLane"],
|
|
1942
|
+
changeMode: TaskContract["changeMode"]
|
|
1943
|
+
): WorkPlanStep[] {
|
|
1944
|
+
if (changeMode === "read-only") {
|
|
1945
|
+
if (riskLane === "tiny") {
|
|
1946
|
+
return [{
|
|
1947
|
+
id: "scout",
|
|
1948
|
+
title: summary.slice(0, 160) || "Inspect the bounded target read-only.",
|
|
1949
|
+
role: "parent",
|
|
1950
|
+
mode: "read-only",
|
|
1951
|
+
status: "pending"
|
|
1952
|
+
}];
|
|
1953
|
+
}
|
|
1954
|
+
const readOnlySteps: WorkPlanStep[] = [
|
|
1955
|
+
{
|
|
1956
|
+
id: "scout",
|
|
1957
|
+
title: summary.slice(0, 160) || "Inspect the bounded target read-only.",
|
|
1958
|
+
role: "parent",
|
|
1959
|
+
mode: "read-only",
|
|
1960
|
+
status: "pending"
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
id: "review",
|
|
1964
|
+
title: "Review cited evidence, risks, and unknowns before handoff.",
|
|
1965
|
+
role: "piagent-reviewer",
|
|
1966
|
+
mode: "review",
|
|
1967
|
+
status: "pending",
|
|
1968
|
+
dependsOn: ["scout"]
|
|
1969
|
+
}
|
|
1970
|
+
];
|
|
1971
|
+
if (riskLane === "high-risk") {
|
|
1972
|
+
readOnlySteps.unshift({
|
|
1973
|
+
id: "scope",
|
|
1974
|
+
title: "Confirm the high-risk read-only scope and evidence boundaries.",
|
|
1975
|
+
role: "parent",
|
|
1976
|
+
mode: "read-only",
|
|
1977
|
+
status: "pending"
|
|
1978
|
+
});
|
|
1979
|
+
readOnlySteps.splice(1, 0, {
|
|
1980
|
+
id: "challenge",
|
|
1981
|
+
title: "Challenge security, data, release, or architecture assumptions.",
|
|
1982
|
+
role: "piagent-oracle",
|
|
1983
|
+
mode: "review",
|
|
1984
|
+
status: "pending",
|
|
1985
|
+
dependsOn: ["scope"]
|
|
1986
|
+
});
|
|
1987
|
+
const scout = readOnlySteps.find((step) => step.id === "scout");
|
|
1988
|
+
if (scout) scout.dependsOn = ["scope", "challenge"];
|
|
1989
|
+
}
|
|
1990
|
+
return readOnlySteps;
|
|
1991
|
+
}
|
|
1873
1992
|
if (riskLane === "tiny") {
|
|
1874
1993
|
return [
|
|
1875
1994
|
{
|
|
@@ -1929,6 +2048,16 @@ function defaultWorkPlan(summary: string, riskLane: TaskContract["riskLane"]): W
|
|
|
1929
2048
|
return steps;
|
|
1930
2049
|
}
|
|
1931
2050
|
|
|
2051
|
+
function validateNewWorkPlan(steps: WorkPlanStep[]): string | undefined {
|
|
2052
|
+
const ids = new Set<string>();
|
|
2053
|
+
for (const step of steps) {
|
|
2054
|
+
if (ids.has(step.id)) return `duplicate work-plan step id: ${step.id}`;
|
|
2055
|
+
if (step.status !== "pending") return `new work-plan step ${step.id} must start pending`;
|
|
2056
|
+
ids.add(step.id);
|
|
2057
|
+
}
|
|
2058
|
+
return workPlanDependencyError(steps)?.replace(/^workPlan/, "work-plan");
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1932
2061
|
function uniqueStrings(values: string[]): string[] {
|
|
1933
2062
|
return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
|
|
1934
2063
|
}
|
|
@@ -2017,6 +2146,384 @@ function collectPatchTargetPaths(value: unknown, key = "", depth = 0): string[]
|
|
|
2017
2146
|
return paths;
|
|
2018
2147
|
}
|
|
2019
2148
|
|
|
2149
|
+
function taskMutationIdentity(toolName: string, input: Record<string, unknown>): string {
|
|
2150
|
+
if (normalizeActionToken(toolName) !== "mcp") return toolName;
|
|
2151
|
+
const proxyTool = typeof input.tool === "string" ? input.tool.trim() : "";
|
|
2152
|
+
return proxyTool || toolName;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function isTaskMutationTool(toolName: string, input: Record<string, unknown>): boolean {
|
|
2156
|
+
if (isPiagentTool(toolName)) return false;
|
|
2157
|
+
if (WRITE_TOOL_NAMES.has(toolName) || SHELL_TOOL_NAMES.has(toolName)) return true;
|
|
2158
|
+
const tokens = new Set(actionTokens(taskMutationIdentity(toolName, input)));
|
|
2159
|
+
return ["write", "edit", "patch", "create", "delete", "remove", "move", "rename", "update", "apply"]
|
|
2160
|
+
.some((token) => tokens.has(token));
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function isReadOnlyTaskShellCommand(
|
|
2164
|
+
command: string,
|
|
2165
|
+
segments: Array<{ words: string[] }>
|
|
2166
|
+
): boolean {
|
|
2167
|
+
if (/[<>]|`|\$\(/.test(command)) {
|
|
2168
|
+
return false;
|
|
2169
|
+
}
|
|
2170
|
+
const safeCommands = new Set(["pwd", "ls", "find", "rg", "grep", "cat", "sed", "head", "tail", "wc", "stat", "file", "test", "[", "which"]);
|
|
2171
|
+
const safeGitSubcommands = new Set(["status", "diff", "log", "show", "ls-files", "rev-parse"]);
|
|
2172
|
+
return segments.length > 0 && segments.every((segment) => {
|
|
2173
|
+
const words = segment.words.filter(Boolean);
|
|
2174
|
+
const executable = path.basename(words[0] ?? "");
|
|
2175
|
+
if (executable === "sed" && words.some((word) => /^-.*i/.test(word))) return false;
|
|
2176
|
+
if (executable === "find" && words.some((word) => ["-delete", "-exec", "-execdir", "-ok", "-okdir"].includes(word))) return false;
|
|
2177
|
+
if (safeCommands.has(executable)) return true;
|
|
2178
|
+
if (executable === "git") return safeGitSubcommands.has(words[1] ?? "");
|
|
2179
|
+
if (executable === "command") return words[1] === "-v";
|
|
2180
|
+
return false;
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const PROJECT_MUTATING_EXECUTABLES = new Set([
|
|
2185
|
+
"apply_patch", "bash", "chmod", "chown", "cp", "dd", "install", "ln", "make", "mkdir", "mv",
|
|
2186
|
+
"node", "patch", "perl", "php", "python", "python3", "rm", "rmdir", "rsync", "ruby", "scp", "sh",
|
|
2187
|
+
"tee", "touch", "truncate", "zsh"
|
|
2188
|
+
]);
|
|
2189
|
+
|
|
2190
|
+
function isProjectMutatingShellCommand(command: string, segments: Array<{ words: string[] }>): boolean {
|
|
2191
|
+
if (shellHasFileWriteRedirection(command)) return true;
|
|
2192
|
+
const noAliases = new Map<string, string>();
|
|
2193
|
+
for (const segment of segments) {
|
|
2194
|
+
const words = segment.words.filter(Boolean);
|
|
2195
|
+
if (words.length === 0) continue;
|
|
2196
|
+
if (externalExecutableIndex(words, PROJECT_MUTATING_EXECUTABLES, noAliases) !== undefined) return true;
|
|
2197
|
+
const executable = path.basename(words[0] ?? "").toLowerCase();
|
|
2198
|
+
if (executable === "find" && words.some((word) => ["-delete", "-exec", "-execdir", "-ok", "-okdir"].includes(word))) return true;
|
|
2199
|
+
if (executable === "sed" && words.some((word) => /^-[^-]*i/.test(word) || word === "--in-place" || word.startsWith("--in-place="))) return true;
|
|
2200
|
+
if (executable === "git") {
|
|
2201
|
+
const subcommand = words.slice(1).find((word, index, values) => {
|
|
2202
|
+
if (!word.startsWith("-")) return index === 0 || !["-C", "-c", "--git-dir", "--work-tree"].includes(values[index - 1]);
|
|
2203
|
+
return false;
|
|
2204
|
+
});
|
|
2205
|
+
if (["apply", "checkout", "clean", "mv", "reset", "restore", "rm", "switch"].includes(subcommand ?? "")) return true;
|
|
2206
|
+
}
|
|
2207
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
|
|
2208
|
+
const subcommand = words.find((word, index) => index > 0 && !word.startsWith("-"));
|
|
2209
|
+
if (["add", "ci", "install", "link", "remove", "uninstall", "update", "upgrade"].includes(subcommand ?? "")) return true;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return false;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function taskMutationTargets(cwd: string, toolName: string, input: Record<string, unknown>): string[] {
|
|
2216
|
+
if (!isTaskMutationTool(toolName, input) || SHELL_TOOL_NAMES.has(toolName)) return [];
|
|
2217
|
+
const identity = taskMutationIdentity(toolName, input);
|
|
2218
|
+
const collectWritePaths = (value: Record<string, unknown>, valueIdentity: string) => {
|
|
2219
|
+
const strongFilesystemSemantics = usesFilesystemContentFields(valueIdentity, false);
|
|
2220
|
+
return inspectPathInputsFromInput(cwd, value, strongFilesystemSemantics).paths
|
|
2221
|
+
.filter((item) => isFilesystemScopeField(item.field))
|
|
2222
|
+
.filter((item) => {
|
|
2223
|
+
const field = item.field.split(".").at(-1)?.toLowerCase().replace(/[-_]/g, "") ?? "";
|
|
2224
|
+
return strongFilesystemSemantics || !COPY_SOURCE_FIELDS.has(field);
|
|
2225
|
+
})
|
|
2226
|
+
.filter((item) => filesystemFieldAccessMode(valueIdentity, item.field, true) === "write")
|
|
2227
|
+
.map((item) => item.path);
|
|
2228
|
+
};
|
|
2229
|
+
const rawTargets = [
|
|
2230
|
+
...collectWritePaths(input, identity),
|
|
2231
|
+
...collectPatchTargetPaths(input)
|
|
2232
|
+
];
|
|
2233
|
+
if (typeof input.args === "string" && input.args.length <= MAX_MCP_PROXY_ARGS_CHARS) {
|
|
2234
|
+
try {
|
|
2235
|
+
const parsed = JSON.parse(input.args);
|
|
2236
|
+
if (isPlainRecord(parsed)) rawTargets.push(...collectWritePaths(parsed, identity));
|
|
2237
|
+
rawTargets.push(...collectPatchTargetPaths(parsed));
|
|
2238
|
+
} catch {
|
|
2239
|
+
// Invalid proxy JSON is blocked before execution; it contributes no evidence.
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return uniqueStrings(rawTargets
|
|
2243
|
+
.map((candidate) => normalizeRelative(cwd, candidate))
|
|
2244
|
+
.filter((candidate): candidate is string => Boolean(
|
|
2245
|
+
candidate
|
|
2246
|
+
&& candidate !== "."
|
|
2247
|
+
&& candidate !== ".."
|
|
2248
|
+
&& !candidate.startsWith("../")
|
|
2249
|
+
&& !candidate.startsWith(".pi/piagent-state/")
|
|
2250
|
+
)));
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
type ObservedTaskContext = { path: string; reason: string };
|
|
2254
|
+
|
|
2255
|
+
function observedTaskContextFromToolResult(
|
|
2256
|
+
cwd: string,
|
|
2257
|
+
event: { toolName: string; input?: unknown; isError?: boolean },
|
|
2258
|
+
readProtectedPaths: string[]
|
|
2259
|
+
): ObservedTaskContext | undefined {
|
|
2260
|
+
if (event.isError || !["read", "piagent_document_read"].includes(event.toolName)) return undefined;
|
|
2261
|
+
const input = isPlainRecord(event.input) ? event.input : {};
|
|
2262
|
+
const relative = extractLikelyPathFromInput(cwd, input);
|
|
2263
|
+
if (
|
|
2264
|
+
!relative
|
|
2265
|
+
|| relative === "."
|
|
2266
|
+
|| relative === ".."
|
|
2267
|
+
|| relative.startsWith("../")
|
|
2268
|
+
|| relative.startsWith(".pi/piagent-state/")
|
|
2269
|
+
|| matchesProtectedPath(relative, readProtectedPaths)
|
|
2270
|
+
) return undefined;
|
|
2271
|
+
try {
|
|
2272
|
+
if (!fs.statSync(path.join(cwd, relative)).isFile()) return undefined;
|
|
2273
|
+
} catch {
|
|
2274
|
+
return undefined;
|
|
2275
|
+
}
|
|
2276
|
+
return {
|
|
2277
|
+
path: relative,
|
|
2278
|
+
reason: event.toolName === "read" ? "Runtime observed successful source read." : "Runtime observed successful document read."
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function mergeObservedTaskContext(
|
|
2283
|
+
task: TaskContract,
|
|
2284
|
+
entries: ObservedTaskContext[],
|
|
2285
|
+
maxManifestFiles: number
|
|
2286
|
+
): string[] {
|
|
2287
|
+
const known = new Set(task.contextManifest.map((item) => item.path));
|
|
2288
|
+
const added: string[] = [];
|
|
2289
|
+
for (const entry of entries) {
|
|
2290
|
+
if (task.contextManifest.length >= maxManifestFiles || known.has(entry.path)) continue;
|
|
2291
|
+
task.contextManifest.push({ path: entry.path, reason: redactText(entry.reason) });
|
|
2292
|
+
known.add(entry.path);
|
|
2293
|
+
added.push(entry.path);
|
|
2294
|
+
}
|
|
2295
|
+
return added;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function passingVerifyCommandsForDigest(task: TaskContract, digest: string): Set<string> {
|
|
2299
|
+
return new Set(task.verifyEvidence
|
|
2300
|
+
.filter((evidence) => (
|
|
2301
|
+
evidence.exitCode === 0
|
|
2302
|
+
&& evidence.observed === true
|
|
2303
|
+
&& evidence.matchedProfileCommand === true
|
|
2304
|
+
&& evidence.workingTreeDigest === digest
|
|
2305
|
+
))
|
|
2306
|
+
.map((evidence) => evidence.command.trim()));
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function allVerifyCommandsPassCurrentTree(task: TaskContract, digest: string): boolean {
|
|
2310
|
+
const planned = meaningfulVerifyCommands(task.verifyCommands);
|
|
2311
|
+
if (planned.length === 0) return false;
|
|
2312
|
+
const passing = passingVerifyCommandsForDigest(task, digest);
|
|
2313
|
+
return planned.every((command) => passing.has(command.trim()));
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function compactTaskDetails(task: TaskContract): Record<string, unknown> {
|
|
2317
|
+
return {
|
|
2318
|
+
schemaVersion: task.schemaVersion,
|
|
2319
|
+
taskRunId: task.taskRunId,
|
|
2320
|
+
taskId: task.taskId,
|
|
2321
|
+
sessionId: task.sessionId,
|
|
2322
|
+
sessionName: task.sessionName,
|
|
2323
|
+
changeMode: task.changeMode,
|
|
2324
|
+
attempt: task.attempt,
|
|
2325
|
+
maxAttempts: task.maxAttempts,
|
|
2326
|
+
previousAttempts: task.previousAttempts,
|
|
2327
|
+
riskLane: task.riskLane,
|
|
2328
|
+
intakeMode: task.intakeMode ?? "model",
|
|
2329
|
+
scope: task.scope,
|
|
2330
|
+
verifyGroup: task.verifyGroup,
|
|
2331
|
+
verifyCommands: task.verifyCommands,
|
|
2332
|
+
workPlan: task.workPlan,
|
|
2333
|
+
reviewLenses: task.reviewLenses,
|
|
2334
|
+
orchestration: task.orchestration
|
|
2335
|
+
? {
|
|
2336
|
+
mode: task.orchestration.mode,
|
|
2337
|
+
subagents: task.orchestration.subagents,
|
|
2338
|
+
reason: task.orchestration.reason
|
|
2339
|
+
}
|
|
2340
|
+
: undefined,
|
|
2341
|
+
lifecycleMode: runtimeLifecycleMode(task)
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function changedSnapshotFiles(
|
|
2346
|
+
before: Record<string, string>,
|
|
2347
|
+
after: Record<string, string>
|
|
2348
|
+
): string[] {
|
|
2349
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
2350
|
+
.filter((file) => before[file] !== after[file])
|
|
2351
|
+
.sort();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function recordObservedTaskChanges(
|
|
2355
|
+
pi: ExtensionAPI,
|
|
2356
|
+
ctx: ExtensionContext,
|
|
2357
|
+
event: { toolName: string; input?: unknown; isError?: boolean },
|
|
2358
|
+
pendingContext: ObservedTaskContext[],
|
|
2359
|
+
maxManifestFiles: number,
|
|
2360
|
+
shellSnapshotBefore?: Record<string, string>
|
|
2361
|
+
): TaskContract | undefined {
|
|
2362
|
+
if (event.isError) return;
|
|
2363
|
+
const input = isPlainRecord(event.input) ? event.input : {};
|
|
2364
|
+
if (!isTaskMutationTool(event.toolName, input)) return;
|
|
2365
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
2366
|
+
if (!task || task.trace.outcome !== "pending") return;
|
|
2367
|
+
|
|
2368
|
+
const targets = taskMutationTargets(ctx.cwd, event.toolName, input);
|
|
2369
|
+
const shellMutationObserved = SHELL_TOOL_NAMES.has(event.toolName) && shellSnapshotBefore !== undefined;
|
|
2370
|
+
const shellChangedFiles = shellMutationObserved
|
|
2371
|
+
? changedSnapshotFiles(shellSnapshotBefore, workingTreeSnapshot(ctx.cwd) as Record<string, string>)
|
|
2372
|
+
: [];
|
|
2373
|
+
const nextObserved = uniqueStrings([...task.observedChangedFiles, ...shellChangedFiles, ...targets]).sort();
|
|
2374
|
+
const added = nextObserved.filter((file) => !task.observedChangedFiles.includes(file));
|
|
2375
|
+
const contextAdded = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
|
|
2376
|
+
const lifecycle = (targets.length > 0 || shellChangedFiles.length > 0)
|
|
2377
|
+
? applyRuntimeLifecycleObservation(task, "mutation", nowIso())
|
|
2378
|
+
: { changed: false, mode: runtimeLifecycleMode(task) };
|
|
2379
|
+
if (added.length === 0 && contextAdded.length === 0 && !lifecycle.changed) return;
|
|
2380
|
+
task.observedChangedFiles = nextObserved;
|
|
2381
|
+
const written = writeTask(ctx.cwd, task);
|
|
2382
|
+
const trace = {
|
|
2383
|
+
event: "task_changes_observed",
|
|
2384
|
+
taskId: written.taskId,
|
|
2385
|
+
taskRunId: written.taskRunId,
|
|
2386
|
+
sessionId: written.sessionId,
|
|
2387
|
+
toolName: event.toolName,
|
|
2388
|
+
files: added,
|
|
2389
|
+
contextFiles: contextAdded,
|
|
2390
|
+
lifecycleMode: lifecycle.mode,
|
|
2391
|
+
lifecycleAdvanced: lifecycle.changed
|
|
2392
|
+
};
|
|
2393
|
+
appendTrace(ctx.cwd, trace);
|
|
2394
|
+
appendSessionTrace(pi, trace);
|
|
2395
|
+
return written;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function recordObservedTaskVerification(
|
|
2399
|
+
pi: ExtensionAPI,
|
|
2400
|
+
ctx: ExtensionContext,
|
|
2401
|
+
observed: {
|
|
2402
|
+
command?: string;
|
|
2403
|
+
normalizedCommand?: string;
|
|
2404
|
+
commandHash?: string;
|
|
2405
|
+
exitCode?: number;
|
|
2406
|
+
isError?: boolean;
|
|
2407
|
+
recordedAt?: string;
|
|
2408
|
+
},
|
|
2409
|
+
pendingContext: ObservedTaskContext[],
|
|
2410
|
+
maxManifestFiles: number
|
|
2411
|
+
): TaskContract | undefined {
|
|
2412
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
2413
|
+
if (!task || task.trace.outcome !== "pending") return;
|
|
2414
|
+
const command = String(observed.normalizedCommand ?? observed.command ?? "").trim();
|
|
2415
|
+
const observedAtMs = Date.parse(observed.recordedAt ?? "");
|
|
2416
|
+
const taskCreatedAtMs = Date.parse(task.createdAt);
|
|
2417
|
+
if (
|
|
2418
|
+
!command
|
|
2419
|
+
|| !observed.commandHash
|
|
2420
|
+
|| !commandMatchesVerifyPlan(command, task.verifyCommands)
|
|
2421
|
+
|| !Number.isFinite(observedAtMs)
|
|
2422
|
+
|| !Number.isFinite(taskCreatedAtMs)
|
|
2423
|
+
|| observedAtMs < taskCreatedAtMs
|
|
2424
|
+
) return;
|
|
2425
|
+
|
|
2426
|
+
const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
2427
|
+
const currentDigest = workingTreeEvidenceDigest(currentDigests);
|
|
2428
|
+
const exitCode = Number.isInteger(observed.exitCode) ? observed.exitCode as number : observed.isError ? 1 : 0;
|
|
2429
|
+
const duplicate = task.verifyEvidence.some((evidence) => (
|
|
2430
|
+
evidence.command.trim() === command
|
|
2431
|
+
&& evidence.exitCode === exitCode
|
|
2432
|
+
&& evidence.workingTreeDigest === currentDigest
|
|
2433
|
+
));
|
|
2434
|
+
const contextAdded = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
|
|
2435
|
+
if (!duplicate) {
|
|
2436
|
+
task.verifyEvidence.push({
|
|
2437
|
+
command: redactText(command),
|
|
2438
|
+
exitCode,
|
|
2439
|
+
summary: `Runtime observed configured verifier exit ${exitCode}.`,
|
|
2440
|
+
recordedAt: nowIso(),
|
|
2441
|
+
observed: true,
|
|
2442
|
+
observedAt: observed.recordedAt ?? nowIso(),
|
|
2443
|
+
isError: observed.isError === true,
|
|
2444
|
+
matchedProfileCommand: true,
|
|
2445
|
+
workingTreeDigest: currentDigest
|
|
2446
|
+
});
|
|
2447
|
+
task.verifyEvidence = task.verifyEvidence.slice(-100);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
const hasChanges = taskChangedFileEvidence(ctx.cwd, task, currentDigests).expected.length > 0;
|
|
2451
|
+
const allPassing = hasChanges && allVerifyCommandsPassCurrentTree(task, currentDigest);
|
|
2452
|
+
const lifecycle = hasChanges
|
|
2453
|
+
? applyRuntimeLifecycleObservation(task, allPassing ? "verification-complete" : "verification-pending", nowIso())
|
|
2454
|
+
: { changed: false, mode: runtimeLifecycleMode(task) };
|
|
2455
|
+
if (duplicate && contextAdded.length === 0 && !lifecycle.changed) return task;
|
|
2456
|
+
|
|
2457
|
+
const written = writeTask(ctx.cwd, task);
|
|
2458
|
+
const trace = {
|
|
2459
|
+
event: "verify_observed",
|
|
2460
|
+
taskId: written.taskId,
|
|
2461
|
+
taskRunId: written.taskRunId,
|
|
2462
|
+
sessionId: written.sessionId,
|
|
2463
|
+
command: redactText(command),
|
|
2464
|
+
exitCode,
|
|
2465
|
+
workingTreeDigest: currentDigest,
|
|
2466
|
+
contextFiles: contextAdded,
|
|
2467
|
+
lifecycleMode: lifecycle.mode,
|
|
2468
|
+
lifecycleAdvanced: lifecycle.changed,
|
|
2469
|
+
allConfiguredVerifiersPassing: allPassing
|
|
2470
|
+
};
|
|
2471
|
+
appendTrace(ctx.cwd, trace);
|
|
2472
|
+
appendSessionTrace(pi, trace);
|
|
2473
|
+
return written;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function flushObservedTaskContext(
|
|
2477
|
+
pi: ExtensionAPI,
|
|
2478
|
+
ctx: ExtensionContext,
|
|
2479
|
+
pendingContext: ObservedTaskContext[],
|
|
2480
|
+
maxManifestFiles: number,
|
|
2481
|
+
event: string
|
|
2482
|
+
): TaskContract | undefined {
|
|
2483
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
2484
|
+
if (!task || task.trace.outcome !== "pending") return task;
|
|
2485
|
+
const added = mergeObservedTaskContext(task, pendingContext, maxManifestFiles);
|
|
2486
|
+
const lifecycle = task.changeMode === "read-only" && task.contextManifest.length > 0
|
|
2487
|
+
? applyRuntimeLifecycleObservation(task, "context-complete", nowIso())
|
|
2488
|
+
: { changed: false, mode: runtimeLifecycleMode(task) };
|
|
2489
|
+
if (added.length === 0 && !lifecycle.changed) return task;
|
|
2490
|
+
const written = writeTask(ctx.cwd, task);
|
|
2491
|
+
const trace = {
|
|
2492
|
+
event,
|
|
2493
|
+
taskId: written.taskId,
|
|
2494
|
+
taskRunId: written.taskRunId,
|
|
2495
|
+
sessionId: written.sessionId,
|
|
2496
|
+
files: added,
|
|
2497
|
+
lifecycleMode: lifecycle.mode,
|
|
2498
|
+
lifecycleAdvanced: lifecycle.changed
|
|
2499
|
+
};
|
|
2500
|
+
appendTrace(ctx.cwd, trace);
|
|
2501
|
+
appendSessionTrace(pi, trace);
|
|
2502
|
+
return written;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function completionTaskProjection(
|
|
2506
|
+
cwd: string,
|
|
2507
|
+
task: TaskContract,
|
|
2508
|
+
finalFileDigests: Record<string, string> = workingTreeSnapshot(cwd) as Record<string, string>
|
|
2509
|
+
): TaskContract {
|
|
2510
|
+
const changedFiles = taskChangedFileEvidence(cwd, task, finalFileDigests).expected;
|
|
2511
|
+
return {
|
|
2512
|
+
...task,
|
|
2513
|
+
changedFiles,
|
|
2514
|
+
finalWorkingTreeFiles: Object.keys(finalFileDigests).sort(),
|
|
2515
|
+
finalFileDigests,
|
|
2516
|
+
failedAt: undefined,
|
|
2517
|
+
failureReason: undefined,
|
|
2518
|
+
ruledOut: undefined,
|
|
2519
|
+
trace: {
|
|
2520
|
+
outcome: "completed",
|
|
2521
|
+
notes: "Runtime finalized from observed context, working-tree changes, current verification, and completed work-plan evidence.",
|
|
2522
|
+
recordedAt: nowIso()
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2020
2527
|
function prepareToolInputForPolicy(
|
|
2021
2528
|
toolName: string,
|
|
2022
2529
|
input: Record<string, unknown>,
|
|
@@ -2473,11 +2980,13 @@ function searchMemoryFiles(cwd: string, profile: ProjectProfile, query: string,
|
|
|
2473
2980
|
}
|
|
2474
2981
|
|
|
2475
2982
|
function writeProjectOnboarding(cwd: string, snapshot: ProjectOnboardingSnapshot, markdown: string): ProjectOnboardingSnapshot {
|
|
2476
|
-
|
|
2983
|
+
ensurePrivateStateDirectory(cwd, path.join(cwd, ".pi"), "Piagent project directory");
|
|
2477
2984
|
ensureStateDirs(cwd);
|
|
2478
2985
|
const safeSnapshot = redactForStorage(snapshot) as ProjectOnboardingSnapshot;
|
|
2479
|
-
|
|
2480
|
-
|
|
2986
|
+
const contextFile = resolveLocalStatePath(cwd, projectContextFilePath(cwd), { label: "Project context file" });
|
|
2987
|
+
const onboardingFile = resolveLocalStatePath(cwd, onboardingStateFilePath(cwd), { label: "Project onboarding state" });
|
|
2988
|
+
fs.writeFileSync(contextFile, `${redactText(markdown).trimEnd()}\n`);
|
|
2989
|
+
fs.writeFileSync(onboardingFile, `${JSON.stringify(safeSnapshot, null, 2)}\n`, { mode: 0o600 });
|
|
2481
2990
|
return safeSnapshot;
|
|
2482
2991
|
}
|
|
2483
2992
|
|
|
@@ -3391,21 +3900,78 @@ function writeProfileFromAdapter(extensionDir: string, cwd: string, profileName:
|
|
|
3391
3900
|
return resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile as ProjectProfile;
|
|
3392
3901
|
}
|
|
3393
3902
|
|
|
3394
|
-
function readTask(cwd: string, taskId: string): TaskContract | undefined {
|
|
3395
|
-
return
|
|
3903
|
+
function readTask(cwd: string, taskId: string, sessionId?: string): TaskContract | undefined {
|
|
3904
|
+
return resolveTaskContract(cwd, taskId, sessionId) as TaskContract | undefined;
|
|
3396
3905
|
}
|
|
3397
3906
|
|
|
3398
3907
|
function writeTask(cwd: string, task: TaskContract): TaskContract {
|
|
3399
|
-
|
|
3400
|
-
task.updatedAt = nowIso();
|
|
3401
|
-
fs.writeFileSync(taskFilePath(cwd, task.taskId), `${JSON.stringify(task, null, 2)}\n`);
|
|
3402
|
-
return task;
|
|
3908
|
+
return writeTaskContract(cwd, task) as TaskContract;
|
|
3403
3909
|
}
|
|
3404
3910
|
|
|
3405
|
-
function
|
|
3911
|
+
function meaningfulVerifyCommands(commands: string[]): string[] {
|
|
3912
|
+
return commands.filter((command) => {
|
|
3913
|
+
const normalized = command.trim().toLowerCase();
|
|
3914
|
+
if (!normalized) return false;
|
|
3915
|
+
if (/no (?:project|backend|data|frontend|runtime|docs|mobile) verify command configured/.test(normalized) && !/\b(?:if|elif)\b/.test(normalized)) return false;
|
|
3916
|
+
if (/^(?:true|:|echo\b|printf\b)/.test(normalized)) return false;
|
|
3917
|
+
return true;
|
|
3918
|
+
});
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
function taskChangedFileEvidence(
|
|
3922
|
+
cwd: string,
|
|
3923
|
+
task: TaskContract,
|
|
3924
|
+
currentDigests: Record<string, string> = workingTreeSnapshot(cwd) as Record<string, string>
|
|
3925
|
+
): {
|
|
3926
|
+
current: string[];
|
|
3927
|
+
expected: string[];
|
|
3928
|
+
undeclared: string[];
|
|
3929
|
+
unsupportedClaims: string[];
|
|
3930
|
+
outsideScope: string[];
|
|
3931
|
+
} {
|
|
3932
|
+
const current = Object.keys(currentDigests).sort();
|
|
3933
|
+
const baselineDigests = task.baselineFileDigests ?? {};
|
|
3934
|
+
// Observed mutations are audit history, not proof of a final source change.
|
|
3935
|
+
// A file edited and then restored to its task-start digest must not satisfy a
|
|
3936
|
+
// source-change gate merely because a write tool touched it earlier.
|
|
3937
|
+
const expected = current
|
|
3938
|
+
.filter((file) => baselineDigests[file] !== currentDigests[file])
|
|
3939
|
+
.sort();
|
|
3940
|
+
const declared = new Set(task.changedFiles ?? []);
|
|
3941
|
+
const expectedSet = new Set(expected);
|
|
3942
|
+
return {
|
|
3943
|
+
current,
|
|
3944
|
+
expected,
|
|
3945
|
+
undeclared: expected.filter((file) => !declared.has(file)),
|
|
3946
|
+
unsupportedClaims: [...declared].filter((file) => !expectedSet.has(file)),
|
|
3947
|
+
outsideScope: expected.filter((file) => !taskScopeIncludesPath(task.scope, file))
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
function taskScopeIncludesPath(scope: string[], file: string): boolean {
|
|
3952
|
+
const normalizedFile = normalizePathCandidate(file);
|
|
3953
|
+
return scope.some((candidate) => {
|
|
3954
|
+
const normalized = normalizePathCandidate(candidate);
|
|
3955
|
+
if (!normalized) return false;
|
|
3956
|
+
if ([".", "**", "**/*"].includes(normalized)) return true;
|
|
3957
|
+
if (matchesAnyPath(normalizedFile, [normalized])) return true;
|
|
3958
|
+
return !/[?*[\]{}]/.test(normalized) && normalizedFile.startsWith(`${normalized.replace(/\/$/, "")}/`);
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
function evaluateTaskGate(
|
|
3963
|
+
cwd: string,
|
|
3964
|
+
task: TaskContract | undefined,
|
|
3965
|
+
policy: BasePolicy,
|
|
3966
|
+
options: {
|
|
3967
|
+
currentDigests?: Record<string, string>;
|
|
3968
|
+
currentWorkingTreeDigest?: string;
|
|
3969
|
+
} = {}
|
|
3970
|
+
): {
|
|
3406
3971
|
decision: "pass" | "fail";
|
|
3407
3972
|
missing: string[];
|
|
3408
3973
|
warnings: string[];
|
|
3974
|
+
changedFileEvidence?: ReturnType<typeof taskChangedFileEvidence>;
|
|
3409
3975
|
} {
|
|
3410
3976
|
const finalGate = finalGateConfig(policy);
|
|
3411
3977
|
const missing: string[] = [];
|
|
@@ -3413,20 +3979,90 @@ function evaluateTaskGate(task: TaskContract | undefined, policy: BasePolicy): {
|
|
|
3413
3979
|
if (!task) {
|
|
3414
3980
|
return { decision: "fail", missing: ["task contract"], warnings };
|
|
3415
3981
|
}
|
|
3982
|
+
const currentDigests = options.currentDigests ?? workingTreeSnapshot(cwd) as Record<string, string>;
|
|
3983
|
+
const currentWorkingTreeDigest = options.currentWorkingTreeDigest ?? workingTreeEvidenceDigest(currentDigests);
|
|
3984
|
+
if (task.schemaVersion !== 2 || !task.taskRunId || !task.sessionId) missing.push("session-bound task contract v2");
|
|
3985
|
+
if (task.attempt > task.maxAttempts) missing.push(`attempt within maxAttempts (${task.attempt}/${task.maxAttempts})`);
|
|
3986
|
+
const plannedVerifyCommands = meaningfulVerifyCommands(task.verifyCommands);
|
|
3987
|
+
if (task.changeMode === "source-change" && plannedVerifyCommands.length === 0) missing.push("meaningful verify command");
|
|
3416
3988
|
if (finalGate.requireContextManifest && task.contextManifest.length === 0) missing.push("context manifest");
|
|
3417
|
-
if (finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
|
|
3989
|
+
if (task.changeMode === "source-change" && finalGate.requireVerifyEvidence && task.verifyEvidence.length === 0) missing.push("verify evidence");
|
|
3418
3990
|
if (task.verifyEvidence.some((evidence) => evidence.observed !== true)) {
|
|
3419
3991
|
warnings.push("Unobserved verify evidence is ignored by the passing verify gate.");
|
|
3420
3992
|
}
|
|
3421
3993
|
if (task.verifyEvidence.some((evidence) => evidence.observed === true && evidence.matchedProfileCommand !== true)) {
|
|
3422
3994
|
warnings.push("Observed verify evidence that does not exactly match task verifyCommands is advisory only.");
|
|
3423
3995
|
}
|
|
3424
|
-
if (
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3996
|
+
if (task.verifyEvidence.some((evidence) => (
|
|
3997
|
+
evidence.observed === true
|
|
3998
|
+
&& evidence.matchedProfileCommand === true
|
|
3999
|
+
&& evidence.workingTreeDigest !== currentWorkingTreeDigest
|
|
4000
|
+
))) {
|
|
4001
|
+
warnings.push("Verification evidence from a different working-tree snapshot is stale and is ignored.");
|
|
4002
|
+
}
|
|
4003
|
+
if (task.changeMode === "source-change" && finalGate.requirePassingVerify && plannedVerifyCommands.length > 0) {
|
|
4004
|
+
const passingCommands = new Set(task.verifyEvidence
|
|
4005
|
+
.filter((evidence) => (
|
|
4006
|
+
evidence.exitCode === 0
|
|
4007
|
+
&& evidence.observed === true
|
|
4008
|
+
&& evidence.matchedProfileCommand === true
|
|
4009
|
+
&& evidence.workingTreeDigest === currentWorkingTreeDigest
|
|
4010
|
+
))
|
|
4011
|
+
.map((evidence) => evidence.command.trim()));
|
|
4012
|
+
const missingCommands = plannedVerifyCommands.filter((command) => !passingCommands.has(command.trim()));
|
|
4013
|
+
if (missingCommands.length > 0) missing.push(`observed passing verify evidence for every command (${missingCommands.join(" | ")})`);
|
|
4014
|
+
}
|
|
4015
|
+
if (finalGate.requireTrace && task.trace.outcome !== "completed") missing.push("completed final trace");
|
|
4016
|
+
const incompleteSteps = (task.workPlan ?? []).filter((step) => step.status === "pending" || step.status === "in-progress" || step.status === "failed");
|
|
4017
|
+
if (task.trace.outcome === "completed" && incompleteSteps.length > 0) {
|
|
4018
|
+
missing.push(`completed work plan (${incompleteSteps.map((step) => `${step.id}:${step.status}`).join(", ")})`);
|
|
4019
|
+
}
|
|
4020
|
+
const changedFileEvidence = taskChangedFileEvidence(cwd, task, currentDigests);
|
|
4021
|
+
if (task.changeMode === "source-change" && task.trace.outcome === "completed") {
|
|
4022
|
+
if (task.changedFiles.length === 0) missing.push("changed files");
|
|
4023
|
+
if (changedFileEvidence.undeclared.length > 0) missing.push(`declared observed changes (${changedFileEvidence.undeclared.join(", ")})`);
|
|
4024
|
+
if (changedFileEvidence.unsupportedClaims.length > 0) missing.push(`supported changed-file claims (${changedFileEvidence.unsupportedClaims.join(", ")})`);
|
|
4025
|
+
if (changedFileEvidence.outsideScope.length > 0) missing.push(`changes within task scope (${changedFileEvidence.outsideScope.join(", ")})`);
|
|
4026
|
+
}
|
|
4027
|
+
if (task.changeMode === "read-only" && changedFileEvidence.expected.length > 0) {
|
|
4028
|
+
missing.push(`read-only task has observed changes (${changedFileEvidence.expected.join(", ")})`);
|
|
4029
|
+
}
|
|
4030
|
+
return { decision: missing.length === 0 ? "pass" : "fail", missing, warnings, changedFileEvidence };
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
function assistantMessageText(message: unknown): string {
|
|
4034
|
+
if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return "";
|
|
4035
|
+
const content = (message as { content?: unknown }).content;
|
|
4036
|
+
if (!Array.isArray(content)) return "";
|
|
4037
|
+
return content
|
|
4038
|
+
.filter((item): item is { type: "text"; text: string } => Boolean(item && typeof item === "object" && (item as { type?: unknown }).type === "text" && typeof (item as { text?: unknown }).text === "string"))
|
|
4039
|
+
.map((item) => item.text)
|
|
4040
|
+
.join("\n");
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
function assistantMessageHasToolCall(message: unknown): boolean {
|
|
4044
|
+
const content = message && typeof message === "object" ? (message as { content?: unknown }).content : undefined;
|
|
4045
|
+
return Array.isArray(content) && content.some((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "toolCall");
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
function normalizeLanguageSignal(text: string): string {
|
|
4049
|
+
return text
|
|
4050
|
+
.normalize("NFKD")
|
|
4051
|
+
.replace(/\p{M}/gu, "")
|
|
4052
|
+
.toLowerCase()
|
|
4053
|
+
.replaceAll("đ", "d");
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
function looksLikeIncompleteHandoff(text: string): boolean {
|
|
4057
|
+
const normalized = normalizeLanguageSignal(text);
|
|
4058
|
+
return /\b(?:not done|not complete|not finished|still working|blocked|cannot complete|unable to complete|need clarification|tests? (?:fail|failed|failing)|verification (?:fail|failed|failing)|chua xong|chua hoan tat|chua hoan thanh|dang lam|bi chan|can lam them|test (?:loi|fail))\b/.test(normalized);
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
function looksLikeCompletionClaim(text: string): boolean {
|
|
4062
|
+
const normalized = normalizeLanguageSignal(text);
|
|
4063
|
+
if (!normalized.trim() || normalized.includes("[piagent completion gate:")) return false;
|
|
4064
|
+
if (looksLikeIncompleteHandoff(text)) return false;
|
|
4065
|
+
return /\b(?:done|completed|complete|finished|fixed|implemented|resolved|shipped|all tests pass(?:ed)?|tests? pass(?:ed)?|da xong|da sua|da fix|da hoan tat|da hoan thanh|da trien khai|test da pass|kiem tra da pass)\b/.test(normalized);
|
|
3430
4066
|
}
|
|
3431
4067
|
|
|
3432
4068
|
function formatCount(value: number | null | undefined): string {
|
|
@@ -3620,14 +4256,27 @@ function writeToolResultCapture(
|
|
|
3620
4256
|
};
|
|
3621
4257
|
|
|
3622
4258
|
try {
|
|
3623
|
-
|
|
3624
|
-
|
|
4259
|
+
ensurePrivateStateDirectory(cwd, path.dirname(absolutePath), "Tool-result capture directory");
|
|
4260
|
+
const capturePath = resolveLocalStatePath(cwd, absolutePath, { label: "Tool-result capture" });
|
|
4261
|
+
fs.writeFileSync(capturePath, [
|
|
3625
4262
|
"# Piagent compacted tool result capture",
|
|
3626
4263
|
JSON.stringify(metadata),
|
|
3627
4264
|
"---",
|
|
3628
4265
|
capture.text
|
|
3629
|
-
].join("\n"));
|
|
3630
|
-
|
|
4266
|
+
].join("\n"), { mode: 0o600 });
|
|
4267
|
+
appendJsonlBounded(toolResultCaptureIndexPath(cwd), metadata, { maxBytes: CAPTURE_INDEX_MAX_BYTES, mode: 0o600, projectRoot: cwd });
|
|
4268
|
+
const writes = (CAPTURE_WRITES_SINCE_PRUNE.get(cwd) ?? 0) + 1;
|
|
4269
|
+
if (writes >= 25) {
|
|
4270
|
+
pruneCaptureFiles(toolResultCaptureRoot(cwd), {
|
|
4271
|
+
maxFiles: CAPTURE_RETENTION_MAX_FILES,
|
|
4272
|
+
maxBytes: CAPTURE_RETENTION_MAX_BYTES,
|
|
4273
|
+
maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
|
|
4274
|
+
projectRoot: cwd
|
|
4275
|
+
});
|
|
4276
|
+
CAPTURE_WRITES_SINCE_PRUNE.set(cwd, 0);
|
|
4277
|
+
} else {
|
|
4278
|
+
CAPTURE_WRITES_SINCE_PRUNE.set(cwd, writes);
|
|
4279
|
+
}
|
|
3631
4280
|
const summary = {
|
|
3632
4281
|
path: relativePath,
|
|
3633
4282
|
source,
|
|
@@ -3731,16 +4380,9 @@ function attachToolResultCompactionDetails(details: unknown, captures: ToolResul
|
|
|
3731
4380
|
|
|
3732
4381
|
function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
|
|
3733
4382
|
const indexPath = toolResultCaptureIndexPath(cwd);
|
|
3734
|
-
if (!fs.existsSync(indexPath)) return [];
|
|
3735
|
-
const lines = fs.readFileSync(indexPath, "utf8").trim().split(/\n/).filter(Boolean);
|
|
3736
4383
|
const captures: Record<string, unknown>[] = [];
|
|
3737
|
-
for (const
|
|
3738
|
-
|
|
3739
|
-
const parsed = JSON.parse(line);
|
|
3740
|
-
if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
|
|
3741
|
-
} catch {
|
|
3742
|
-
// A partial JSONL line should not make a status command noisy or unusable.
|
|
3743
|
-
}
|
|
4384
|
+
for (const parsed of readJsonlTail(indexPath, { limit: Math.max(limit * 3, limit), maxBytes: CAPTURE_INDEX_MAX_BYTES, projectRoot: cwd })) {
|
|
4385
|
+
if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
|
|
3744
4386
|
}
|
|
3745
4387
|
return captures.slice(-limit);
|
|
3746
4388
|
}
|
|
@@ -4269,7 +4911,7 @@ function attachLocalImagesFromText(
|
|
|
4269
4911
|
function appendTrace(cwd: string, payload: Record<string, unknown>): void {
|
|
4270
4912
|
ensureStateDirs(cwd);
|
|
4271
4913
|
const safePayload = redactForStorage(payload) as Record<string, unknown>;
|
|
4272
|
-
|
|
4914
|
+
appendJsonlBounded(traceFilePath(cwd), { recordedAt: nowIso(), ...safePayload }, { maxBytes: TRACE_MAX_BYTES, mode: 0o600, projectRoot: cwd });
|
|
4273
4915
|
}
|
|
4274
4916
|
|
|
4275
4917
|
function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>): void {
|
|
@@ -4281,8 +4923,51 @@ function appendSessionTrace(pi: ExtensionAPI, payload: Record<string, unknown>):
|
|
|
4281
4923
|
});
|
|
4282
4924
|
}
|
|
4283
4925
|
|
|
4284
|
-
function
|
|
4285
|
-
|
|
4926
|
+
function selectVerifyPlan(
|
|
4927
|
+
profile: ProjectProfile,
|
|
4928
|
+
requestedGroup: string | undefined,
|
|
4929
|
+
changeMode: TaskContract["changeMode"],
|
|
4930
|
+
cwd?: string
|
|
4931
|
+
): { group?: string; commands: string[]; error?: string } {
|
|
4932
|
+
if (changeMode === "read-only") return { commands: [] };
|
|
4933
|
+
const groups = profile.verifyCommands ?? {};
|
|
4934
|
+
const names = Object.keys(groups);
|
|
4935
|
+
const requested = requestedGroup?.trim();
|
|
4936
|
+
if (requested && !Object.hasOwn(groups, requested)) {
|
|
4937
|
+
return { commands: [], error: `Unknown verify group ${requested}. Available groups: ${names.join(", ") || "none"}.` };
|
|
4938
|
+
}
|
|
4939
|
+
const group = requested
|
|
4940
|
+
?? (Object.hasOwn(groups, "source") ? "source" : undefined)
|
|
4941
|
+
?? (Object.hasOwn(groups, "frontendSource") ? "frontendSource" : undefined)
|
|
4942
|
+
?? names.find((name) => !/docs|runtime/i.test(name));
|
|
4943
|
+
let commands = group ? uniqueStrings(groups[group] ?? []) : [];
|
|
4944
|
+
if (
|
|
4945
|
+
cwd
|
|
4946
|
+
&& commands.length === 1
|
|
4947
|
+
&& commands[0].includes("No Node source verifier is configured")
|
|
4948
|
+
) {
|
|
4949
|
+
try {
|
|
4950
|
+
const manifestPath = path.join(cwd, "package.json");
|
|
4951
|
+
if (!fs.lstatSync(manifestPath).isSymbolicLink()) {
|
|
4952
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { scripts?: Record<string, unknown> };
|
|
4953
|
+
const scripts = manifest.scripts && typeof manifest.scripts === "object" ? manifest.scripts : {};
|
|
4954
|
+
const selected = ["type-check", "typecheck", "lint", "test"].filter((name) => typeof scripts[name] === "string");
|
|
4955
|
+
if (selected.length > 0) {
|
|
4956
|
+
commands = selected.map((name) => name === "test" ? "npm test" : `npm run ${name}`);
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
} catch {
|
|
4960
|
+
// Preserve the profile's fail-closed verifier when package metadata is absent or invalid.
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
if (meaningfulVerifyCommands(commands).length === 0) {
|
|
4964
|
+
return {
|
|
4965
|
+
group,
|
|
4966
|
+
commands,
|
|
4967
|
+
error: `Verify group ${group ?? "(none)"} has no meaningful command. Configure .pi/piagent-profile.json before source changes.`
|
|
4968
|
+
};
|
|
4969
|
+
}
|
|
4970
|
+
return { group, commands };
|
|
4286
4971
|
}
|
|
4287
4972
|
|
|
4288
4973
|
function parseReferenceRepoRef(input: string): { host: string; owner: string; repo: string } {
|
|
@@ -4404,7 +5089,7 @@ function checkoutReferenceRepo(repoRef: string, forceUpdate = false): ReferenceR
|
|
|
4404
5089
|
function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
|
|
4405
5090
|
const signal = classifyContextTask(prompt);
|
|
4406
5091
|
const lower = prompt.toLowerCase();
|
|
4407
|
-
const groups = new Set<PiagentToolGroup>(
|
|
5092
|
+
const groups = new Set<PiagentToolGroup>();
|
|
4408
5093
|
|
|
4409
5094
|
if (signal.workflow === "usage") {
|
|
4410
5095
|
groups.add("usage");
|
|
@@ -4419,45 +5104,110 @@ function toolGroupsForPrompt(prompt: string): PiagentToolGroup[] {
|
|
|
4419
5104
|
return [...groups];
|
|
4420
5105
|
}
|
|
4421
5106
|
|
|
4422
|
-
groups.add("
|
|
4423
|
-
|
|
5107
|
+
groups.add("intake");
|
|
5108
|
+
// Keep the common task surface stable across the first tool call so provider
|
|
5109
|
+
// prompt caches do not lose the full system prefix after task_start.
|
|
5110
|
+
if (signal.lane !== "tiny") groups.add("task");
|
|
4424
5111
|
if (
|
|
4425
|
-
|
|
4426
|
-
|| ["scout", "review", "plan", "release"].includes(signal.workflow)
|
|
4427
|
-
|| /\b(context|document|memory|repository|source|subagent|vendor)\b/.test(lower)
|
|
5112
|
+
/\b(context (?:engine|index|search|diagnostic)|source checkout|vendor checkout)\b/.test(lower)
|
|
4428
5113
|
) {
|
|
4429
5114
|
groups.add("retrieval");
|
|
4430
5115
|
}
|
|
4431
5116
|
if (
|
|
4432
|
-
|
|
4433
|
-
|| ["scout", "review", "plan", "release"].includes(signal.workflow)
|
|
4434
|
-
|| /\b(document|memory|orchestration|source checkout|subagent|vendor)\b/.test(lower)
|
|
5117
|
+
/\b(document intake|project memory|orchestration policy|source checkout|subagent policy|vendor checkout)\b/.test(lower)
|
|
4435
5118
|
) {
|
|
4436
5119
|
groups.add("knowledge");
|
|
4437
5120
|
}
|
|
5121
|
+
if (/\b(permission|capability|exec policy|tool policy)\b/.test(lower)) groups.add("policy");
|
|
4438
5122
|
if (/\b(profile|tech stack|onboard|project context)\b/.test(lower)) groups.add("onboarding");
|
|
4439
5123
|
if (/\b(token|usage|cost)\b/.test(lower)) groups.add("usage");
|
|
5124
|
+
if (/\b(?:piagent tool loader|load piagent tools?|piagent tool groups?)\b/.test(lower)) groups.add("loader");
|
|
4440
5125
|
return [...groups];
|
|
4441
5126
|
}
|
|
4442
5127
|
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
5128
|
+
const AUTO_INTAKE_MAX_PROMPT_CHARS = 700;
|
|
5129
|
+
const AUTO_INTAKE_SNAPSHOT_PATTERNS = [
|
|
5130
|
+
"src/**", "app/**", "lib/**", "packages/**", "test/**", "tests/**", "spec/**", "__tests__/**"
|
|
5131
|
+
];
|
|
5132
|
+
const AUTO_INTAKE_CHANGE_INTENT = /\b(?:add|build|change|correct|create|fix|implement|modify|refactor|remove|rename|replace|update|write|sua|them|doi|cap nhat|xoa|tao)\b/i;
|
|
5133
|
+
const AUTO_INTAKE_READ_ONLY_LEAD = /^\s*\/?(?:analy[sz]e|audit|check|discuss|explain|inspect|plan|research|review|scout|summari[sz]e|why|how|can\s+(?:you|we)|kiem tra|nghien cuu|giai thich|danh gia)\b/i;
|
|
5134
|
+
const AUTO_INTAKE_MANUAL_RISK = /\b(?:credential|database|deploy|destructive|encryption|external provider|migration|payment|permission|production|publish|release|secret|schema migration|token rotation)\b/i;
|
|
5135
|
+
|
|
5136
|
+
function automaticTaskIntakeEligible(prompt: string, readProtectedPaths: string[]): boolean {
|
|
5137
|
+
const text = String(prompt ?? "").trim();
|
|
5138
|
+
if (!text || text.length > AUTO_INTAKE_MAX_PROMPT_CHARS) return false;
|
|
5139
|
+
const signal = classifyContextTask(text);
|
|
5140
|
+
if (signal.workflow !== "task" || !AUTO_INTAKE_CHANGE_INTENT.test(text)) return false;
|
|
5141
|
+
if (AUTO_INTAKE_READ_ONLY_LEAD.test(text) || AUTO_INTAKE_MANUAL_RISK.test(text)) return false;
|
|
5142
|
+
if (/\bpiagent_task_start\b/i.test(text)) return false;
|
|
5143
|
+
return !signal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
5144
|
+
}
|
|
5145
|
+
|
|
5146
|
+
function automaticTaskScope(prompt: string, context: Array<{ path: string }>): string[] {
|
|
5147
|
+
const signal = classifyContextTask(prompt);
|
|
5148
|
+
const explicit = signal.paths.filter((candidate) => candidate && candidate !== "." && !candidate.startsWith(".pi/"));
|
|
5149
|
+
const navigated = context
|
|
5150
|
+
.map((item) => normalizePathCandidate(item.path))
|
|
5151
|
+
.filter((candidate): candidate is string => Boolean(
|
|
5152
|
+
candidate
|
|
5153
|
+
&& candidate !== "."
|
|
5154
|
+
&& !candidate.startsWith(".pi/")
|
|
5155
|
+
&& !["AGENTS.md", "README.md", "REVIEW_GUIDELINES.md"].includes(candidate)
|
|
5156
|
+
&& /^(?:app|lib|packages|spec|src|test|tests|__tests__)\//.test(candidate)
|
|
5157
|
+
))
|
|
5158
|
+
.slice(0, 8);
|
|
5159
|
+
const scope = uniqueStrings([...explicit, ...navigated]);
|
|
5160
|
+
if (scope.length === 0) scope.push("src/**", "app/**", "lib/**");
|
|
5161
|
+
if (/\b(?:test|tests|verification|verify|kiem thu)\b/i.test(prompt)) {
|
|
5162
|
+
scope.push("test/**", "tests/**", "spec/**", "__tests__/**");
|
|
5163
|
+
}
|
|
5164
|
+
return uniqueStrings(scope);
|
|
5165
|
+
}
|
|
5166
|
+
|
|
5167
|
+
function automaticReviewLenses(prompt: string): ReviewLens[] {
|
|
5168
|
+
const lenses: ReviewLens[] = ["correctness", "tests", "scope"];
|
|
5169
|
+
if (/\b(?:auth|authorization|credential|permission|security|session|secret|xac thuc|phan quyen|bao mat)\b/i.test(prompt)) {
|
|
5170
|
+
lenses.push("security");
|
|
5171
|
+
}
|
|
5172
|
+
return lenses;
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5175
|
+
function validTaskScopePattern(value: string): boolean {
|
|
5176
|
+
const candidate = String(value ?? "").trim().replaceAll("\\", "/");
|
|
5177
|
+
if (!candidate || /\s|\0/.test(candidate)) return false;
|
|
5178
|
+
if (candidate.startsWith("/") || candidate.startsWith("~/") || /^[A-Za-z]:\//.test(candidate)) return false;
|
|
5179
|
+
return !candidate.split("/").includes("..");
|
|
5180
|
+
}
|
|
5181
|
+
|
|
5182
|
+
function activeTaskToolGroups(task: TaskContract): PiagentToolGroup[] {
|
|
5183
|
+
const mode = runtimeLifecycleMode(task);
|
|
5184
|
+
if (mode.startsWith("automatic")) return [];
|
|
5185
|
+
if (mode.startsWith("assisted")) return ["task"];
|
|
5186
|
+
return ["task", "recovery"];
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
function sessionTaskReference(ctx: ExtensionContext): { taskId?: string; taskRunId?: string } | undefined {
|
|
5190
|
+
const entries = ctx.sessionManager.getBranch();
|
|
5191
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
5192
|
+
const entry = entries[index] as unknown as { type?: string; customType?: string; data?: Record<string, unknown> };
|
|
5193
|
+
if (entry.type !== "custom" || entry.customType !== PIAGENT_TRACE_STATE_TYPE || !entry.data) continue;
|
|
5194
|
+
const taskId = typeof entry.data.taskId === "string" ? entry.data.taskId : undefined;
|
|
5195
|
+
const taskRunId = typeof entry.data.taskRunId === "string" ? entry.data.taskRunId : undefined;
|
|
5196
|
+
if (taskId || taskRunId) return { taskId, taskRunId };
|
|
5197
|
+
}
|
|
5198
|
+
return undefined;
|
|
5199
|
+
}
|
|
5200
|
+
|
|
5201
|
+
function compactSessionTask(cwd: string, sessionId: string): TaskContract | undefined {
|
|
5202
|
+
return activeSessionTask(cwd, sessionId) as TaskContract | undefined;
|
|
4454
5203
|
}
|
|
4455
5204
|
|
|
4456
|
-
function semanticCompactionInstructions(cwd: string): string {
|
|
4457
|
-
const task = compactSessionTask(cwd);
|
|
5205
|
+
function semanticCompactionInstructions(cwd: string, sessionId: string): string {
|
|
5206
|
+
const task = compactSessionTask(cwd, sessionId);
|
|
4458
5207
|
const taskState = task
|
|
4459
5208
|
? [
|
|
4460
5209
|
`Current task: ${task.taskId} (${task.riskLane})`,
|
|
5210
|
+
`Session: ${task.sessionName ?? task.sessionId}`,
|
|
4461
5211
|
`Goal: ${task.summary}`,
|
|
4462
5212
|
`Acceptance: ${task.acceptanceCriteria.join("; ") || "not recorded"}`,
|
|
4463
5213
|
`Scope: ${task.scope.join(", ") || "not recorded"}`,
|
|
@@ -4490,6 +5240,48 @@ function environmentFeatureEnabled(name: string, fallback = true): boolean {
|
|
|
4490
5240
|
return !["0", "false", "no", "off", "disabled"].includes(value);
|
|
4491
5241
|
}
|
|
4492
5242
|
|
|
5243
|
+
function rewriteLegacyProjectInstructions(systemPrompt: string): {
|
|
5244
|
+
systemPrompt: string;
|
|
5245
|
+
rewritten: boolean;
|
|
5246
|
+
} {
|
|
5247
|
+
const start = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_START);
|
|
5248
|
+
if (start < 0) return { systemPrompt, rewritten: false };
|
|
5249
|
+
const endStart = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_END, start);
|
|
5250
|
+
if (endStart < 0) return { systemPrompt, rewritten: false };
|
|
5251
|
+
const end = endStart + LEGACY_PROJECT_INSTRUCTIONS_END.length;
|
|
5252
|
+
return {
|
|
5253
|
+
systemPrompt: `${systemPrompt.slice(0, start)}${RUNTIME_MANAGED_PROJECT_INSTRUCTIONS}${systemPrompt.slice(end)}`,
|
|
5254
|
+
rewritten: true
|
|
5255
|
+
};
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5258
|
+
function compactManagedProjectInstructions(
|
|
5259
|
+
systemPrompt: string,
|
|
5260
|
+
mode: "automatic" | "protected"
|
|
5261
|
+
): { systemPrompt: string; compacted: boolean } {
|
|
5262
|
+
const concise = mode === "protected"
|
|
5263
|
+
? "Piagent protected-path policy: do not read, disclose, or mutate protected content. Refuse without tool calls."
|
|
5264
|
+
: "Piagent runtime task is injected below. Root project instructions are already loaded; do not re-read root AGENTS.md. Use task-relevant source/tests and ordinary tools, run the exact verifier after final edits, and make no task-management calls.";
|
|
5265
|
+
const markerStart = "<!-- piagent-managed:start -->";
|
|
5266
|
+
const markerEnd = "<!-- piagent-managed:end -->";
|
|
5267
|
+
const start = systemPrompt.indexOf(markerStart);
|
|
5268
|
+
const endStart = start >= 0 ? systemPrompt.indexOf(markerEnd, start) : -1;
|
|
5269
|
+
if (start >= 0 && endStart >= 0) {
|
|
5270
|
+
const end = endStart + markerEnd.length;
|
|
5271
|
+
return {
|
|
5272
|
+
systemPrompt: `${systemPrompt.slice(0, start)}${markerStart}\n${concise}\n${markerEnd}${systemPrompt.slice(end)}`,
|
|
5273
|
+
compacted: true
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
5276
|
+
if (systemPrompt.includes(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS)) {
|
|
5277
|
+
return {
|
|
5278
|
+
systemPrompt: systemPrompt.replace(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS, concise),
|
|
5279
|
+
compacted: true
|
|
5280
|
+
};
|
|
5281
|
+
}
|
|
5282
|
+
return { systemPrompt, compacted: false };
|
|
5283
|
+
}
|
|
5284
|
+
|
|
4493
5285
|
export default function piagentGuard(pi: ExtensionAPI) {
|
|
4494
5286
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
4495
5287
|
const policy = loadPolicy(extensionDir);
|
|
@@ -4497,22 +5289,126 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4497
5289
|
const dynamicToolsEnabled = environmentFeatureEnabled("PIAGENT_DYNAMIC_TOOLS");
|
|
4498
5290
|
const contextTelemetryEnabled = environmentFeatureEnabled("PIAGENT_CONTEXT_TELEMETRY");
|
|
4499
5291
|
const autoContextEnabled = environmentFeatureEnabled("PIAGENT_AUTO_CONTEXT");
|
|
5292
|
+
const autoRecoveryEnabled = environmentFeatureEnabled("PIAGENT_AUTO_RECOVERY");
|
|
4500
5293
|
// Advisory tool-registry verdicts are surfaced once per tool per session. A
|
|
4501
5294
|
// notice on every call would be constant noise for a tool used all day, and
|
|
4502
5295
|
// noise that is always present is read as background rather than as a warning.
|
|
4503
5296
|
const advisedTools = new Set<string>();
|
|
4504
5297
|
const seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
|
|
4505
5298
|
const autoPackedPrompts = new Set<string>();
|
|
5299
|
+
const injectedContextPacks = new Map<string, { queryHash: string; confidence: string; estimatedTokens: number; paths: string[] }>();
|
|
5300
|
+
const taskIdentityBySession = new Map<string, { taskId: string; taskRunId: string }>();
|
|
5301
|
+
const observedContextBySession = new Map<string, Map<string, ObservedTaskContext>>();
|
|
5302
|
+
const completionRecoveryAttempts = new Map<string, number>();
|
|
5303
|
+
const shellMutationSnapshots = new Map<string, Record<string, string>>();
|
|
5304
|
+
|
|
5305
|
+
function sessionCacheKey(ctx: ExtensionContext): string {
|
|
5306
|
+
return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}`;
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
function cacheTaskIdentity(ctx: ExtensionContext, task: TaskContract | undefined): void {
|
|
5310
|
+
const key = sessionCacheKey(ctx);
|
|
5311
|
+
if (task) taskIdentityBySession.set(key, { taskId: task.taskId, taskRunId: task.taskRunId });
|
|
5312
|
+
else taskIdentityBySession.delete(key);
|
|
5313
|
+
if (taskIdentityBySession.size > 100) taskIdentityBySession.delete(taskIdentityBySession.keys().next().value as string);
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
function rememberObservedContext(ctx: ExtensionContext, entry: ObservedTaskContext): void {
|
|
5317
|
+
const key = sessionCacheKey(ctx);
|
|
5318
|
+
let observed = observedContextBySession.get(key);
|
|
5319
|
+
if (!observed) {
|
|
5320
|
+
observed = new Map();
|
|
5321
|
+
observedContextBySession.set(key, observed);
|
|
5322
|
+
}
|
|
5323
|
+
if (!observed.has(entry.path)) observed.set(entry.path, entry);
|
|
5324
|
+
while (observed.size > contextBudgetConfig(policy).maxManifestFiles) {
|
|
5325
|
+
observed.delete(observed.keys().next().value as string);
|
|
5326
|
+
}
|
|
5327
|
+
if (observedContextBySession.size > 100) observedContextBySession.delete(observedContextBySession.keys().next().value as string);
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
function observedContext(ctx: ExtensionContext): ObservedTaskContext[] {
|
|
5331
|
+
return [...(observedContextBySession.get(sessionCacheKey(ctx))?.values() ?? [])];
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5334
|
+
function clearObservedContext(ctx: ExtensionContext): void {
|
|
5335
|
+
observedContextBySession.delete(sessionCacheKey(ctx));
|
|
5336
|
+
}
|
|
5337
|
+
|
|
5338
|
+
function shellMutationSnapshotKey(
|
|
5339
|
+
ctx: ExtensionContext,
|
|
5340
|
+
toolName: string,
|
|
5341
|
+
input: unknown
|
|
5342
|
+
): string {
|
|
5343
|
+
return `${sessionCacheKey(ctx)}\u0000${toolResultFingerprint(toolName, input, []).key}`;
|
|
5344
|
+
}
|
|
5345
|
+
|
|
5346
|
+
function rememberShellMutationSnapshot(
|
|
5347
|
+
ctx: ExtensionContext,
|
|
5348
|
+
toolName: string,
|
|
5349
|
+
input: unknown
|
|
5350
|
+
): void {
|
|
5351
|
+
shellMutationSnapshots.set(
|
|
5352
|
+
shellMutationSnapshotKey(ctx, toolName, input),
|
|
5353
|
+
workingTreeSnapshot(ctx.cwd) as Record<string, string>
|
|
5354
|
+
);
|
|
5355
|
+
if (shellMutationSnapshots.size > 100) {
|
|
5356
|
+
shellMutationSnapshots.delete(shellMutationSnapshots.keys().next().value as string);
|
|
5357
|
+
}
|
|
5358
|
+
}
|
|
5359
|
+
|
|
5360
|
+
function consumeShellMutationSnapshot(
|
|
5361
|
+
ctx: ExtensionContext,
|
|
5362
|
+
toolName: string,
|
|
5363
|
+
input: unknown
|
|
5364
|
+
): Record<string, string> | undefined {
|
|
5365
|
+
const key = shellMutationSnapshotKey(ctx, toolName, input);
|
|
5366
|
+
const snapshot = shellMutationSnapshots.get(key);
|
|
5367
|
+
shellMutationSnapshots.delete(key);
|
|
5368
|
+
return snapshot;
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5371
|
+
function clearShellMutationSnapshots(ctx: ExtensionContext): void {
|
|
5372
|
+
const prefix = `${sessionCacheKey(ctx)}\u0000`;
|
|
5373
|
+
for (const key of shellMutationSnapshots.keys()) {
|
|
5374
|
+
if (key.startsWith(prefix)) shellMutationSnapshots.delete(key);
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
|
|
5378
|
+
function retrievalKey(ctx: ExtensionContext, query: string): string {
|
|
5379
|
+
const signal = classifyContextTask(query);
|
|
5380
|
+
return crypto.createHash("sha256")
|
|
5381
|
+
.update(JSON.stringify({
|
|
5382
|
+
cwd: ctx.cwd,
|
|
5383
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
5384
|
+
terms: [...signal.terms].sort(),
|
|
5385
|
+
paths: [...signal.paths].sort()
|
|
5386
|
+
}))
|
|
5387
|
+
.digest("hex");
|
|
5388
|
+
}
|
|
5389
|
+
|
|
5390
|
+
function promptPackKey(ctx: ExtensionContext, promptHash: string): string {
|
|
5391
|
+
return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}\u0000${promptHash}`;
|
|
5392
|
+
}
|
|
4506
5393
|
|
|
4507
5394
|
function telemetry(ctx: ExtensionContext, payload: Record<string, unknown>): void {
|
|
4508
5395
|
if (!contextTelemetryEnabled) return;
|
|
4509
5396
|
try {
|
|
4510
5397
|
const safePayload = redactForStorage(payload) as Record<string, unknown>;
|
|
5398
|
+
let taskIdentity = taskIdentityBySession.get(sessionCacheKey(ctx));
|
|
5399
|
+
if (!taskIdentity) {
|
|
5400
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
5401
|
+
if (task) {
|
|
5402
|
+
cacheTaskIdentity(ctx, task);
|
|
5403
|
+
taskIdentity = { taskId: task.taskId, taskRunId: task.taskRunId };
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
4511
5406
|
appendContextTelemetry(ctx.cwd, {
|
|
4512
5407
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
4513
5408
|
sessionName: currentSessionName(ctx),
|
|
4514
5409
|
model: `${ctx.model?.provider ?? "unknown"}/${ctx.model?.id ?? "unknown"}`,
|
|
4515
5410
|
thinkingLevel: String(pi.getThinkingLevel()),
|
|
5411
|
+
...taskIdentity,
|
|
4516
5412
|
...safePayload
|
|
4517
5413
|
});
|
|
4518
5414
|
} catch {
|
|
@@ -4531,7 +5427,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4531
5427
|
if (!PIAGENT_TOOL_NAMES.has(toolName)) selected.add(toolName);
|
|
4532
5428
|
}
|
|
4533
5429
|
}
|
|
4534
|
-
const normalizedGroups = [...new Set<PiagentToolGroup>(
|
|
5430
|
+
const normalizedGroups = [...new Set<PiagentToolGroup>(groups)];
|
|
4535
5431
|
for (const toolName of PIAGENT_TOOL_ORDER) {
|
|
4536
5432
|
if (normalizedGroups.some((group) => PIAGENT_TOOL_GROUPS[group].includes(toolName as never))) {
|
|
4537
5433
|
selected.add(toolName);
|
|
@@ -4564,6 +5460,42 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4564
5460
|
if (!hasOperatorSessionName(operatorSessionName)) {
|
|
4565
5461
|
pi.setSessionName(`pi:${name}`);
|
|
4566
5462
|
}
|
|
5463
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
5464
|
+
const sessionName = currentSessionName(ctx);
|
|
5465
|
+
const taskReference = sessionTaskReference(ctx);
|
|
5466
|
+
let captureRetention: Record<string, unknown>;
|
|
5467
|
+
try {
|
|
5468
|
+
captureRetention = pruneCaptureFiles(toolResultCaptureRoot(ctx.cwd), {
|
|
5469
|
+
maxFiles: CAPTURE_RETENTION_MAX_FILES,
|
|
5470
|
+
maxBytes: CAPTURE_RETENTION_MAX_BYTES,
|
|
5471
|
+
maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
|
|
5472
|
+
projectRoot: ctx.cwd
|
|
5473
|
+
});
|
|
5474
|
+
} catch (error) {
|
|
5475
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5476
|
+
captureRetention = { removed: 0, kept: 0, bytes: 0, error: message };
|
|
5477
|
+
ctx.ui.notify(`Piagent local state needs recovery: ${message}`, "warning");
|
|
5478
|
+
}
|
|
5479
|
+
const migration = migrateTaskState(ctx.cwd, {
|
|
5480
|
+
sessionId,
|
|
5481
|
+
sessionName,
|
|
5482
|
+
taskId: taskReference?.taskId
|
|
5483
|
+
});
|
|
5484
|
+
if (migration.warnings.length > 0) {
|
|
5485
|
+
ctx.ui.notify(`Piagent task-state migration needs recovery: ${migration.warnings.join("; ")}`, "warning");
|
|
5486
|
+
}
|
|
5487
|
+
let resumedTask = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
|
|
5488
|
+
if (!resumedTask && taskReference) {
|
|
5489
|
+
resumedTask = resolveTaskContract(ctx.cwd, taskReference.taskRunId ?? taskReference.taskId, sessionId) as TaskContract | undefined;
|
|
5490
|
+
if (resumedTask) bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
|
|
5491
|
+
}
|
|
5492
|
+
if (resumedTask && resumedTask.sessionName !== sessionName) {
|
|
5493
|
+
resumedTask.sessionName = sessionName;
|
|
5494
|
+
resumedTask = writeTask(ctx.cwd, resumedTask);
|
|
5495
|
+
bindSessionTask(ctx.cwd, sessionId, sessionName, resumedTask);
|
|
5496
|
+
}
|
|
5497
|
+
cacheTaskIdentity(ctx, resumedTask);
|
|
5498
|
+
if (resumedTask?.trace.outcome === "pending") activateToolGroups(ctx, activeTaskToolGroups(resumedTask));
|
|
4567
5499
|
const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
|
|
4568
5500
|
? ""
|
|
4569
5501
|
: " (run /onboard to select a profile)";
|
|
@@ -4604,7 +5536,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4604
5536
|
} catch (error) {
|
|
4605
5537
|
engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
|
|
4606
5538
|
}
|
|
4607
|
-
telemetry(ctx, {
|
|
5539
|
+
telemetry(ctx, {
|
|
5540
|
+
event: "session_start",
|
|
5541
|
+
activeTools: pi.getActiveTools().length,
|
|
5542
|
+
index: engineStatus,
|
|
5543
|
+
taskMigration: migration,
|
|
5544
|
+
taskId: resumedTask?.taskId,
|
|
5545
|
+
taskRunId: resumedTask?.taskRunId,
|
|
5546
|
+
captureRetention
|
|
5547
|
+
});
|
|
5548
|
+
});
|
|
5549
|
+
|
|
5550
|
+
pi.on("session_info_changed", async (event, ctx) => {
|
|
5551
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
5552
|
+
const task = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
|
|
5553
|
+
if (!task) return;
|
|
5554
|
+
const sessionName = String(event.name ?? "").trim() || undefined;
|
|
5555
|
+
task.sessionName = sessionName;
|
|
5556
|
+
const written = writeTask(ctx.cwd, task);
|
|
5557
|
+
bindSessionTask(ctx.cwd, sessionId, sessionName, written);
|
|
5558
|
+
appendTrace(ctx.cwd, {
|
|
5559
|
+
event: "task_session_renamed",
|
|
5560
|
+
taskId: written.taskId,
|
|
5561
|
+
taskRunId: written.taskRunId,
|
|
5562
|
+
sessionId,
|
|
5563
|
+
sessionName
|
|
5564
|
+
});
|
|
4608
5565
|
});
|
|
4609
5566
|
|
|
4610
5567
|
pi.on("input", async (event, ctx) => {
|
|
@@ -4614,7 +5571,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4614
5571
|
return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
|
|
4615
5572
|
}
|
|
4616
5573
|
const taskSignal = classifyContextTask(text);
|
|
4617
|
-
|
|
5574
|
+
const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
5575
|
+
const readProtectedPaths = effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths;
|
|
5576
|
+
const protectedTarget = taskSignal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
5577
|
+
const runtimeIntake = !activeTask && automaticTaskIntakeEligible(text, readProtectedPaths);
|
|
5578
|
+
const promptGroups = protectedTarget
|
|
5579
|
+
? []
|
|
5580
|
+
: toolGroupsForPrompt(text).filter((group) => (
|
|
5581
|
+
runtimeIntake ? group !== "intake" && group !== "task" : true
|
|
5582
|
+
));
|
|
5583
|
+
activateToolGroups(ctx, activeTask?.trace.outcome === "pending"
|
|
5584
|
+
? [...promptGroups.filter((group) => group !== "intake"), ...activeTaskToolGroups(activeTask)]
|
|
5585
|
+
: promptGroups);
|
|
4618
5586
|
telemetry(ctx, {
|
|
4619
5587
|
event: "user_input",
|
|
4620
5588
|
source: event.source,
|
|
@@ -4622,6 +5590,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4622
5590
|
promptChars: taskSignal.promptChars,
|
|
4623
5591
|
workflow: taskSignal.workflow,
|
|
4624
5592
|
riskLane: taskSignal.lane,
|
|
5593
|
+
intakeMode: runtimeIntake ? "runtime" : "model",
|
|
5594
|
+
protectedTarget,
|
|
4625
5595
|
explicitPathCount: taskSignal.paths.length,
|
|
4626
5596
|
termCount: taskSignal.terms.length
|
|
4627
5597
|
});
|
|
@@ -4685,6 +5655,26 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4685
5655
|
});
|
|
4686
5656
|
|
|
4687
5657
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
5658
|
+
const projectInstructions = rewriteLegacyProjectInstructions(event.systemPrompt);
|
|
5659
|
+
const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
|
|
5660
|
+
const signal = classifyContextTask(query);
|
|
5661
|
+
const profile = loadProfileFromContext(ctx);
|
|
5662
|
+
const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
|
|
5663
|
+
const protectedTarget = signal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
5664
|
+
const activeTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
5665
|
+
const runtimeIntake = !activeTask && automaticTaskIntakeEligible(query, readProtectedPaths);
|
|
5666
|
+
const compactMode = protectedTarget
|
|
5667
|
+
? "protected"
|
|
5668
|
+
: runtimeIntake || activeTask?.intakeMode === "runtime"
|
|
5669
|
+
? "automatic"
|
|
5670
|
+
: undefined;
|
|
5671
|
+
const compactedInstructions = compactMode
|
|
5672
|
+
? compactManagedProjectInstructions(projectInstructions.systemPrompt, compactMode)
|
|
5673
|
+
: { systemPrompt: projectInstructions.systemPrompt, compacted: false };
|
|
5674
|
+
const effectiveSystemPrompt = compactedInstructions.systemPrompt;
|
|
5675
|
+
const systemPromptUpdate = effectiveSystemPrompt !== event.systemPrompt
|
|
5676
|
+
? { systemPrompt: effectiveSystemPrompt }
|
|
5677
|
+
: undefined;
|
|
4688
5678
|
const active = new Set<string>(pi.getActiveTools() as string[]);
|
|
4689
5679
|
const toolMetadata = pi.getAllTools()
|
|
4690
5680
|
.filter((tool) => active.has(tool.name))
|
|
@@ -4695,9 +5685,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4695
5685
|
promptGuidelines: tool.promptGuidelines
|
|
4696
5686
|
}));
|
|
4697
5687
|
const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
|
|
4698
|
-
const systemPromptTokens = estimateContextTokens(
|
|
4699
|
-
const
|
|
4700
|
-
|
|
5688
|
+
const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
|
|
5689
|
+
const autoPackUseful = activeTask?.trace.outcome !== "pending"
|
|
5690
|
+
&& (runtimeIntake || signal.paths.length === 0);
|
|
4701
5691
|
telemetry(ctx, {
|
|
4702
5692
|
event: "agent_prompt",
|
|
4703
5693
|
promptHash: signal.promptHash,
|
|
@@ -4708,21 +5698,54 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4708
5698
|
activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
|
|
4709
5699
|
toolSchemaTokens,
|
|
4710
5700
|
systemPromptTokens,
|
|
4711
|
-
systemPromptHash: crypto.createHash("sha256").update(
|
|
5701
|
+
systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
|
|
5702
|
+
legacyProjectInstructionsRewritten: projectInstructions.rewritten,
|
|
5703
|
+
managedInstructionsCompacted: compactedInstructions.compacted,
|
|
4712
5704
|
contextUsage: ctx.getContextUsage()
|
|
4713
5705
|
});
|
|
4714
5706
|
|
|
5707
|
+
const finishAgentStart = async (contextMessage?: {
|
|
5708
|
+
customType: string;
|
|
5709
|
+
content: string;
|
|
5710
|
+
details: Record<string, unknown>;
|
|
5711
|
+
}) => {
|
|
5712
|
+
const intake = await maybeStartAutomaticTask(query, ctx);
|
|
5713
|
+
if (!contextMessage && !intake) return systemPromptUpdate;
|
|
5714
|
+
const content = [contextMessage?.content, intake?.text].filter(Boolean).join("\n\n");
|
|
5715
|
+
return {
|
|
5716
|
+
...(systemPromptUpdate ?? {}),
|
|
5717
|
+
message: {
|
|
5718
|
+
customType: contextMessage?.customType ?? "piagent-runtime-task-intake",
|
|
5719
|
+
content,
|
|
5720
|
+
display: false,
|
|
5721
|
+
details: {
|
|
5722
|
+
...(contextMessage?.details ?? {}),
|
|
5723
|
+
runtimeTask: intake?.task
|
|
5724
|
+
? {
|
|
5725
|
+
taskId: intake.task.taskId,
|
|
5726
|
+
taskRunId: intake.task.taskRunId,
|
|
5727
|
+
scope: intake.task.scope,
|
|
5728
|
+
verifyCommands: intake.task.verifyCommands,
|
|
5729
|
+
intakeMode: intake.task.intakeMode
|
|
5730
|
+
}
|
|
5731
|
+
: undefined,
|
|
5732
|
+
runtimeIntakeStarted: intake?.started ?? false
|
|
5733
|
+
}
|
|
5734
|
+
}
|
|
5735
|
+
};
|
|
5736
|
+
};
|
|
5737
|
+
|
|
4715
5738
|
if (
|
|
4716
5739
|
query.length < 20
|
|
4717
5740
|
|| signal.workflow === "usage"
|
|
4718
5741
|
|| !autoContextEnabled
|
|
4719
|
-
||
|
|
4720
|
-
||
|
|
5742
|
+
|| !autoPackUseful
|
|
5743
|
+
|| autoPackedPrompts.has(promptPackKey(ctx, signal.promptHash))
|
|
4721
5744
|
) {
|
|
4722
|
-
return;
|
|
5745
|
+
return finishAgentStart();
|
|
4723
5746
|
}
|
|
4724
5747
|
|
|
4725
|
-
autoPackedPrompts.add(signal.promptHash);
|
|
5748
|
+
autoPackedPrompts.add(promptPackKey(ctx, signal.promptHash));
|
|
4726
5749
|
if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
|
|
4727
5750
|
try {
|
|
4728
5751
|
const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
|
|
@@ -4740,13 +5763,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4740
5763
|
selected: 0,
|
|
4741
5764
|
skipped: status.exists ? "stale-index" : "missing-index"
|
|
4742
5765
|
});
|
|
4743
|
-
return;
|
|
5766
|
+
return finishAgentStart();
|
|
4744
5767
|
}
|
|
4745
|
-
const budgetTokens =
|
|
5768
|
+
const budgetTokens = runtimeIntake
|
|
5769
|
+
? 900
|
|
5770
|
+
: signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
|
|
4746
5771
|
const pack = await buildContextPack(ctx.cwd, query, {
|
|
4747
5772
|
budgetTokens,
|
|
4748
|
-
includeCode:
|
|
4749
|
-
|
|
5773
|
+
includeCode: runtimeIntake,
|
|
5774
|
+
includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
|
|
5775
|
+
currentSnapshot: runtimeIntake,
|
|
5776
|
+
limit: runtimeIntake ? 8 : 12,
|
|
4750
5777
|
excludePatterns
|
|
4751
5778
|
});
|
|
4752
5779
|
telemetry(ctx, {
|
|
@@ -4757,23 +5784,35 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4757
5784
|
selected: pack.selected.length,
|
|
4758
5785
|
estimatedTokens: pack.estimatedTokens,
|
|
4759
5786
|
selectedPaths: pack.selected.map((item) => item.path),
|
|
4760
|
-
finderRecommended: pack.finderRecommended
|
|
5787
|
+
finderRecommended: pack.finderRecommended,
|
|
5788
|
+
currentSnapshot: runtimeIntake
|
|
4761
5789
|
});
|
|
4762
|
-
if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return;
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
5790
|
+
if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return finishAgentStart();
|
|
5791
|
+
injectedContextPacks.set(retrievalKey(ctx, query), {
|
|
5792
|
+
queryHash: pack.queryHash,
|
|
5793
|
+
confidence: pack.confidence,
|
|
5794
|
+
estimatedTokens: pack.estimatedTokens,
|
|
5795
|
+
paths: pack.selected.map((item) => item.path)
|
|
5796
|
+
});
|
|
5797
|
+
for (const selected of pack.selected) {
|
|
5798
|
+
rememberObservedContext(ctx, {
|
|
5799
|
+
path: selected.path,
|
|
5800
|
+
reason: "Runtime injected a bounded Context Engine navigation pack."
|
|
5801
|
+
});
|
|
5802
|
+
}
|
|
5803
|
+
if (injectedContextPacks.size > 50) injectedContextPacks.delete(injectedContextPacks.keys().next().value as string);
|
|
5804
|
+
return finishAgentStart({
|
|
5805
|
+
customType: "piagent-context-pack-v2",
|
|
5806
|
+
content: pack.text,
|
|
5807
|
+
details: {
|
|
5808
|
+
schemaVersion: 2,
|
|
5809
|
+
queryHash: pack.queryHash,
|
|
5810
|
+
confidence: pack.confidence,
|
|
5811
|
+
estimatedTokens: pack.estimatedTokens,
|
|
5812
|
+
paths: pack.selected.map((item) => item.path),
|
|
5813
|
+
currentSnapshot: runtimeIntake
|
|
4775
5814
|
}
|
|
4776
|
-
};
|
|
5815
|
+
});
|
|
4777
5816
|
} catch (error) {
|
|
4778
5817
|
telemetry(ctx, {
|
|
4779
5818
|
event: "context_pack",
|
|
@@ -4783,6 +5822,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4783
5822
|
selected: 0,
|
|
4784
5823
|
error: error instanceof Error ? error.message : String(error)
|
|
4785
5824
|
});
|
|
5825
|
+
return finishAgentStart();
|
|
4786
5826
|
}
|
|
4787
5827
|
});
|
|
4788
5828
|
|
|
@@ -4799,6 +5839,158 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4799
5839
|
});
|
|
4800
5840
|
});
|
|
4801
5841
|
|
|
5842
|
+
pi.on("message_end", async (event, ctx) => {
|
|
5843
|
+
if (!event.message || event.message.role !== "assistant" || assistantMessageHasToolCall(event.message)) return;
|
|
5844
|
+
const budget = contextBudgetConfig(policy);
|
|
5845
|
+
let task = flushObservedTaskContext(
|
|
5846
|
+
pi,
|
|
5847
|
+
ctx,
|
|
5848
|
+
observedContext(ctx),
|
|
5849
|
+
budget.maxManifestFiles,
|
|
5850
|
+
"context_observed_before_handoff"
|
|
5851
|
+
) ?? activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
5852
|
+
if (!task) return;
|
|
5853
|
+
const text = assistantMessageText(event.message);
|
|
5854
|
+
const completionClaim = looksLikeCompletionClaim(text);
|
|
5855
|
+
const incompleteHandoff = looksLikeIncompleteHandoff(text);
|
|
5856
|
+
if (task.trace.outcome !== "pending") return;
|
|
5857
|
+
const readOnlyEvidenceObserved = task.changeMode === "read-only" && task.contextManifest.length > 0;
|
|
5858
|
+
const potentiallyFinalEvidence = task.observedChangedFiles.length > 0
|
|
5859
|
+
|| task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)
|
|
5860
|
+
|| readOnlyEvidenceObserved;
|
|
5861
|
+
if (!completionClaim && (incompleteHandoff || !potentiallyFinalEvidence)) return;
|
|
5862
|
+
const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
5863
|
+
const currentDigest = workingTreeEvidenceDigest(currentDigests);
|
|
5864
|
+
const currentPassingVerifierObserved = task.verifyEvidence.some((evidence) => (
|
|
5865
|
+
evidence.exitCode === 0
|
|
5866
|
+
&& evidence.observed === true
|
|
5867
|
+
&& evidence.matchedProfileCommand === true
|
|
5868
|
+
&& evidence.workingTreeDigest === currentDigest
|
|
5869
|
+
));
|
|
5870
|
+
const handoffAttempt = completionClaim || (
|
|
5871
|
+
!incompleteHandoff
|
|
5872
|
+
&& (task.observedChangedFiles.length > 0 || currentPassingVerifierObserved || readOnlyEvidenceObserved)
|
|
5873
|
+
);
|
|
5874
|
+
let completionGate: ReturnType<typeof evaluateTaskGate> | undefined;
|
|
5875
|
+
if (task.trace.outcome === "pending" && handoffAttempt) {
|
|
5876
|
+
const projected = completionTaskProjection(ctx.cwd, task, currentDigests);
|
|
5877
|
+
const projectedGate = evaluateTaskGate(ctx.cwd, projected, policy, {
|
|
5878
|
+
currentDigests,
|
|
5879
|
+
currentWorkingTreeDigest: currentDigest
|
|
5880
|
+
});
|
|
5881
|
+
completionGate = projectedGate;
|
|
5882
|
+
if (projectedGate.decision === "pass") {
|
|
5883
|
+
task = writeTask(ctx.cwd, projected);
|
|
5884
|
+
cacheTaskIdentity(ctx, task);
|
|
5885
|
+
completionRecoveryAttempts.delete(task.taskRunId);
|
|
5886
|
+
clearObservedContext(ctx);
|
|
5887
|
+
activateToolGroups(ctx, []);
|
|
5888
|
+
const trace = {
|
|
5889
|
+
event: "task_auto_completed",
|
|
5890
|
+
taskId: task.taskId,
|
|
5891
|
+
taskRunId: task.taskRunId,
|
|
5892
|
+
sessionId: task.sessionId,
|
|
5893
|
+
changedFiles: task.changedFiles,
|
|
5894
|
+
lifecycleMode: runtimeLifecycleMode(task)
|
|
5895
|
+
};
|
|
5896
|
+
appendTrace(ctx.cwd, trace);
|
|
5897
|
+
appendSessionTrace(pi, trace);
|
|
5898
|
+
telemetry(ctx, trace);
|
|
5899
|
+
return;
|
|
5900
|
+
}
|
|
5901
|
+
}
|
|
5902
|
+
if (!handoffAttempt || resolveRuntimePolicy(loadProfileFromContext(ctx)).finalGate !== "enforce") return;
|
|
5903
|
+
const gate = completionGate ?? evaluateTaskGate(ctx.cwd, task, policy, {
|
|
5904
|
+
currentDigests,
|
|
5905
|
+
currentWorkingTreeDigest: currentDigest
|
|
5906
|
+
});
|
|
5907
|
+
if (gate.decision === "pass") return;
|
|
5908
|
+
|
|
5909
|
+
const recoveryAttempt = completionRecoveryAttempts.get(task.taskRunId) ?? 0;
|
|
5910
|
+
const lifecycleMode = runtimeLifecycleMode(task);
|
|
5911
|
+
if (
|
|
5912
|
+
autoRecoveryEnabled
|
|
5913
|
+
&& handoffAttempt
|
|
5914
|
+
&& lifecycleMode !== "manual"
|
|
5915
|
+
&& recoveryAttempt < 1
|
|
5916
|
+
) {
|
|
5917
|
+
completionRecoveryAttempts.set(task.taskRunId, recoveryAttempt + 1);
|
|
5918
|
+
const recoveryGuidance = task.changeMode === "read-only"
|
|
5919
|
+
? [
|
|
5920
|
+
"Continue the same read-only task. Review the cited evidence against the requested scope, state concrete unknowns, and complete the returned review step when required.",
|
|
5921
|
+
"Do not mutate project source or repeat diagnostic Piagent tools; use ordinary targeted reads and the active progress tool only."
|
|
5922
|
+
]
|
|
5923
|
+
: [
|
|
5924
|
+
"Continue the same bounded task. Re-check the requested behavior against current source, make a relevant in-scope change when required, and run every exact configured verifier.",
|
|
5925
|
+
"A pre-existing passing test without a relevant source diff is not completion. Do not repeat diagnostic Piagent tools; use ordinary read/edit/bash work."
|
|
5926
|
+
];
|
|
5927
|
+
const recovery = [
|
|
5928
|
+
`[Piagent continuation required]`,
|
|
5929
|
+
`Task ${task.taskId} cannot finish yet. Missing: ${gate.missing.join(", ")}.`,
|
|
5930
|
+
...recoveryGuidance
|
|
5931
|
+
].join("\n");
|
|
5932
|
+
pi.sendMessage(
|
|
5933
|
+
{ customType: "piagent-completion-recovery", content: recovery, display: false, details: { taskId: task.taskId, missing: gate.missing } },
|
|
5934
|
+
{ deliverAs: "followUp", triggerTurn: true }
|
|
5935
|
+
);
|
|
5936
|
+
const recoveryTrace = {
|
|
5937
|
+
event: "completion_recovery_scheduled",
|
|
5938
|
+
taskId: task.taskId,
|
|
5939
|
+
taskRunId: task.taskRunId,
|
|
5940
|
+
sessionId: task.sessionId,
|
|
5941
|
+
attempt: recoveryAttempt + 1,
|
|
5942
|
+
missing: gate.missing
|
|
5943
|
+
};
|
|
5944
|
+
appendTrace(ctx.cwd, recoveryTrace);
|
|
5945
|
+
appendSessionTrace(pi, recoveryTrace);
|
|
5946
|
+
telemetry(ctx, recoveryTrace);
|
|
5947
|
+
const continuingNotice = [
|
|
5948
|
+
"[Piagent completion gate: CONTINUING]",
|
|
5949
|
+
`Task ${task.taskId} needs one bounded correction pass before handoff.`,
|
|
5950
|
+
""
|
|
5951
|
+
].join("\n");
|
|
5952
|
+
const content = Array.isArray(event.message.content)
|
|
5953
|
+
? [{ type: "text" as const, text: continuingNotice }, ...event.message.content]
|
|
5954
|
+
: [{ type: "text" as const, text: `${continuingNotice}${text}` }];
|
|
5955
|
+
return { message: { ...event.message, content } };
|
|
5956
|
+
}
|
|
5957
|
+
|
|
5958
|
+
const notice = [
|
|
5959
|
+
`[Piagent completion gate: NOT APPROVED]`,
|
|
5960
|
+
`Task ${task.taskId} (${task.taskRunId}) is still open.`,
|
|
5961
|
+
`Missing: ${gate.missing.join(", ") || "a completed task trace"}.`,
|
|
5962
|
+
"The response below is preserved as work in progress and must not be treated as a completion report.",
|
|
5963
|
+
""
|
|
5964
|
+
].join("\n");
|
|
5965
|
+
const content = Array.isArray(event.message.content)
|
|
5966
|
+
? [{ type: "text" as const, text: notice }, ...event.message.content]
|
|
5967
|
+
: [{ type: "text" as const, text: `${notice}${text}` }];
|
|
5968
|
+
const trace = {
|
|
5969
|
+
event: "completion_claim_blocked",
|
|
5970
|
+
taskId: task.taskId,
|
|
5971
|
+
taskRunId: task.taskRunId,
|
|
5972
|
+
sessionId: task.sessionId,
|
|
5973
|
+
missing: gate.missing,
|
|
5974
|
+
responseHash: crypto.createHash("sha256").update(text).digest("hex"),
|
|
5975
|
+
responseChars: text.length
|
|
5976
|
+
};
|
|
5977
|
+
appendTrace(ctx.cwd, trace);
|
|
5978
|
+
appendSessionTrace(pi, trace);
|
|
5979
|
+
telemetry(ctx, trace);
|
|
5980
|
+
return { message: { ...event.message, content } };
|
|
5981
|
+
});
|
|
5982
|
+
|
|
5983
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
5984
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
5985
|
+
telemetry(ctx, {
|
|
5986
|
+
event: "agent_settled",
|
|
5987
|
+
idle: ctx.isIdle(),
|
|
5988
|
+
taskId: task?.taskId,
|
|
5989
|
+
taskRunId: task?.taskRunId,
|
|
5990
|
+
taskOutcome: task?.trace.outcome
|
|
5991
|
+
});
|
|
5992
|
+
});
|
|
5993
|
+
|
|
4802
5994
|
pi.on("session_compact", async (event, ctx) => {
|
|
4803
5995
|
telemetry(ctx, {
|
|
4804
5996
|
event: "session_compact",
|
|
@@ -4809,11 +6001,20 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4809
6001
|
});
|
|
4810
6002
|
|
|
4811
6003
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
6004
|
+
flushObservedTaskContext(
|
|
6005
|
+
pi,
|
|
6006
|
+
ctx,
|
|
6007
|
+
observedContext(ctx),
|
|
6008
|
+
contextBudgetConfig(policy).maxManifestFiles,
|
|
6009
|
+
"context_observed_before_shutdown"
|
|
6010
|
+
);
|
|
4812
6011
|
telemetry(ctx, {
|
|
4813
6012
|
event: "session_shutdown",
|
|
4814
6013
|
reason: event.reason,
|
|
4815
6014
|
targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
|
|
4816
6015
|
});
|
|
6016
|
+
clearObservedContext(ctx);
|
|
6017
|
+
clearShellMutationSnapshots(ctx);
|
|
4817
6018
|
});
|
|
4818
6019
|
|
|
4819
6020
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -4827,13 +6028,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4827
6028
|
appendObservedBashResult(observedBashLedgerPath(ctx.cwd), {
|
|
4828
6029
|
...observed,
|
|
4829
6030
|
redactedCommand: redactText(observed.command)
|
|
4830
|
-
});
|
|
6031
|
+
}, { projectRoot: ctx.cwd });
|
|
4831
6032
|
} catch (error) {
|
|
4832
6033
|
const message = error instanceof Error ? error.message : String(error);
|
|
4833
6034
|
ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
|
|
4834
6035
|
}
|
|
4835
6036
|
}
|
|
4836
6037
|
|
|
6038
|
+
const observedContextEntry = observedTaskContextFromToolResult(ctx.cwd, event, pathPolicy.readProtectedPaths);
|
|
6039
|
+
if (observedContextEntry) rememberObservedContext(ctx, observedContextEntry);
|
|
6040
|
+
const pendingContext = observedContext(ctx);
|
|
6041
|
+
const maxManifestFiles = contextBudgetConfig(policy).maxManifestFiles;
|
|
6042
|
+
const shellSnapshotBefore = SHELL_TOOL_NAMES.has(event.toolName)
|
|
6043
|
+
? consumeShellMutationSnapshot(ctx, event.toolName, event.input)
|
|
6044
|
+
: undefined;
|
|
6045
|
+
recordObservedTaskChanges(pi, ctx, event, pendingContext, maxManifestFiles, shellSnapshotBefore);
|
|
6046
|
+
if (observed) recordObservedTaskVerification(pi, ctx, observed, pendingContext, maxManifestFiles);
|
|
6047
|
+
|
|
4837
6048
|
let resultContent: unknown = event.content;
|
|
4838
6049
|
let resultDetails: unknown = event.details;
|
|
4839
6050
|
let resultChanged = false;
|
|
@@ -4993,6 +6204,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
4993
6204
|
const toolInput = event.input && typeof event.input === "object"
|
|
4994
6205
|
? event.input as Record<string, unknown>
|
|
4995
6206
|
: {};
|
|
6207
|
+
const sessionTask = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
6208
|
+
const profileMode = profile.mode ?? "unprofiled";
|
|
6209
|
+
const governedTaskProfile = !profileMode.startsWith("unprofiled") && !profileMode.includes("unreadable");
|
|
6210
|
+
const taskContractRequired = governedTaskProfile
|
|
6211
|
+
&& runtime.finalGate === "enforce"
|
|
6212
|
+
&& finalGateConfig(policy).requireTaskContract;
|
|
6213
|
+
if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !SHELL_TOOL_NAMES.has(event.toolName) && isTaskMutationTool(event.toolName, toolInput)) {
|
|
6214
|
+
return {
|
|
6215
|
+
block: true,
|
|
6216
|
+
reason: `Task ${sessionTask.taskId} is read-only; ${event.toolName} cannot mutate project or external state in this task. Start a source-change task for implementation.`
|
|
6217
|
+
};
|
|
6218
|
+
}
|
|
4996
6219
|
const preparedInput = prepareToolInputForPolicy(event.toolName, toolInput, policy);
|
|
4997
6220
|
if (preparedInput.reason) {
|
|
4998
6221
|
return { block: true, reason: `Blocked ${event.toolName}: ${preparedInput.reason}.` };
|
|
@@ -5005,6 +6228,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5005
6228
|
return { block: true, reason: approvalDecision.reason };
|
|
5006
6229
|
}
|
|
5007
6230
|
|
|
6231
|
+
let shellProjectMutation = false;
|
|
5008
6232
|
if (SHELL_TOOL_NAMES.has(event.toolName)) {
|
|
5009
6233
|
const shellInput = extractShellCommandInput(toolInput);
|
|
5010
6234
|
if (!shellInput.command) {
|
|
@@ -5012,6 +6236,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5012
6236
|
}
|
|
5013
6237
|
const command = normalizeShellCommandForPolicy(shellInput.command);
|
|
5014
6238
|
const execDecision = evaluateExecPolicy(command, profile, policy);
|
|
6239
|
+
shellProjectMutation = isProjectMutatingShellCommand(command, execDecision.segments);
|
|
6240
|
+
if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "read-only" && !isReadOnlyTaskShellCommand(command, execDecision.segments)) {
|
|
6241
|
+
return {
|
|
6242
|
+
block: true,
|
|
6243
|
+
reason: `Task ${sessionTask.taskId} is read-only; this shell command is not in the read-only inspection allowlist.`
|
|
6244
|
+
};
|
|
6245
|
+
}
|
|
5015
6246
|
if (execDecision.mode !== "off" && execDecision.decision === "forbid") {
|
|
5016
6247
|
return { block: true, reason: execDecision.reasons.join("; ") };
|
|
5017
6248
|
}
|
|
@@ -5110,6 +6341,34 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5110
6341
|
}
|
|
5111
6342
|
}
|
|
5112
6343
|
|
|
6344
|
+
const mutationTargets = taskMutationTargets(ctx.cwd, event.toolName, toolInput);
|
|
6345
|
+
const directProjectMutation = !SHELL_TOOL_NAMES.has(event.toolName) && (
|
|
6346
|
+
WRITE_TOOL_NAMES.has(event.toolName)
|
|
6347
|
+
|| mutationTargets.length > 0
|
|
6348
|
+
|| preparedInput.proxyShellCarrier === true
|
|
6349
|
+
);
|
|
6350
|
+
if (sessionTask && sessionTask.trace.outcome !== "pending" && (shellProjectMutation || directProjectMutation)) {
|
|
6351
|
+
return {
|
|
6352
|
+
block: true,
|
|
6353
|
+
reason: `Task ${sessionTask.taskId} is ${sessionTask.trace.outcome}; start a new attempt or a fresh task before further project mutations.`
|
|
6354
|
+
};
|
|
6355
|
+
}
|
|
6356
|
+
if (!sessionTask && taskContractRequired && (shellProjectMutation || directProjectMutation)) {
|
|
6357
|
+
return {
|
|
6358
|
+
block: true,
|
|
6359
|
+
reason: "A session-bound Task Implementation Contract is required before project files can be mutated. Start the task first."
|
|
6360
|
+
};
|
|
6361
|
+
}
|
|
6362
|
+
if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "source-change") {
|
|
6363
|
+
const outsideScope = mutationTargets.filter((file) => !taskScopeIncludesPath(sessionTask.scope, file));
|
|
6364
|
+
if (outsideScope.length > 0) {
|
|
6365
|
+
return {
|
|
6366
|
+
block: true,
|
|
6367
|
+
reason: `Task ${sessionTask.taskId} cannot mutate paths outside its declared scope: ${outsideScope.join(", ")}.`
|
|
6368
|
+
};
|
|
6369
|
+
}
|
|
6370
|
+
}
|
|
6371
|
+
|
|
5113
6372
|
if (runtime.contextBudget !== "off" && ["write", "edit"].includes(event.toolName)) {
|
|
5114
6373
|
const relativePath = extractLikelyPathFromInput(ctx.cwd, event.input as Record<string, unknown>);
|
|
5115
6374
|
if (relativePath) {
|
|
@@ -5120,18 +6379,27 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5120
6379
|
}
|
|
5121
6380
|
}
|
|
5122
6381
|
}
|
|
6382
|
+
|
|
6383
|
+
if (
|
|
6384
|
+
sessionTask?.trace.outcome === "pending"
|
|
6385
|
+
&& sessionTask.changeMode === "source-change"
|
|
6386
|
+
&& shellProjectMutation
|
|
6387
|
+
) {
|
|
6388
|
+
rememberShellMutationSnapshot(ctx, event.toolName, event.input);
|
|
6389
|
+
}
|
|
5123
6390
|
});
|
|
5124
6391
|
|
|
5125
6392
|
pi.registerTool({
|
|
5126
6393
|
name: "piagent_tools",
|
|
5127
6394
|
label: "Piagent Tool Loader",
|
|
5128
6395
|
description: "Activate an additional Piagent tool group only when the current task needs it.",
|
|
5129
|
-
promptSnippet: "Load
|
|
6396
|
+
promptSnippet: "Load diagnostic or recovery Piagent tools only when the runtime requests them.",
|
|
5130
6397
|
promptGuidelines: [
|
|
5131
|
-
"
|
|
6398
|
+
"Do not call this for an ordinary implementation task; runtime evidence collection needs no extra tools.",
|
|
6399
|
+
"When recovery is necessary, load only the smallest group that resolves the reported missing evidence."
|
|
5132
6400
|
],
|
|
5133
6401
|
parameters: Type.Object({
|
|
5134
|
-
groups: Type.Array(StringEnum(["governance", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
|
|
6402
|
+
groups: Type.Array(StringEnum(["intake", "governance", "task", "recovery", "policy", "retrieval", "knowledge", "onboarding", "usage"] as const), { minItems: 1 })
|
|
5135
6403
|
}),
|
|
5136
6404
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
5137
6405
|
const groups = [...new Set(params.groups)] as PiagentToolGroup[];
|
|
@@ -5169,7 +6437,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5169
6437
|
action: StringEnum(["status", "rebuild", "search", "pack", "impact", "efficiency"] as const),
|
|
5170
6438
|
query: Type.Optional(Type.String()),
|
|
5171
6439
|
files: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
5172
|
-
budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum:
|
|
6440
|
+
budgetTokens: Type.Optional(Type.Number({ minimum: 200, maximum: 12000 })),
|
|
5173
6441
|
refresh: Type.Optional(Type.Boolean())
|
|
5174
6442
|
}),
|
|
5175
6443
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -5216,6 +6484,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5216
6484
|
].join("\n");
|
|
5217
6485
|
} else if (params.action === "pack") {
|
|
5218
6486
|
if (!params.query?.trim()) throw new Error("query is required for a context pack");
|
|
6487
|
+
const injected = injectedContextPacks.get(retrievalKey(ctx, params.query));
|
|
6488
|
+
if (injected && params.refresh !== true) {
|
|
6489
|
+
result = { reusedInjectedPack: true, ...injected };
|
|
6490
|
+
text = [
|
|
6491
|
+
"Context pack already injected for this task; duplicate payload skipped.",
|
|
6492
|
+
`confidence: ${injected.confidence}; estimatedTokensSaved: ${injected.estimatedTokens}`,
|
|
6493
|
+
`paths: ${injected.paths.join(", ")}`,
|
|
6494
|
+
"Read only the listed files or request refresh=true when the repository changed."
|
|
6495
|
+
].join("\n");
|
|
6496
|
+
telemetry(ctx, {
|
|
6497
|
+
event: "context_pack_reused",
|
|
6498
|
+
queryHash: injected.queryHash,
|
|
6499
|
+
estimatedTokensSaved: injected.estimatedTokens,
|
|
6500
|
+
selectedPaths: injected.paths
|
|
6501
|
+
});
|
|
6502
|
+
return { content: [{ type: "text", text }], details: result };
|
|
6503
|
+
}
|
|
5219
6504
|
const ensured = await ensureContextIndexV2(ctx.cwd, {
|
|
5220
6505
|
excludePatterns,
|
|
5221
6506
|
refresh: params.refresh,
|
|
@@ -5223,7 +6508,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5223
6508
|
});
|
|
5224
6509
|
const status = ensured.status;
|
|
5225
6510
|
const pack = await buildContextPack(ctx.cwd, params.query, {
|
|
5226
|
-
budgetTokens: params.budgetTokens ??
|
|
6511
|
+
budgetTokens: params.budgetTokens ?? 2_400,
|
|
5227
6512
|
includeCode: true,
|
|
5228
6513
|
limit: 18,
|
|
5229
6514
|
excludePatterns
|
|
@@ -5483,11 +6768,17 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5483
6768
|
description: "Check whether a governed task has enough context, verify evidence, and trace before claiming done.",
|
|
5484
6769
|
promptSnippet: "Use this before final on source-changing tasks.",
|
|
5485
6770
|
parameters: Type.Object({
|
|
5486
|
-
taskId: Type.String({ minLength: 1 })
|
|
6771
|
+
taskId: Type.String({ minLength: 1 }),
|
|
6772
|
+
changedFiles: Type.Optional(Type.Array(Type.String()))
|
|
5487
6773
|
}),
|
|
5488
6774
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
5489
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
5490
|
-
const
|
|
6775
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6776
|
+
const projected = task ? {
|
|
6777
|
+
...task,
|
|
6778
|
+
changedFiles: params.changedFiles ?? task.changedFiles,
|
|
6779
|
+
trace: { ...task.trace, outcome: "completed" as const }
|
|
6780
|
+
} : undefined;
|
|
6781
|
+
const result = evaluateTaskGate(ctx.cwd, projected, policy);
|
|
5491
6782
|
const runtime = resolveRuntimePolicy(loadProfileFromContext(ctx));
|
|
5492
6783
|
const text = [
|
|
5493
6784
|
`decision: ${result.decision}`,
|
|
@@ -5495,7 +6786,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
5495
6786
|
`missing: ${result.missing.join(", ") || "none"}`,
|
|
5496
6787
|
`warnings: ${result.warnings.join("; ") || "none"}`
|
|
5497
6788
|
].join("\n");
|
|
5498
|
-
return { content: [{ type: "text", text }], details: { ...result, task } };
|
|
6789
|
+
return { content: [{ type: "text", text }], details: { ...result, task: projected } };
|
|
5499
6790
|
}
|
|
5500
6791
|
});
|
|
5501
6792
|
|
|
@@ -6113,24 +7404,32 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6113
7404
|
}
|
|
6114
7405
|
});
|
|
6115
7406
|
|
|
6116
|
-
|
|
7407
|
+
const taskStartTool = {
|
|
6117
7408
|
name: "piagent_task_start",
|
|
6118
7409
|
label: "Piagent Task Start",
|
|
6119
7410
|
description: "Create a Task Implementation Contract for the current project before editing.",
|
|
6120
7411
|
promptSnippet: "Start a governed implementation task and persist the task contract.",
|
|
6121
7412
|
promptGuidelines: [
|
|
6122
|
-
"Call this before source edits in a project managed by Pi Agent Platform.",
|
|
6123
|
-
"
|
|
7413
|
+
"Call this exactly once before source edits in a project managed by Pi Agent Platform.",
|
|
7414
|
+
"Do not call context, status, policy, evidence-recording, trace, or gate tools first; runtime hooks provide those checks automatically.",
|
|
6124
7415
|
"Use tiny for a bounded low-risk change, normal for ordinary multi-file work, and high-risk for security, data, release, migration, or external-impact work.",
|
|
6125
|
-
"
|
|
7416
|
+
"Every scope entry must be a project-relative path or glob such as src/file.ts, src/**, or test/**; never put prose in scope.",
|
|
7417
|
+
"Leave workPlan unset for ordinary tiny/normal tasks so runtime automation stays active; pass a custom workPlan only when the operator explicitly requests custom subagent or checkpoint orchestration.",
|
|
7418
|
+
"Tiny tasks use automatic lifecycle evidence; normal tasks retain one explicit review step; high-risk/custom plans keep manual checkpoints."
|
|
6126
7419
|
],
|
|
6127
7420
|
parameters: Type.Object({
|
|
6128
7421
|
taskId: Type.Optional(Type.String({ minLength: 1 })),
|
|
6129
7422
|
summary: Type.String({ minLength: 10 }),
|
|
6130
7423
|
riskLane: StringEnum(["tiny", "normal", "high-risk"] as const),
|
|
7424
|
+
changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const)),
|
|
7425
|
+
verifyGroup: Type.Optional(Type.String({ minLength: 1 })),
|
|
7426
|
+
maxAttempts: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
|
6131
7427
|
expectedOutput: Type.String({ minLength: 10 }),
|
|
6132
7428
|
acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
6133
|
-
scope: Type.Array(Type.String({
|
|
7429
|
+
scope: Type.Array(Type.String({
|
|
7430
|
+
minLength: 1,
|
|
7431
|
+
description: "Project-relative path or glob only (for example src/file.ts, src/**, or test/**); do not use prose."
|
|
7432
|
+
}), { minItems: 1 }),
|
|
6134
7433
|
outOfScope: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
6135
7434
|
reviewLenses: Type.Optional(Type.Array(StringEnum(REVIEW_LENSES))),
|
|
6136
7435
|
workPlan: Type.Optional(Type.Array(Type.Object({
|
|
@@ -6138,33 +7437,143 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6138
7437
|
title: Type.String({ minLength: 1 }),
|
|
6139
7438
|
role: Type.Optional(StringEnum(ORCHESTRATION_ROLES)),
|
|
6140
7439
|
mode: Type.Optional(StringEnum(["read-only", "single-writer", "review"] as const)),
|
|
6141
|
-
status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped"] as const)),
|
|
6142
|
-
dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 })))
|
|
7440
|
+
status: Type.Optional(StringEnum(["pending", "in-progress", "done", "skipped", "failed"] as const)),
|
|
7441
|
+
dependsOn: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
7442
|
+
note: Type.Optional(Type.String())
|
|
6143
7443
|
})))
|
|
6144
7444
|
}),
|
|
6145
|
-
async execute(
|
|
7445
|
+
async execute(
|
|
7446
|
+
_toolCallId: string,
|
|
7447
|
+
params: TaskStartParameters,
|
|
7448
|
+
_signal: AbortSignal | undefined,
|
|
7449
|
+
_onUpdate: ((update: unknown) => void) | undefined,
|
|
7450
|
+
ctx: ExtensionContext
|
|
7451
|
+
) {
|
|
6146
7452
|
const profile = loadProfileFromContext(ctx);
|
|
6147
7453
|
const createdAt = nowIso();
|
|
6148
7454
|
const safeSummary = redactText(params.summary);
|
|
6149
7455
|
const taskId = safeTaskId(redactText(params.taskId ?? params.summary));
|
|
7456
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
7457
|
+
const sessionName = currentSessionName(ctx);
|
|
7458
|
+
const active = activeSessionTask(ctx.cwd, sessionId) as TaskContract | undefined;
|
|
7459
|
+
if (active) {
|
|
7460
|
+
if (active.taskId === taskId) {
|
|
7461
|
+
if (active.trace.outcome !== "pending") {
|
|
7462
|
+
return {
|
|
7463
|
+
content: [{ type: "text", text: `Session already belongs to terminal task ${active.taskId} (${active.trace.outcome}). Start a fresh Pi session for a retry or new task.` }],
|
|
7464
|
+
details: active,
|
|
7465
|
+
isError: true
|
|
7466
|
+
};
|
|
7467
|
+
}
|
|
7468
|
+
return {
|
|
7469
|
+
content: [{ type: "text", text: `Task already active in this session: ${active.taskId} (${active.taskRunId}). Reusing it instead of overwriting state.` }],
|
|
7470
|
+
details: compactTaskDetails(active)
|
|
7471
|
+
};
|
|
7472
|
+
}
|
|
7473
|
+
return {
|
|
7474
|
+
content: [{ type: "text", text: `Session already belongs to task ${active.taskId} (${active.taskRunId}, ${active.trace.outcome}). Use one Pi session per task and start a fresh session before another task.` }],
|
|
7475
|
+
details: active,
|
|
7476
|
+
isError: true
|
|
7477
|
+
};
|
|
7478
|
+
}
|
|
7479
|
+
const invalidScope = params.scope.find((entry) => !validTaskScopePattern(entry));
|
|
7480
|
+
if (invalidScope) {
|
|
7481
|
+
return {
|
|
7482
|
+
content: [{
|
|
7483
|
+
type: "text",
|
|
7484
|
+
text: `Task start refused: scope entries must be project-relative paths or globs; invalid entry ${JSON.stringify(invalidScope)}. Use values such as src/file.ts, src/**, or test/**.`
|
|
7485
|
+
}],
|
|
7486
|
+
isError: true
|
|
7487
|
+
};
|
|
7488
|
+
}
|
|
7489
|
+
const priorAttempts = priorTaskAttempts(ctx.cwd, taskId) as TaskContract[];
|
|
7490
|
+
const pendingElsewhere = priorAttempts.find((task) => task.trace.outcome === "pending" && task.sessionId !== sessionId);
|
|
7491
|
+
if (pendingElsewhere) {
|
|
7492
|
+
return {
|
|
7493
|
+
content: [{ type: "text", text: `Task ${taskId} is already active in session ${pendingElsewhere.sessionName ?? pendingElsewhere.sessionId} (${pendingElsewhere.taskRunId}).` }],
|
|
7494
|
+
details: pendingElsewhere,
|
|
7495
|
+
isError: true
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
7498
|
+
const latestCompleted = priorAttempts.find((task) => task.trace.outcome === "completed");
|
|
7499
|
+
if (latestCompleted) {
|
|
7500
|
+
return {
|
|
7501
|
+
content: [{ type: "text", text: `Task ${taskId} already completed as ${latestCompleted.taskRunId}. Use a distinct taskId for new work instead of replacing its evidence.` }],
|
|
7502
|
+
details: latestCompleted,
|
|
7503
|
+
isError: true
|
|
7504
|
+
};
|
|
7505
|
+
}
|
|
7506
|
+
const attempt = priorAttempts.reduce((maximum, task) => Math.max(maximum, task.attempt ?? 1), 0) + 1;
|
|
7507
|
+
const firstAttempt = priorAttempts.find((task) => task.attempt === 1)
|
|
7508
|
+
?? [...priorAttempts].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))[0];
|
|
7509
|
+
const maxAttempts = firstAttempt
|
|
7510
|
+
? firstAttempt.maxAttempts
|
|
7511
|
+
: Number.isInteger(params.maxAttempts)
|
|
7512
|
+
? Math.max(1, Math.min(10, params.maxAttempts))
|
|
7513
|
+
: DEFAULT_MAX_TASK_ATTEMPTS;
|
|
7514
|
+
if (attempt > maxAttempts) {
|
|
7515
|
+
return {
|
|
7516
|
+
content: [{ type: "text", text: `Task ${taskId} reached its retry limit (${attempt - 1}/${maxAttempts}). Report the blocker or ask the operator to create a new scoped task.` }],
|
|
7517
|
+
details: { taskId, attempt, maxAttempts, previousAttempts: priorAttempts.map(summarizeAttempt) },
|
|
7518
|
+
isError: true
|
|
7519
|
+
};
|
|
7520
|
+
}
|
|
6150
7521
|
const orchestration = resolveOrchestrationPolicy(profile, policy);
|
|
7522
|
+
const changeMode = params.changeMode === "read-only" ? "read-only" : "source-change";
|
|
7523
|
+
if (changeMode === "source-change" && !isGitWorkingTree(ctx.cwd)) {
|
|
7524
|
+
return {
|
|
7525
|
+
content: [{ type: "text", text: "Task start refused: source-change tasks require a Git working tree so changed-file evidence cannot silently disappear. Initialize Git or use read-only mode." }],
|
|
7526
|
+
isError: true
|
|
7527
|
+
};
|
|
7528
|
+
}
|
|
7529
|
+
const verifyPlan = selectVerifyPlan(profile, params.verifyGroup, changeMode, ctx.cwd);
|
|
7530
|
+
if (verifyPlan.error) {
|
|
7531
|
+
return {
|
|
7532
|
+
content: [{ type: "text", text: `Task start refused: ${verifyPlan.error}` }],
|
|
7533
|
+
details: { verifyGroup: verifyPlan.group, verifyCommands: verifyPlan.commands },
|
|
7534
|
+
isError: true
|
|
7535
|
+
};
|
|
7536
|
+
}
|
|
6151
7537
|
const reviewLenses = normalizeReviewLenses(params.reviewLenses, orchestration.defaultReviewLenses);
|
|
6152
7538
|
const providedWorkPlan = normalizeWorkPlanSteps(params.workPlan);
|
|
6153
|
-
const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane);
|
|
7539
|
+
const workPlan = providedWorkPlan.length ? providedWorkPlan : defaultWorkPlan(safeSummary, params.riskLane, changeMode);
|
|
7540
|
+
const seededContext = observedContext(ctx).slice(0, contextBudgetConfig(policy).maxManifestFiles);
|
|
7541
|
+
const workPlanError = validateNewWorkPlan(workPlan);
|
|
7542
|
+
if (workPlanError) {
|
|
7543
|
+
return { content: [{ type: "text", text: `Task start refused: ${workPlanError}.` }], details: workPlan, isError: true };
|
|
7544
|
+
}
|
|
7545
|
+
const firstReady = workPlan.find((step) => (step.dependsOn ?? []).length === 0);
|
|
7546
|
+
if (!firstReady) {
|
|
7547
|
+
return { content: [{ type: "text", text: "Task start refused: work plan has no dependency-ready first step." }], details: workPlan, isError: true };
|
|
7548
|
+
}
|
|
7549
|
+
firstReady.status = "in-progress";
|
|
7550
|
+
firstReady.updatedAt = createdAt;
|
|
7551
|
+
const taskRunId = createTaskRunId(taskId, sessionId, createdAt);
|
|
7552
|
+
const baselineFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
6154
7553
|
const task: TaskContract = {
|
|
7554
|
+
schemaVersion: 2,
|
|
7555
|
+
taskRunId,
|
|
6155
7556
|
taskId,
|
|
7557
|
+
sessionId,
|
|
7558
|
+
sessionName,
|
|
7559
|
+
changeMode,
|
|
7560
|
+
attempt,
|
|
7561
|
+
maxAttempts,
|
|
7562
|
+
previousAttempts: priorAttempts.filter((task) => task.trace.outcome !== "pending").slice(0, 10).reverse().map(summarizeAttempt),
|
|
6156
7563
|
summary: safeSummary,
|
|
6157
7564
|
riskLane: params.riskLane,
|
|
7565
|
+
intakeMode: params.intakeMode === "runtime" ? "runtime" : "model",
|
|
6158
7566
|
expectedOutput: redactText(params.expectedOutput),
|
|
6159
7567
|
acceptanceCriteria: redactTextArray(params.acceptanceCriteria),
|
|
6160
7568
|
scope: redactTextArray(params.scope),
|
|
6161
7569
|
outOfScope: redactTextArray(params.outOfScope),
|
|
6162
7570
|
protectedPaths: profile.protectedPaths ?? [],
|
|
6163
7571
|
requiredContext: profile.requiredContext ?? [],
|
|
6164
|
-
contextManifest:
|
|
7572
|
+
contextManifest: seededContext,
|
|
6165
7573
|
memoryCitations: [],
|
|
6166
7574
|
mcpCapabilities: profile.mcpCapabilities ?? [],
|
|
6167
|
-
|
|
7575
|
+
verifyGroup: verifyPlan.group,
|
|
7576
|
+
verifyCommands: verifyPlan.commands,
|
|
6168
7577
|
workPlan,
|
|
6169
7578
|
reviewLenses,
|
|
6170
7579
|
orchestration: {
|
|
@@ -6174,19 +7583,229 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6174
7583
|
fieldGuidePath: orchestration.fieldGuide.enabled ? orchestration.fieldGuide.path : undefined,
|
|
6175
7584
|
modelRoles: orchestration.roleModelGuidance
|
|
6176
7585
|
},
|
|
7586
|
+
baselineChangedFiles: Object.keys(baselineFileDigests).sort(),
|
|
7587
|
+
baselineFileDigests,
|
|
7588
|
+
observedChangedFiles: [],
|
|
7589
|
+
finalWorkingTreeFiles: [],
|
|
7590
|
+
finalFileDigests: {},
|
|
6177
7591
|
changedFiles: [],
|
|
6178
7592
|
verifyEvidence: [],
|
|
6179
7593
|
trace: { outcome: "pending" },
|
|
6180
7594
|
createdAt,
|
|
6181
7595
|
updatedAt: createdAt
|
|
6182
7596
|
};
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
7597
|
+
if (changeMode === "read-only" && seededContext.length > 0) {
|
|
7598
|
+
applyRuntimeLifecycleObservation(task, "context-complete", createdAt);
|
|
7599
|
+
}
|
|
7600
|
+
const written = writeTask(ctx.cwd, task);
|
|
7601
|
+
bindSessionTask(ctx.cwd, sessionId, sessionName, written);
|
|
7602
|
+
cacheTaskIdentity(ctx, written);
|
|
7603
|
+
if (written.intakeMode !== "runtime") {
|
|
7604
|
+
// Intake classification defines the cache-stable surface for this agent turn.
|
|
7605
|
+
// A model may choose a narrower lane than the prompt classifier; never remove
|
|
7606
|
+
// schemas mid-turn, but add recovery tools when the chosen lane requires them.
|
|
7607
|
+
activateToolGroups(ctx, activeTaskToolGroups(written), true);
|
|
7608
|
+
}
|
|
7609
|
+
const lifecycleMode = runtimeLifecycleMode(written);
|
|
7610
|
+
appendTrace(ctx.cwd, { taskId, taskRunId, sessionId, sessionName, attempt, event: "task_start", summary: task.summary, riskLane: params.riskLane, intakeMode: task.intakeMode, changeMode: task.changeMode, lifecycleMode, seededContext: seededContext.map((item) => item.path) });
|
|
7611
|
+
appendSessionTrace(pi, { taskId, taskRunId, sessionId, sessionName, attempt, event: "task_start", summary: task.summary, riskLane: params.riskLane, intakeMode: task.intakeMode, changeMode: task.changeMode, lifecycleMode, seededContext: seededContext.map((item) => item.path) });
|
|
6186
7612
|
|
|
6187
7613
|
return {
|
|
6188
|
-
content: [{
|
|
6189
|
-
|
|
7614
|
+
content: [{
|
|
7615
|
+
type: "text",
|
|
7616
|
+
text: [
|
|
7617
|
+
`Task ${taskId} started (${params.riskLane}, ${lifecycleMode}; attempt ${attempt}/${maxAttempts}).`,
|
|
7618
|
+
written.verifyCommands.length > 0
|
|
7619
|
+
? `Verify: ${written.verifyCommands.join(" | ")}.`
|
|
7620
|
+
: "Verify: none (read-only).",
|
|
7621
|
+
lifecycleMode === "automatic-readonly"
|
|
7622
|
+
? "Runtime records targeted reads and final completion automatically. Stay read-only and report cited evidence."
|
|
7623
|
+
: lifecycleMode === "assisted-readonly"
|
|
7624
|
+
? "Runtime records read-only evidence automatically; complete only the explicit evidence-review step before handoff."
|
|
7625
|
+
: lifecycleMode === "automatic"
|
|
7626
|
+
? "Runtime will record reads, changes, exact verifier results, and final completion automatically. Continue with ordinary read/edit/bash work."
|
|
7627
|
+
: lifecycleMode === "assisted"
|
|
7628
|
+
? "Runtime records objective evidence automatically; complete only the explicit review step before handoff."
|
|
7629
|
+
: "Use the active progress/recovery tools for the custom or high-risk checkpoints."
|
|
7630
|
+
].join("\n")
|
|
7631
|
+
}],
|
|
7632
|
+
details: compactTaskDetails(written)
|
|
7633
|
+
};
|
|
7634
|
+
}
|
|
7635
|
+
};
|
|
7636
|
+
pi.registerTool(taskStartTool);
|
|
7637
|
+
|
|
7638
|
+
async function maybeStartAutomaticTask(
|
|
7639
|
+
prompt: string,
|
|
7640
|
+
ctx: ExtensionContext
|
|
7641
|
+
): Promise<{ started: boolean; text: string; task?: TaskContract } | undefined> {
|
|
7642
|
+
const profile = loadProfileFromContext(ctx);
|
|
7643
|
+
const readProtectedPaths = effectiveProtectedPaths(policy, profile).readProtectedPaths;
|
|
7644
|
+
if (!automaticTaskIntakeEligible(prompt, readProtectedPaths)) return undefined;
|
|
7645
|
+
const active = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
7646
|
+
if (active?.trace.outcome === "pending") return undefined;
|
|
7647
|
+
|
|
7648
|
+
const summary = redactText(prompt).replace(/\s+/g, " ").trim().slice(0, 320);
|
|
7649
|
+
const sessionName = currentSessionName(ctx);
|
|
7650
|
+
const scope = automaticTaskScope(prompt, observedContext(ctx));
|
|
7651
|
+
const started = await taskStartTool.execute(
|
|
7652
|
+
`runtime-intake-${ctx.sessionManager.getSessionId()}`,
|
|
7653
|
+
{
|
|
7654
|
+
taskId: hasOperatorSessionName(sessionName) ? sessionName : summary,
|
|
7655
|
+
summary,
|
|
7656
|
+
riskLane: "tiny",
|
|
7657
|
+
intakeMode: "runtime",
|
|
7658
|
+
changeMode: "source-change",
|
|
7659
|
+
expectedOutput: "The requested bounded change is implemented and passes the configured verification.",
|
|
7660
|
+
acceptanceCriteria: [
|
|
7661
|
+
"The requested behavior is implemented without changing unrelated behavior.",
|
|
7662
|
+
"Changes stay within the runtime-derived task scope.",
|
|
7663
|
+
"The configured verification command passes after the final mutation."
|
|
7664
|
+
],
|
|
7665
|
+
scope,
|
|
7666
|
+
outOfScope: ["Unrelated files and behavior outside the operator request."],
|
|
7667
|
+
reviewLenses: automaticReviewLenses(prompt)
|
|
7668
|
+
},
|
|
7669
|
+
undefined,
|
|
7670
|
+
undefined,
|
|
7671
|
+
ctx
|
|
7672
|
+
);
|
|
7673
|
+
if (started.isError) {
|
|
7674
|
+
activateToolGroups(ctx, ["intake"], true);
|
|
7675
|
+
const reason = started.content?.[0]?.text ?? "runtime intake could not create a task contract";
|
|
7676
|
+
return {
|
|
7677
|
+
started: false,
|
|
7678
|
+
text: `Piagent runtime intake paused: ${reason}\nUse piagent_task_start once with explicit project-relative scope before mutation.`
|
|
7679
|
+
};
|
|
7680
|
+
}
|
|
7681
|
+
const task = activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()) as TaskContract | undefined;
|
|
7682
|
+
if (!task) {
|
|
7683
|
+
activateToolGroups(ctx, ["intake"], true);
|
|
7684
|
+
return {
|
|
7685
|
+
started: false,
|
|
7686
|
+
text: "Piagent runtime intake did not persist a task contract. Use piagent_task_start once before mutation."
|
|
7687
|
+
};
|
|
7688
|
+
}
|
|
7689
|
+
const verify = task.verifyCommands.length > 0 ? task.verifyCommands.join(" | ") : "none";
|
|
7690
|
+
return {
|
|
7691
|
+
started: true,
|
|
7692
|
+
task,
|
|
7693
|
+
text: [
|
|
7694
|
+
`Piagent runtime task: ${task.taskId}; scope: ${task.scope.join(", ")}; verify: ${verify}.`,
|
|
7695
|
+
"Root project instructions are loaded. Do not re-read root AGENTS.md or inspect Piagent/platform files; work directly in relevant source/tests with ordinary tools.",
|
|
7696
|
+
"Finish intended edits, then run the exact verifier once; rerun only after a later mutation. Runtime records evidence and completion; do not call task-management tools."
|
|
7697
|
+
].join("\n")
|
|
7698
|
+
};
|
|
7699
|
+
}
|
|
7700
|
+
|
|
7701
|
+
pi.registerTool({
|
|
7702
|
+
name: "piagent_task_progress",
|
|
7703
|
+
label: "Piagent Task Progress",
|
|
7704
|
+
description: "Advance or fail one dependency-aware work-plan step in the current session task.",
|
|
7705
|
+
promptSnippet: "Record durable task progress as each planned phase starts, completes, skips, or fails.",
|
|
7706
|
+
promptGuidelines: [
|
|
7707
|
+
"Do not mark a step done until its work and evidence are actually complete.",
|
|
7708
|
+
"A failed step requires a concrete note and records where the attempt failed.",
|
|
7709
|
+
"Completing a step automatically starts the next dependency-ready pending step."
|
|
7710
|
+
],
|
|
7711
|
+
parameters: Type.Object({
|
|
7712
|
+
taskId: Type.String({ minLength: 1 }),
|
|
7713
|
+
stepId: Type.String({ minLength: 1 }),
|
|
7714
|
+
status: StringEnum(["in-progress", "done", "skipped", "failed"] as const),
|
|
7715
|
+
note: Type.Optional(Type.String()),
|
|
7716
|
+
failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
|
|
7717
|
+
ruledOut: Type.Optional(Type.String())
|
|
7718
|
+
}),
|
|
7719
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
7720
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
7721
|
+
const task = readTask(ctx.cwd, params.taskId, sessionId);
|
|
7722
|
+
if (!task) {
|
|
7723
|
+
return { content: [{ type: "text", text: `Task not found in this session: ${params.taskId}` }], isError: true };
|
|
7724
|
+
}
|
|
7725
|
+
if (task.trace.outcome !== "pending") {
|
|
7726
|
+
return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; start a fresh session for another attempt.` }], details: task, isError: true };
|
|
7727
|
+
}
|
|
7728
|
+
const stepId = safeTaskId(params.stepId).slice(0, 40);
|
|
7729
|
+
const step = task.workPlan.find((item) => item.id === stepId);
|
|
7730
|
+
if (!step) {
|
|
7731
|
+
return { content: [{ type: "text", text: `Work-plan step not found: ${stepId}` }], details: task.workPlan, isError: true };
|
|
7732
|
+
}
|
|
7733
|
+
const dependencies = step.dependsOn ?? [];
|
|
7734
|
+
const unresolved = dependencies.filter((dependency) => {
|
|
7735
|
+
const required = task.workPlan.find((item) => item.id === dependency);
|
|
7736
|
+
return !required || (required.status !== "done" && required.status !== "skipped");
|
|
7737
|
+
});
|
|
7738
|
+
if ((params.status === "in-progress" || params.status === "done") && unresolved.length > 0) {
|
|
7739
|
+
return {
|
|
7740
|
+
content: [{ type: "text", text: `Step ${stepId} is blocked by unfinished dependencies: ${unresolved.join(", ")}` }],
|
|
7741
|
+
details: { step, unresolved },
|
|
7742
|
+
isError: true
|
|
7743
|
+
};
|
|
7744
|
+
}
|
|
7745
|
+
const note = params.note ? redactText(params.note).slice(0, 500) : undefined;
|
|
7746
|
+
if (params.status === "failed" && !note) {
|
|
7747
|
+
return { content: [{ type: "text", text: `A concrete note is required when step ${stepId} fails.` }], isError: true };
|
|
7748
|
+
}
|
|
7749
|
+
if ((step.status === "done" || step.status === "skipped") && params.status !== step.status) {
|
|
7750
|
+
return { content: [{ type: "text", text: `Work-plan step ${stepId} is already ${step.status} and cannot be reopened in the same attempt.` }], details: step, isError: true };
|
|
7751
|
+
}
|
|
7752
|
+
if (step.status === params.status) {
|
|
7753
|
+
return { content: [{ type: "text", text: `Work-plan step ${stepId} is already ${params.status}; no state change was recorded.` }], details: step, isError: true };
|
|
7754
|
+
}
|
|
7755
|
+
if (step.status === "failed" && params.status === "in-progress" && !note) {
|
|
7756
|
+
return { content: [{ type: "text", text: `A concrete note is required to reopen failed step ${stepId} within this attempt.` }], details: step, isError: true };
|
|
7757
|
+
}
|
|
7758
|
+
|
|
7759
|
+
const recordedAt = nowIso();
|
|
7760
|
+
step.status = params.status;
|
|
7761
|
+
step.note = note;
|
|
7762
|
+
step.updatedAt = recordedAt;
|
|
7763
|
+
if (params.status === "failed") {
|
|
7764
|
+
task.failedAt = params.failedAt ?? (step.mode === "review" ? "review" : step.id === "plan" ? "plan" : "execute");
|
|
7765
|
+
task.failureReason = note;
|
|
7766
|
+
task.ruledOut = params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : undefined;
|
|
7767
|
+
} else if (task.failureReason && task.workPlan.every((item) => item.status !== "failed")) {
|
|
7768
|
+
task.failedAt = undefined;
|
|
7769
|
+
task.failureReason = undefined;
|
|
7770
|
+
task.ruledOut = undefined;
|
|
7771
|
+
}
|
|
7772
|
+
|
|
7773
|
+
let startedStep: WorkPlanStep | undefined;
|
|
7774
|
+
if (params.status === "done" || params.status === "skipped") {
|
|
7775
|
+
startedStep = task.workPlan.find((candidate) => {
|
|
7776
|
+
if (candidate.status !== "pending") return false;
|
|
7777
|
+
return (candidate.dependsOn ?? []).every((dependency) => {
|
|
7778
|
+
const required = task.workPlan.find((item) => item.id === dependency);
|
|
7779
|
+
return required?.status === "done" || required?.status === "skipped";
|
|
7780
|
+
});
|
|
7781
|
+
});
|
|
7782
|
+
if (startedStep) {
|
|
7783
|
+
startedStep.status = "in-progress";
|
|
7784
|
+
startedStep.updatedAt = recordedAt;
|
|
7785
|
+
}
|
|
7786
|
+
}
|
|
7787
|
+
|
|
7788
|
+
const written = writeTask(ctx.cwd, task);
|
|
7789
|
+
const trace = {
|
|
7790
|
+
taskId: written.taskId,
|
|
7791
|
+
taskRunId: written.taskRunId,
|
|
7792
|
+
sessionId,
|
|
7793
|
+
event: "task_progress",
|
|
7794
|
+
stepId,
|
|
7795
|
+
status: params.status,
|
|
7796
|
+
note,
|
|
7797
|
+
startedStep: startedStep?.id,
|
|
7798
|
+
failedAt: written.failedAt,
|
|
7799
|
+
ruledOut: written.ruledOut
|
|
7800
|
+
};
|
|
7801
|
+
appendTrace(ctx.cwd, trace);
|
|
7802
|
+
appendSessionTrace(pi, trace);
|
|
7803
|
+
return {
|
|
7804
|
+
content: [{
|
|
7805
|
+
type: "text",
|
|
7806
|
+
text: `Task ${written.taskId}: ${stepId} -> ${params.status}${startedStep ? `; started ${startedStep.id}` : ""}`
|
|
7807
|
+
}],
|
|
7808
|
+
details: compactTaskDetails(written)
|
|
6190
7809
|
};
|
|
6191
7810
|
}
|
|
6192
7811
|
});
|
|
@@ -6346,10 +7965,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6346
7965
|
const profile = loadProfileFromContext(ctx);
|
|
6347
7966
|
const runtime = resolveRuntimePolicy(profile);
|
|
6348
7967
|
const budget = contextBudgetConfig(policy);
|
|
6349
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
7968
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6350
7969
|
if (!task) {
|
|
6351
7970
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6352
7971
|
}
|
|
7972
|
+
if (task.trace.outcome !== "pending") {
|
|
7973
|
+
return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; context evidence was not changed.` }], details: task, isError: true };
|
|
7974
|
+
}
|
|
6353
7975
|
if (runtime.contextBudget !== "off" && task.contextManifest.length + params.files.length > budget.maxManifestFiles) {
|
|
6354
7976
|
return {
|
|
6355
7977
|
content: [{ type: "text", text: `Context manifest budget exceeded: ${task.contextManifest.length + params.files.length} files > ${budget.maxManifestFiles}` }],
|
|
@@ -6375,13 +7997,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6375
7997
|
const key = `${file.path}\u0000${file.reason}`;
|
|
6376
7998
|
if (!seen.has(key)) task.contextManifest.push(file);
|
|
6377
7999
|
}
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
8000
|
+
const lifecycle = task.changeMode === "read-only"
|
|
8001
|
+
? applyRuntimeLifecycleObservation(task, "context-complete", nowIso())
|
|
8002
|
+
: { changed: false, mode: runtimeLifecycleMode(task) };
|
|
8003
|
+
const written = writeTask(ctx.cwd, task);
|
|
8004
|
+
appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
|
|
8005
|
+
appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "context_record", files: safeFiles, lifecycleMode: lifecycle.mode, lifecycleAdvanced: lifecycle.changed });
|
|
6381
8006
|
|
|
6382
8007
|
return {
|
|
6383
8008
|
content: [{ type: "text", text: `Context recorded for ${task.taskId}: ${params.files.length} file(s)` }],
|
|
6384
|
-
details:
|
|
8009
|
+
details: compactTaskDetails(written)
|
|
6385
8010
|
};
|
|
6386
8011
|
}
|
|
6387
8012
|
});
|
|
@@ -6398,13 +8023,16 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6398
8023
|
summary: Type.String({ minLength: 1 })
|
|
6399
8024
|
}),
|
|
6400
8025
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6401
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8026
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6402
8027
|
if (!task) {
|
|
6403
8028
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6404
8029
|
}
|
|
8030
|
+
if (task.trace.outcome !== "pending") {
|
|
8031
|
+
return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; verify evidence was not changed.` }], details: task, isError: true };
|
|
8032
|
+
}
|
|
6405
8033
|
|
|
6406
8034
|
const observedEntries = [
|
|
6407
|
-
...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000 }),
|
|
8035
|
+
...readObservedBashResults(observedBashLedgerPath(ctx.cwd), { maxEntries: 10000, projectRoot: ctx.cwd }),
|
|
6408
8036
|
...bashResults.list()
|
|
6409
8037
|
];
|
|
6410
8038
|
const observed = findMatchingObservedBashResult(observedEntries, {
|
|
@@ -6424,24 +8052,50 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6424
8052
|
const safeCommand = redactText(params.command);
|
|
6425
8053
|
const safeSummary = redactText(params.summary);
|
|
6426
8054
|
const matchedProfileCommand = commandMatchesVerifyPlan(params.command, task.verifyCommands);
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
8055
|
+
const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
8056
|
+
const workingTreeDigest = workingTreeEvidenceDigest(currentDigests);
|
|
8057
|
+
const duplicate = task.verifyEvidence.some((evidence) => (
|
|
8058
|
+
evidence.command.trim() === safeCommand.trim()
|
|
8059
|
+
&& evidence.exitCode === params.exitCode
|
|
8060
|
+
&& evidence.workingTreeDigest === workingTreeDigest
|
|
8061
|
+
));
|
|
8062
|
+
if (!duplicate) {
|
|
8063
|
+
task.verifyEvidence.push({
|
|
8064
|
+
command: safeCommand,
|
|
8065
|
+
exitCode: params.exitCode,
|
|
8066
|
+
summary: safeSummary,
|
|
8067
|
+
recordedAt: nowIso(),
|
|
8068
|
+
observed: true,
|
|
8069
|
+
observedAt: observed.entry.recordedAt,
|
|
8070
|
+
isError: observed.entry.isError,
|
|
8071
|
+
matchedProfileCommand,
|
|
8072
|
+
workingTreeDigest
|
|
8073
|
+
});
|
|
8074
|
+
task.verifyEvidence = task.verifyEvidence.slice(-100);
|
|
8075
|
+
}
|
|
8076
|
+
const hasChanges = taskChangedFileEvidence(ctx.cwd, task, currentDigests).expected.length > 0;
|
|
8077
|
+
const allPassing = matchedProfileCommand && hasChanges && allVerifyCommandsPassCurrentTree(task, workingTreeDigest);
|
|
8078
|
+
if (matchedProfileCommand && hasChanges) {
|
|
8079
|
+
applyRuntimeLifecycleObservation(task, allPassing ? "verification-complete" : "verification-pending", nowIso());
|
|
8080
|
+
}
|
|
8081
|
+
const written = writeTask(ctx.cwd, task);
|
|
8082
|
+
appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand, workingTreeDigest, duplicate });
|
|
8083
|
+
appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "verify_record", command: safeCommand, exitCode: params.exitCode, observedAt: observed.entry.recordedAt, matchedProfileCommand, workingTreeDigest, duplicate });
|
|
6440
8084
|
|
|
6441
8085
|
const advisorySuffix = matchedProfileCommand ? "" : " Advisory only: command does not exactly match task verifyCommands and will not satisfy the passing final gate.";
|
|
6442
8086
|
return {
|
|
6443
8087
|
content: [{ type: "text", text: `Verify evidence recorded for ${task.taskId}: observed exit ${params.exitCode}.${advisorySuffix}` }],
|
|
6444
|
-
details: {
|
|
8088
|
+
details: {
|
|
8089
|
+
task: compactTaskDetails(written),
|
|
8090
|
+
evidence: {
|
|
8091
|
+
command: safeCommand,
|
|
8092
|
+
exitCode: params.exitCode,
|
|
8093
|
+
observed: true,
|
|
8094
|
+
matchedProfileCommand,
|
|
8095
|
+
workingTreeDigest
|
|
8096
|
+
},
|
|
8097
|
+
duplicate
|
|
8098
|
+
}
|
|
6445
8099
|
};
|
|
6446
8100
|
}
|
|
6447
8101
|
});
|
|
@@ -6459,10 +8113,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6459
8113
|
}), { minItems: 1 })
|
|
6460
8114
|
}),
|
|
6461
8115
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6462
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8116
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6463
8117
|
if (!task) {
|
|
6464
8118
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6465
8119
|
}
|
|
8120
|
+
if (task.trace.outcome !== "pending") {
|
|
8121
|
+
return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; memory evidence was not changed.` }], details: task, isError: true };
|
|
8122
|
+
}
|
|
6466
8123
|
|
|
6467
8124
|
const safeFiles = params.files.map((file) => ({
|
|
6468
8125
|
path: file.path,
|
|
@@ -6474,8 +8131,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6474
8131
|
if (!seen.has(key)) task.memoryCitations.push(file);
|
|
6475
8132
|
}
|
|
6476
8133
|
writeTask(ctx.cwd, task);
|
|
6477
|
-
appendTrace(ctx.cwd, { taskId: task.taskId, event: "memory_citation_record", files: safeFiles });
|
|
6478
|
-
appendSessionTrace(pi, { taskId: task.taskId, event: "memory_citation_record", files: safeFiles });
|
|
8134
|
+
appendTrace(ctx.cwd, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
|
|
8135
|
+
appendSessionTrace(pi, { taskId: task.taskId, taskRunId: task.taskRunId, sessionId: task.sessionId, event: "memory_citation_record", files: safeFiles });
|
|
6479
8136
|
|
|
6480
8137
|
return {
|
|
6481
8138
|
content: [{ type: "text", text: `Memory citations recorded for ${task.taskId}: ${params.files.length} file(s)` }],
|
|
@@ -6494,19 +8151,44 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6494
8151
|
outcome: StringEnum(["completed", "blocked", "partial", "failed"] as const),
|
|
6495
8152
|
changedFiles: Type.Optional(Type.Array(Type.String())),
|
|
6496
8153
|
friction: Type.Optional(Type.String()),
|
|
6497
|
-
notes: Type.Optional(Type.String())
|
|
8154
|
+
notes: Type.Optional(Type.String()),
|
|
8155
|
+
failedAt: Type.Optional(StringEnum(["research", "plan", "execute", "verify", "review"] as const)),
|
|
8156
|
+
ruledOut: Type.Optional(Type.String())
|
|
6498
8157
|
}),
|
|
6499
8158
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
6500
8159
|
const profile = loadProfileFromContext(ctx);
|
|
6501
8160
|
const runtime = resolveRuntimePolicy(profile);
|
|
6502
|
-
const task = readTask(ctx.cwd, params.taskId);
|
|
8161
|
+
const task = readTask(ctx.cwd, params.taskId, ctx.sessionManager.getSessionId());
|
|
6503
8162
|
if (!task) {
|
|
6504
8163
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], isError: true };
|
|
6505
8164
|
}
|
|
8165
|
+
if (task.trace.outcome !== "pending") {
|
|
8166
|
+
return { content: [{ type: "text", text: `Task ${task.taskId} is immutable after ${task.trace.outcome}; its final trace was not replaced.` }], details: task, isError: true };
|
|
8167
|
+
}
|
|
8168
|
+
if (params.outcome !== "completed" && !params.friction?.trim() && !task.failureReason?.trim()) {
|
|
8169
|
+
return { content: [{ type: "text", text: `Trace ${params.outcome} requires a concrete friction/reason so the next attempt does not repeat the same work.` }], details: task, isError: true };
|
|
8170
|
+
}
|
|
8171
|
+
if (params.outcome === "failed" && !params.failedAt && !task.failedAt) {
|
|
8172
|
+
return { content: [{ type: "text", text: "A failed trace requires failedAt to identify the lifecycle phase." }], details: task, isError: true };
|
|
8173
|
+
}
|
|
6506
8174
|
|
|
8175
|
+
const rawChangedFiles = params.changedFiles ?? task.changedFiles;
|
|
8176
|
+
const normalizedChangedFiles = rawChangedFiles.map((file) => normalizeRelative(ctx.cwd, file));
|
|
8177
|
+
if (normalizedChangedFiles.some((file) => !file || file === "." || file === ".." || file.startsWith("../") || file.startsWith(".pi/piagent-state/"))) {
|
|
8178
|
+
return {
|
|
8179
|
+
content: [{ type: "text", text: "Trace refused: changedFiles must be project-relative paths outside .pi/piagent-state/." }],
|
|
8180
|
+
isError: true
|
|
8181
|
+
};
|
|
8182
|
+
}
|
|
8183
|
+
const finalFileDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
6507
8184
|
const nextTask: TaskContract = {
|
|
6508
8185
|
...task,
|
|
6509
|
-
changedFiles:
|
|
8186
|
+
changedFiles: uniqueStrings(normalizedChangedFiles as string[]).sort(),
|
|
8187
|
+
finalWorkingTreeFiles: Object.keys(finalFileDigests).sort(),
|
|
8188
|
+
finalFileDigests,
|
|
8189
|
+
failedAt: params.outcome === "completed" ? undefined : params.failedAt ?? task.failedAt,
|
|
8190
|
+
failureReason: params.outcome === "completed" ? undefined : params.friction ? redactText(params.friction) : task.failureReason,
|
|
8191
|
+
ruledOut: params.outcome === "completed" ? undefined : params.ruledOut ? redactText(params.ruledOut).slice(0, 1000) : task.ruledOut,
|
|
6510
8192
|
trace: {
|
|
6511
8193
|
outcome: params.outcome,
|
|
6512
8194
|
friction: params.friction ? redactText(params.friction) : undefined,
|
|
@@ -6514,8 +8196,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6514
8196
|
recordedAt: nowIso()
|
|
6515
8197
|
}
|
|
6516
8198
|
};
|
|
6517
|
-
const gate = evaluateTaskGate(nextTask, policy
|
|
8199
|
+
const gate = evaluateTaskGate(ctx.cwd, nextTask, policy, {
|
|
8200
|
+
currentDigests: finalFileDigests,
|
|
8201
|
+
currentWorkingTreeDigest: workingTreeEvidenceDigest(finalFileDigests)
|
|
8202
|
+
});
|
|
6518
8203
|
if (params.outcome === "completed" && runtime.finalGate === "enforce" && gate.decision === "fail") {
|
|
8204
|
+
const blockedTrace = {
|
|
8205
|
+
taskId: nextTask.taskId,
|
|
8206
|
+
taskRunId: nextTask.taskRunId,
|
|
8207
|
+
sessionId: nextTask.sessionId,
|
|
8208
|
+
event: "completion_gate_blocked",
|
|
8209
|
+
missing: gate.missing,
|
|
8210
|
+
changedFiles: nextTask.changedFiles
|
|
8211
|
+
};
|
|
8212
|
+
appendTrace(ctx.cwd, blockedTrace);
|
|
8213
|
+
appendSessionTrace(pi, blockedTrace);
|
|
6519
8214
|
return {
|
|
6520
8215
|
content: [{ type: "text", text: `Final gate blocked completion: missing ${gate.missing.join(", ")}` }],
|
|
6521
8216
|
details: { gate, task: nextTask },
|
|
@@ -6523,27 +8218,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
6523
8218
|
};
|
|
6524
8219
|
}
|
|
6525
8220
|
|
|
6526
|
-
writeTask(ctx.cwd, nextTask);
|
|
6527
|
-
|
|
6528
|
-
taskId: nextTask.taskId,
|
|
6529
|
-
event: "trace_record",
|
|
6530
|
-
outcome: params.outcome,
|
|
6531
|
-
changedFiles: nextTask.changedFiles,
|
|
6532
|
-
friction: nextTask.trace.friction,
|
|
6533
|
-
notes: nextTask.trace.notes
|
|
6534
|
-
});
|
|
6535
|
-
appendSessionTrace(pi, {
|
|
8221
|
+
const written = writeTask(ctx.cwd, nextTask);
|
|
8222
|
+
const trace = {
|
|
6536
8223
|
taskId: nextTask.taskId,
|
|
8224
|
+
taskRunId: nextTask.taskRunId,
|
|
8225
|
+
sessionId: nextTask.sessionId,
|
|
6537
8226
|
event: "trace_record",
|
|
6538
8227
|
outcome: params.outcome,
|
|
6539
8228
|
changedFiles: nextTask.changedFiles,
|
|
6540
8229
|
friction: nextTask.trace.friction,
|
|
6541
|
-
notes: nextTask.trace.notes
|
|
6542
|
-
|
|
8230
|
+
notes: nextTask.trace.notes,
|
|
8231
|
+
failedAt: nextTask.failedAt,
|
|
8232
|
+
ruledOut: nextTask.ruledOut
|
|
8233
|
+
};
|
|
8234
|
+
appendTrace(ctx.cwd, trace);
|
|
8235
|
+
appendSessionTrace(pi, trace);
|
|
6543
8236
|
|
|
6544
8237
|
return {
|
|
6545
8238
|
content: [{ type: "text", text: `Trace recorded for ${nextTask.taskId}: ${params.outcome}${gate.decision === "fail" ? ` (gate warning: missing ${gate.missing.join(", ")})` : ""}` }],
|
|
6546
|
-
details: { task:
|
|
8239
|
+
details: { task: written, gate }
|
|
6547
8240
|
};
|
|
6548
8241
|
}
|
|
6549
8242
|
});
|
|
@@ -7501,12 +9194,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
7501
9194
|
}
|
|
7502
9195
|
|
|
7503
9196
|
function compactCurrentSession(ctx: ExtensionContext): void {
|
|
7504
|
-
const
|
|
9197
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
9198
|
+
const instructions = semanticCompactionInstructions(ctx.cwd, sessionId);
|
|
7505
9199
|
telemetry(ctx, {
|
|
7506
9200
|
event: "compaction_requested",
|
|
7507
9201
|
mode: "semantic",
|
|
7508
9202
|
instructionTokens: estimateContextTokens(instructions),
|
|
7509
|
-
hasTaskContract: Boolean(compactSessionTask(ctx.cwd))
|
|
9203
|
+
hasTaskContract: Boolean(compactSessionTask(ctx.cwd, sessionId))
|
|
7510
9204
|
});
|
|
7511
9205
|
ctx.compact({
|
|
7512
9206
|
customInstructions: instructions
|