gsd-pi 2.59.0 → 2.60.0-dev.d9052f5

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 (219) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +54 -1
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +8 -3
  3. package/dist/resources/extensions/gsd/auto-post-unit.js +40 -1
  4. package/dist/resources/extensions/gsd/auto-prompts.js +13 -0
  5. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +70 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +51 -5
  7. package/dist/resources/extensions/gsd/captures.js +54 -1
  8. package/dist/resources/extensions/gsd/complexity-classifier.js +1 -1
  9. package/dist/resources/extensions/gsd/context-masker.js +68 -0
  10. package/dist/resources/extensions/gsd/docs/preferences-reference.md +7 -0
  11. package/dist/resources/extensions/gsd/gsd-db.js +2 -2
  12. package/dist/resources/extensions/gsd/model-router.js +123 -4
  13. package/dist/resources/extensions/gsd/phase-anchor.js +56 -0
  14. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  15. package/dist/resources/extensions/gsd/preferences-validation.js +46 -0
  16. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -0
  17. package/dist/resources/extensions/gsd/prompts/rethink.md +7 -0
  18. package/dist/resources/extensions/gsd/prompts/triage-captures.md +6 -1
  19. package/dist/resources/extensions/gsd/rethink.js +5 -2
  20. package/dist/resources/extensions/gsd/state.js +1 -1
  21. package/dist/resources/extensions/gsd/status-guards.js +4 -3
  22. package/dist/resources/extensions/gsd/triage-resolution.js +128 -1
  23. package/dist/resources/extensions/gsd/triage-ui.js +12 -3
  24. package/dist/resources/skills/btw/SKILL.md +42 -0
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  27. package/dist/web/standalone/.next/build-manifest.json +3 -3
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/required-server-files.json +3 -3
  30. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  31. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  33. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  41. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  51. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  52. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  53. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  55. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  57. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  58. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  59. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  60. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  61. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  64. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  66. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  68. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  69. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  74. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  76. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  80. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  84. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  90. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  96. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  97. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  102. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  103. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  104. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  105. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  108. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  110. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  111. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  114. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
  117. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  119. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  120. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
  121. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  122. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  123. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  124. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  125. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  126. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  127. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  128. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  129. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  130. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  131. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  132. package/dist/web/standalone/.next/server/app/index.html +1 -1
  133. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  134. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  135. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  136. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  137. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  138. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  139. package/dist/web/standalone/.next/server/app/page.js +2 -2
  140. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  142. package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
  143. package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
  144. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  145. package/dist/web/standalone/.next/server/middleware.js +2 -2
  146. package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
  147. package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
  148. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  149. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  150. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  151. package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
  152. package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
  153. package/dist/web/standalone/.next/static/chunks/app/page-0c485498795110d6.js +1 -0
  154. package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
  155. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
  156. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  157. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  158. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  159. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
  160. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
  161. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
  162. package/dist/web/standalone/server.js +1 -1
  163. package/package.json +1 -1
  164. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts +1 -0
  165. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts.map +1 -1
  166. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js +6 -0
  167. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js.map +1 -1
  168. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.test.d.ts +2 -0
  169. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.test.d.ts.map +1 -0
  170. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.test.js +122 -0
  171. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.test.js.map +1 -0
  172. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  173. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  174. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +30 -0
  175. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  176. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.d.ts.map +1 -1
  177. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js +1 -7
  178. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js.map +1 -1
  179. package/packages/pi-coding-agent/package.json +1 -1
  180. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts +156 -0
  181. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +7 -0
  182. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +38 -0
  183. package/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +1 -8
  184. package/pkg/package.json +1 -1
  185. package/src/resources/extensions/gsd/auto/phases.ts +60 -1
  186. package/src/resources/extensions/gsd/auto-model-selection.ts +12 -3
  187. package/src/resources/extensions/gsd/auto-post-unit.ts +48 -1
  188. package/src/resources/extensions/gsd/auto-prompts.ts +17 -0
  189. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +78 -0
  190. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +53 -4
  191. package/src/resources/extensions/gsd/captures.ts +71 -2
  192. package/src/resources/extensions/gsd/complexity-classifier.ts +1 -1
  193. package/src/resources/extensions/gsd/context-masker.ts +74 -0
  194. package/src/resources/extensions/gsd/docs/preferences-reference.md +7 -0
  195. package/src/resources/extensions/gsd/gsd-db.ts +2 -2
  196. package/src/resources/extensions/gsd/model-router.ts +171 -8
  197. package/src/resources/extensions/gsd/phase-anchor.ts +71 -0
  198. package/src/resources/extensions/gsd/preferences-types.ts +9 -0
  199. package/src/resources/extensions/gsd/preferences-validation.ts +38 -0
  200. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -0
  201. package/src/resources/extensions/gsd/prompts/rethink.md +7 -0
  202. package/src/resources/extensions/gsd/prompts/triage-captures.md +6 -1
  203. package/src/resources/extensions/gsd/rethink.ts +5 -2
  204. package/src/resources/extensions/gsd/state.ts +1 -1
  205. package/src/resources/extensions/gsd/status-guards.ts +4 -3
  206. package/src/resources/extensions/gsd/tests/context-masker.test.ts +122 -0
  207. package/src/resources/extensions/gsd/tests/model-router.test.ts +87 -1
  208. package/src/resources/extensions/gsd/tests/phase-anchor.test.ts +83 -0
  209. package/src/resources/extensions/gsd/tests/status-guards.test.ts +4 -0
  210. package/src/resources/extensions/gsd/tests/stop-backtrack.test.ts +216 -0
  211. package/src/resources/extensions/gsd/tests/tool-naming.test.ts +1 -1
  212. package/src/resources/extensions/gsd/triage-resolution.ts +144 -1
  213. package/src/resources/extensions/gsd/triage-ui.ts +12 -3
  214. package/src/resources/skills/btw/SKILL.md +42 -0
  215. package/dist/web/standalone/.next/static/chunks/app/page-62be3b5fa91e4c8f.js +0 -1
  216. package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
  217. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
  218. /package/dist/web/standalone/.next/static/{DGvT_c5Vb7Wu3X-fEOVUU → JVkoVYumy0cDhOQISEYdG}/_buildManifest.js +0 -0
  219. /package/dist/web/standalone/.next/static/{DGvT_c5Vb7Wu3X-fEOVUU → JVkoVYumy0cDhOQISEYdG}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@ import type { ResolvedModelConfig } from "./preferences.js";
