@piagent/platform 1.5.5 → 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.
Files changed (227) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +9 -8
  3. package/README.vi.md +9 -5
  4. package/adapters/web-frontend/profile.json +2 -4
  5. package/architecture/layers.json +18 -3
  6. package/benchmarks/deep-logic-v1/SPECIALIST_PROTOCOL.vi.md +122 -0
  7. package/benchmarks/deep-logic-v1/grade.mjs +272 -10
  8. package/benchmarks/deep-logic-v1/project/apps/admin/src/billing-summary.js +3 -0
  9. package/benchmarks/deep-logic-v1/project/benchmark-contract/public-smoke.test.js +166 -0
  10. package/benchmarks/deep-logic-v1/project/package.json +1 -1
  11. package/benchmarks/deep-logic-v1/project/packages/billing/src/close-period.js +3 -0
  12. package/benchmarks/deep-logic-v1/project/packages/billing/src/plan-timeline.js +3 -0
  13. package/benchmarks/deep-logic-v1/project/test/smoke.test.js +7 -1
  14. package/benchmarks/deep-logic-v1/prompts/budgeted-context-graph.md +2 -0
  15. package/benchmarks/deep-logic-v1/prompts/fair-dependency-scheduler.md +2 -0
  16. package/benchmarks/deep-logic-v1/prompts/layered-policy-resolution.md +2 -0
  17. package/benchmarks/deep-logic-v1/prompts/resumable-stream-assembly.md +3 -1
  18. package/benchmarks/deep-logic-v1/prompts/revision-event-reconciliation.md +2 -0
  19. package/benchmarks/deep-logic-v1/prompts/temporal-usage-billing-close.md +123 -0
  20. package/benchmarks/deep-logic-v1/prompts/transactional-config-merge.md +2 -0
  21. package/benchmarks/deep-logic-v1/suite.json +51 -3
  22. package/benchmarks/deep-logic-v1/variant.mjs +22 -1
  23. package/benchmarks/production-v1/spend-control.v1.json +68 -0
  24. package/benchmarks/production-v1/suite.json +42 -3
  25. package/catalog/capabilities.json +6 -6
  26. package/docs/command-reference-vietnamese.md +6 -6
  27. package/docs/context-engine.md +7 -1
  28. package/docs/distribution-standard.md +7 -7
  29. package/docs/en/architecture.md +1 -0
  30. package/docs/languages.json +4 -4
  31. package/docs/model-options.md +8 -5
  32. package/docs/operator-manual-vietnamese.md +13 -11
  33. package/docs/project-adapters.md +2 -2
  34. package/docs/publishing-for-teams.md +5 -0
  35. package/docs/quality-benchmark.md +271 -62
  36. package/docs/release-install-policy.md +10 -9
  37. package/docs/runtime-policy-design.md +4 -4
  38. package/docs/runtime-quality-baseline.md +13 -6
  39. package/docs/security-threat-model.md +2 -2
  40. package/docs/task-lifecycle-vietnamese.md +2 -2
  41. package/docs/team-onboarding.md +2 -2
  42. package/docs/usage-observability.md +7 -2
  43. package/docs/vi/architecture.md +1 -0
  44. package/docs-site/architecture.html +1 -1
  45. package/docs-site/benchmark.html +1 -1
  46. package/docs-site/commands.html +1 -1
  47. package/docs-site/context-engine.html +1 -1
  48. package/docs-site/en/architecture.html +1 -1
  49. package/docs-site/en/benchmark.html +1 -1
  50. package/docs-site/en/commands.html +1 -1
  51. package/docs-site/en/context-engine.html +1 -1
  52. package/docs-site/en/index.html +1 -1
  53. package/docs-site/en/mcp-approval.html +1 -1
  54. package/docs-site/en/mcp-auth.html +1 -1
  55. package/docs-site/en/mcp-servers.html +1 -1
  56. package/docs-site/en/mcp.html +1 -1
  57. package/docs-site/en/permissions.html +1 -1
  58. package/docs-site/en/profiles.html +1 -1
  59. package/docs-site/en/quickstart.html +1 -1
  60. package/docs-site/en/runtime.html +1 -1
  61. package/docs-site/en/scope.html +1 -1
  62. package/docs-site/en/security.html +1 -1
  63. package/docs-site/en/team.html +1 -1
  64. package/docs-site/en/whats-new.html +1 -1
  65. package/docs-site/en/workflows.html +1 -1
  66. package/docs-site/index.html +1 -1
  67. package/docs-site/mcp-approval.html +1 -1
  68. package/docs-site/mcp-auth.html +1 -1
  69. package/docs-site/mcp-servers.html +1 -1
  70. package/docs-site/mcp.html +1 -1
  71. package/docs-site/permissions.html +1 -1
  72. package/docs-site/profiles.html +1 -1
  73. package/docs-site/quickstart.html +1 -1
  74. package/docs-site/runtime.html +1 -1
  75. package/docs-site/scope.html +1 -1
  76. package/docs-site/security.html +1 -1
  77. package/docs-site/team.html +1 -1
  78. package/docs-site/whats-new.html +1 -1
  79. package/docs-site/workflows.html +1 -1
  80. package/evals/fixtures/benchmark-assurance-evidence.invalid.json +1 -1
  81. package/evals/fixtures/benchmark-assurance-evidence.valid.json +1 -1
  82. package/evals/fixtures/piagent-webui/session-live-state-v1.invalid.json +18 -0
  83. package/evals/fixtures/piagent-webui/session-live-state-v1.valid.json +27 -0
  84. package/evals/fs4-readiness-matrix.v1.json +2 -2
  85. package/evals/fs5-causal-arm.v1.json +2 -2
  86. package/evals/fs5-pilot-protocol.v1.json +2 -2
  87. package/evals/fs5-pilot-protocol.v4.json +6 -5
  88. package/evals/fs5-pilot-protocol.v5.json +7 -6
  89. package/evals/private-holdout-v1/public-exposure.v1.json +1 -1
  90. package/package.json +3 -1
  91. package/packages/piagent-core/benchmark/benchmark-assurance.js +4 -1
  92. package/packages/piagent-core/benchmark/benchmark-bootstrap.js +91 -9
  93. package/packages/piagent-core/benchmark/benchmark-claim-restrictions.js +38 -7
  94. package/packages/piagent-core/benchmark/benchmark-cli.js +7 -7
  95. package/packages/piagent-core/benchmark/benchmark-codex.js +3 -1
  96. package/packages/piagent-core/benchmark/benchmark-comparison.js +271 -2
  97. package/packages/piagent-core/benchmark/benchmark-core.js +732 -661
  98. package/packages/piagent-core/benchmark/benchmark-execution-guard.js +7 -5
  99. package/packages/piagent-core/benchmark/benchmark-forensics.js +439 -4
  100. package/packages/piagent-core/benchmark/benchmark-host-readiness.js +639 -0
  101. package/packages/piagent-core/benchmark/benchmark-matrix.js +22 -4
  102. package/packages/piagent-core/benchmark/benchmark-normalized-cost.js +317 -0
  103. package/packages/piagent-core/benchmark/benchmark-preflight.js +3 -1
  104. package/packages/piagent-core/benchmark/benchmark-process.js +6 -5
  105. package/packages/piagent-core/benchmark/benchmark-provider-wire.js +126 -0
  106. package/packages/piagent-core/benchmark/benchmark-record-validation.js +183 -0
  107. package/packages/piagent-core/benchmark/benchmark-report.js +116 -12
  108. package/packages/piagent-core/benchmark/benchmark-resume-recovery.js +14 -0
  109. package/packages/piagent-core/benchmark/benchmark-runner-policy.js +26 -0
  110. package/packages/piagent-core/benchmark/benchmark-stage-diagnostic.js +782 -0
  111. package/packages/piagent-core/benchmark/benchmark-statistics.js +64 -0
  112. package/packages/piagent-core/benchmark/benchmark-stop-policy.js +8 -5
  113. package/packages/piagent-core/benchmark/benchmark-suite-identity.js +31 -0
  114. package/packages/piagent-core/benchmark/benchmark-suite-runtime.js +13 -9
  115. package/packages/piagent-core/benchmark/benchmark-suite.js +128 -2
  116. package/packages/piagent-core/benchmark/benchmark-summary-support.js +376 -0
  117. package/packages/piagent-core/benchmark/benchmark-timing-diagnostics.js +971 -0
  118. package/packages/piagent-core/benchmark/benchmark-usage.js +311 -0
  119. package/packages/piagent-core/benchmark/benchmark-workflow.js +166 -0
  120. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +1080 -0
  121. package/packages/piagent-core/extensions/acceptance-contract-semantics.js +83 -69
  122. package/packages/piagent-core/extensions/acceptance-error-classes.js +160 -0
  123. package/packages/piagent-core/extensions/acceptance-executable-evidence.js +12 -9
  124. package/packages/piagent-core/extensions/acceptance-receipt.js +34 -11
  125. package/packages/piagent-core/extensions/acceptance-state-machine-evidence.js +518 -0
  126. package/packages/piagent-core/extensions/acceptance-test-binding-evidence.js +60 -0
  127. package/packages/piagent-core/extensions/context-efficiency-metrics.js +302 -0
  128. package/packages/piagent-core/extensions/context-efficiency-report.js +167 -0
  129. package/packages/piagent-core/extensions/context-engine.js +148 -145
  130. package/packages/piagent-core/extensions/context-evidence.js +48 -0
  131. package/packages/piagent-core/extensions/context-import-links.js +296 -0
  132. package/packages/piagent-core/extensions/context-telemetry-integrity.js +65 -0
  133. package/packages/piagent-core/extensions/criterion-context-pack.js +422 -7
  134. package/packages/piagent-core/extensions/criterion-graph.js +78 -8
  135. package/packages/piagent-core/extensions/guard-types.ts +2 -0
  136. package/packages/piagent-core/extensions/javascript-regex-evidence.js +32 -0
  137. package/packages/piagent-core/extensions/piagent-guard.ts +233 -41
  138. package/packages/piagent-core/extensions/task-contract-view.js +17 -3
  139. package/packages/piagent-core/extensions/task-lifecycle.js +7 -2
  140. package/packages/piagent-core/extensions/task-state.js +40 -134
  141. package/packages/piagent-core/extensions/verification-intelligence.js +3 -1
  142. package/packages/piagent-core/extensions/workspace-evidence-roots.js +157 -0
  143. package/packages/piagent-core/package.json +1 -1
  144. package/packages/piagent-core/policies/base-policy.json +2 -0
  145. package/packages/piagent-core/runtime/context/context-delivery.ts +103 -0
  146. package/packages/piagent-core/runtime/context/context-delta-shadow.ts +53 -0
  147. package/packages/piagent-core/runtime/context/context-efficiency-format.ts +47 -0
  148. package/packages/piagent-core/runtime/context/context-evidence-qualification.ts +32 -0
  149. package/packages/piagent-core/runtime/context/prefix-telemetry.ts +47 -0
  150. package/packages/piagent-core/runtime/context/repository-memory-hints.ts +27 -0
  151. package/packages/piagent-core/runtime/hooks/agent-prompt-telemetry.ts +56 -0
  152. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +162 -102
  153. package/packages/piagent-core/runtime/hooks/completion-hook.ts +5 -6
  154. package/packages/piagent-core/runtime/hooks/input-hook.ts +11 -1
  155. package/packages/piagent-core/runtime/hooks/session-hooks.ts +22 -2
  156. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +7 -2
  157. package/packages/piagent-core/runtime/hooks/tool-result-content-guards.ts +95 -0
  158. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +84 -102
  159. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +4 -2
  160. package/packages/piagent-core/runtime/inspection/activity-recovery.ts +104 -0
  161. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +1 -1
  162. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +1 -1
  163. package/packages/piagent-core/runtime/inspection/tool-failure-classification.ts +53 -0
  164. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +34 -6
  165. package/packages/piagent-core/runtime/model/authenticated-catalog.ts +17 -9
  166. package/packages/piagent-core/runtime/model/openai-codex-reasoning.ts +83 -0
  167. package/packages/piagent-core/runtime/model/provider-wire-fingerprint.ts +238 -0
  168. package/packages/piagent-core/runtime/orchestration/role-policy.ts +1 -1
  169. package/packages/piagent-core/runtime/product/activity-inspector.ts +7 -2
  170. package/packages/piagent-core/runtime/product/efficiency-metrics.ts +4 -2
  171. package/packages/piagent-core/runtime/quality/exact-output-contract.ts +2 -1
  172. package/packages/piagent-core/runtime/quality/performance-assurance-signals.ts +23 -0
  173. package/packages/piagent-core/runtime/quality/performance-assurance.ts +6 -2
  174. package/packages/piagent-core/runtime/recovery/edit-recovery-context.ts +113 -0
  175. package/packages/piagent-core/runtime/recovery/edit-recovery-delivery.ts +42 -0
  176. package/packages/piagent-core/runtime/recovery/semantic-repair-handshake.ts +55 -2
  177. package/packages/piagent-core/runtime/recovery/semantic-repair-runtime.ts +55 -2
  178. package/packages/piagent-core/runtime/registration/context-commands.ts +66 -5
  179. package/packages/piagent-core/runtime/registration/policy-tools.ts +46 -11
  180. package/packages/piagent-core/runtime/registration/task-completion-tools.ts +49 -16
  181. package/packages/piagent-core/runtime/registration/task-start-guidance.ts +163 -0
  182. package/packages/piagent-core/runtime/registration/task-start-retry-helpers.ts +37 -0
  183. package/packages/piagent-core/runtime/registration/task-start-tool.ts +67 -62
  184. package/packages/piagent-core/runtime/session/runtime-state.ts +154 -2
  185. package/packages/piagent-core/runtime/session/system-prompt.ts +4 -4
  186. package/packages/piagent-core/runtime/tools/apply-patch-tool.ts +500 -0
  187. package/packages/piagent-core/runtime/trajectory/trajectory-runtime.ts +2 -1
  188. package/packages/piagent-core/runtime/workflows/task-intake.ts +43 -2
  189. package/packages/piagent-core/security/sensitive-data.js +13 -218
  190. package/packages/piagent-core/security/sensitive-project-file.js +42 -0
  191. package/packages/piagent-core/security/sensitive-source-expression.js +330 -0
  192. package/packages/piagent-core/security/sensitive-source-formats.js +394 -0
  193. package/packages/piagent-core/security/sensitive-text.js +234 -0
  194. package/packages/piagent-core/subagents/piagent-worker.md +1 -1
  195. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-rD9WJKxs.js → MarkdownMessage-CxeZqTte.js} +1 -1
  196. package/packages/piagent-webui/dist/client/assets/index-DynIMe8I.js +176 -0
  197. package/packages/piagent-webui/dist/client/index.html +1 -1
  198. package/packages/piagent-webui/gateway/gateway-events.ts +109 -5
  199. package/packages/piagent-webui/gateway/gateway-service.ts +3 -0
  200. package/packages/piagent-webui/gateway/gateway-session-stream.ts +93 -7
  201. package/packages/piagent-webui/gateway/session-command-controller.ts +18 -3
  202. package/packages/piagent-webui/gateway/session-effective-options.ts +47 -0
  203. package/packages/piagent-webui/gateway/session-live-state.ts +21 -0
  204. package/packages/piagent-webui/gateway/session-operation-start.ts +5 -1
  205. package/packages/piagent-webui/gateway/session-operation-watchdog.ts +158 -0
  206. package/packages/piagent-webui/gateway/session-runtime-factory.ts +45 -0
  207. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +139 -124
  208. package/packages/piagent-webui/server/loopback-server.ts +8 -0
  209. package/packages/piagent-webui/server/transcript-projection.ts +22 -4
  210. package/packages/piagent-webui/shared/text-visibility.ts +10 -0
  211. package/schemas/piagent-webui/catalog-v1.json +9 -0
  212. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +53 -3
  213. package/schemas/piagent-webui/session-live-state-v1.schema.json +76 -0
  214. package/schemas/task-contract.schema.json +12 -1
  215. package/scripts/benchmark-runner-core.mjs +355 -359
  216. package/scripts/benchmark-runner-finalization.mjs +309 -0
  217. package/scripts/benchmark-runner-host-readiness.mjs +94 -0
  218. package/scripts/benchmark-runner-support.mjs +200 -0
  219. package/scripts/benchmark-session.mjs +95 -41
  220. package/scripts/configure-model-scope.sh +4 -3
  221. package/scripts/helper-evaluation.mjs +1 -1
  222. package/scripts/install-global.sh +2 -2
  223. package/scripts/pi-usage-history.mjs +37 -10
  224. package/scripts/setup.sh +2 -2
  225. package/scripts/verify-local.sh +5 -1
  226. package/templates/global/settings.json +2 -1
  227. 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.contextManifest.length > 0 || stepObserved(task, ["scope", "challenge", "scout"]))) return "context-observed";
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|return|throw|without)\b/i;
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
- return uniqueStrings([...criteria, ...generic]).slice(0, AUTO_ACCEPTANCE_CRITERIA_MAX);
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 {