pi-ui-extend 1.0.42 → 1.0.46

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 (233) hide show
  1. package/README.md +41 -12
  2. package/dist/app/app.d.ts +10 -0
  3. package/dist/app/app.js +132 -8
  4. package/dist/app/cli/install.js +2 -2
  5. package/dist/app/commands/command-controller.d.ts +4 -0
  6. package/dist/app/commands/command-controller.js +11 -0
  7. package/dist/app/commands/command-git-actions.d.ts +25 -0
  8. package/dist/app/commands/command-git-actions.js +381 -0
  9. package/dist/app/commands/command-host.d.ts +3 -0
  10. package/dist/app/commands/command-host.js +5 -2
  11. package/dist/app/commands/command-model-actions.d.ts +1 -0
  12. package/dist/app/commands/command-model-actions.js +51 -34
  13. package/dist/app/commands/command-navigation-actions.d.ts +1 -0
  14. package/dist/app/commands/command-navigation-actions.js +16 -3
  15. package/dist/app/commands/command-registry.d.ts +2 -0
  16. package/dist/app/commands/command-registry.js +14 -0
  17. package/dist/app/commands/reload-context-inventory.js +12 -1
  18. package/dist/app/input/autocomplete-controller.js +61 -34
  19. package/dist/app/input/input-action-controller.d.ts +2 -0
  20. package/dist/app/input/input-action-controller.js +8 -1
  21. package/dist/app/input/input-controller.d.ts +2 -1
  22. package/dist/app/input/input-controller.js +7 -2
  23. package/dist/app/input/prompt-enhancer-controller.js +53 -35
  24. package/dist/app/input/voice-controller.d.ts +41 -45
  25. package/dist/app/input/voice-controller.js +351 -419
  26. package/dist/app/model/model-usage-controller.js +17 -7
  27. package/dist/app/model/model-usage-status.d.ts +4 -1
  28. package/dist/app/model/model-usage-status.js +270 -46
  29. package/dist/app/popup/menu-items-controller.d.ts +13 -3
  30. package/dist/app/popup/menu-items-controller.js +37 -21
  31. package/dist/app/popup/popup-action-controller.d.ts +12 -2
  32. package/dist/app/popup/popup-action-controller.js +77 -24
  33. package/dist/app/popup/popup-menu-controller.d.ts +36 -14
  34. package/dist/app/popup/popup-menu-controller.js +239 -69
  35. package/dist/app/rendering/dcp-stats.d.ts +9 -4
  36. package/dist/app/rendering/dcp-stats.js +40 -425
  37. package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
  38. package/dist/app/rendering/popup-menu-renderer.js +42 -37
  39. package/dist/app/rendering/render-controller.js +23 -2
  40. package/dist/app/rendering/status-line-renderer.d.ts +5 -0
  41. package/dist/app/rendering/status-line-renderer.js +41 -5
  42. package/dist/app/rendering/tab-line-renderer.js +26 -20
  43. package/dist/app/runtime.d.ts +12 -1
  44. package/dist/app/runtime.js +120 -12
  45. package/dist/app/screen/mouse-controller.d.ts +2 -0
  46. package/dist/app/screen/mouse-controller.js +17 -7
  47. package/dist/app/screen/status-controller.d.ts +4 -0
  48. package/dist/app/screen/status-controller.js +5 -0
  49. package/dist/app/session/lazy-session-manager.js +34 -0
  50. package/dist/app/session/session-event-controller.d.ts +1 -0
  51. package/dist/app/session/session-event-controller.js +10 -1
  52. package/dist/app/session/session-history.d.ts +1 -0
  53. package/dist/app/session/session-history.js +12 -1
  54. package/dist/app/session/session-lifecycle-controller.d.ts +6 -1
  55. package/dist/app/session/session-lifecycle-controller.js +10 -1
  56. package/dist/app/session/tabs-controller.d.ts +25 -1
  57. package/dist/app/session/tabs-controller.js +442 -153
  58. package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
  59. package/dist/app/subagents/subagents-widget-controller.js +6 -0
  60. package/dist/app/types.d.ts +11 -1
  61. package/dist/app/workspace/workspace-actions-controller.js +10 -2
  62. package/dist/app/workspace/workspace-undo.d.ts +1 -0
  63. package/dist/app/workspace/workspace-undo.js +1 -0
  64. package/dist/bundled-extensions/question/index.js +9 -1
  65. package/dist/bundled-extensions/question/remote.d.ts +4 -0
  66. package/dist/bundled-extensions/question/remote.js +33 -0
  67. package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
  68. package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
  69. package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
  70. package/dist/bundled-extensions/telegram-connector/config.js +87 -0
  71. package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
  72. package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
  73. package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
  74. package/dist/bundled-extensions/telegram-connector/index.js +195 -0
  75. package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
  76. package/dist/bundled-extensions/terminal-bell/index.js +0 -76
  77. package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
  78. package/dist/bundled-extensions/workspace-undo/index.js +191 -0
  79. package/dist/config.d.ts +21 -2
  80. package/dist/config.js +159 -32
  81. package/dist/default-pix-config.js +20 -10
  82. package/dist/schemas/pi-tools-suite-schema.d.ts +8 -4
  83. package/dist/schemas/pi-tools-suite-schema.js +11 -5
  84. package/dist/schemas/pix-schema.d.ts +12 -2
  85. package/dist/schemas/pix-schema.js +37 -9
  86. package/dist/schemas/tasks-schema.js +1 -1
  87. package/docs/concurrency.md +3 -1
  88. package/docs/desktop-mvp.md +10 -5
  89. package/docs/desktop-task-manager.md +81 -28
  90. package/docs/release.md +30 -6
  91. package/external/pi-tools-suite/README.md +165 -39
  92. package/external/pi-tools-suite/docs/session-recovery.md +7 -0
  93. package/external/pi-tools-suite/docs/subagent-model-pools.md +12 -5
  94. package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
  95. package/external/pi-tools-suite/package.json +6 -1
  96. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
  97. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
  98. package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
  99. package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
  100. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
  101. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
  102. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
  103. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
  104. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
  105. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
  106. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
  107. package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
  108. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
  109. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
  110. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
  111. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
  112. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
  113. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
  114. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
  115. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
  116. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
  117. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
  118. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
  119. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
  120. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
  121. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
  122. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
  123. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
  124. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
  125. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
  126. package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
  127. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  128. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  129. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +0 -2
  130. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
  131. package/external/pi-tools-suite/src/async-subagents/core/config.ts +40 -17
  132. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
  133. package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
  134. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +7 -4
  135. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +34 -24
  136. package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
  137. package/external/pi-tools-suite/src/async-subagents/lib.ts +9 -1
  138. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +0 -1
  139. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
  140. package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
  141. package/external/pi-tools-suite/src/config.ts +14 -0
  142. package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
  143. package/external/pi-tools-suite/src/context-gateway/config.ts +66 -1
  144. package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
  145. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +123 -3
  146. package/external/pi-tools-suite/src/context-gateway/index.ts +137 -4
  147. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +5 -5
  148. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +2 -2
  149. package/external/pi-tools-suite/src/context-gateway/types.ts +8 -1
  150. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
  151. package/external/pi-tools-suite/src/dcp/auto-compress.ts +41 -14
  152. package/external/pi-tools-suite/src/dcp/commands.ts +14 -156
  153. package/external/pi-tools-suite/src/dcp/compress-tool.ts +8 -2
  154. package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
  155. package/external/pi-tools-suite/src/dcp/config.ts +58 -4
  156. package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
  157. package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
  158. package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
  159. package/external/pi-tools-suite/src/dcp/index.ts +338 -66
  160. package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
  161. package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
  162. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +92 -2
  163. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
  164. package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
  165. package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
  166. package/external/pi-tools-suite/src/dcp/state.ts +9 -0
  167. package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
  168. package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
  169. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +68 -16
  170. package/external/pi-tools-suite/src/lib/project.ts +36 -1
  171. package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
  172. package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
  173. package/external/pi-tools-suite/src/resource-registry/index.ts +282 -21
  174. package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
  175. package/external/pi-tools-suite/src/todo/index.ts +21 -0
  176. package/external/pi-tools-suite/src/todo/todo.ts +19 -1
  177. package/external/pi-tools-suite/src/tool-descriptions.ts +44 -15
  178. package/package.json +9 -10
  179. package/schemas/pi-tools-suite.json +44 -12
  180. package/schemas/pix.json +109 -15
  181. package/schemas/tasks.json +1 -2
  182. package/skills/simplify/SKILL.md +33 -5
  183. package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -196
  184. package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
  185. package/skills/playwright-cli/SKILL.md +0 -420
  186. package/skills/playwright-cli/references/element-attributes.md +0 -23
  187. package/skills/playwright-cli/references/playwright-tests.md +0 -50
  188. package/skills/playwright-cli/references/request-mocking.md +0 -87
  189. package/skills/playwright-cli/references/running-code.md +0 -241
  190. package/skills/playwright-cli/references/session-management.md +0 -273
  191. package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
  192. package/skills/playwright-cli/references/storage-state.md +0 -290
  193. package/skills/playwright-cli/references/test-generation.md +0 -142
  194. package/skills/playwright-cli/references/tracing.md +0 -154
  195. package/skills/playwright-cli/references/video-recording.md +0 -147
  196. package/skills/spec-wiki/SKILL.md +0 -55
  197. package/skills/spec-wiki/evals/evals.json +0 -132
  198. package/skills/spec-wiki/evals/files/bootstrap/architecture/rfc-007.md +0 -14
  199. package/skills/spec-wiki/evals/files/bootstrap/docs/how-to-pay.md +0 -4
  200. package/skills/spec-wiki/evals/files/bootstrap/docs/overview.md +0 -9
  201. package/skills/spec-wiki/evals/files/bootstrap/requirements/payment-behavior.md +0 -17
  202. package/skills/spec-wiki/evals/files/bootstrap/src/payments.ts +0 -12
  203. package/skills/spec-wiki/evals/files/bootstrap/tests/payments.test.ts +0 -11
  204. package/skills/spec-wiki/evals/files/focused/src/session.ts +0 -5
  205. package/skills/spec-wiki/evals/files/focused/tests/session.test.ts +0 -17
  206. package/skills/spec-wiki/evals/files/maintenance/low-signal/incoming/capture.txt +0 -2
  207. package/skills/spec-wiki/evals/files/maintenance/low-signal/src/capture.ts +0 -3
  208. package/skills/spec-wiki/evals/files/maintenance/move/docs/old-job-contract.md +0 -18
  209. package/skills/spec-wiki/evals/files/maintenance/move/src/job.ts +0 -5
  210. package/skills/spec-wiki/evals/files/maintenance/new-relation/docs/session-refresh.md +0 -24
  211. package/skills/spec-wiki/evals/files/maintenance/new-relation/incoming/refresh-worker.ts +0 -7
  212. package/skills/spec-wiki/evals/files/maintenance/new-relation/src/session.ts +0 -7
  213. package/skills/spec-wiki/evals/files/maintenance/no-impact/docs/auth-contract.md +0 -17
  214. package/skills/spec-wiki/evals/files/maintenance/no-impact/incoming/telemetry-format.ts +0 -3
  215. package/skills/spec-wiki/evals/files/maintenance/no-impact/src/auth.ts +0 -3
  216. package/skills/spec-wiki/evals/files/query/docs/how-to-run-jobs.md +0 -4
  217. package/skills/spec-wiki/evals/files/query/docs/spec-overview.md +0 -9
  218. package/skills/spec-wiki/evals/files/query/features/jobs/cancellation-contract.md +0 -15
  219. package/skills/spec-wiki/evals/files/query/features/jobs/retry-spec.md +0 -6
  220. package/skills/spec-wiki/evals/maintenance_regression.py +0 -311
  221. package/skills/spec-wiki/evals/retrieval-eval.md +0 -37
  222. package/skills/spec-wiki/evals/retrieval-evals.json +0 -37
  223. package/skills/spec-wiki/evals/trigger-evals.json +0 -94
  224. package/skills/spec-wiki/references/bootstrap.md +0 -100
  225. package/skills/spec-wiki/references/focused-spec.md +0 -117
  226. package/skills/spec-wiki/references/index-schema.md +0 -330
  227. package/skills/spec-wiki/references/maintenance.md +0 -134
  228. package/skills/spec-wiki/references/query.md +0 -74
  229. package/skills/spec-wiki/scripts/spec_wiki.py +0 -2076
  230. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
  231. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
  232. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
  233. /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, promises as fs } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
