@piagent/platform 1.5.4 → 1.6.0
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 +136 -0
- package/README.md +9 -8
- package/README.vi.md +9 -5
- package/adapters/web-frontend/profile.json +2 -4
- package/architecture/layers.json +18 -3
- package/benchmarks/deep-logic-v1/SPECIALIST_PROTOCOL.vi.md +122 -0
- package/benchmarks/deep-logic-v1/grade.mjs +272 -10
- package/benchmarks/deep-logic-v1/project/apps/admin/src/billing-summary.js +3 -0
- package/benchmarks/deep-logic-v1/project/benchmark-contract/public-smoke.test.js +166 -0
- package/benchmarks/deep-logic-v1/project/package.json +1 -1
- package/benchmarks/deep-logic-v1/project/packages/billing/src/close-period.js +3 -0
- package/benchmarks/deep-logic-v1/project/packages/billing/src/plan-timeline.js +3 -0
- package/benchmarks/deep-logic-v1/project/test/smoke.test.js +7 -1
- package/benchmarks/deep-logic-v1/prompts/budgeted-context-graph.md +2 -0
- package/benchmarks/deep-logic-v1/prompts/fair-dependency-scheduler.md +2 -0
- package/benchmarks/deep-logic-v1/prompts/layered-policy-resolution.md +2 -0
- package/benchmarks/deep-logic-v1/prompts/resumable-stream-assembly.md +3 -1
- package/benchmarks/deep-logic-v1/prompts/revision-event-reconciliation.md +2 -0
- package/benchmarks/deep-logic-v1/prompts/temporal-usage-billing-close.md +123 -0
- package/benchmarks/deep-logic-v1/prompts/transactional-config-merge.md +2 -0
- package/benchmarks/deep-logic-v1/suite.json +51 -3
- package/benchmarks/deep-logic-v1/variant.mjs +22 -1
- package/benchmarks/production-v1/spend-control.v1.json +68 -0
- package/benchmarks/production-v1/suite.json +42 -3
- package/catalog/capabilities.json +6 -6
- package/docs/command-reference-vietnamese.md +6 -6
- package/docs/context-engine.md +7 -1
- package/docs/distribution-standard.md +7 -7
- package/docs/en/architecture.md +1 -0
- package/docs/languages.json +4 -4
- package/docs/model-options.md +8 -5
- package/docs/operator-manual-vietnamese.md +13 -11
- package/docs/project-adapters.md +2 -2
- package/docs/publishing-for-teams.md +9 -0
- package/docs/quality-benchmark.md +271 -62
- package/docs/release-install-policy.md +10 -9
- package/docs/runtime-policy-design.md +4 -4
- package/docs/runtime-quality-baseline.md +13 -6
- package/docs/security-threat-model.md +2 -2
- package/docs/task-lifecycle-vietnamese.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs/usage-observability.md +7 -2
- package/docs/vi/architecture.md +1 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/index.html +1 -1
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +1 -1
- package/docs-site/en/workflows.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 +1 -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/whats-new.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/benchmark-assurance-evidence.invalid.json +1 -1
- package/evals/fixtures/benchmark-assurance-evidence.valid.json +1 -1
- package/evals/fixtures/piagent-webui/session-live-state-v1.invalid.json +18 -0
- package/evals/fixtures/piagent-webui/session-live-state-v1.valid.json +27 -0
- package/evals/fs4-readiness-matrix.v1.json +2 -2
- package/evals/fs5-causal-arm.v1.json +2 -2
- package/evals/fs5-pilot-protocol.v1.json +2 -2
- package/evals/fs5-pilot-protocol.v4.json +6 -5
- package/evals/fs5-pilot-protocol.v5.json +7 -6
- package/evals/private-holdout-v1/public-exposure.v1.json +1 -1
- package/package.json +3 -1
- package/packages/piagent-core/benchmark/benchmark-assurance.js +4 -1
- package/packages/piagent-core/benchmark/benchmark-bootstrap.js +91 -9
- package/packages/piagent-core/benchmark/benchmark-claim-restrictions.js +38 -7
- package/packages/piagent-core/benchmark/benchmark-cli.js +7 -7
- package/packages/piagent-core/benchmark/benchmark-codex.js +3 -1
- package/packages/piagent-core/benchmark/benchmark-comparison.js +271 -2
- package/packages/piagent-core/benchmark/benchmark-core.js +732 -661
- package/packages/piagent-core/benchmark/benchmark-execution-guard.js +7 -5
- package/packages/piagent-core/benchmark/benchmark-forensics.js +439 -4
- package/packages/piagent-core/benchmark/benchmark-host-readiness.js +639 -0
- package/packages/piagent-core/benchmark/benchmark-matrix.js +22 -4
- package/packages/piagent-core/benchmark/benchmark-normalized-cost.js +317 -0
- package/packages/piagent-core/benchmark/benchmark-preflight.js +3 -1
- package/packages/piagent-core/benchmark/benchmark-process.js +6 -5
- package/packages/piagent-core/benchmark/benchmark-provider-wire.js +126 -0
- package/packages/piagent-core/benchmark/benchmark-record-validation.js +183 -0
- package/packages/piagent-core/benchmark/benchmark-report.js +116 -12
- package/packages/piagent-core/benchmark/benchmark-resume-recovery.js +14 -0
- package/packages/piagent-core/benchmark/benchmark-runner-policy.js +26 -0
- package/packages/piagent-core/benchmark/benchmark-stage-diagnostic.js +782 -0
- package/packages/piagent-core/benchmark/benchmark-statistics.js +64 -0
- package/packages/piagent-core/benchmark/benchmark-stop-policy.js +8 -5
- package/packages/piagent-core/benchmark/benchmark-suite-identity.js +31 -0
- package/packages/piagent-core/benchmark/benchmark-suite-runtime.js +13 -9
- package/packages/piagent-core/benchmark/benchmark-suite.js +128 -2
- package/packages/piagent-core/benchmark/benchmark-summary-support.js +376 -0
- package/packages/piagent-core/benchmark/benchmark-timing-diagnostics.js +971 -0
- package/packages/piagent-core/benchmark/benchmark-usage.js +311 -0
- package/packages/piagent-core/benchmark/benchmark-workflow.js +166 -0
- package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +1080 -0
- package/packages/piagent-core/extensions/acceptance-contract-semantics.js +83 -69
- package/packages/piagent-core/extensions/acceptance-error-classes.js +160 -0
- package/packages/piagent-core/extensions/acceptance-executable-evidence.js +12 -9
- package/packages/piagent-core/extensions/acceptance-receipt.js +34 -11
- package/packages/piagent-core/extensions/acceptance-state-machine-evidence.js +518 -0
- package/packages/piagent-core/extensions/acceptance-test-binding-evidence.js +60 -0
- package/packages/piagent-core/extensions/context-efficiency-metrics.js +302 -0
- package/packages/piagent-core/extensions/context-efficiency-report.js +167 -0
- package/packages/piagent-core/extensions/context-engine.js +148 -145
- package/packages/piagent-core/extensions/context-evidence.js +48 -0
- package/packages/piagent-core/extensions/context-import-links.js +296 -0
- package/packages/piagent-core/extensions/context-telemetry-integrity.js +65 -0
- package/packages/piagent-core/extensions/criterion-context-pack.js +422 -7
- package/packages/piagent-core/extensions/criterion-graph.js +78 -8
- package/packages/piagent-core/extensions/guard-types.ts +3 -0
- package/packages/piagent-core/extensions/javascript-regex-evidence.js +32 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +250 -43
- package/packages/piagent-core/extensions/policy-core.js +1 -1
- package/packages/piagent-core/extensions/task-contract-view.js +17 -3
- package/packages/piagent-core/extensions/task-lifecycle.js +7 -2
- package/packages/piagent-core/extensions/task-state.js +40 -134
- package/packages/piagent-core/extensions/verification-intelligence.js +3 -1
- package/packages/piagent-core/extensions/workspace-evidence-roots.js +157 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +3 -0
- package/packages/piagent-core/runtime/context/context-delivery.ts +103 -0
- package/packages/piagent-core/runtime/context/context-delta-shadow.ts +53 -0
- package/packages/piagent-core/runtime/context/context-efficiency-format.ts +47 -0
- package/packages/piagent-core/runtime/context/context-evidence-qualification.ts +32 -0
- package/packages/piagent-core/runtime/context/prefix-telemetry.ts +47 -0
- package/packages/piagent-core/runtime/context/repository-memory-hints.ts +27 -0
- package/packages/piagent-core/runtime/hooks/agent-prompt-telemetry.ts +56 -0
- package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +162 -102
- package/packages/piagent-core/runtime/hooks/completion-hook.ts +5 -6
- package/packages/piagent-core/runtime/hooks/input-hook.ts +11 -1
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +22 -2
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +7 -2
- package/packages/piagent-core/runtime/hooks/tool-result-content-guards.ts +95 -0
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +84 -102
- package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +9 -3
- package/packages/piagent-core/runtime/inspection/activity-recovery.ts +104 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +29 -5
- package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +1 -1
- package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +1 -1
- package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +53 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +34 -6
- package/packages/piagent-core/runtime/model/authenticated-catalog.ts +17 -9
- package/packages/piagent-core/runtime/model/openai-codex-reasoning.ts +83 -0
- package/packages/piagent-core/runtime/model/provider-wire-fingerprint.ts +238 -0
- package/packages/piagent-core/runtime/orchestration/role-policy.ts +1 -1
- package/packages/piagent-core/runtime/product/activity-inspector.ts +7 -2
- package/packages/piagent-core/runtime/product/efficiency-metrics.ts +4 -2
- package/packages/piagent-core/runtime/quality/exact-output-contract.ts +2 -1
- package/packages/piagent-core/runtime/quality/performance-assurance-signals.ts +23 -0
- package/packages/piagent-core/runtime/quality/performance-assurance.ts +6 -2
- package/packages/piagent-core/runtime/recovery/edit-recovery-context.ts +113 -0
- package/packages/piagent-core/runtime/recovery/edit-recovery-delivery.ts +42 -0
- package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +55 -2
- package/packages/piagent-core/runtime/recovery/semantic-repair-runtime.ts +55 -2
- package/packages/piagent-core/runtime/registration/context-commands.ts +66 -5
- package/packages/piagent-core/runtime/registration/policy-tools.ts +46 -11
- package/packages/piagent-core/runtime/registration/task-completion-tools.ts +49 -16
- package/packages/piagent-core/runtime/registration/task-start-guidance.ts +163 -0
- package/packages/piagent-core/runtime/registration/task-start-retry-helpers.ts +37 -0
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +67 -62
- package/packages/piagent-core/runtime/session/runtime-state.ts +154 -2
- package/packages/piagent-core/runtime/session/system-prompt.ts +4 -4
- package/packages/piagent-core/runtime/tools/apply-patch-tool.ts +500 -0
- package/packages/piagent-core/runtime/trajectory/trajectory-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/task-intake.ts +43 -2
- package/packages/piagent-core/security/sensitive-data.js +13 -218
- package/packages/piagent-core/security/sensitive-project-file.js +42 -0
- package/packages/piagent-core/security/sensitive-source-expression.js +330 -0
- package/packages/piagent-core/security/sensitive-source-formats.js +394 -0
- package/packages/piagent-core/security/sensitive-text.js +234 -0
- package/packages/piagent-core/subagents/piagent-worker.md +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-rD9WJKxs.js → MarkdownMessage-CxeZqTte.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-DynIMe8I.js +176 -0
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/gateway-events.ts +109 -5
- package/packages/piagent-webui/gateway/gateway-service.ts +3 -0
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +93 -7
- package/packages/piagent-webui/gateway/session-command-controller.ts +18 -3
- package/packages/piagent-webui/gateway/session-effective-options.ts +47 -0
- package/packages/piagent-webui/gateway/session-live-state.ts +21 -0
- package/packages/piagent-webui/gateway/session-operation-start.ts +5 -1
- package/packages/piagent-webui/gateway/session-operation-watchdog.ts +158 -0
- package/packages/piagent-webui/gateway/session-runtime-factory.ts +45 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +139 -120
- package/packages/piagent-webui/server/loopback-server.ts +8 -0
- package/packages/piagent-webui/server/transcript-projection.ts +22 -4
- package/packages/piagent-webui/shared/text-visibility.ts +10 -0
- package/schemas/piagent-webui/catalog-v1.json +9 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +53 -3
- package/schemas/piagent-webui/session-live-state-v1.schema.json +76 -0
- package/schemas/task-contract.schema.json +12 -1
- package/scripts/benchmark-runner-core.mjs +355 -359
- package/scripts/benchmark-runner-finalization.mjs +309 -0
- package/scripts/benchmark-runner-host-readiness.mjs +94 -0
- package/scripts/benchmark-runner-support.mjs +200 -0
- package/scripts/benchmark-session.mjs +95 -41
- package/scripts/configure-model-scope.sh +4 -3
- package/scripts/helper-evaluation.mjs +1 -1
- package/scripts/install-global.sh +2 -2
- package/scripts/pi-usage-history.mjs +37 -10
- package/scripts/piagent-dashboard.mjs +13 -1
- package/scripts/setup.sh +2 -2
- package/scripts/verify-local.sh +5 -1
- package/templates/global/settings.json +2 -1
- package/packages/piagent-webui/dist/client/assets/index-BZRFw1u6.js +0 -174
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
// Keep exact parity with model-mutation-proof.ts so every successful write can
|
|
8
|
+
// retain content-digest provenance instead of degrading to inferred authorship.
|
|
9
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
const MAX_TRANSACTION_PREIMAGE_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const MAX_FILES = 100;
|
|
12
|
+
const MAX_HUNKS = 1_000;
|
|
13
|
+
const MAX_MATCH_LINE_COMPARISONS = 5_000_000;
|
|
14
|
+
|
|
15
|
+
type PatchOperation = {
|
|
16
|
+
kind: "add" | "update";
|
|
17
|
+
repoPath: string;
|
|
18
|
+
section: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type UpdateHunk = {
|
|
22
|
+
oldLines: string[];
|
|
23
|
+
newLines: string[];
|
|
24
|
+
};
|
|
25
|
+
type PatchWorkBudget = { matchLineComparisons: number };
|
|
26
|
+
type FileVersion = {
|
|
27
|
+
dev: number;
|
|
28
|
+
ino: number;
|
|
29
|
+
mode: number;
|
|
30
|
+
size: number;
|
|
31
|
+
mtimeMs: number;
|
|
32
|
+
ctimeMs: number;
|
|
33
|
+
digest: string;
|
|
34
|
+
};
|
|
35
|
+
type PlannedWrite = PatchOperation & {
|
|
36
|
+
absolute: string;
|
|
37
|
+
bytes: Buffer;
|
|
38
|
+
before?: FileVersion;
|
|
39
|
+
beforeBytes?: Buffer;
|
|
40
|
+
temporary?: string;
|
|
41
|
+
backup?: string;
|
|
42
|
+
committed?: boolean;
|
|
43
|
+
};
|
|
44
|
+
export class ApplyPatchError extends Error {
|
|
45
|
+
readonly code: string;
|
|
46
|
+
|
|
47
|
+
constructor(code: string, message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ApplyPatchError";
|
|
50
|
+
this.code = code;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fail(code: string, message: string): never {
|
|
55
|
+
throw new ApplyPatchError(code, message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function digest(bytes: Buffer): string {
|
|
59
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inside(root: string, candidate: string): boolean {
|
|
63
|
+
const relative = path.relative(root, candidate);
|
|
64
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeRepoPath(candidate: string): string {
|
|
68
|
+
const value = candidate.trim();
|
|
69
|
+
const segments = value.split("/");
|
|
70
|
+
if (
|
|
71
|
+
!value || value.length > 1_024 || /[\x00-\x1f\x7f]/.test(value) || value.includes("\\") || value.includes("%")
|
|
72
|
+
|| path.posix.isAbsolute(value) || path.win32.isAbsolute(value)
|
|
73
|
+
|| segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
74
|
+
|| path.posix.normalize(value) !== value
|
|
75
|
+
) fail("unsafe-path", "Patch targets must be normalized project-relative paths without percent escapes");
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parsePatch(patch: string): PatchOperation[] {
|
|
80
|
+
if (typeof patch !== "string" || !patch || Buffer.byteLength(patch, "utf8") > MAX_PATCH_BYTES) {
|
|
81
|
+
fail("invalid-patch", `Patch must contain between 1 and ${MAX_PATCH_BYTES} UTF-8 bytes`);
|
|
82
|
+
}
|
|
83
|
+
if (patch.includes("\0")) {
|
|
84
|
+
fail("invalid-patch", "Patch contains unsupported control or line-ending bytes");
|
|
85
|
+
}
|
|
86
|
+
let normalized = patch.replace(/\r\n/g, "\n");
|
|
87
|
+
if (normalized.includes("\r")) fail("invalid-patch", "Patch contains unsupported control or line-ending bytes");
|
|
88
|
+
if (normalized.endsWith("\n")) normalized = normalized.slice(0, -1);
|
|
89
|
+
const lines = normalized.split("\n");
|
|
90
|
+
if (lines[0] !== "*** Begin Patch" || lines.at(-1) !== "*** End Patch") {
|
|
91
|
+
fail("invalid-patch", "Patch must have exact Begin Patch and End Patch delimiters");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const operations: PatchOperation[] = [];
|
|
95
|
+
const targets = new Set<string>();
|
|
96
|
+
for (let index = 1; index < lines.length - 1;) {
|
|
97
|
+
const marker = lines[index].match(/^\*\*\* (Add|Update) File:\s*(.+?)\s*$/);
|
|
98
|
+
if (!marker) fail("invalid-patch", `Unsupported or malformed patch marker at line ${index + 1}`);
|
|
99
|
+
const repoPath = normalizeRepoPath(marker[2]);
|
|
100
|
+
if (targets.has(repoPath)) fail("duplicate-target", `Patch target appears more than once: ${repoPath}`);
|
|
101
|
+
if ([...targets].some((target) => repoPath.startsWith(`${target}/`) || target.startsWith(`${repoPath}/`))) {
|
|
102
|
+
fail("overlapping-target", `Patch file targets cannot contain one another: ${repoPath}`);
|
|
103
|
+
}
|
|
104
|
+
targets.add(repoPath);
|
|
105
|
+
index += 1;
|
|
106
|
+
const section: string[] = [];
|
|
107
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) section.push(lines[index++]);
|
|
108
|
+
if (section.length === 0) fail("invalid-patch", `Patch section is empty: ${repoPath}`);
|
|
109
|
+
operations.push({ kind: marker[1] === "Add" ? "add" : "update", repoPath, section });
|
|
110
|
+
if (operations.length > MAX_FILES) fail("too-many-files", `Patch exceeds the ${MAX_FILES}-file limit`);
|
|
111
|
+
}
|
|
112
|
+
if (operations.length === 0) fail("invalid-patch", "Patch contains no Add File or Update File section");
|
|
113
|
+
let totalHunks = 0;
|
|
114
|
+
for (const operation of operations) {
|
|
115
|
+
if (operation.kind === "add") { addContent(operation.section, operation.repoPath); continue; }
|
|
116
|
+
totalHunks += parseUpdateHunks(operation.section, operation.repoPath).length;
|
|
117
|
+
if (totalHunks > MAX_HUNKS) fail("too-many-hunks", `Patch exceeds the global ${MAX_HUNKS}-hunk limit`);
|
|
118
|
+
}
|
|
119
|
+
return operations;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseApplyPatchTargets(patch: string): string[] {
|
|
123
|
+
return parsePatch(patch).map((operation) => operation.repoPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function exactMatchOffsets(lines: string[], expected: string[], work: PatchWorkBudget): number[] {
|
|
127
|
+
const matches: number[] = [];
|
|
128
|
+
for (let at = 0; at + expected.length <= lines.length; at += 1) {
|
|
129
|
+
let matched = true;
|
|
130
|
+
for (let offset = 0; offset < expected.length; offset += 1) {
|
|
131
|
+
if (++work.matchLineComparisons > MAX_MATCH_LINE_COMPARISONS) {
|
|
132
|
+
fail("work-limit", `Patch exact-match scanning exceeds ${MAX_MATCH_LINE_COMPARISONS} line comparisons`);
|
|
133
|
+
}
|
|
134
|
+
if (lines[at + offset] !== expected[offset]) { matched = false; break; }
|
|
135
|
+
}
|
|
136
|
+
if (matched) matches.push(at);
|
|
137
|
+
}
|
|
138
|
+
return matches;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseUpdateHunks(section: string[], repoPath: string): UpdateHunk[] {
|
|
142
|
+
const hunks: UpdateHunk[] = [];
|
|
143
|
+
let hunkCount = 0;
|
|
144
|
+
for (let index = 0; index < section.length;) {
|
|
145
|
+
if (!/^@@(?:\s.*)?$/.test(section[index]) || section[index].startsWith("@@@")) {
|
|
146
|
+
fail("malformed-hunk", `Update section must start each hunk with @@: ${repoPath}`);
|
|
147
|
+
}
|
|
148
|
+
hunkCount += 1;
|
|
149
|
+
if (hunkCount > MAX_HUNKS) fail("too-many-hunks", `Patch exceeds the ${MAX_HUNKS}-hunk limit`);
|
|
150
|
+
index += 1;
|
|
151
|
+
const oldLines: string[] = [];
|
|
152
|
+
const newLines: string[] = [];
|
|
153
|
+
let changed = false;
|
|
154
|
+
let bodyLines = 0;
|
|
155
|
+
while (index < section.length && !section[index].startsWith("@@")) {
|
|
156
|
+
const line = section[index++];
|
|
157
|
+
if (!line || ![" ", "+", "-"].includes(line[0]) || line.startsWith("\\ No newline")) {
|
|
158
|
+
fail("malformed-hunk", `Hunk contains an unprefixed or unsupported line: ${repoPath}`);
|
|
159
|
+
}
|
|
160
|
+
bodyLines += 1;
|
|
161
|
+
if (line[0] !== "+") oldLines.push(line.slice(1));
|
|
162
|
+
if (line[0] !== "-") newLines.push(line.slice(1));
|
|
163
|
+
if (line[0] === "+" || line[0] === "-") changed = true;
|
|
164
|
+
}
|
|
165
|
+
if (!bodyLines || !changed || oldLines.length === 0) {
|
|
166
|
+
fail("malformed-hunk", `Hunk must contain a change with stable old-side context: ${repoPath}`);
|
|
167
|
+
}
|
|
168
|
+
hunks.push({ oldLines, newLines });
|
|
169
|
+
}
|
|
170
|
+
if (!hunkCount) fail("malformed-hunk", `Update contains no hunks: ${repoPath}`);
|
|
171
|
+
return hunks;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function applyUpdateHunks(content: string, section: string[], repoPath: string, work: PatchWorkBudget): string {
|
|
175
|
+
const projected = content.split("\n");
|
|
176
|
+
const hunks = parseUpdateHunks(section, repoPath);
|
|
177
|
+
for (const { oldLines, newLines } of hunks) {
|
|
178
|
+
const matches = exactMatchOffsets(projected, oldLines, work);
|
|
179
|
+
if (matches.length !== 1) {
|
|
180
|
+
fail("ambiguous-hunk", `Hunk precondition matched ${matches.length} locations in ${repoPath}; add exact context`);
|
|
181
|
+
}
|
|
182
|
+
projected.splice(matches[0], oldLines.length, ...newLines);
|
|
183
|
+
}
|
|
184
|
+
const result = projected.join("\n");
|
|
185
|
+
if (result === content) fail("malformed-hunk", `Update does not change ${repoPath}`);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function addContent(section: string[], repoPath: string): string {
|
|
190
|
+
if (section.some((line) => !line.startsWith("+"))) {
|
|
191
|
+
fail("malformed-add", `Every Add File line must start with +: ${repoPath}`);
|
|
192
|
+
}
|
|
193
|
+
return `${section.map((line) => line.slice(1)).join("\n")}\n`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeTarget(root: string, repoPath: string): { absolute: string; missingParents: string[] } {
|
|
197
|
+
const segments = repoPath.split("/");
|
|
198
|
+
let cursor = root;
|
|
199
|
+
const missingParents: string[] = [];
|
|
200
|
+
let missing = false;
|
|
201
|
+
for (const segment of segments.slice(0, -1)) {
|
|
202
|
+
cursor = path.join(cursor, segment);
|
|
203
|
+
if (!inside(root, cursor)) fail("unsafe-path", `Patch target escapes the project: ${repoPath}`);
|
|
204
|
+
if (missing) {
|
|
205
|
+
missingParents.push(cursor);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const stat = fs.lstatSync(cursor);
|
|
210
|
+
if (stat.isSymbolicLink()) fail("symlink-path", `Patch target traverses a symbolic link: ${repoPath}`);
|
|
211
|
+
if (!stat.isDirectory()) fail("unsafe-path", `Patch target has a non-directory ancestor: ${repoPath}`);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
214
|
+
missing = true;
|
|
215
|
+
missingParents.push(cursor);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const absolute = path.join(root, ...segments);
|
|
219
|
+
if (!inside(root, absolute)) fail("unsafe-path", `Patch target escapes the project: ${repoPath}`);
|
|
220
|
+
return { absolute, missingParents };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sameVersion(left: FileVersion, right: FileVersion): boolean {
|
|
224
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size
|
|
225
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.digest === right.digest;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sameLinkedPreimage(left: FileVersion, right: FileVersion): boolean {
|
|
229
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size
|
|
230
|
+
&& left.mtimeMs === right.mtimeMs && left.digest === right.digest;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readStableFile(absolute: string, repoPath: string): { bytes: Buffer; version: FileVersion } {
|
|
234
|
+
let descriptor: number | undefined;
|
|
235
|
+
try {
|
|
236
|
+
const initial = fs.lstatSync(absolute);
|
|
237
|
+
if (!initial.isFile() || initial.isSymbolicLink() || initial.size > MAX_FILE_BYTES) {
|
|
238
|
+
fail("unsafe-target", `Update target must be a bounded regular file: ${repoPath}`);
|
|
239
|
+
}
|
|
240
|
+
descriptor = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
241
|
+
const opened = fs.fstatSync(descriptor);
|
|
242
|
+
if (opened.dev !== initial.dev || opened.ino !== initial.ino || opened.size !== initial.size) {
|
|
243
|
+
fail("stale-precondition", `Update target changed while it was opened: ${repoPath}`);
|
|
244
|
+
}
|
|
245
|
+
const bytes = Buffer.alloc(opened.size);
|
|
246
|
+
let offset = 0;
|
|
247
|
+
while (offset < bytes.length) {
|
|
248
|
+
const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
249
|
+
if (count <= 0) fail("stale-precondition", `Update target changed while it was read: ${repoPath}`);
|
|
250
|
+
offset += count;
|
|
251
|
+
}
|
|
252
|
+
const after = fs.fstatSync(descriptor);
|
|
253
|
+
const current = fs.lstatSync(absolute);
|
|
254
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
|
|
255
|
+
|| after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
|
|
256
|
+
|| current.dev !== after.dev || current.ino !== after.ino || current.isSymbolicLink()) {
|
|
257
|
+
fail("stale-precondition", `Update target changed during validation: ${repoPath}`);
|
|
258
|
+
}
|
|
259
|
+
const text = bytes.toString("utf8");
|
|
260
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) fail("binary-file", `Update target is not valid UTF-8 text: ${repoPath}`);
|
|
261
|
+
return { bytes, version: { dev: after.dev, ino: after.ino, mode: after.mode, size: after.size,
|
|
262
|
+
mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs, digest: digest(bytes) } };
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") fail("missing-target", `Update target does not exist: ${repoPath}`);
|
|
265
|
+
throw error;
|
|
266
|
+
} finally {
|
|
267
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function targetAbsent(absolute: string, repoPath: string): void {
|
|
272
|
+
try {
|
|
273
|
+
fs.lstatSync(absolute);
|
|
274
|
+
fail("existing-target", `Add target already exists: ${repoPath}`);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function planPatch(cwd: string, patch: string): { root: string; writes: PlannedWrite[]; missingParents: string[] } {
|
|
281
|
+
const root = fs.realpathSync.native(cwd);
|
|
282
|
+
if (!fs.lstatSync(root).isDirectory()) fail("unsafe-root", "Patch project root must be a directory");
|
|
283
|
+
const missingParents = new Set<string>();
|
|
284
|
+
const work: PatchWorkBudget = { matchLineComparisons: 0 };
|
|
285
|
+
let preimageBytes = 0;
|
|
286
|
+
const writes = parsePatch(patch).map((operation): PlannedWrite => {
|
|
287
|
+
const target = safeTarget(root, operation.repoPath);
|
|
288
|
+
target.missingParents.forEach((item) => missingParents.add(item));
|
|
289
|
+
if (operation.kind === "add") {
|
|
290
|
+
targetAbsent(target.absolute, operation.repoPath);
|
|
291
|
+
const bytes = Buffer.from(addContent(operation.section, operation.repoPath), "utf8");
|
|
292
|
+
if (bytes.length > MAX_FILE_BYTES) fail("oversized-file", `Added file exceeds ${MAX_FILE_BYTES} bytes: ${operation.repoPath}`);
|
|
293
|
+
return { ...operation, absolute: target.absolute, bytes };
|
|
294
|
+
}
|
|
295
|
+
if (target.missingParents.length) fail("missing-target", `Update target does not exist: ${operation.repoPath}`);
|
|
296
|
+
const before = readStableFile(target.absolute, operation.repoPath);
|
|
297
|
+
preimageBytes += before.bytes.length;
|
|
298
|
+
if (preimageBytes > MAX_TRANSACTION_PREIMAGE_BYTES) {
|
|
299
|
+
fail("oversized-transaction", `Patch update preimages exceed ${MAX_TRANSACTION_PREIMAGE_BYTES} bytes`);
|
|
300
|
+
}
|
|
301
|
+
const bytes = Buffer.from(applyUpdateHunks(before.bytes.toString("utf8"), operation.section, operation.repoPath, work), "utf8");
|
|
302
|
+
if (bytes.length > MAX_FILE_BYTES) fail("oversized-file", `Updated file exceeds ${MAX_FILE_BYTES} bytes: ${operation.repoPath}`);
|
|
303
|
+
return { ...operation, absolute: target.absolute, bytes, before: before.version, beforeBytes: before.bytes };
|
|
304
|
+
});
|
|
305
|
+
return { root, writes, missingParents: [...missingParents].sort((left, right) => left.split(path.sep).length - right.split(path.sep).length) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function revalidate(plan: ReturnType<typeof planPatch>): void {
|
|
309
|
+
for (const write of plan.writes) {
|
|
310
|
+
safeTarget(plan.root, write.repoPath);
|
|
311
|
+
if (write.kind === "add") targetAbsent(write.absolute, write.repoPath);
|
|
312
|
+
else if (!write.before || !sameVersion(write.before, readStableFile(write.absolute, write.repoPath).version)) {
|
|
313
|
+
fail("stale-precondition", `Update target changed before commit: ${write.repoPath}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function temporaryName(absolute: string, role: "stage" | "backup"): string {
|
|
319
|
+
return path.join(path.dirname(absolute), `.${path.basename(absolute)}.piagent-${role}.${process.pid}.${crypto.randomBytes(8).toString("hex")}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function clean(plan: ReturnType<typeof planPatch>, created: string[], preserveRecoveryBackups = false): void {
|
|
323
|
+
for (const write of plan.writes) {
|
|
324
|
+
if (write.temporary) try { fs.rmSync(write.temporary, { force: true }); } catch {}
|
|
325
|
+
if (write.backup && !(preserveRecoveryBackups && write.committed)) try { fs.rmSync(write.backup, { force: true }); } catch {}
|
|
326
|
+
}
|
|
327
|
+
for (const directory of [...created].reverse()) try { fs.rmdirSync(directory); } catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ensureRollbackRecoveryBackup(plan: ReturnType<typeof planPatch>, write: PlannedWrite): void {
|
|
331
|
+
if (write.backup) return;
|
|
332
|
+
if (!write.before || !write.beforeBytes) throw new Error("missing rollback preimage");
|
|
333
|
+
safeTarget(plan.root, write.repoPath);
|
|
334
|
+
const backup = temporaryName(write.absolute, "backup");
|
|
335
|
+
const descriptor = fs.openSync(backup, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
336
|
+
try {
|
|
337
|
+
fs.writeFileSync(descriptor, write.beforeBytes); fs.fsyncSync(descriptor);
|
|
338
|
+
fs.fchmodSync(descriptor, write.before.mode & 0o777); write.backup = backup;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
try { fs.rmSync(backup, { force: true }); } catch {} throw error;
|
|
341
|
+
} finally { fs.closeSync(descriptor); }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function requireExpectedRollbackPostimage(plan: ReturnType<typeof planPatch>, write: PlannedWrite): void {
|
|
345
|
+
try {
|
|
346
|
+
safeTarget(plan.root, write.repoPath);
|
|
347
|
+
if (readStableFile(write.absolute, write.repoPath).version.digest !== digest(write.bytes)) throw new Error("postimage changed");
|
|
348
|
+
} catch (error) { ensureRollbackRecoveryBackup(plan, write); throw error; }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function rollback(plan: ReturnType<typeof planPatch>): string[] {
|
|
352
|
+
const failed: string[] = [];
|
|
353
|
+
for (const write of [...plan.writes].reverse()) {
|
|
354
|
+
if (!write.committed) continue;
|
|
355
|
+
try {
|
|
356
|
+
if (write.kind === "update") {
|
|
357
|
+
requireExpectedRollbackPostimage(plan, write);
|
|
358
|
+
let restored = false;
|
|
359
|
+
if (write.backup) {
|
|
360
|
+
try {
|
|
361
|
+
fs.renameSync(write.backup, write.absolute);
|
|
362
|
+
write.backup = undefined;
|
|
363
|
+
restored = true;
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
if (!restored) {
|
|
367
|
+
if (!write.before || !write.beforeBytes) throw new Error("missing rollback preimage");
|
|
368
|
+
const temporary = temporaryName(write.absolute, "stage");
|
|
369
|
+
const descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
370
|
+
try {
|
|
371
|
+
fs.writeFileSync(descriptor, write.beforeBytes);
|
|
372
|
+
fs.fsyncSync(descriptor);
|
|
373
|
+
fs.fchmodSync(descriptor, write.before.mode & 0o777);
|
|
374
|
+
} finally { fs.closeSync(descriptor); }
|
|
375
|
+
try { fs.renameSync(temporary, write.absolute); }
|
|
376
|
+
finally { try { fs.rmSync(temporary, { force: true }); } catch {} }
|
|
377
|
+
}
|
|
378
|
+
const observed = readStableFile(write.absolute, write.repoPath);
|
|
379
|
+
if (!write.before || observed.version.digest !== write.before.digest) throw new Error("rollback postcondition failed");
|
|
380
|
+
} else {
|
|
381
|
+
const current = readStableFile(write.absolute, write.repoPath);
|
|
382
|
+
if (current.version.digest !== digest(write.bytes)) throw new Error("postimage changed");
|
|
383
|
+
fs.unlinkSync(write.absolute);
|
|
384
|
+
}
|
|
385
|
+
write.committed = false;
|
|
386
|
+
} catch {
|
|
387
|
+
failed.push(write.repoPath);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return failed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function applyValidatedPatch(cwd: string, patch: string, signal?: AbortSignal): { added: string[]; updated: string[] } {
|
|
394
|
+
const plan = planPatch(cwd, patch);
|
|
395
|
+
const created: string[] = [];
|
|
396
|
+
let rollbackIncomplete = false;
|
|
397
|
+
try {
|
|
398
|
+
if (signal?.aborted) fail("cancelled", "Patch was cancelled before commit");
|
|
399
|
+
for (const directory of plan.missingParents) {
|
|
400
|
+
try { fs.mkdirSync(directory, { mode: 0o755 }); created.push(directory); }
|
|
401
|
+
catch (error) {
|
|
402
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
403
|
+
const stat = fs.lstatSync(directory);
|
|
404
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail("symlink-path", "Patch parent changed before commit");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
revalidate(plan);
|
|
408
|
+
for (const write of plan.writes) {
|
|
409
|
+
write.temporary = temporaryName(write.absolute, "stage");
|
|
410
|
+
const descriptor = fs.openSync(write.temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
411
|
+
try {
|
|
412
|
+
fs.writeFileSync(descriptor, write.bytes);
|
|
413
|
+
fs.fsyncSync(descriptor);
|
|
414
|
+
fs.fchmodSync(descriptor, write.kind === "update" ? (write.before!.mode & 0o777) : 0o644);
|
|
415
|
+
} finally { fs.closeSync(descriptor); }
|
|
416
|
+
}
|
|
417
|
+
revalidate(plan);
|
|
418
|
+
for (const write of plan.writes.filter((item) => item.kind === "update")) {
|
|
419
|
+
write.backup = temporaryName(write.absolute, "backup");
|
|
420
|
+
fs.linkSync(write.absolute, write.backup);
|
|
421
|
+
const backup = readStableFile(write.backup, write.repoPath);
|
|
422
|
+
if (!write.before || !sameLinkedPreimage(write.before, backup.version)) fail("stale-precondition", `Update target changed before commit: ${write.repoPath}`);
|
|
423
|
+
write.before = backup.version;
|
|
424
|
+
}
|
|
425
|
+
revalidate(plan);
|
|
426
|
+
if (signal?.aborted) fail("cancelled", "Patch was cancelled before commit");
|
|
427
|
+
|
|
428
|
+
for (const write of plan.writes) {
|
|
429
|
+
if (write.kind === "update") {
|
|
430
|
+
if (!write.before || !sameVersion(write.before, readStableFile(write.absolute, write.repoPath).version)) {
|
|
431
|
+
fail("stale-precondition", `Update target changed at commit: ${write.repoPath}`);
|
|
432
|
+
}
|
|
433
|
+
fs.renameSync(write.temporary!, write.absolute);
|
|
434
|
+
write.temporary = undefined;
|
|
435
|
+
write.committed = true;
|
|
436
|
+
} else {
|
|
437
|
+
fs.linkSync(write.temporary!, write.absolute);
|
|
438
|
+
// From this point the destination exists. Mark it before cleanup so an
|
|
439
|
+
// unlink failure still enters rollback and cannot leave a partial add.
|
|
440
|
+
write.committed = true;
|
|
441
|
+
fs.unlinkSync(write.temporary!);
|
|
442
|
+
write.temporary = undefined;
|
|
443
|
+
}
|
|
444
|
+
const observed = readStableFile(write.absolute, write.repoPath);
|
|
445
|
+
if (observed.version.digest !== digest(write.bytes)) fail("postcondition", `Patch postcondition failed: ${write.repoPath}`);
|
|
446
|
+
}
|
|
447
|
+
// Cleanup is part of transaction success. Retained in-memory preimages let
|
|
448
|
+
// rollback restore earlier updates even if a later backup unlink fails.
|
|
449
|
+
for (const write of plan.writes) {
|
|
450
|
+
if (!write.backup) continue;
|
|
451
|
+
fs.unlinkSync(write.backup);
|
|
452
|
+
try {
|
|
453
|
+
fs.lstatSync(write.backup);
|
|
454
|
+
fail("cleanup-postcondition", `Patch backup cleanup did not remove ${write.repoPath}`);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
457
|
+
}
|
|
458
|
+
write.backup = undefined;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
added: plan.writes.filter((item) => item.kind === "add").map((item) => item.repoPath),
|
|
462
|
+
updated: plan.writes.filter((item) => item.kind === "update").map((item) => item.repoPath)
|
|
463
|
+
};
|
|
464
|
+
} catch (error) {
|
|
465
|
+
const rollbackFailures = rollback(plan);
|
|
466
|
+
if (rollbackFailures.length) {
|
|
467
|
+
rollbackIncomplete = true;
|
|
468
|
+
throw new ApplyPatchError("rollback-incomplete", `Patch failed and rollback could not restore: ${rollbackFailures.join(", ")}`);
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
} finally {
|
|
472
|
+
clean(plan, created, rollbackIncomplete);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function registerApplyPatchTool(
|
|
477
|
+
pi: ExtensionAPI,
|
|
478
|
+
Type: Record<string, (...args: any[]) => any>
|
|
479
|
+
): void {
|
|
480
|
+
pi.registerTool({
|
|
481
|
+
name: "apply_patch",
|
|
482
|
+
label: "Apply Patch",
|
|
483
|
+
description: "Apply one validated OpenAI-style patch across multiple project files in a single guarded tool call.",
|
|
484
|
+
promptSnippet: "Use apply_patch for bounded multi-file Add File and Update File changes when exact old context is available.",
|
|
485
|
+
promptGuidelines: [
|
|
486
|
+
"Wrap the patch in exact *** Begin Patch and *** End Patch delimiters.",
|
|
487
|
+
"Use each project-relative target once and include enough unchanged context for every update hunk to match exactly once."
|
|
488
|
+
],
|
|
489
|
+
parameters: Type.Object({ patch: Type.String({ minLength: 1, maxLength: MAX_PATCH_BYTES }) }, { additionalProperties: false }),
|
|
490
|
+
executionMode: "sequential",
|
|
491
|
+
async execute(_toolCallId: string, params: { patch: string }, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
492
|
+
const result = applyValidatedPatch(ctx.cwd, params.patch, signal);
|
|
493
|
+
const changedPaths = [...result.updated, ...result.added];
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text", text: `Applied validated patch to ${changedPaths.length} file${changedPaths.length === 1 ? "" : "s"}: ${changedPaths.join(", ")}` }],
|
|
496
|
+
details: { changedPaths, updatedPaths: result.updated, addedPaths: result.added }
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
} as any);
|
|
500
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { hasDurableContextEvidence } from "../../extensions/context-evidence.js";
|
|
3
4
|
import type { TaskContract } from "../../extensions/guard-types.ts";
|
|
4
5
|
import { runtimeAutomaticSourceExecutionReady, sourceExecutionAuthorized, sourcePlanningAuthorized } from "../../extensions/task-lifecycle.js";
|
|
5
6
|
import { latestObservedVerification, verificationEvidenceProvesStableTree } from "../../extensions/verification-intelligence.js";
|
|
@@ -59,7 +60,7 @@ function phaseObservation(
|
|
|
59
60
|
currentTreeDigest?: string | null
|
|
60
61
|
): TrajectoryTransitionEvent["cause"] | undefined {
|
|
61
62
|
const latest = latestVerification(task);
|
|
62
|
-
if (phase === "scout" && (options.contextObserved === true || task
|
|
63
|
+
if (phase === "scout" && (options.contextObserved === true || hasDurableContextEvidence(task) || stepObserved(task, ["scope", "challenge", "scout"]))) return "context-observed";
|
|
63
64
|
if (phase === "plan" && stepObserved(task, ["plan"])) return "plan-observed";
|
|
64
65
|
if (phase === "execute") {
|
|
65
66
|
if (sourceExecutionAuthorized(task)) return "execution-authorized";
|
|
@@ -77,6 +77,13 @@ export function automaticTaskIntakeMode(prompt: string, readProtectedPaths: stri
|
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export function automaticTaskMutationPolicy(
|
|
81
|
+
prompt: string,
|
|
82
|
+
changeMode: "source-change" | "read-only"
|
|
83
|
+
): "required" | "forbidden" {
|
|
84
|
+
return changeMode === "read-only" || AUTO_READ_ONLY_BOUNDARY.test(String(prompt ?? "")) ? "forbidden" : "required";
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
export function automaticTaskRiskLane(prompt: string): "tiny" | "normal" {
|
|
81
88
|
return classifyContextTask(prompt).lane === "tiny" ? "tiny" : "normal";
|
|
82
89
|
}
|
|
@@ -107,16 +114,46 @@ function splitAcceptanceCriterion(value: string): string[] {
|
|
|
107
114
|
return fragments;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
function isPathOnlyCriterion(value: string): boolean {
|
|
118
|
+
const normalized = value
|
|
119
|
+
.replace(/^\s*(?:[-*+] |\d+[.)]\s+)/, "")
|
|
120
|
+
.replace(/^`|`$/g, "")
|
|
121
|
+
.trim();
|
|
122
|
+
return /^(?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+$/.test(normalized);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function evenlySpacedCriteria(values: string[], limit: number): string[] {
|
|
126
|
+
if (limit <= 0) return [];
|
|
127
|
+
if (values.length <= limit) return values;
|
|
128
|
+
if (limit === 1) return [values[values.length - 1]];
|
|
129
|
+
return Array.from({ length: limit }, (_entry, index) => (
|
|
130
|
+
values[Math.round((index * (values.length - 1)) / (limit - 1))]
|
|
131
|
+
));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function boundedAcceptanceCriteria(values: string[], limit: number): string[] {
|
|
135
|
+
if (limit <= 0 || values.length === 0) return [];
|
|
136
|
+
if (values.length <= limit) return values;
|
|
137
|
+
const highSignal = /\b(?:do not|fail(?:s|ed)?(?:[-\s]+)closed|invalid|missing|must|never|reject(?:s|ed|ion)?|throws?|typeerror|without mutat(?:e|ing)|unchanged)\b/i;
|
|
138
|
+
const required = values.filter((criterion) => highSignal.test(criterion));
|
|
139
|
+
const selectedRequired = evenlySpacedCriteria(required, Math.min(limit, required.length));
|
|
140
|
+
const selected = new Set(selectedRequired);
|
|
141
|
+
const remaining = values.filter((criterion) => !selected.has(criterion));
|
|
142
|
+
for (const criterion of evenlySpacedCriteria(remaining, limit - selected.size)) selected.add(criterion);
|
|
143
|
+
return values.filter((criterion) => selected.has(criterion));
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
export function automaticAcceptanceCriteria(
|
|
111
147
|
prompt: string,
|
|
112
148
|
changeMode: "source-change" | "read-only" = "source-change"
|
|
113
149
|
): string[] {
|
|
114
150
|
const lines = String(prompt ?? "").split(/\r?\n/);
|
|
115
151
|
const criteria: string[] = [];
|
|
116
|
-
const obligation = /\b(?:add|change only|do not|ensure|exactly|invalid|must|never|preserve|reject|
|
|
152
|
+
const obligation = /\b(?:add|change only|do not|emits?|ensure|exactly|fail(?:s|ed)?(?:[-\s]+)closed|invalid|missing|must|never|preserve|reject|returns?|throw|without)\b/i;
|
|
117
153
|
let current = "";
|
|
118
154
|
let currentIsBullet = false;
|
|
119
155
|
const push = (value: string) => {
|
|
156
|
+
if (isPathOnlyCriterion(value)) return;
|
|
120
157
|
criteria.push(...splitAcceptanceCriterion(value));
|
|
121
158
|
};
|
|
122
159
|
const flush = () => {
|
|
@@ -142,7 +179,11 @@ export function automaticAcceptanceCriteria(
|
|
|
142
179
|
const generic = changeMode === "read-only"
|
|
143
180
|
? ["No project files are changed.", "The final response addresses the requested diagnostic result."]
|
|
144
181
|
: ["Changes stay within the runtime-derived task scope.", "The configured verification command passes after the final mutation."];
|
|
145
|
-
|
|
182
|
+
const uniqueCriteria = uniqueStrings(criteria);
|
|
183
|
+
const selected = boundedAcceptanceCriteria(uniqueCriteria, AUTO_ACCEPTANCE_CRITERIA_MAX);
|
|
184
|
+
if (selected.length === AUTO_ACCEPTANCE_CRITERIA_MAX) return selected;
|
|
185
|
+
const missingGeneric = generic.filter((criterion) => !selected.includes(criterion));
|
|
186
|
+
return [...selected, ...missingGeneric].slice(0, AUTO_ACCEPTANCE_CRITERIA_MAX);
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
export function manualTaskIntakeEligible(prompt: string, readProtectedPaths: string[]): boolean {
|