10
10
 
11
11
  export interface DynamicRoutingConfig {
12
12
  enabled?: boolean;
13
+ capability_routing?: boolean; // default: false — enable capability profile scoring
13
14
  tier_models?: {
14
15
  light?: string;
15
16
  standard?: string;
@@ -32,6 +33,12 @@ export interface RoutingDecision {
32
33
  wasDowngraded: boolean;
33
34
  /** Human-readable reason for this decision */
34
35
  reason: string;
36
+ /** How the model was selected. */
37
+ selectionMethod?: "tier-only" | "capability-scored";
38
+ /** Capability scores per model (when capability-scored). */
39
+ capabilityScores?: Record<string, number>;
40
+ /** Task requirement vector (when capability-scored). */
41
+ taskRequirements?: Partial<Record<string, number>>;
35
42
  }
36
43
 
37
44
  // ─── Known Model Tiers ───────────────────────────────────────────────────────
@@ -114,6 +121,91 @@ const MODEL_COST_PER_1K_INPUT: Record<string, number> = {
114
121
  "deepseek-chat": 0.00014,
115
122
  };
116
123
 
124
+ // ─── Capability Profiles (ADR-004 Phase 2) ──────────────────────────────────
125
+ // 7-dimension profiles, 0–100 normalized. Models without a profile
126
+ // score 50 uniformly — capability scoring is a no-op for them.
127
+
128
+ export interface ModelCapabilities {
129
+ coding: number;
130
+ debugging: number;
131
+ research: number;
132
+ reasoning: number;
133
+ speed: number;
134
+ longContext: number;
135
+ instruction: number;
136
+ }
137
+
138
+ export const MODEL_CAPABILITY_PROFILES: Record<string, ModelCapabilities> = {
139
+ "claude-opus-4-6": { coding: 95, debugging: 90, research: 85, reasoning: 95, speed: 30, longContext: 80, instruction: 90 },
140
+ "claude-sonnet-4-6": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 },
141
+ "claude-haiku-4-5": { coding: 60, debugging: 50, research: 45, reasoning: 50, speed: 95, longContext: 50, instruction: 75 },
142
+ "gpt-4o": { coding: 80, debugging: 75, research: 70, reasoning: 75, speed: 65, longContext: 70, instruction: 80 },
143
+ "gpt-4o-mini": { coding: 55, debugging: 45, research: 40, reasoning: 45, speed: 90, longContext: 45, instruction: 70 },
144
+ "gemini-2.5-pro": { coding: 75, debugging: 70, research: 85, reasoning: 75, speed: 55, longContext: 90, instruction: 75 },
145
+ "gemini-2.0-flash": { coding: 50, debugging: 40, research: 50, reasoning: 40, speed: 95, longContext: 60, instruction: 65 },
146
+ "deepseek-chat": { coding: 75, debugging: 65, research: 55, reasoning: 70, speed: 70, longContext: 55, instruction: 65 },
147
+ "o3": { coding: 80, debugging: 85, research: 80, reasoning: 92, speed: 25, longContext: 70, instruction: 85 },
148
+ };
149
+
150
+ const BASE_REQUIREMENTS: Record<string, Partial<Record<keyof ModelCapabilities, number>>> = {
151
+ "execute-task": { coding: 0.9, instruction: 0.7, speed: 0.3 },
152
+ "research-milestone": { research: 0.9, longContext: 0.7, reasoning: 0.5 },
153
+ "research-slice": { research: 0.9, longContext: 0.7, reasoning: 0.5 },
154
+ "plan-milestone": { reasoning: 0.9, coding: 0.5 },
155
+ "plan-slice": { reasoning: 0.9, coding: 0.5 },
156
+ "replan-slice": { reasoning: 0.9, debugging: 0.6, coding: 0.5 },
157
+ "reassess-roadmap": { reasoning: 0.9, research: 0.5 },
158
+ "complete-slice": { instruction: 0.8, speed: 0.7 },
159
+ "run-uat": { instruction: 0.7, speed: 0.8 },
160
+ "discuss-milestone": { reasoning: 0.6, instruction: 0.7 },
161
+ "complete-milestone": { instruction: 0.8, reasoning: 0.5 },
162
+ };
163
+
164
+ /**
165
+ * Compute a task requirement vector from unit type and optional metadata.
166
+ */
167
+ export function computeTaskRequirements(
168
+ unitType: string,
169
+ metadata?: { tags?: string[]; complexityKeywords?: string[]; fileCount?: number; estimatedLines?: number },
170
+ ): Partial<Record<keyof ModelCapabilities, number>> {
171
+ const base = { ...(BASE_REQUIREMENTS[unitType] ?? { reasoning: 0.5 }) };
172
+
173
+ if (unitType === "execute-task" && metadata) {
174
+ if (metadata.tags?.some(t => /^(docs?|readme|comment|config|typo|rename)$/i.test(t))) {
175
+ return { ...base, instruction: 0.9, coding: 0.3, speed: 0.7 };
176
+ }
177
+ if (metadata.complexityKeywords?.some(k => k === "concurrency" || k === "compatibility")) {
178
+ return { ...base, debugging: 0.9, reasoning: 0.8 };
179
+ }
180
+ if (metadata.complexityKeywords?.some(k => k === "migration" || k === "architecture")) {
181
+ return { ...base, reasoning: 0.9, coding: 0.8 };
182
+ }
183
+ if ((metadata.fileCount ?? 0) >= 6 || (metadata.estimatedLines ?? 0) >= 500) {
184
+ return { ...base, coding: 0.9, reasoning: 0.7 };
185
+ }
186
+ }
187
+
188
+ return base;
189
+ }
190
+
191
+ /**
192
+ * Score a model against a task requirement vector.
193
+ * Returns weighted average in range 0–100. Returns 50 for empty requirements.
194
+ */
195
+ export function scoreModel(
196
+ capabilities: ModelCapabilities,
197
+ requirements: Partial<Record<keyof ModelCapabilities, number>>,
198
+ ): number {
199
+ let weightedSum = 0;
200
+ let weightSum = 0;
201
+ for (const [dim, weight] of Object.entries(requirements)) {
202
+ const capability = capabilities[dim as keyof ModelCapabilities] ?? 50;
203
+ weightedSum += weight * capability;
204
+ weightSum += weight;
205
+ }
206
+ return weightSum > 0 ? weightedSum / weightSum : 50;
207
+ }
208
+
117
209
  // ─── Public API ──────────────────────────────────────────────────────────────
118
210
 
119
211
  /**
@@ -132,6 +224,8 @@ export function resolveModelForComplexity(
132
224
  phaseConfig: ResolvedModelConfig | undefined,
133
225
  routingConfig: DynamicRoutingConfig,
134
226
  availableModelIds: string[],
227
+ unitType?: string,
228
+ metadata?: { tags?: string[]; complexityKeywords?: string[]; fileCount?: number; estimatedLines?: number },
135
229
  ): RoutingDecision {
136
230
  // If no phase config or routing disabled, pass through
137
231
  if (!phaseConfig || !routingConfig.enabled) {
@@ -175,25 +269,40 @@ export function resolveModelForComplexity(
175
269
  }
176
270
 
177
271
  // Find the best model for the requested tier
178
- const targetModelId = findModelForTier(
179
- requestedTier,
180
- routingConfig,
181
- availableModelIds,
182
- routingConfig.cross_provider !== false,
183
- );
272
+ const useCapabilityScoring = routingConfig.capability_routing && unitType;
273
+
274
+ let targetModelId: string | null;
275
+ let capabilityScores: Record<string, number> | undefined;
276
+ let taskRequirements: Partial<Record<string, number>> | undefined;
277
+ let selectionMethod: "tier-only" | "capability-scored" = "tier-only";
278
+
279
+ if (useCapabilityScoring) {
280
+ const result = findModelForTierWithCapability(
281
+ requestedTier, routingConfig, availableModelIds,
282
+ routingConfig.cross_provider !== false, unitType, metadata,
283
+ );
284
+ targetModelId = result.modelId;
285
+ capabilityScores = Object.keys(result.scores).length > 0 ? result.scores : undefined;
286
+ taskRequirements = Object.keys(result.requirements).length > 0 ? result.requirements : undefined;
287
+ selectionMethod = capabilityScores ? "capability-scored" : "tier-only";
288
+ } else {
289
+ targetModelId = findModelForTier(
290
+ requestedTier, routingConfig, availableModelIds,
291
+ routingConfig.cross_provider !== false,
292
+ );
293
+ }
184
294
 
185
295
  if (!targetModelId) {
186
- // No suitable model found — use configured primary
187
296
  return {
188
297
  modelId: configuredPrimary,
189
298
  fallbacks: phaseConfig.fallbacks,
190
299
  tier: requestedTier,
191
300
  wasDowngraded: false,
192
301
  reason: `no ${requestedTier}-tier model available`,
302
+ selectionMethod,
193
303
  };
194
304
  }
195
305
 
196
- // Build fallback chain: [downgraded_model, ...configured_fallbacks, configured_primary]
197
306
  const fallbacks = [
198
307
  ...phaseConfig.fallbacks.filter(f => f !== targetModelId),
199
308
  configuredPrimary,
@@ -205,6 +314,9 @@ export function resolveModelForComplexity(
205
314
  tier: requestedTier,
206
315
  wasDowngraded: true,
207
316
  reason: classification.reason,
317
+ selectionMethod,
318
+ capabilityScores,
319
+ taskRequirements,
208
320
  };
209
321
  }
210
322
 
@@ -226,6 +338,7 @@ export function escalateTier(currentTier: ComplexityTier): ComplexityTier | null
226
338
  export function defaultRoutingConfig(): DynamicRoutingConfig {
227
339
  return {
228
340
  enabled: true,
341
+ capability_routing: false,
229
342
  escalate_on_failure: true,
230
343
  budget_pressure: true,
231
344
  cross_provider: true,
@@ -298,6 +411,56 @@ function findModelForTier(
298
411
  return candidates[0] ?? null;
299
412
  }
300
413
 
414
+ function findModelForTierWithCapability(
415
+ tier: ComplexityTier,
416
+ config: DynamicRoutingConfig,
417
+ availableModelIds: string[],
418
+ crossProvider: boolean,
419
+ unitType: string,
420
+ metadata?: { tags?: string[]; complexityKeywords?: string[]; fileCount?: number; estimatedLines?: number },
421
+ ): { modelId: string | null; scores: Record<string, number>; requirements: Partial<Record<string, number>> } {
422
+ const explicitModel = config.tier_models?.[tier];
423
+ if (explicitModel) {
424
+ const match = availableModelIds.find(id => {
425
+ const bareAvail = id.includes("/") ? id.split("/").pop()! : id;
426
+ const bareExplicit = explicitModel.includes("/") ? explicitModel.split("/").pop()! : explicitModel;
427
+ return bareAvail === bareExplicit || id === explicitModel;
428
+ });
429
+ if (match) return { modelId: match, scores: {}, requirements: {} };
430
+ }
431
+
432
+ const requirements = computeTaskRequirements(unitType, metadata);
433
+ const candidates = availableModelIds.filter(id => getModelTier(id) === tier);
434
+ if (candidates.length === 0) return { modelId: null, scores: {}, requirements };
435
+
436
+ const scores: Record<string, number> = {};
437
+ for (const id of candidates) {
438
+ const bareId = id.includes("/") ? id.split("/").pop()! : id;
439
+ const profile = getModelProfile(bareId);
440
+ scores[id] = scoreModel(profile, requirements);
441
+ }
442
+
443
+ candidates.sort((a, b) => {
444
+ const scoreDiff = scores[b] - scores[a];
445
+ if (Math.abs(scoreDiff) > 2) return scoreDiff;
446
+ if (crossProvider) {
447
+ const costDiff = getModelCost(a) - getModelCost(b);
448
+ if (costDiff !== 0) return costDiff;
449
+ }
450
+ return a.localeCompare(b);
451
+ });
452
+
453
+ return { modelId: candidates[0], scores, requirements };
454
+ }
455
+
456
+ function getModelProfile(bareId: string): ModelCapabilities {
457
+ if (MODEL_CAPABILITY_PROFILES[bareId]) return MODEL_CAPABILITY_PROFILES[bareId];
458
+ for (const [knownId, profile] of Object.entries(MODEL_CAPABILITY_PROFILES)) {
459
+ if (bareId.includes(knownId) || knownId.includes(bareId)) return profile;
460
+ }
461
+ return { coding: 50, debugging: 50, research: 50, reasoning: 50, speed: 50, longContext: 50, instruction: 50 };
462
+ }
463
+
301
464
  function getModelCost(modelId: string): number {
302
465
  const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
303
466
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Phase handoff anchors — compact structured summaries written between
3
+ * GSD auto-mode phases so downstream agents inherit decisions, blockers,
4
+ * and intent without re-inferring from scratch.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { gsdRoot } from "./paths.js";
10
+
11
+ export interface PhaseAnchor {
12
+ phase: string;
13
+ milestoneId: string;
14
+ generatedAt: string;
15
+ intent: string;
16
+ decisions: string[];
17
+ blockers: string[];
18
+ nextSteps: string[];
19
+ }
20
+
21
+ function anchorsDir(basePath: string, milestoneId: string): string {
22
+ return join(gsdRoot(basePath), "milestones", milestoneId, "anchors");
23
+ }
24
+
25
+ function anchorPath(basePath: string, milestoneId: string, phase: string): string {
26
+ return join(anchorsDir(basePath, milestoneId), `${phase}.json`);
27
+ }
28
+
29
+ export function writePhaseAnchor(basePath: string, milestoneId: string, anchor: PhaseAnchor): void {
30
+ const dir = anchorsDir(basePath, milestoneId);
31
+ if (!existsSync(dir)) {
32
+ mkdirSync(dir, { recursive: true });
33
+ }
34
+ writeFileSync(anchorPath(basePath, milestoneId, anchor.phase), JSON.stringify(anchor, null, 2), "utf-8");
35
+ }
36
+
37
+ export function readPhaseAnchor(basePath: string, milestoneId: string, phase: string): PhaseAnchor | null {
38
+ const path = anchorPath(basePath, milestoneId, phase);
39
+ if (!existsSync(path)) return null;
40
+ try {
41
+ return JSON.parse(readFileSync(path, "utf-8")) as PhaseAnchor;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function formatAnchorForPrompt(anchor: PhaseAnchor): string {
48
+ const lines: string[] = [
49
+ `## Handoff from ${anchor.phase}`,
50
+ "",
51
+ `**Intent:** ${anchor.intent}`,
52
+ ];
53
+
54
+ if (anchor.decisions.length > 0) {
55
+ lines.push("", "**Decisions:**");
56
+ for (const d of anchor.decisions) lines.push(`- ${d}`);
57
+ }
58
+
59
+ if (anchor.blockers.length > 0) {
60
+ lines.push("", "**Blockers:**");
61
+ for (const b of anchor.blockers) lines.push(`- ${b}`);
62
+ }
63
+
64
+ if (anchor.nextSteps.length > 0) {
65
+ lines.push("", "**Next steps:**");
66
+ for (const s of anchor.nextSteps) lines.push(`- ${s}`);
67
+ }
68
+
69
+ lines.push("", "---");
70
+ return lines.join("\n");
71
+ }
@@ -21,6 +21,13 @@ import type {
21
21
  GateEvaluationConfig,
22
22
  } from "./types.js";
23
23
  import type { DynamicRoutingConfig } from "./model-router.js";
24
+
25
+ export interface ContextManagementConfig {
26
+ observation_masking?: boolean; // default: true
27
+ observation_mask_turns?: number; // default: 8, range: 1-50
28
+ compaction_threshold_percent?: number; // default: 0.70, range: 0.5-0.95
29
+ tool_result_max_chars?: number; // default: 800, range: 200-10000
30
+ }
24
31
  import type { GitHubSyncConfig } from "../github-sync/types.js";
25
32
 
26
33
  // ─── Workflow Modes ──────────────────────────────────────────────────────────
@@ -94,6 +101,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
94
101
  "forensics_dedup",
95
102
  "show_token_cost",
96
103
  "stale_commit_threshold_minutes",
104
+ "context_management",
97
105
  "experimental",
98
106
  ]);
99
107
 
@@ -227,6 +235,7 @@ export interface GSDPreferences {
227
235
  post_unit_hooks?: PostUnitHookConfig[];
228
236
  pre_dispatch_hooks?: PreDispatchHookConfig[];
229
237
  dynamic_routing?: DynamicRoutingConfig;
238
+ context_management?: ContextManagementConfig;
230
239
  token_profile?: TokenProfile;
231
240
  phases?: PhaseSkipPreferences;
232
241
  auto_visualize?: boolean;
@@ -428,6 +428,10 @@ export function validatePreferences(preferences: GSDPreferences): {
428
428
  if (typeof dr.hooks === "boolean") validDr.hooks = dr.hooks;
429
429
  else errors.push("dynamic_routing.hooks must be a boolean");
430
430
  }
431
+ if (dr.capability_routing !== undefined) {
432
+ if (typeof dr.capability_routing === "boolean") validDr.capability_routing = dr.capability_routing;
433
+ else errors.push("dynamic_routing.capability_routing must be a boolean");
434
+ }
431
435
  if (dr.tier_models !== undefined) {
432
436
  if (typeof dr.tier_models === "object" && dr.tier_models !== null) {
433
437
  const tm = dr.tier_models as Record<string, unknown>;
@@ -452,6 +456,40 @@ export function validatePreferences(preferences: GSDPreferences): {
452
456
  }
453
457
  }
454
458
 
459
+ // ─── Context Management ──────────────────────────────────────────────
460
+ if (preferences.context_management !== undefined) {
461
+ if (typeof preferences.context_management === "object" && preferences.context_management !== null) {
462
+ const cm = preferences.context_management as unknown as Record<string, unknown>;
463
+ const validCm: Record<string, unknown> = {};
464
+
465
+ if (cm.observation_masking !== undefined) {
466
+ if (typeof cm.observation_masking === "boolean") validCm.observation_masking = cm.observation_masking;
467
+ else errors.push("context_management.observation_masking must be a boolean");
468
+ }
469
+ if (cm.observation_mask_turns !== undefined) {
470
+ const turns = cm.observation_mask_turns;
471
+ if (typeof turns === "number" && turns >= 1 && turns <= 50) validCm.observation_mask_turns = turns;
472
+ else errors.push("context_management.observation_mask_turns must be a number between 1 and 50");
473
+ }
474
+ if (cm.compaction_threshold_percent !== undefined) {
475
+ const pct = cm.compaction_threshold_percent;
476
+ if (typeof pct === "number" && pct >= 0.5 && pct <= 0.95) validCm.compaction_threshold_percent = pct;
477
+ else errors.push("context_management.compaction_threshold_percent must be a number between 0.5 and 0.95");
478
+ }
479
+ if (cm.tool_result_max_chars !== undefined) {
480
+ const chars = cm.tool_result_max_chars;
481
+ if (typeof chars === "number" && chars >= 200 && chars <= 10000) validCm.tool_result_max_chars = chars;
482
+ else errors.push("context_management.tool_result_max_chars must be a number between 200 and 10000");
483
+ }
484
+
485
+ if (Object.keys(validCm).length > 0) {
486
+ validated.context_management = validCm as any;
487
+ }
488
+ } else {
489
+ errors.push("context_management must be an object");
490
+ }
491
+ }
492
+
455
493
  // ─── Parallel Config ────────────────────────────────────────────────────
456
494
  if (preferences.parallel && typeof preferences.parallel === "object") {
457
495
  const p = preferences.parallel as unknown as Record<string, unknown>;
@@ -12,6 +12,8 @@ A researcher explored the codebase and a planner decomposed the work — you are
12
12
 
13
13
  {{runtimeContext}}
14
14
 
15
+ {{phaseAnchorSection}}
16
+
15
17
  {{resumeSection}}
16
18
 
17
19
  {{carryForwardSection}}
@@ -45,6 +45,13 @@ reason: "<reason>"
45
45
  ### Unpark a milestone
46
46
  Remove the `{ID}-PARKED.md` file from the milestone directory to reactivate it.
47
47
 
48
+ ### Skip a slice
49
+ Mark a slice as skipped so auto-mode advances past it without executing. Use the `gsd_skip_slice` tool:
50
+ ```
51
+ gsd_skip_slice({ milestone_id: "M003", slice_id: "S02", reason: "Descoped — feature moved to M005" })
52
+ ```
53
+ Skipped slices are treated as closed by the state machine (like "complete" but distinct). Use when a slice is no longer needed or has been superseded. The slice data is preserved for reference.
54
+
48
55
  ### Discard a milestone
49
56
  **Permanently** delete a milestone directory and prune it from QUEUE-ORDER.json. **Always confirm with the user before discarding.** Warn explicitly if the milestone has completed work.
50
57
 
@@ -20,6 +20,8 @@ The user captured thoughts during execution using `/gsd capture`. Your job is to
20
20
 
21
21
  For each capture, classify it as one of:
22
22
 
23
+ - **stop**: User directive to halt auto-mode immediately. Use when the user says "stop", "halt", "abort", "don't continue", "pause", or otherwise wants execution to cease. Auto-mode will pause after the current unit completes. Examples: "stop running", "halt execution", "don't continue".
24
+ - **backtrack**: User directive to abandon the current milestone and return to a previous one. The user believes earlier milestones missed critical features or need rework. Include the target milestone ID (e.g., M003) in the Resolution field. Auto-mode will pause and write a regression marker. Examples: "restart from M003", "go back to milestone 3", "M004 and M005 failed, restart from M003".
23
25
  - **quick-task**: Small, self-contained, no downstream impact. Can be done in minutes without modifying the plan. Examples: fix a typo, add a missing import, tweak a config value.
24
26
  - **inject**: Belongs in the current slice but wasn't planned. Needs a new task added to the slice plan. Examples: add error handling to a module being built, add a missing test case for current work.
25
27
  - **defer**: Belongs in a future slice or milestone. Not urgent for current work. Examples: performance optimization, feature that depends on unbuilt infrastructure, nice-to-have enhancement.
@@ -28,10 +30,12 @@ For each capture, classify it as one of:
28
30
 
29
31
  ## Decision Guidelines
30
32
 
33
+ - **ALWAYS classify as stop** when the user explicitly says "stop", "halt", "abort", or "don't continue". Never shoe-horn a stop directive into "replan" or "note".
34
+ - **ALWAYS classify as backtrack** when the user references returning to a previous milestone, restarting from an earlier point, or abandoning current milestone work. Include the target milestone ID in the Resolution field (e.g., "Backtrack to M003").
31
35
  - Prefer **quick-task** when the work is clearly small and self-contained.
32
36
  - Prefer **inject** over **replan** when only a new task is needed, not rewriting existing ones.
33
37
  - Prefer **defer** over **inject** when the work doesn't belong in the current slice's scope.
34
- - Use **replan** only when remaining incomplete tasks need to change — not just for adding work.
38
+ - Use **replan** only when remaining incomplete tasks in the *current slice* need to change — not for cross-milestone issues.
35
39
  - Use **note** for observations that don't require action.
36
40
  - When unsure between quick-task and inject, consider: will this take more than 10 minutes? If yes, inject.
37
41
 
@@ -46,6 +50,7 @@ For each capture, classify it as one of:
46
50
  - If applicable, which files would be affected
47
51
 
48
52
  For captures classified as **note** or **defer**, auto-confirm without asking — these are low-impact.
53
+ For captures classified as **stop** or **backtrack**, auto-confirm without asking — these are urgent user directives that must be honored immediately.
49
54
  For captures classified as **quick-task**, **inject**, or **replan**, ask the user to confirm or choose a different classification.
50
55
 
51
56
  3. **Update** `.gsd/CAPTURES.md` — for each capture, update its section with the confirmed classification:
@@ -112,8 +112,11 @@ function buildRethinkData(
112
112
  if (dbAvailable && status !== "complete") {
113
113
  const slices = getMilestoneSlices(mid);
114
114
  if (slices.length > 0) {
115
- const done = slices.filter(s => s.status === "complete").length;
116
- sliceInfo = `${done}/${slices.length} complete`;
115
+ const done = slices.filter(s => s.status === "complete" || s.status === "done").length;
116
+ const skipped = slices.filter(s => s.status === "skipped").length;
117
+ sliceInfo = skipped > 0
118
+ ? `${done}/${slices.length} complete, ${skipped} skipped`
119
+ : `${done}/${slices.length} complete`;
117
120
  }
118
121
  }
119
122
 
@@ -295,7 +295,7 @@ function extractContextTitle(content: string | null, fallback: string): string {
295
295
  * Helper: check if a DB status counts as "done" (handles K002 ambiguity).
296
296
  */
297
297
  function isStatusDone(status: string): boolean {
298
- return status === 'complete' || status === 'done';
298
+ return status === 'complete' || status === 'done' || status === 'skipped';
299
299
  }
300
300
 
301
301
  /**
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Status predicates for GSD state-machine guards.
3
3
  *
4
- * The DB stores status as free-form strings. Two values indicate
5
- * "closed": "complete" (canonical) and "done" (legacy / alias).
4
+ * The DB stores status as free-form strings. Three values indicate
5
+ * "closed": "complete" (canonical), "done" (legacy / alias), and
6
+ * "skipped" (user-directed skip via rethink or backtrack).
6
7
  * Every inline `status === "complete" || status === "done"` should
7
8
  * use isClosedStatus() instead.
8
9
  */
9
10
 
10
11
  /** Returns true when a milestone, slice, or task status indicates closure. */
11
12
  export function isClosedStatus(status: string): boolean {
12
- return status === "complete" || status === "done";
13
+ return status === "complete" || status === "done" || status === "skipped";
13
14
  }
@@ -0,0 +1,122 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { createObservationMask } from "../context-masker.js";
5
+
6
+ // These helpers produce messages in the pi-ai LLM payload format
7
+ // (post-convertToLlm, pre-provider), which is what before_provider_request sees.
8
+
9
+ function userMsg(content: string) {
10
+ return { role: "user", content: [{ type: "text", text: content }] };
11
+ }
12
+
13
+ function assistantMsg(content: string) {
14
+ return { role: "assistant", content: [{ type: "text", text: content }] };
15
+ }
16
+
17
+ /** toolResult in pi-ai format: role "toolResult", content as TextContent[] */
18
+ function toolResult(text: string) {
19
+ return { role: "toolResult", content: [{ type: "text", text }], toolCallId: "toolu_test", toolName: "Read", isError: false };
20
+ }
21
+
22
+ /** bashExecution after convertToLlm: becomes a user message with "Ran `cmd`" prefix */
23
+ function bashResult(text: string) {
24
+ return { role: "user", content: [{ type: "text", text: `Ran \`echo test\`\n\`\`\`\n${text}\n\`\`\`` }] };
25
+ }
26
+
27
+ const MASK_TEXT = "[result masked — within summarized history]";
28
+
29
+ test("masks nothing when message count is within keepRecentTurns", () => {
30
+ const mask = createObservationMask(8);
31
+ const messages = [
32
+ userMsg("hello"),
33
+ assistantMsg("hi"),
34
+ toolResult("file contents"),
35
+ ];
36
+ const result = mask(messages as any);
37
+ assert.equal(result.length, 3);
38
+ assert.deepEqual((result[2].content as any)[0].text, "file contents");
39
+ });
40
+
41
+ test("masks tool results older than keepRecentTurns", () => {
42
+ const mask = createObservationMask(2);
43
+ const messages = [
44
+ userMsg("turn 1"),
45
+ toolResult("old tool output"),
46
+ assistantMsg("response 1"),
47
+ userMsg("turn 2"),
48
+ toolResult("newer tool output"),
49
+ assistantMsg("response 2"),
50
+ userMsg("turn 3"),
51
+ toolResult("newest tool output"),
52
+ assistantMsg("response 3"),
53
+ ];
54
+ const result = mask(messages as any);
55
+ // Old tool result (before boundary) should be masked
56
+ assert.equal((result[1].content as any)[0].text, MASK_TEXT);
57
+ // Recent tool results (within keep window) should be preserved
58
+ assert.equal((result[4].content as any)[0].text, "newer tool output");
59
+ assert.equal((result[7].content as any)[0].text, "newest tool output");
60
+ });
61
+
62
+ test("never masks assistant messages", () => {
63
+ const mask = createObservationMask(1);
64
+ const messages = [
65
+ userMsg("turn 1"),
66
+ assistantMsg("old reasoning"),
67
+ userMsg("turn 2"),
68
+ assistantMsg("new reasoning"),
69
+ ];
70
+ const result = mask(messages as any);
71
+ assert.equal((result[1].content as any)[0].text, "old reasoning");
72
+ assert.equal((result[3].content as any)[0].text, "new reasoning");
73
+ });
74
+
75
+ test("never masks user messages", () => {
76
+ const mask = createObservationMask(1);
77
+ const messages = [
78
+ userMsg("old user message"),
79
+ assistantMsg("response"),
80
+ userMsg("new user message"),
81
+ assistantMsg("response"),
82
+ ];
83
+ const result = mask(messages as any);
84
+ assert.equal((result[0].content as any)[0].text, "old user message");
85
+ });
86
+
87
+ test("masks bash result user messages", () => {
88
+ const mask = createObservationMask(1);
89
+ const messages = [
90
+ userMsg("turn 1"),
91
+ bashResult("huge log output"),
92
+ assistantMsg("response 1"),
93
+ userMsg("turn 2"),
94
+ assistantMsg("response 2"),
95
+ ];
96
+ const result = mask(messages as any);
97
+ assert.equal((result[1].content as any)[0].text, MASK_TEXT);
98
+ });
99
+
100
+ test("returns same array length", () => {
101
+ const mask = createObservationMask(1);
102
+ const messages = [
103
+ userMsg("a"), toolResult("b"), assistantMsg("c"),
104
+ userMsg("d"), toolResult("e"), assistantMsg("f"),
105
+ ];
106
+ const result = mask(messages as any);
107
+ assert.equal(result.length, messages.length);
108
+ });
109
+
110
+ test("masks toolResult by role, not by type field", () => {
111
+ const mask = createObservationMask(1);
112
+ const messages = [
113
+ userMsg("turn 1"),
114
+ // This is the actual pi-ai format: role "toolResult", no type field
115
+ { role: "toolResult", content: [{ type: "text", text: "old result" }], toolCallId: "t1", toolName: "Read", isError: false },
116
+ assistantMsg("response 1"),
117
+ userMsg("turn 2"),
118
+ assistantMsg("response 2"),
119
+ ];
120
+ const result = mask(messages as any);
121
+ assert.equal((result[1].content as any)[0].text, MASK_TEXT);
122
+ });