7
  import { applyEdits, modify } from "jsonc-parser";
7
8
 
@@ -19,8 +20,10 @@ const REGISTRY_AGENTS_DIR = "agents";
19
20
  const REGISTRY_PROJECTS_DIR = "projects";
20
21
  const SKILL_FILE = "SKILL.md";
21
22
  const PROJECT_TASKS_FILE = "tasks.jsonc";
23
+ const PROJECT_TASK_ATTACHMENTS_DIR = "task-attachments";
22
24
  const PROJECT_PLANS_DIR = "plans";
23
25
  const PROJECT_TODO_FILE = "TODO.md";
26
+ const REGISTRY_TASK_ATTACHMENT_SCHEME = "pix-task-attachment:";
24
27
  const PROVENANCE_FILE = "registry.json";
25
28
  const PROVENANCE_VERSION = 1;
26
29
  const SYSTEM_CUSTOM_MESSAGE_TYPE = "pix-system";
@@ -240,6 +243,10 @@ function projectArtifactLocalPath(cwd: string, artifact: ProjectArtifact): strin
240
243
  }
241
244
  }
242
245
 
246
+ function projectTaskAttachmentsLocalPath(cwd: string): string {
247
+ return join(cwd, PROJECT_DIR, PROJECT_TASK_ATTACHMENTS_DIR);
248
+ }
249
+
243
250
  function projectArtifactRelativePath(projectKey: string, artifact: ProjectArtifact): string {
244
251
  validateName(projectKey);
245
252
  switch (artifact) {
@@ -249,6 +256,18 @@ function projectArtifactRelativePath(projectKey: string, artifact: ProjectArtifa
249
256
  }
250
257
  }
251
258
 
259
+ function projectTaskAttachmentsRelativePath(projectKey: string): string {
260
+ validateName(projectKey);
261
+ return `${REGISTRY_PROJECTS_DIR}/${projectKey}/${PROJECT_TASK_ATTACHMENTS_DIR}`;
262
+ }
263
+
264
+ function projectArtifactRelativePaths(projectKey: string, artifact: ProjectArtifact): string[] {
265
+ const primary = projectArtifactRelativePath(projectKey, artifact);
266
+ return artifact === "tasks"
267
+ ? [primary, projectTaskAttachmentsRelativePath(projectKey)]
268
+ : [primary];
269
+ }
270
+
252
271
  function projectArtifactDisplayName(artifact: ProjectArtifact): string {
253
272
  switch (artifact) {
254
273
  case "tasks": return PROJECT_TASKS_FILE;
@@ -261,6 +280,10 @@ function projectArtifactRegistryPath(cacheDir: string, projectKey: string, artif
261
280
  return join(cacheDir, ...projectArtifactRelativePath(projectKey, artifact).split("/"));
262
281
  }
263
282
 
283
+ function projectTaskAttachmentsRegistryPath(cacheDir: string, projectKey: string): string {
284
+ return join(cacheDir, ...projectTaskAttachmentsRelativePath(projectKey).split("/"));
285
+ }
286
+
264
287
  function canonicalGitRemote(remote: string): string | undefined {
265
288
  const trimmed = remote.trim();
266
289
  if (!trimmed) return undefined;
@@ -510,6 +533,22 @@ async function pathRevision(pi: ExtensionAPI, runtime: RegistryRuntime, rel: str
510
533
  return revision || undefined;
511
534
  }
512
535
 
536
+ async function projectArtifactRevision(
537
+ pi: ExtensionAPI,
538
+ runtime: RegistryRuntime,
539
+ projectKey: string,
540
+ artifact: ProjectArtifact,
541
+ ): Promise<string | undefined> {
542
+ const result = await runGit(
543
+ pi,
544
+ runtime.cacheDir,
545
+ ["log", "-1", "--format=%H", "--", ...projectArtifactRelativePaths(projectKey, artifact)],
546
+ { allowFailure: true },
547
+ );
548
+ const revision = result.stdout.trim();
549
+ return revision || undefined;
550
+ }
551
+
513
552
  async function resourceRevision(pi: ExtensionAPI, runtime: RegistryRuntime, type: ResourceType, name: string): Promise<string | undefined> {
514
553
  return pathRevision(pi, runtime, registryResourceRelativePath(type, name));
515
554
  }
@@ -666,6 +705,226 @@ async function hashPath(path: string): Promise<string> {
666
705
  return hash.digest("hex");
667
706
  }
668
707
 
708
+ type TaskBundle = {
709
+ source: string;
710
+ attachments: Map<string, string>;
711
+ };
712
+
713
+ function pathIsWithin(parent: string, child: string): boolean {
714
+ const rel = relative(parent, child);
715
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
716
+ }
717
+
718
+ function registryTaskAttachmentMarker(name: string): string {
719
+ return `[Pix attachment: ${REGISTRY_TASK_ATTACHMENT_SCHEME}${encodeURIComponent(name)}]`;
720
+ }
721
+
722
+ function registryTaskAttachmentName(encoded: string): string {
723
+ let name: string;
724
+ try {
725
+ name = decodeURIComponent(encoded);
726
+ } catch {
727
+ throw new Error(`Invalid registry task attachment name: ${encoded}`);
728
+ }
729
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") || basename(name) !== name) {
730
+ throw new Error(`Invalid registry task attachment name: ${encoded}`);
731
+ }
732
+ return name;
733
+ }
734
+
735
+ async function readLocalTaskBundle(cwd: string): Promise<TaskBundle> {
736
+ const tasksPath = projectArtifactLocalPath(cwd, "tasks");
737
+ const source = await fs.readFile(tasksPath, "utf8");
738
+ const attachmentsRoot = projectTaskAttachmentsLocalPath(cwd);
739
+ const attachments = new Map<string, string>();
740
+ const replacements = new Map<string, string>();
741
+ const rootExists = await pathExists(attachmentsRoot);
742
+ let canonicalRoot: string | undefined;
743
+ if (rootExists) {
744
+ const rootStat = await fs.lstat(attachmentsRoot);
745
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
746
+ throw new Error(`Project task attachment storage must be a regular directory: ${attachmentsRoot}`);
747
+ }
748
+ const [canonicalCwd, resolvedRoot] = await Promise.all([
749
+ fs.realpath(cwd),
750
+ fs.realpath(attachmentsRoot),
751
+ ]);
752
+ if (!pathIsWithin(canonicalCwd, resolvedRoot)) {
753
+ throw new Error(`Project task attachment storage resolves outside the project: ${attachmentsRoot}`);
754
+ }
755
+ canonicalRoot = resolvedRoot;
756
+ }
757
+
758
+ for (const match of source.matchAll(/\[Pix attachment: (file:\/\/[^\]]+)\]/g)) {
759
+ const marker = match[0];
760
+ const uri = match[1];
761
+ if (!uri || replacements.has(marker)) continue;
762
+ let candidate: string;
763
+ try {
764
+ candidate = fileURLToPath(uri);
765
+ } catch {
766
+ continue;
767
+ }
768
+ if (!canonicalRoot) continue;
769
+ const lexicalProjectAttachment = pathIsWithin(attachmentsRoot, candidate);
770
+
771
+ let canonicalFile: string;
772
+ try {
773
+ canonicalFile = await fs.realpath(candidate);
774
+ } catch (error) {
775
+ if (lexicalProjectAttachment) {
776
+ throw new Error(`Task attachment is missing: ${candidate}. ${error instanceof Error ? error.message : String(error)}`);
777
+ }
778
+ continue;
779
+ }
780
+ if (!pathIsWithin(canonicalRoot, canonicalFile)) {
781
+ if (lexicalProjectAttachment) throw new Error(`Task attachment resolves outside project task storage: ${candidate}`);
782
+ continue;
783
+ }
784
+ const stat = await fs.lstat(candidate);
785
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Task attachment is not a regular file: ${candidate}`);
786
+ const name = basename(canonicalFile);
787
+ const previous = attachments.get(name);
788
+ if (previous && previous !== canonicalFile) {
789
+ throw new Error(`Task attachments contain duplicate file name "${name}".`);
790
+ }
791
+ attachments.set(name, canonicalFile);
792
+ replacements.set(marker, registryTaskAttachmentMarker(name));
793
+ }
794
+
795
+ let normalizedSource = source;
796
+ for (const [marker, portableMarker] of replacements) {
797
+ normalizedSource = normalizedSource.split(marker).join(portableMarker);
798
+ }
799
+ return { source: normalizedSource, attachments };
800
+ }
801
+
802
+ async function readRemoteTaskBundle(runtime: RegistryRuntime, projectKey: string): Promise<TaskBundle> {
803
+ const tasksPath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, "tasks");
804
+ const source = await fs.readFile(tasksPath, "utf8");
805
+ const attachmentsRoot = projectTaskAttachmentsRegistryPath(runtime.cacheDir, projectKey);
806
+ const attachments = new Map<string, string>();
807
+ for (const match of source.matchAll(/\[Pix attachment: pix-task-attachment:([^\]]+)\]/g)) {
808
+ const encoded = match[1];
809
+ if (!encoded) continue;
810
+ const name = registryTaskAttachmentName(encoded);
811
+ if (attachments.has(name)) continue;
812
+ const filePath = join(attachmentsRoot, name);
813
+ let stat: import("node:fs").Stats;
814
+ try {
815
+ stat = await fs.lstat(filePath);
816
+ } catch (error) {
817
+ throw new Error(`Registry task attachment is missing: ${name}. ${error instanceof Error ? error.message : String(error)}`);
818
+ }
819
+ if (stat.isSymbolicLink() || !stat.isFile()) {
820
+ throw new Error(`Registry task attachment is not a regular file: ${name}`);
821
+ }
822
+ attachments.set(name, filePath);
823
+ }
824
+ return { source, attachments };
825
+ }
826
+
827
+ async function hashTaskBundle(bundle: TaskBundle): Promise<string> {
828
+ const hash = createHash("sha256");
829
+ hash.update("tasks-bundle\0");
830
+ hash.update(bundle.source);
831
+ for (const name of [...bundle.attachments.keys()].sort()) {
832
+ hash.update(`\0attachment\0${name}\0`);
833
+ hash.update(await fs.readFile(bundle.attachments.get(name)!));
834
+ }
835
+ return hash.digest("hex");
836
+ }
837
+
838
+ async function hashProjectArtifactLocal(cwd: string, artifact: ProjectArtifact): Promise<string> {
839
+ return artifact === "tasks"
840
+ ? hashTaskBundle(await readLocalTaskBundle(cwd))
841
+ : hashPath(projectArtifactLocalPath(cwd, artifact));
842
+ }
843
+
844
+ async function hashProjectArtifactRemote(
845
+ runtime: RegistryRuntime,
846
+ projectKey: string,
847
+ artifact: ProjectArtifact,
848
+ ): Promise<string> {
849
+ return artifact === "tasks"
850
+ ? hashTaskBundle(await readRemoteTaskBundle(runtime, projectKey))
851
+ : hashPath(projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact));
852
+ }
853
+
854
+ async function replaceRemoteTaskBundle(cwd: string, runtime: RegistryRuntime, projectKey: string): Promise<void> {
855
+ const bundle = await readLocalTaskBundle(cwd);
856
+ const tasksPath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, "tasks");
857
+ const attachmentsPath = projectTaskAttachmentsRegistryPath(runtime.cacheDir, projectKey);
858
+ await fs.rm(tasksPath, { recursive: true, force: true });
859
+ await fs.rm(attachmentsPath, { recursive: true, force: true });
860
+ await fs.mkdir(dirname(tasksPath), { recursive: true });
861
+ await fs.writeFile(tasksPath, bundle.source, "utf8");
862
+ if (bundle.attachments.size === 0) return;
863
+ await fs.mkdir(attachmentsPath, { recursive: true });
864
+ for (const name of [...bundle.attachments.keys()].sort()) {
865
+ await fs.copyFile(bundle.attachments.get(name)!, join(attachmentsPath, name));
866
+ }
867
+ }
868
+
869
+ async function replaceLocalTaskBundle(cwd: string, runtime: RegistryRuntime, projectKey: string): Promise<void> {
870
+ const bundle = await readRemoteTaskBundle(runtime, projectKey);
871
+ const tasksPath = projectArtifactLocalPath(cwd, "tasks");
872
+ const attachmentsPath = projectTaskAttachmentsLocalPath(cwd);
873
+ await fs.mkdir(dirname(tasksPath), { recursive: true });
874
+
875
+ let materializedSource = bundle.source;
876
+ for (const name of bundle.attachments.keys()) {
877
+ materializedSource = materializedSource
878
+ .split(registryTaskAttachmentMarker(name))
879
+ .join(`[Pix attachment: ${pathToFileURL(join(attachmentsPath, name)).href}]`);
880
+ }
881
+
882
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
883
+ const stagedTasks = join(dirname(tasksPath), `.tasks.registry-${stamp}.tmp`);
884
+ const stagedAttachments = join(dirname(tasksPath), `.task-attachments.registry-${stamp}.tmp`);
885
+ const backupTasks = join(dirname(tasksPath), `.tasks.registry-${stamp}.bak`);
886
+ const backupAttachments = join(dirname(tasksPath), `.task-attachments.registry-${stamp}.bak`);
887
+ let tasksBackedUp = false;
888
+ let attachmentsBackedUp = false;
889
+ let tasksInstalled = false;
890
+ let attachmentsInstalled = false;
891
+ try {
892
+ await fs.writeFile(stagedTasks, materializedSource, "utf8");
893
+ if (bundle.attachments.size > 0) {
894
+ await fs.mkdir(stagedAttachments, { recursive: true });
895
+ for (const name of [...bundle.attachments.keys()].sort()) {
896
+ await fs.copyFile(bundle.attachments.get(name)!, join(stagedAttachments, name));
897
+ }
898
+ }
899
+
900
+ if (await pathExists(tasksPath)) {
901
+ await fs.rename(tasksPath, backupTasks);
902
+ tasksBackedUp = true;
903
+ }
904
+ if (await pathExists(attachmentsPath)) {
905
+ await fs.rename(attachmentsPath, backupAttachments);
906
+ attachmentsBackedUp = true;
907
+ }
908
+ if (bundle.attachments.size > 0) {
909
+ await fs.rename(stagedAttachments, attachmentsPath);
910
+ attachmentsInstalled = true;
911
+ }
912
+ await fs.rename(stagedTasks, tasksPath);
913
+ tasksInstalled = true;
914
+ } catch (error) {
915
+ if (tasksInstalled) await fs.rm(tasksPath, { recursive: true, force: true }).catch(() => undefined);
916
+ if (attachmentsInstalled) await fs.rm(attachmentsPath, { recursive: true, force: true }).catch(() => undefined);
917
+ if (tasksBackedUp) await fs.rename(backupTasks, tasksPath).catch(() => undefined);
918
+ if (attachmentsBackedUp) await fs.rename(backupAttachments, attachmentsPath).catch(() => undefined);
919
+ throw error;
920
+ } finally {
921
+ await fs.rm(stagedTasks, { recursive: true, force: true }).catch(() => undefined);
922
+ await fs.rm(stagedAttachments, { recursive: true, force: true }).catch(() => undefined);
923
+ }
924
+ await fs.rm(backupTasks, { recursive: true, force: true }).catch(() => undefined);
925
+ await fs.rm(backupAttachments, { recursive: true, force: true }).catch(() => undefined);
926
+ }
927
+
669
928
  async function readProvenance(project: ProjectContext): Promise<Provenance> {
670
929
  try {
671
930
  const parsed = JSON.parse(await fs.readFile(provenancePath(project), "utf8")) as Partial<Provenance>;
@@ -872,10 +1131,9 @@ async function collectProjectStatuses(
872
1131
  statuses.push({ artifact, kind: "removed-remote", localExists, remoteExists, provenance: tracked });
873
1132
  continue;
874
1133
  }
875
- const rel = projectArtifactRelativePath(projectKey, artifact);
876
1134
  const [localHash, remoteRevision] = await Promise.all([
877
- hashPath(localPath),
878
- pathRevision(pi, runtime, rel),
1135
+ hashProjectArtifactLocal(cwd, artifact),
1136
+ projectArtifactRevision(pi, runtime, projectKey, artifact),
879
1137
  ]);
880
1138
  const localChanged = localHash !== tracked.hash;
881
1139
  const remoteChanged = remoteRevision !== tracked.revision;
@@ -1518,18 +1776,21 @@ async function pushProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext,
1518
1776
  for (const { artifact, deleteRemote } of targets) {
1519
1777
  const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1520
1778
  const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1521
- const rel = projectArtifactRelativePath(projectKey, artifact);
1522
1779
  const tracked = provenance.projectResources[artifact];
1523
1780
  if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1524
1781
  throw new Error(`${artifact} state is tracked under another registry/branch/project key. Run /${COMMAND} status before pushing.`);
1525
1782
  }
1526
1783
  const remoteExists = await pathExists(remotePath);
1527
- const remoteRevision = remoteExists ? await pathRevision(pi, runtime, rel) : undefined;
1784
+ const remoteRevision = remoteExists
1785
+ ? await projectArtifactRevision(pi, runtime, projectKey, artifact)
1786
+ : undefined;
1528
1787
  if (tracked && remoteRevision && remoteRevision !== tracked.revision) {
1529
1788
  throw new Error(`${artifact} state changed in the registry since revision ${tracked.revision.slice(0, 8)}. Pull or resolve it before pushing.`);
1530
1789
  }
1531
- const localHash = await hashPath(localPath);
1532
- if (!tracked && remoteExists && (deleteRemote || await hashPath(remotePath) !== localHash)) overwriteConflicts.push(artifact);
1790
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1791
+ if (!tracked && remoteExists && (deleteRemote || await hashProjectArtifactRemote(runtime, projectKey, artifact) !== localHash)) {
1792
+ overwriteConflicts.push(artifact);
1793
+ }
1533
1794
  localHashes.set(artifact, localHash);
1534
1795
  }
1535
1796
 
@@ -1543,18 +1804,18 @@ async function pushProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext,
1543
1804
  if (!confirmed) throw new Error(`Project state push cancelled because remote state already exists for: ${names}.`);
1544
1805
  }
1545
1806
 
1546
- const rels: string[] = [];
1547
1807
  for (const { artifact, deleteRemote } of targets) {
1548
1808
  const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1549
1809
  const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1550
1810
  if (deleteRemote) await fs.rm(remotePath, { recursive: true, force: true });
1811
+ else if (artifact === "tasks") await replaceRemoteTaskBundle(ctx.cwd, runtime, projectKey);
1551
1812
  else await replaceResource(localPath, remotePath);
1552
- rels.push(projectArtifactRelativePath(projectKey, artifact));
1553
1813
  }
1554
- await runGit(pi, runtime.cacheDir, ["add", "-A", "--", ...rels]);
1555
- const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", ...rels])).stdout.trim();
1814
+ const projectRel = `${REGISTRY_PROJECTS_DIR}/${projectKey}`;
1815
+ await runGit(pi, runtime.cacheDir, ["add", "-A", "--", projectRel]);
1816
+ const changed = (await runGit(pi, runtime.cacheDir, ["status", "--porcelain", "--", projectRel])).stdout.trim();
1556
1817
  if (changed) {
1557
- await runGit(pi, runtime.cacheDir, ["commit", "-m", `Sync project state ${projectKey}`, "--", ...rels]);
1818
+ await runGit(pi, runtime.cacheDir, ["commit", "-m", `Sync project state ${projectKey}`, "--", projectRel]);
1558
1819
  try {
1559
1820
  await runGit(pi, runtime.cacheDir, ["push", "-u", "origin", runtime.branch], { timeout: 180_000 });
1560
1821
  } catch (error) {
@@ -1568,8 +1829,7 @@ async function pushProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext,
1568
1829
  removedArtifacts.push(artifact);
1569
1830
  continue;
1570
1831
  }
1571
- const rel = projectArtifactRelativePath(projectKey, artifact);
1572
- const revision = await pathRevision(pi, runtime, rel);
1832
+ const revision = await projectArtifactRevision(pi, runtime, projectKey, artifact);
1573
1833
  if (!revision) throw new Error(`Cannot determine registry revision for project ${artifact} state.`);
1574
1834
  await recordProjectProvenance(ctx, runtime, projectKey, artifact, revision, localHashes.get(artifact)!);
1575
1835
  }
@@ -1599,16 +1859,15 @@ async function pullProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext,
1599
1859
  for (const artifact of targets) {
1600
1860
  const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1601
1861
  const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1602
- const rel = projectArtifactRelativePath(projectKey, artifact);
1603
1862
  const tracked = provenance.projectResources[artifact];
1604
1863
  if (tracked && (tracked.remote !== runtime.remote || tracked.branch !== runtime.branch || tracked.projectKey !== projectKey)) {
1605
1864
  throw new Error(`${artifact} state is tracked under another registry/branch/project key. Run /${COMMAND} status before pulling.`);
1606
1865
  }
1607
- const remoteRevision = await pathRevision(pi, runtime, rel);
1866
+ const remoteRevision = await projectArtifactRevision(pi, runtime, projectKey, artifact);
1608
1867
  if (!remoteRevision) throw new Error(`Cannot determine registry revision for project ${artifact} state.`);
1609
- const remoteHash = await hashPath(remotePath);
1868
+ const remoteHash = await hashProjectArtifactRemote(runtime, projectKey, artifact);
1610
1869
  if (await pathExists(localPath)) {
1611
- const localHash = await hashPath(localPath);
1870
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1612
1871
  if (tracked && localHash !== tracked.hash) {
1613
1872
  throw new Error(`Project ${artifact} state has local changes. Push or resolve them before pulling.`);
1614
1873
  }
@@ -1630,9 +1889,11 @@ async function pullProjectState(pi: ExtensionAPI, ctx: ExtensionCommandContext,
1630
1889
  for (const artifact of targets) {
1631
1890
  const localPath = projectArtifactLocalPath(ctx.cwd, artifact);
1632
1891
  const remotePath = projectArtifactRegistryPath(runtime.cacheDir, projectKey, artifact);
1633
- await replaceResource(remotePath, localPath);
1892
+ if (artifact === "tasks") await replaceLocalTaskBundle(ctx.cwd, runtime, projectKey);
1893
+ else await replaceResource(remotePath, localPath);
1634
1894
  const item = metadata.get(artifact)!;
1635
- await recordProjectProvenance(ctx, runtime, projectKey, artifact, item.remoteRevision, item.remoteHash);
1895
+ const localHash = await hashProjectArtifactLocal(ctx.cwd, artifact);
1896
+ await recordProjectProvenance(ctx, runtime, projectKey, artifact, item.remoteRevision, localHash);
1636
1897
  }
1637
1898
  notify(ctx, `Pulled ${targets.join(" + ")} for ${projectKey}. Reloading project state…`);
1638
1899
  await reloadAfterResourceChange(ctx);
@@ -201,6 +201,14 @@ function toolResultFrom(entry: EntryLike): UnknownRecord | undefined {
201
201
  return message?.role === "toolResult" ? message : undefined;
202
202
  }
203
203
 
204
+ function recoverableGatewayDetails(message: UnknownRecord): UnknownRecord | undefined {
205
+ const toolName = typeof message.toolName === "string" ? message.toolName.toLowerCase() : "";
206
+ if (toolName !== "web_search" && toolName !== "web_fetch") return undefined;
207
+ if (!isRecord(message.details) || !isRecord(message.details.contextGateway)) return undefined;
208
+ if (message.details.contextGateway.representation !== "web-recoverable-compact") return undefined;
209
+ return message.details;
210
+ }
211
+
204
212
  function summaryFrom(entry: EntryLike): string {
205
213
  if ((entry.type === "compaction" || entry.type === "branch_summary") && typeof entry.summary === "string") {
206
214
  return entry.summary;
@@ -218,6 +226,11 @@ function entryText(entry: EntryLike): string {
218
226
  for (const call of toolCallsFrom(entry)) {
219
227
  chunks.push(call.name, serializeJson(call.arguments));
220
228
  }
229
+ if (message.role === "toolResult") {
230
+ const toolName = typeof message.toolName === "string" ? message.toolName : "unknown";
231
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown";
232
+ chunks.push(`tool_result ${toolName}#${callId}`);
233
+ }
221
234
  } else if (entry.type === "custom_message") {
222
235
  const text = textFromContent(entry.content);
223
236
  if (text) chunks.push(text);
@@ -416,6 +429,10 @@ function renderEntryFull(entry: EntryLike): string {
416
429
  const toolName = typeof message.toolName === "string" ? message.toolName : "unknown";
417
430
  const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown";
418
431
  lines.push(`tool_result ${toolName}#${callId}${message.isError === true ? " error" : ""}`);
432
+ const recoverableDetails = recoverableGatewayDetails(message);
433
+ if (recoverableDetails) {
434
+ lines.push("recoverable_raw_details:", serializeJson(recoverableDetails));
435
+ }
419
436
  }
420
437
  } else if (entry.type === "compaction") {
421
438
  lines.push(summaryFrom(entry));
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { loadPiToolsSuiteConfig, type TodoThinkingLevel as ConfigTodoThinkingLevel } from "../config.js";
3
3
  import { isAgentBusyRaceError } from "../context-usage.js";
4
+ import { hasAvailableIndexedProjectRoot } from "../lib/project.js";
4
5
  import { autoClearCompletedTodos } from "./state/auto-clear.js";
5
6
  import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js";
6
7
  import { replayFromBranch } from "./state/replay.js";
@@ -32,6 +33,25 @@ const TODO_NUDGE_IDLE_RETRY_DELAY_MS = 100;
32
33
  const TODO_NUDGE_MAX_IDLE_ATTEMPTS = 40;
33
34
  const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
34
35
  const TODO_THINKING_RESTORE_METADATA_KEY = "__piTodoRestoreThinking";
36
+ const REPO_KNOWLEDGE_FINALIZATION_REMINDER =
37
+ "📚 Before completing the final todo: if behavior/contracts changed, reconcile affected specs and repo knowledge; skip for mechanical changes.";
38
+
39
+ function completesTodo(info: { action: string; params: TaskMutationParams }): boolean {
40
+ if (info.action === "update") return info.params.status === "completed";
41
+ if (info.action !== "batch_update") return false;
42
+ return (info.params.items ?? []).some((item) => item.status === "completed");
43
+ }
44
+
45
+ function repoKnowledgeFinalizationReminder(
46
+ state: ReturnType<typeof getState>,
47
+ ctx: ExtensionContext,
48
+ info: { action: string; params: TaskMutationParams },
49
+ ): string | undefined {
50
+ if (!hasAvailableIndexedProjectRoot(ctx.cwd)) return undefined;
51
+ if (!completesTodo(info)) return undefined;
52
+ const remainingActive = selectVisibleTasks(state).filter((task) => ACTIVE_STATUSES.has(task.status));
53
+ return remainingActive.length === 1 ? REPO_KNOWLEDGE_FINALIZATION_REMINDER : undefined;
54
+ }
35
55
 
36
56
  function isStaleExtensionContextError(error: unknown): boolean {
37
57
  return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
@@ -241,6 +261,7 @@ export default function (pi: ExtensionAPI) {
241
261
  registerTodoTool(pi, {
242
262
  ...thinkingPrompt,
243
263
  ...(availableThinkingLevels ? { parameters: todoParamsSchemaForThinkingLevels(availableThinkingLevels) } : {}),
264
+ resultReminder: repoKnowledgeFinalizationReminder,
244
265
  prepareMutation: (state, ctx, info) => {
245
266
  if (!todoThinkingEnabled) return info.params;
246
267
  if (info.action === "create" || info.action === "update") return prepareTodoThinkingMutation(state, ctx, info.params);
@@ -84,6 +84,11 @@ interface TodoToolHooks {
84
84
  ctx: ExtensionContext,
85
85
  info: { action: TaskAction; params: TaskMutationParams },
86
86
  ) => TaskMutationParams | Promise<TaskMutationParams>;
87
+ resultReminder?: (
88
+ state: ReturnType<typeof getState>,
89
+ ctx: ExtensionContext,
90
+ info: { action: TaskAction; params: TaskMutationParams },
91
+ ) => string | undefined | Promise<string | undefined>;
87
92
  afterCommit?: (
88
93
  state: ReturnType<typeof getState>,
89
94
  ctx: ExtensionContext,
@@ -448,7 +453,20 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolRegistrationOp
448
453
  params: preparedParams,
449
454
  committedState: autoClear.state,
450
455
  });
451
- const toolResult = buildToolResult(params.action, preparedParams, autoClear.state, result.op);
456
+ let toolResult = buildToolResult(params.action, preparedParams, autoClear.state, result.op);
457
+ const resultReminder = await hooks.resultReminder?.(result.state, _ctx as ExtensionContext, {
458
+ action: params.action,
459
+ params: preparedParams,
460
+ });
461
+ if (resultReminder?.trim()) {
462
+ toolResult = {
463
+ ...toolResult,
464
+ content: [{
465
+ type: "text" as const,
466
+ text: `${toolResult.content[0]?.text ?? ""}\n\n${resultReminder.trim()}`.trim(),
467
+ }],
468
+ };
469
+ }
452
470
  if (!autoClear.cleared) return toolResult;
453
471
  return {
454
472
  ...toolResult,
@@ -17,6 +17,8 @@ export type RepoDiscoveryToolDescription = ToolDescription & Required<Pick<ToolD
17
17
  targetDescription?: string;
18
18
  };
19
19
 
20
+ export type RepoKnowledgeToolDescription = ToolDescription & Required<Pick<ToolDescription, "promptSnippet" | "promptGuidelines">>;
21
+
20
22
  export type ToolDescriptionSetOptions = {
21
23
  repoDiscovery?: boolean;
22
24
  };
@@ -69,9 +71,9 @@ export function asyncSubagentToolDescriptions(options: ToolDescriptionSetOptions
69
71
  name: "subagents",
70
72
  label: "Subagents",
71
73
  description: [
72
- "For every real-browser QA request, immediately spawn subagentType='browser-qa' before checking files, URLs, servers, or other prerequisites; the QA sub-agent owns feasibility checks and blocked reports, so the parent must not attempt browser QA itself.",
73
- "If browser-qa reports that credentials are required, it must identify the generated project-local template and explicitly ask the user to fill it; the parent relays that request without reading or editing the credential file.",
74
- "After browser testing, browser-qa must return clickable links for every available screenshot, video, and trace; the parent must preserve those links in its user-facing report.",
74
+ "For every real user-interface QA request (browser, terminal/TUI, or desktop GUI), immediately spawn subagentType='ui-qa' before checking files, URLs, servers, launch commands, or other prerequisites; the QA sub-agent owns feasibility checks and blocked reports, so the parent must not substitute static checks for requested UI QA.",
75
+ "If ui-qa browser testing reports that credentials are required, it must identify the generated project-local template and explicitly ask the user to fill it; the parent relays that request without reading or editing the credential file.",
76
+ "After UI testing, ui-qa must return clickable links for every available screenshot, terminal capture, video, trace, or other retained evidence; the parent must preserve those links in its user-facing report.",
75
77
  SUBAGENT_DELEGATION_GUIDANCE,
76
78
  "Presets declare available models; each agent's ordered models selects the first usable model in that pool. AGENTS_PRESET or /subagent-preset session <name> selects the current session pool. Bundled pools live with agent files, and projects can override/add pools in .pi/agents/presets.jsonc. Do not override the model merely to choose a role.",
77
79
  SUBAGENT_TYPE_SELECTION_GUIDANCE,
@@ -82,18 +84,18 @@ export function asyncSubagentToolDescriptions(options: ToolDescriptionSetOptions
82
84
  "Results are compact with artifact links. Agents run isolated pi processes with extensions disabled to prevent recursive spawning; spawn/task timeoutSeconds can shorten the default 30m watchdog, project concurrency queues excess agents, and retry backoff/fallback models/Antigravity account rotation are config-driven.",
83
85
  ].join(" "),
84
86
  promptSnippet:
85
- "For every browser-based visual QA, UI bug reproduction, or real-browser fix-verification request, immediately spawn subagentType='browser-qa' even for a single track and before inspecting files or checking prerequisites. The browser-qa sub-agent must discover the target and report missing prerequisites; do not preflight, perform, or substitute browser QA in the parent agent. " +
86
- "Give browser-qa a concise acceptance brief: the known target URL/app, user-visible flow, expected observable result, and required artifacts. Do not prescribe repository files, searches, commands, server setup, or mock/synthetic substitutes; unknown setup belongs to the QA sub-agent's discovery. " +
87
+ "For every real UI QA, UI bug reproduction, or user-facing fix-verification request across browser, terminal/TUI, or desktop GUI, immediately spawn subagentType='ui-qa' even for a single track and before inspecting files or checking prerequisites. The ui-qa sub-agent must discover the actual target/control path and report missing prerequisites; do not preflight, perform, or substitute requested UI QA in the parent agent. " +
88
+ "Give ui-qa a concise acceptance brief: the known target URL/app/command, user-visible flow, expected observable result, and required artifacts. Do not prescribe repository files, searches, commands, server setup, or mock/synthetic substitutes; unknown setup belongs to the QA sub-agent's discovery. " +
87
89
  "For other work, use subagents action='spawn' for economical execution or context isolation, including one bounded sequential task or explicit delegate/parallelize/split work requests. " +
88
90
  SUBAGENT_TYPE_SELECTION_GUIDANCE + " Avoid trivial reads/edits and do not call status/wait immediately after spawn just for progress. " +
89
91
  (repoDiscovery
90
92
  ? "For one semantic code-discovery question, use repo_search; for independent tracks/hypotheses/review axes, delegate even when repo_* tools exist. Read result only after completion when findings are needed."
91
93
  : "For one focused code-discovery question, use direct read/grep. Without repo_* tools, delegate bounded research tracks for broad discovery rather than flooding parent context. Read result only after completion when findings are needed."),
92
94
  promptGuidelines: [
93
- "Treat every real-browser QA request as a mandatory delegation trigger and an explicit exception to the large/parallel threshold: immediately spawn with `subagentType: \"browser-qa\"` before checking prerequisites. The QA sub-agent owns target discovery, feasibility checks, browser automation, evidence, and blocked reports; the parent must not inspect the project first or substitute non-browser checks.",
94
- "Keep the browser-qa task payload at the user-visible acceptance level: known target URL/app, actions to perform, expected observable outcome, and requested evidence. Do not turn it into a repository investigation plan, name internal files or commands, dictate server setup, or invent a mock/synthetic target. Leave unknown prerequisites to the QA sub-agent.",
95
- "When browser-qa reports missing credentials, relay its explicit request and generated `.pi/qa_auth.jsonc` template path; never inspect, populate, or edit that credential file in the parent.",
96
- "After browser-qa completes a test, preserve its clickable screenshot, video, and trace links in the final user-facing response whenever those artifacts exist.",
95
+ "Treat every real UI QA request as a mandatory delegation trigger and an explicit exception to the large/parallel threshold: immediately spawn with `subagentType: \"ui-qa\"` before checking prerequisites. The QA sub-agent owns target discovery, feasibility checks, UI automation, evidence, and blocked reports; the parent must not inspect the project first or substitute non-UI checks.",
96
+ "Keep the ui-qa task payload at the user-visible acceptance level: known target URL/app/command, actions to perform, expected observable outcome, and requested evidence. Do not turn it into a repository investigation plan, name internal files or commands, dictate setup, or invent a mock/synthetic target. Leave unknown prerequisites to the QA sub-agent.",
97
+ "When ui-qa browser testing reports missing credentials, relay its explicit request and generated `.pi/qa_auth.jsonc` template path; never inspect, populate, or edit that credential file in the parent.",
98
+ "After ui-qa completes a test, preserve its clickable screenshot, terminal capture, video, trace, and other evidence links in the final user-facing response whenever those artifacts exist.",
97
99
  SUBAGENT_DELEGATION_GUIDANCE,
98
100
  repoDiscovery
99
101
  ? "For one discovery question, use repo_search; spawn for independent tracks/hypotheses/review axes, and do not let repo_* availability suppress delegation."
@@ -221,7 +223,24 @@ export const REPO_DISCOVERY_TOOLS: RepoDiscoveryToolDescription[] = [
221
223
  },
222
224
  ];
223
225
 
224
- export const REPO_DISCOVERY_TOOL_NAMES = REPO_DISCOVERY_TOOLS.map((tool) => tool.name);
226
+ export const REPO_KNOWLEDGE_TOOL_DESCRIPTION: RepoKnowledgeToolDescription = {
227
+ name: "repo_knowledge",
228
+ label: "Repo Knowledge",
229
+ description:
230
+ "Project behavioral knowledge/spec maintenance backed by idx. Query contracts with context/search/show/status, review task-scoped impact after material behavior changes, discover new/moved docs, and explicitly record/relate/verify metadata only after semantic evidence review. This tool is exposed only when idx is available and the project is indexed.",
231
+ promptSnippet:
232
+ "Use repo_knowledge for project behavior/contracts and their maintenance. For a behavior/contract question that also needs implementation/tests/freshness, prefer action=context; use action=search when only the relevant specs/contracts are needed. Before a material behavior change, find the current primary contract; after implementation, run action=impact on this task's changed paths. Update the existing spec, or create a focused new spec with Edit/Write/apply_patch when no suitable contract exists, then record/relate and verify only after checking code/tests. Skip this lifecycle for mechanical/non-behavioral edits.",
233
+ promptGuidelines: [
234
+ "Primary specs/contracts are authoritative; catalog/search summaries, embeddings, graph proximity and semantic candidates are routing evidence only. record is classification, not verification; non-fresh status requires review before presenting behavior as current.",
235
+ "For material externally visible or project-contract behavior changes: query existing knowledge before/while implementing, keep the authoritative spec aligned in the same task, create a focused primary spec when no suitable contract exists, then run task-scoped impact and reconcile uncovered/new/moved docs before finishing.",
236
+ "Never persist a relation from similarity alone and never add one just to make coverage non-empty. A reviewed no-impact outcome is valid. verify only after reading the primary source and checking relevant implementation/tests; changed code never auto-rewrites spec semantics.",
237
+ ],
238
+ };
239
+
240
+ export const REPO_DISCOVERY_TOOL_NAMES = [
241
+ ...REPO_DISCOVERY_TOOLS.map((tool) => tool.name),
242
+ REPO_KNOWLEDGE_TOOL_DESCRIPTION.name,
243
+ ];
225
244
 
226
245
  export const TODO_TOOL_DESCRIPTION: ToolDescription = {
227
246
  name: "todo",
@@ -332,12 +351,16 @@ export function claudeAliasToolDescriptions(options: ToolDescriptionSetOptions |
332
351
  Edit: {
333
352
  name: "Edit",
334
353
  label: "Edit",
335
- description: "Replace exact text in an existing file. Use for surgical edits; use Write only for intentional whole-file replacement.",
354
+ description: repoDiscovery
355
+ ? "Replace exact text in an existing file. Use for surgical edits; use Write only for whole-file replacement. After a material behavior change, do not finish until repo_knowledge impact reviews this task's changed paths and the authoritative spec is updated or created and verified; skip that lifecycle for mechanical/non-behavioral edits."
356
+ : "Replace exact text in an existing file. Use for surgical edits; use Write only for intentional whole-file replacement.",
336
357
  },
337
358
  Write: {
338
359
  name: "Write",
339
360
  label: "Write",
340
- description: "Create or overwrite a file with complete contents. Use only when replacing the whole file is intended.",
361
+ description: repoDiscovery
362
+ ? "Create or overwrite a file with complete contents. Use only for intentional whole-file writes. When creating/changing material project behavior, keep or create the primary spec in the same task and finish with task-scoped repo_knowledge impact + semantic verification; mechanical/non-behavioral writes do not require it."
363
+ : "Create or overwrite a file with complete contents. Use only when replacing the whole file is intended.",
341
364
  },
342
365
  Bash: {
343
366
  name: "Bash",
@@ -364,7 +387,9 @@ export function claudeAliasToolDescriptions(options: ToolDescriptionSetOptions |
364
387
  export const CLAUDE_ALIAS_TOOL_DESCRIPTIONS = claudeAliasToolDescriptions(false);
365
388
  export const CLAUDE_ALIAS_TOOL_DESCRIPTIONS_WITH_REPO = claudeAliasToolDescriptions(true);
366
389
 
367
- export const CODEX_ALIAS_TOOL_DESCRIPTIONS = {
390
+ export function codexAliasToolDescriptions(options: ToolDescriptionSetOptions | boolean = false) {
391
+ const repoDiscovery = hasRepoDiscovery(options);
392
+ return {
368
393
  shellCommand: {
369
394
  name: "shell",
370
395
  label: "shell",
@@ -373,7 +398,7 @@ export const CODEX_ALIAS_TOOL_DESCRIPTIONS = {
373
398
  applyPatch: {
374
399
  name: "apply_patch",
375
400
  label: "apply_patch",
376
- description: `Apply file edits with a relative-path patch or standard unified diff. Use for creating, updating, moving, or deleting files; keep each patch focused.
401
+ description: `Apply file edits with a relative-path patch or standard unified diff. Use for creating, updating, moving, or deleting files; keep each patch focused.${repoDiscovery ? " After a material behavior change, keep/create the authoritative primary spec in the same task and finish with task-scoped repo_knowledge impact plus semantic verification; skip this for mechanical/non-behavioral edits." : ""}
377
402
 
378
403
  Begin-patch format:
379
404
  *** Begin Patch
@@ -387,4 +412,8 @@ Sections: *** Add File (new lines start with +), *** Update File (optionally ***
387
412
 
388
413
  Unified diff with ---/+++ headers is also supported. Paths must be workspace-relative, never absolute. Provide the complete patch in input.`,
389
414
  },
390
- } satisfies Record<string, ToolDescription>;
415
+ } satisfies Record<string, ToolDescription>;
416
+ }
417
+
418
+ export const CODEX_ALIAS_TOOL_DESCRIPTIONS = codexAliasToolDescriptions(false);
419
+ export const CODEX_ALIAS_TOOL_DESCRIPTIONS_WITH_REPO = codexAliasToolDescriptions(true);