@opengsd/gsd-pi 1.5.0-dev.4fb2d460 → 1.5.0-dev.c4c8885f

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 (234) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/browser-tools/engine/managed-gsd-browser.js +114 -36
  3. package/dist/resources/extensions/gsd/activity-log.js +1 -1
  4. package/dist/resources/extensions/gsd/auto/orchestrator.js +16 -10
  5. package/dist/resources/extensions/gsd/auto-artifact-paths.js +2 -2
  6. package/dist/resources/extensions/gsd/auto-dispatch.js +53 -15
  7. package/dist/resources/extensions/gsd/auto-model-selection.js +28 -1
  8. package/dist/resources/extensions/gsd/auto-post-unit.js +4 -3
  9. package/dist/resources/extensions/gsd/auto-prompts.js +29 -117
  10. package/dist/resources/extensions/gsd/auto-start.js +53 -30
  11. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +12 -2
  12. package/dist/resources/extensions/gsd/auto-timers.js +9 -2
  13. package/dist/resources/extensions/gsd/auto.js +97 -31
  14. package/dist/resources/extensions/gsd/compat/compat-marker.js +44 -0
  15. package/dist/resources/extensions/gsd/config-overlay.js +7 -2
  16. package/dist/resources/extensions/gsd/db/engine.js +33 -0
  17. package/dist/resources/extensions/gsd/db-adapter.js +30 -3
  18. package/dist/resources/extensions/gsd/docs/preferences-reference.md +3 -3
  19. package/dist/resources/extensions/gsd/journal.js +1 -1
  20. package/dist/resources/extensions/gsd/markdown-renderer.js +34 -91
  21. package/dist/resources/extensions/gsd/metrics.js +2 -2
  22. package/dist/resources/extensions/gsd/paths.js +70 -49
  23. package/dist/resources/extensions/gsd/post-unit-hooks.js +27 -0
  24. package/dist/resources/extensions/gsd/preferences-models.js +66 -15
  25. package/dist/resources/extensions/gsd/preferences-validation.js +66 -10
  26. package/dist/resources/extensions/gsd/rule-registry.js +106 -10
  27. package/dist/resources/extensions/gsd/state-reconciliation/drift/artifact-db.js +1 -10
  28. package/dist/resources/extensions/gsd/state-reconciliation/drift/completion.js +3 -24
  29. package/dist/resources/extensions/gsd/state-reconciliation/index.js +10 -0
  30. package/dist/resources/extensions/gsd/tools/complete-milestone.js +3 -5
  31. package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +16 -10
  32. package/dist/resources/extensions/gsd/tools/validate-milestone.js +3 -8
  33. package/dist/resources/extensions/gsd/uat-dispatch.js +94 -0
  34. package/dist/resources/extensions/gsd/uok/audit-toggle.js +31 -2
  35. package/dist/resources/extensions/gsd/uok/audit.js +3 -0
  36. package/dist/resources/extensions/gsd/uok/gate-runner.js +22 -30
  37. package/dist/resources/extensions/gsd/uok/gitops.js +18 -15
  38. package/dist/resources/extensions/gsd/uok/kernel.js +120 -46
  39. package/dist/resources/extensions/gsd/uok/model-policy.js +17 -14
  40. package/dist/resources/extensions/gsd/workflow-event-ledger.js +1 -1
  41. package/dist/resources/extensions/gsd/workflow-logger.js +1 -1
  42. package/dist/resources/extensions/gsd/workflow-projections.js +9 -26
  43. package/dist/resources/extensions/gsd/workspace.js +5 -14
  44. package/dist/resources/extensions/shared/gsd-browser-cli.js +29 -0
  45. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  46. package/dist/web/standalone/.next/BUILD_ID +1 -1
  47. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  48. package/dist/web/standalone/.next/build-manifest.json +2 -2
  49. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  50. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
  67. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
  68. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
  69. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
  70. package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
  71. package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
  72. package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
  73. package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
  74. package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  75. package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
  76. package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
  77. package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
  78. package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
  79. package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
  80. package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
  81. package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
  82. package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js.nft.json +1 -1
  83. package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  84. package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
  85. package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  86. package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
  87. package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
  88. package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
  89. package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +1 -1
  90. package/dist/web/standalone/.next/server/app/api/session/manage/route.js.nft.json +1 -1
  91. package/dist/web/standalone/.next/server/app/api/settings-data/route.js.nft.json +1 -1
  92. package/dist/web/standalone/.next/server/app/api/shutdown/route.js.nft.json +1 -1
  93. package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
  94. package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
  95. package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
  96. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
  97. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
  98. package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
  99. package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
  100. package/dist/web/standalone/.next/server/app/index.html +1 -1
  101. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  102. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  103. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  104. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  105. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  106. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  107. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  108. package/dist/web/standalone/.next/server/chunks/{1128.js → 1991.js} +1 -1
  109. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  110. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  111. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  112. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  113. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  114. package/dist/worktree-cli-create.d.ts +10 -0
  115. package/dist/worktree-cli-create.js +11 -0
  116. package/dist/worktree-cli-format.d.ts +12 -0
  117. package/dist/worktree-cli-format.js +38 -0
  118. package/dist/worktree-cli-plan.d.ts +16 -0
  119. package/dist/worktree-cli-plan.js +19 -0
  120. package/dist/worktree-cli-session.d.ts +13 -0
  121. package/dist/worktree-cli-session.js +18 -0
  122. package/dist/worktree-cli-status.d.ts +35 -0
  123. package/dist/worktree-cli-status.js +54 -0
  124. package/dist/worktree-cli.d.ts +1 -16
  125. package/dist/worktree-cli.js +28 -122
  126. package/integrations/hermes/open_gsd_hermes/gsd_client.py +1 -1
  127. package/integrations/hermes/pyproject.toml +1 -1
  128. package/package.json +1 -1
  129. package/packages/cloud-mcp-gateway/package.json +2 -2
  130. package/packages/contracts/package.json +1 -1
  131. package/packages/daemon/dist/orchestrator-agent.d.ts +21 -0
  132. package/packages/daemon/dist/orchestrator-agent.d.ts.map +1 -0
  133. package/packages/daemon/dist/orchestrator-agent.js +113 -0
  134. package/packages/daemon/dist/orchestrator-agent.js.map +1 -0
  135. package/packages/daemon/dist/orchestrator-tools.d.ts +12 -0
  136. package/packages/daemon/dist/orchestrator-tools.d.ts.map +1 -0
  137. package/packages/daemon/dist/orchestrator-tools.js +137 -0
  138. package/packages/daemon/dist/orchestrator-tools.js.map +1 -0
  139. package/packages/daemon/dist/orchestrator.d.ts +4 -56
  140. package/packages/daemon/dist/orchestrator.d.ts.map +1 -1
  141. package/packages/daemon/dist/orchestrator.js +13 -303
  142. package/packages/daemon/dist/orchestrator.js.map +1 -1
  143. package/packages/daemon/dist/orchestrator.test.js +11 -1
  144. package/packages/daemon/dist/orchestrator.test.js.map +1 -1
  145. package/packages/daemon/package.json +4 -4
  146. package/packages/gsd-agent-core/package.json +5 -5
  147. package/packages/gsd-agent-modes/package.json +7 -7
  148. package/packages/mcp-server/package.json +4 -4
  149. package/packages/native/package.json +1 -1
  150. package/packages/pi-agent-core/package.json +1 -1
  151. package/packages/pi-ai/package.json +1 -1
  152. package/packages/pi-coding-agent/package.json +7 -7
  153. package/packages/pi-tui/package.json +2 -2
  154. package/packages/rpc-client/package.json +2 -2
  155. package/pkg/package.json +1 -1
  156. package/src/resources/extensions/browser-tools/engine/managed-gsd-browser.ts +146 -33
  157. package/src/resources/extensions/browser-tools/tests/gsd-browser-launch-config.test.mjs +25 -0
  158. package/src/resources/extensions/browser-tools/tests/managed-gsd-browser-tools.test.mjs +93 -0
  159. package/src/resources/extensions/gsd/activity-log.ts +1 -1
  160. package/src/resources/extensions/gsd/auto/orchestrator.ts +17 -11
  161. package/src/resources/extensions/gsd/auto-artifact-paths.ts +2 -1
  162. package/src/resources/extensions/gsd/auto-dispatch.ts +59 -14
  163. package/src/resources/extensions/gsd/auto-model-selection.ts +33 -1
  164. package/src/resources/extensions/gsd/auto-post-unit.ts +4 -2
  165. package/src/resources/extensions/gsd/auto-prompts.ts +53 -113
  166. package/src/resources/extensions/gsd/auto-start.ts +57 -28
  167. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +12 -2
  168. package/src/resources/extensions/gsd/auto-timers.ts +9 -2
  169. package/src/resources/extensions/gsd/auto.ts +103 -30
  170. package/src/resources/extensions/gsd/compat/compat-marker.ts +47 -0
  171. package/src/resources/extensions/gsd/config-overlay.ts +7 -1
  172. package/src/resources/extensions/gsd/db/engine.ts +36 -0
  173. package/src/resources/extensions/gsd/db-adapter.ts +29 -3
  174. package/src/resources/extensions/gsd/docs/preferences-reference.md +3 -3
  175. package/src/resources/extensions/gsd/journal.ts +1 -1
  176. package/src/resources/extensions/gsd/markdown-renderer.ts +52 -103
  177. package/src/resources/extensions/gsd/metrics.ts +2 -2
  178. package/src/resources/extensions/gsd/paths.ts +87 -48
  179. package/src/resources/extensions/gsd/post-unit-hooks.ts +32 -0
  180. package/src/resources/extensions/gsd/preferences-models.ts +74 -20
  181. package/src/resources/extensions/gsd/preferences-types.ts +24 -1
  182. package/src/resources/extensions/gsd/preferences-validation.ts +77 -9
  183. package/src/resources/extensions/gsd/rule-registry.ts +111 -9
  184. package/src/resources/extensions/gsd/state-reconciliation/drift/artifact-db.ts +1 -9
  185. package/src/resources/extensions/gsd/state-reconciliation/drift/completion.ts +2 -22
  186. package/src/resources/extensions/gsd/state-reconciliation/index.ts +14 -0
  187. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +41 -2
  188. package/src/resources/extensions/gsd/tests/auto-post-unit-artifact-diagnostic.test.ts +48 -1
  189. package/src/resources/extensions/gsd/tests/auto-start-project-milestone-reconcile.test.ts +29 -1
  190. package/src/resources/extensions/gsd/tests/auto-timeout-recovery-supervisor-inflight-guard.test.ts +159 -0
  191. package/src/resources/extensions/gsd/tests/commands-verdict.test.ts +3 -1
  192. package/src/resources/extensions/gsd/tests/compat-marker.test.ts +85 -0
  193. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +45 -0
  194. package/src/resources/extensions/gsd/tests/db-adapter.test.ts +39 -0
  195. package/src/resources/extensions/gsd/tests/db-writable-probe.test.ts +66 -0
  196. package/src/resources/extensions/gsd/tests/flat-phase-renderer.test.ts +22 -1
  197. package/src/resources/extensions/gsd/tests/flat-phase-validation-path.test.ts +72 -32
  198. package/src/resources/extensions/gsd/tests/integration/paths.test.ts +88 -1
  199. package/src/resources/extensions/gsd/tests/milestone-closeout.test.ts +2 -1
  200. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +0 -1
  201. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +269 -0
  202. package/src/resources/extensions/gsd/tests/post-unit-retry-on-orchestrator-bridge.test.ts +8 -1
  203. package/src/resources/extensions/gsd/tests/read-uat-gate-verdict.test.ts +54 -0
  204. package/src/resources/extensions/gsd/tests/reassess-handler.test.ts +49 -1
  205. package/src/resources/extensions/gsd/tests/rule-registry.test.ts +95 -1
  206. package/src/resources/extensions/gsd/tests/sibling-model-fallbacks.test.ts +189 -0
  207. package/src/resources/extensions/gsd/tests/single-field-model-thinking.test.ts +156 -0
  208. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +6 -1
  209. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +32 -0
  210. package/src/resources/extensions/gsd/tests/supervisor-model-swap-inflight-guard.test.ts +181 -0
  211. package/src/resources/extensions/gsd/tests/supervisor-model-swap-thinking-clamp.test.ts +122 -0
  212. package/src/resources/extensions/gsd/tests/uat-dispatch.test.ts +211 -0
  213. package/src/resources/extensions/gsd/tests/uok-gate-runner.test.ts +39 -0
  214. package/src/resources/extensions/gsd/tests/uok-kernel-path.test.ts +226 -2
  215. package/src/resources/extensions/gsd/tests/uok-model-policy.test.ts +6 -0
  216. package/src/resources/extensions/gsd/tests/workflow-projections.test.ts +213 -2
  217. package/src/resources/extensions/gsd/tools/complete-milestone.ts +9 -5
  218. package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +19 -8
  219. package/src/resources/extensions/gsd/tools/validate-milestone.ts +13 -9
  220. package/src/resources/extensions/gsd/types.ts +23 -4
  221. package/src/resources/extensions/gsd/uat-dispatch.ts +176 -0
  222. package/src/resources/extensions/gsd/uok/audit-toggle.ts +32 -2
  223. package/src/resources/extensions/gsd/uok/audit.ts +2 -0
  224. package/src/resources/extensions/gsd/uok/gate-runner.ts +26 -36
  225. package/src/resources/extensions/gsd/uok/gitops.ts +21 -18
  226. package/src/resources/extensions/gsd/uok/kernel.ts +169 -48
  227. package/src/resources/extensions/gsd/uok/model-policy.ts +20 -17
  228. package/src/resources/extensions/gsd/workflow-event-ledger.ts +1 -1
  229. package/src/resources/extensions/gsd/workflow-logger.ts +1 -1
  230. package/src/resources/extensions/gsd/workflow-projections.ts +9 -24
  231. package/src/resources/extensions/gsd/workspace.ts +5 -21
  232. package/src/resources/extensions/shared/gsd-browser-cli.ts +29 -0
  233. /package/dist/web/standalone/.next/static/{gXoWec4UNqcR0tkuHwYCr → NPMNi2HV8BuK3baQsEUhk}/_buildManifest.js +0 -0
  234. /package/dist/web/standalone/.next/static/{gXoWec4UNqcR0tkuHwYCr → NPMNi2HV8BuK3baQsEUhk}/_ssgManifest.js +0 -0
@@ -23,7 +23,7 @@ import type {
23
23
  GSDModelPhaseKey,
24
24
  GSDThinkingConfig,
25
25
  ResolvedModelConfig,
26
- AutoSupervisorConfig,
26
+ ResolvedAutoSupervisorConfig,
27
27
  } from "./preferences-types.js";
28
28
  import { clearGSDPreferencesCache, loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
29
29
  import { getUnitPhaseChain } from "./unit-registry.js";
@@ -86,6 +86,40 @@ function resolveWinningPhase(
86
86
  return undefined;
87
87
  }
88
88
 
89
+ /**
90
+ * Normalize a single model field into a {@link ResolvedModelConfig}.
91
+ *
92
+ * Accepts either the legacy bare-string form or the extended object form
93
+ * (`{ model, provider?, fallbacks? }`). This is the shared normalization used
94
+ * for phase buckets so sibling single-model fields — `post_unit_hooks[].model`,
95
+ * `auto_supervisor.model`, and `reactive_execution.subagent_model` — honor
96
+ * `fallbacks[]` identically rather than accepting a bare string only (#1229).
97
+ *
98
+ * Returns undefined for an unset, blank, or model-less field.
99
+ */
100
+ export function normalizeModelFieldConfig(
101
+ field: string | GSDPhaseModelConfig | undefined,
102
+ ): ResolvedModelConfig | undefined {
103
+ if (!field) return undefined;
104
+ if (typeof field === "string") {
105
+ return field.trim() ? { primary: field, fallbacks: [] } : undefined;
106
+ }
107
+ if (!field.model) return undefined;
108
+ // When provider is explicitly set, prepend it to the model ID so the
109
+ // resolution code in auto.ts can do an explicit provider match.
110
+ const primary = field.provider && !field.model.includes("/")
111
+ ? `${field.provider}/${field.model}`
112
+ : field.model;
113
+ // Carry the per-field `thinking` sub-field through so the dispatch site can
114
+ // prefer it over the session/floor level, mirroring phase-inline thinking
115
+ // (#1269). `undefined` is preserved as absent so behavior is unchanged.
116
+ return {
117
+ primary,
118
+ fallbacks: field.fallbacks ?? [],
119
+ ...(field.thinking ? { thinking: field.thinking } : {}),
120
+ };
121
+ }
122
+
89
123
  /**
90
124
  * Resolve model and fallbacks for a given auto-mode unit type.
91
125
  * Returns the primary model and ordered fallbacks, or undefined if not configured.
@@ -93,6 +127,15 @@ function resolveWinningPhase(
93
127
  * Supports both legacy string format and extended object format:
94
128
  * - Legacy: `planning: claude-opus-4-6`
95
129
  * - Extended: `planning: { model: claude-opus-4-6, fallbacks: [glm-5, minimax-m2.5] }`
130
+ *
131
+ * Hook unit types (`hook/<name>`) resolve against the matching
132
+ * `post_unit_hooks[]` entry's `model` field so hook sessions honor the same
133
+ * `fallbacks[]` chain as phase buckets — the recovery path can then switch to
134
+ * a fallback provider instead of hard-failing (and potentially pausing) the
135
+ * run on a transient provider trip (#1229).
136
+ *
137
+ * The synthetic `supervisor` unit type resolves against `auto_supervisor.model`
138
+ * for supervisor interventions (wrap-up warnings, timeout recovery).
96
139
  */
97
140
  export function resolveModelWithFallbacksForUnit(
98
141
  unitType: string,
@@ -107,27 +150,26 @@ export function resolveModelWithFallbacksForUnit(
107
150
  ...(skipProfileDefaults ? { skipProfileDefaults: true } : {}),
108
151
  };
109
152
  const prefs = loadEffectiveGSDPreferences(basePath, loadOpts);
110
- const chain = phaseChainForUnit(unitType);
111
- if (!chain) return undefined;
112
- const winner = resolveWinningPhase(prefs?.preferences?.models as GSDModelConfigV2 | undefined, chain);
113
- if (!winner) return undefined;
114
- const phaseConfig = winner.config;
115
153
 
116
- // Normalize: string -> { model, fallbacks: [] }
117
- if (typeof phaseConfig === "string") {
118
- return { primary: phaseConfig, fallbacks: [] };
154
+ // Supervisor interventions resolve against `auto_supervisor.model` (#1229).
155
+ if (unitType === "supervisor") {
156
+ return normalizeModelFieldConfig(prefs?.preferences?.auto_supervisor?.model);
119
157
  }
120
158
 
121
- // When provider is explicitly set, prepend it to the model ID so the
122
- // resolution code in auto.ts can do an explicit provider match.
123
- const primary = phaseConfig.provider && !phaseConfig.model.includes("/")
124
- ? `${phaseConfig.provider}/${phaseConfig.model}`
125
- : phaseConfig.model;
159
+ // Hook sessions resolve against their own `post_unit_hooks[].model` field,
160
+ // which now shares the phase-bucket object form (#1229).
161
+ if (unitType.startsWith("hook/")) {
162
+ const hookName = unitType.slice("hook/".length);
163
+ const hooks = prefs?.preferences?.post_unit_hooks;
164
+ const hook = Array.isArray(hooks) ? hooks.find((h) => h.name === hookName) : undefined;
165
+ return normalizeModelFieldConfig(hook?.model);
166
+ }
126
167
 
127
- return {
128
- primary,
129
- fallbacks: phaseConfig.fallbacks ?? [],
130
- };
168
+ const chain = phaseChainForUnit(unitType);
169
+ if (!chain) return undefined;
170
+ const winner = resolveWinningPhase(prefs?.preferences?.models as GSDModelConfigV2 | undefined, chain);
171
+ if (!winner) return undefined;
172
+ return normalizeModelFieldConfig(winner.config);
131
173
  }
132
174
 
133
175
  /**
@@ -157,6 +199,14 @@ export function resolveThinkingLevelForUnit(
157
199
  basePath?: string,
158
200
  availableModelIds?: string[],
159
201
  ): GSDThinkingLevel | undefined {
202
+ // Single-field configs — `auto_supervisor.model` and `post_unit_hooks[].model`
203
+ // — carry their own inline `thinking` on the object model form and have no
204
+ // phase-bucket chain, so the walk below never covers them. Resolve their
205
+ // per-field level directly (#1269).
206
+ if (unitType === "supervisor" || unitType.startsWith("hook/")) {
207
+ return resolveModelWithFallbacksForUnit(unitType, basePath, availableModelIds)?.thinking;
208
+ }
209
+
160
210
  const loadOpts = availableModelIds !== undefined ? { availableModelIds } : undefined;
161
211
  const prefs = loadEffectiveGSDPreferences(basePath, loadOpts)?.preferences;
162
212
  if (!prefs) return undefined;
@@ -425,16 +475,20 @@ export function resolveDynamicRoutingConfig(): DynamicRoutingConfig {
425
475
  };
426
476
  }
427
477
 
428
- export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
478
+ export function resolveAutoSupervisorConfig(): ResolvedAutoSupervisorConfig {
429
479
  const prefs = loadEffectiveGSDPreferences();
430
480
  const configured = prefs?.preferences.auto_supervisor ?? {};
431
481
 
482
+ const modelConfig = normalizeModelFieldConfig(configured.model);
483
+
432
484
  return {
433
485
  soft_timeout_minutes: configured.soft_timeout_minutes ?? 20,
434
486
  idle_timeout_minutes: configured.idle_timeout_minutes ?? 10,
435
487
  hard_timeout_minutes: configured.hard_timeout_minutes ?? 30,
436
488
  stalled_tool_timeout_minutes: configured.stalled_tool_timeout_minutes ?? 5,
437
- ...(configured.model ? { model: configured.model } : {}),
489
+ ...(modelConfig
490
+ ? { model: modelConfig.primary, modelFallbacks: modelConfig.fallbacks }
491
+ : {}),
438
492
  };
439
493
  }
440
494
 
@@ -312,12 +312,24 @@ export interface GSDModelConfigV2 {
312
312
  export interface ResolvedModelConfig {
313
313
  primary: string;
314
314
  fallbacks: string[];
315
+ /**
316
+ * Per-field reasoning level carried from the object model form's `thinking`
317
+ * sub-field (e.g. `{ model, thinking: high }` on `post_unit_hooks[].model`,
318
+ * `auto_supervisor.model`, or `reactive_execution.subagent_model`). Applied at
319
+ * the dispatch site in preference to the session/floor level (#1269).
320
+ */
321
+ thinking?: GSDThinkingLevel;
315
322
  }
316
323
 
317
324
  export type SkillDiscoveryMode = "auto" | "suggest" | "off";
318
325
 
319
326
  export interface AutoSupervisorConfig {
320
- model?: string;
327
+ /**
328
+ * Model ID for the supervisor process. Accepts a bare model ID or the
329
+ * extended `{ model, provider?, fallbacks? }` object form for parity with
330
+ * phase buckets (#1229). Resolution normalizes it to the primary model ID.
331
+ */
332
+ model?: string | GSDPhaseModelConfig;
321
333
  soft_timeout_minutes?: number;
322
334
  idle_timeout_minutes?: number;
323
335
  hard_timeout_minutes?: number;
@@ -331,6 +343,17 @@ export interface AutoSupervisorConfig {
331
343
  stalled_tool_timeout_minutes?: number;
332
344
  }
333
345
 
346
+ /**
347
+ * The supervisor config after resolution by `resolveAutoSupervisorConfig`.
348
+ * Identical to {@link AutoSupervisorConfig} except `model` is always the
349
+ * normalized primary model ID string and `modelFallbacks` carries the ordered
350
+ * fallback chain from the object form accepted in preferences (#1229).
351
+ */
352
+ export type ResolvedAutoSupervisorConfig = Omit<AutoSupervisorConfig, "model"> & {
353
+ model?: string;
354
+ modelFallbacks?: string[];
355
+ };
356
+
334
357
  export interface RemoteQuestionsConfig {
335
358
  channel: "slack" | "discord" | "telegram";
336
359
  channel_id: string | number;
@@ -22,6 +22,7 @@ import {
22
22
  SKILL_ACTIONS,
23
23
  type WorkflowMode,
24
24
  type GSDPreferences,
25
+ type GSDPhaseModelConfig,
25
26
  type GSDSkillRule,
26
27
  type GSDThinkingLevel,
27
28
  } from "./preferences-types.js";
@@ -41,6 +42,62 @@ const VALID_UOK_TURN_ACTIONS = new Set<"commit" | "snapshot" | "status-only">([
41
42
  "snapshot",
42
43
  "status-only",
43
44
  ]);
45
+ /**
46
+ * Sanitize a single model field that accepts either a bare model ID or the
47
+ * extended `{ model, provider?, fallbacks? }` object form used by phase buckets
48
+ * (#1229). Returns the sanitized value, or undefined when unset/invalid (in
49
+ * which case an error describing `label` is pushed onto `errors`).
50
+ */
51
+ function sanitizeModelField(
52
+ raw: unknown,
53
+ label: string,
54
+ errors: string[],
55
+ warnings?: string[],
56
+ ): string | GSDPhaseModelConfig | undefined {
57
+ if (raw === undefined) return undefined;
58
+ if (typeof raw === "string") {
59
+ const trimmed = raw.trim();
60
+ if (!trimmed) {
61
+ errors.push(`${label} must not be an empty string`);
62
+ return undefined;
63
+ }
64
+ return trimmed;
65
+ }
66
+ if (raw && typeof raw === "object") {
67
+ const obj = raw as Record<string, unknown>;
68
+ const model = typeof obj.model === "string" ? obj.model.trim() : "";
69
+ if (!model) {
70
+ errors.push(`${label} object form requires a non-empty "model"`);
71
+ return undefined;
72
+ }
73
+ const sanitized: GSDPhaseModelConfig = { model };
74
+ if (typeof obj.provider === "string" && obj.provider.trim()) {
75
+ sanitized.provider = obj.provider.trim();
76
+ }
77
+ if (obj.fallbacks !== undefined) {
78
+ const fallbacks = normalizeStringArray(obj.fallbacks);
79
+ if (fallbacks.length > 0) sanitized.fallbacks = fallbacks;
80
+ }
81
+ // Preserve the object form's `thinking` sub-field so the dispatch site can
82
+ // pin the hook/supervisor/subagent reasoning level (#1269). An illegal level
83
+ // is warned-and-stripped, mirroring inline per-phase thinking, so a typo can't
84
+ // reach the resolver and be treated as explicit configuration.
85
+ if (obj.thinking !== undefined) {
86
+ if (VALID_THINKING_LEVELS.has(obj.thinking as GSDThinkingLevel)) {
87
+ sanitized.thinking = obj.thinking as GSDThinkingLevel;
88
+ } else {
89
+ warnings?.push(
90
+ `${label} thinking "${String(obj.thinking)}" is not a valid thinking level ` +
91
+ `(off, minimal, low, medium, high, xhigh) — ignored`,
92
+ );
93
+ }
94
+ }
95
+ return sanitized;
96
+ }
97
+ errors.push(`${label} must be a string or an object with a "model" field`);
98
+ return undefined;
99
+ }
100
+
44
101
  const VALID_POST_UNIT_HOOK_CRITICALITIES = new Set(["advisory", "blocking"]);
45
102
  const VALID_POST_UNIT_HOOK_ON_BLOCK_ACTIONS = new Set([
46
103
  "retry-unit",
@@ -476,7 +533,15 @@ export function validatePreferences(preferences: GSDPreferences): {
476
533
  // ─── Auto Supervisor ────────────────────────────────────────────────
477
534
  if (preferences.auto_supervisor !== undefined) {
478
535
  if (preferences.auto_supervisor && typeof preferences.auto_supervisor === "object") {
479
- validated.auto_supervisor = preferences.auto_supervisor;
536
+ // Validate `model` through the shared sanitizer so the
537
+ // `{ model, provider?, fallbacks? }` object form is rejected on the same
538
+ // terms as its post_unit_hooks / reactive_execution siblings (#1229).
539
+ // Other supervisor fields pass through unchanged.
540
+ const supervisor = { ...preferences.auto_supervisor };
541
+ if (supervisor.model !== undefined) {
542
+ supervisor.model = sanitizeModelField(supervisor.model, "auto_supervisor.model", errors, warnings);
543
+ }
544
+ validated.auto_supervisor = supervisor;
480
545
  } else {
481
546
  errors.push("auto_supervisor must be an object");
482
547
  }
@@ -568,8 +633,9 @@ export function validatePreferences(preferences: GSDPreferences): {
568
633
  const mc = typeof hook.max_cycles === "number" ? hook.max_cycles : Number(hook.max_cycles);
569
634
  validHook.max_cycles = Number.isFinite(mc) ? Math.max(1, Math.min(10, Math.round(mc))) : 1;
570
635
  }
571
- if (typeof hook.model === "string" && hook.model.trim()) {
572
- validHook.model = hook.model.trim();
636
+ const hookModel = sanitizeModelField(hook.model, `post_unit_hooks "${name}" model`, errors, warnings);
637
+ if (hookModel !== undefined) {
638
+ validHook.model = hookModel;
573
639
  }
574
640
  if (typeof hook.artifact === "string" && hook.artifact.trim()) {
575
641
  validHook.artifact = hook.artifact.trim();
@@ -1021,12 +1087,14 @@ export function validatePreferences(preferences: GSDPreferences): {
1021
1087
  }
1022
1088
  }
1023
1089
 
1024
- if (re.subagent_model !== undefined) {
1025
- if (typeof re.subagent_model === "string" && re.subagent_model.length > 0) {
1026
- validRe.subagent_model = re.subagent_model;
1027
- } else {
1028
- errors.push("reactive_execution.subagent_model must be a non-empty string");
1029
- }
1090
+ const subagentModel = sanitizeModelField(
1091
+ re.subagent_model,
1092
+ "reactive_execution.subagent_model",
1093
+ errors,
1094
+ warnings,
1095
+ );
1096
+ if (subagentModel !== undefined) {
1097
+ validRe.subagent_model = subagentModel;
1030
1098
  }
1031
1099
 
1032
1100
  const knownReKeys = new Set(["enabled", "max_parallel", "isolation_mode", "subagent_model"]);
@@ -22,8 +22,9 @@ import type {
22
22
  } from "./types.js";
23
23
  import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
24
24
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
- import { join } from "node:path";
25
+ import { dirname, join } from "node:path";
26
26
  import { parseUnitId } from "./unit-id.js";
27
+ import { resolveMilestonePath } from "./paths.js";
27
28
  import { queryJournal, type JournalEntry } from "./journal.js";
28
29
  import { readUnitRuntimeRecord, type UnitRuntimePhase } from "./unit-runtime.js";
29
30
  import { extractFrontmatterVerdict } from "./verdict-parser.js";
@@ -32,13 +33,45 @@ import { extractFrontmatterVerdict } from "./verdict-parser.js";
32
33
 
33
34
  export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
34
35
  const { milestone, slice, task } = parseUnitId(unitId);
35
- if (task !== undefined && slice !== undefined) {
36
- return join(basePath, ".gsd", "milestones", milestone, "slices", slice, "tasks", `${task}-${artifactName}`);
36
+
37
+ // Prefer the active phase directory (flat-phase layout: .gsd/phases/<NN>-.../).
38
+ // Configured gate artifacts are canonical phase-level files (e.g.
39
+ // BROWSER-RUNTIME-EVIDENCE.md), so resolve the exact declared name first,
40
+ // then a task-prefixed variant for retry sentinels / rework briefs.
41
+ const candidates: string[] = [];
42
+ const phaseDir = resolveMilestonePath(basePath, milestone);
43
+ const isFlatPhaseDir = phaseDir !== null
44
+ && !(dirname(phaseDir).endsWith("/milestones") || dirname(phaseDir).endsWith("\\milestones"));
45
+ if (isFlatPhaseDir) {
46
+ candidates.push(join(phaseDir, artifactName));
47
+ if (task !== undefined) {
48
+ candidates.push(join(phaseDir, `${task}-${artifactName}`));
49
+ }
37
50
  }
38
- if (slice !== undefined) {
39
- return join(basePath, ".gsd", "milestones", milestone, "slices", slice, artifactName);
51
+
52
+ // Legacy nested layout fallbacks (.gsd/milestones/<M>/slices/<S>/tasks/...).
53
+ const legacyBase = join(basePath, ".gsd", "milestones", milestone);
54
+ let legacyDefault: string;
55
+ if (task !== undefined && slice !== undefined) {
56
+ legacyDefault = join(legacyBase, "slices", slice, "tasks", `${task}-${artifactName}`);
57
+ candidates.push(legacyDefault);
58
+ candidates.push(join(legacyBase, "slices", slice, "tasks", artifactName));
59
+ candidates.push(join(legacyBase, "slices", slice, artifactName));
60
+ } else if (slice !== undefined) {
61
+ legacyDefault = join(legacyBase, "slices", slice, artifactName);
62
+ candidates.push(legacyDefault);
63
+ } else {
64
+ legacyDefault = join(legacyBase, artifactName);
40
65
  }
41
- return join(basePath, ".gsd", "milestones", milestone, artifactName);
66
+ candidates.push(join(legacyBase, artifactName));
67
+
68
+ const existing = candidates.find((candidate) => existsSync(candidate));
69
+ if (existing) return existing;
70
+
71
+ // Nothing on disk yet: prefer the active phase path when the phase dir is
72
+ // known, otherwise fall back to the legacy default (preserves pre-flat-phase
73
+ // behavior for missing-artifact diagnostics).
74
+ return isFlatPhaseDir ? join(phaseDir!, artifactName) : legacyDefault;
42
75
  }
43
76
 
44
77
  // ─── Dispatch Rule Conversion ──────────────────────────────────────────────
@@ -126,6 +159,13 @@ export class RuleRegistry {
126
159
  forceRun?: boolean;
127
160
  }> = [];
128
161
  cycleCounts: Map<string, number> = new Map();
162
+ /**
163
+ * Cycle keys that have already been granted a one-shot re-dispatch after a
164
+ * lost/interrupted dispatch (hook charged a cycle but never produced its own
165
+ * unit-end). Bounds the dispatch-cycle refund to exactly one per gate so a
166
+ * hook that repeatedly fails to complete cannot loop forever.
167
+ */
168
+ redispatchedGateKeys: Set<string> = new Set();
129
169
  retryPending: boolean = false;
130
170
  retryTrigger: { unitType: string; unitId: string; retryArtifact?: string } | null = null;
131
171
  hookFailure: HookFailureState | null = null;
@@ -364,6 +404,14 @@ export class RuleRegistry {
364
404
  pendingRetry: false,
365
405
  };
366
406
 
407
+ return this._buildHookDispatch(config, triggerUnitId);
408
+ }
409
+
410
+ /** Construct the sidecar dispatch for a hook without mutating registry state. */
411
+ private _buildHookDispatch(
412
+ config: PostUnitHookConfig,
413
+ triggerUnitId: string,
414
+ ): HookDispatchResult {
367
415
  const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
368
416
  let prompt = config.prompt
369
417
  .replace(/\{milestoneId\}/g, mid ?? "")
@@ -375,12 +423,29 @@ export class RuleRegistry {
375
423
  return {
376
424
  hookName: config.name,
377
425
  prompt,
378
- model: config.model,
426
+ // Model selection (including fallbacks[]) is handled by
427
+ // resolveModelWithFallbacksForUnit for the `hook/<name>` unit type (#1229).
379
428
  unitType: `hook/${config.name}`,
380
429
  unitId: triggerUnitId,
381
430
  };
382
431
  }
383
432
 
433
+ /**
434
+ * Reconstruct the in-flight hook's dispatch from the restored `activeHook`
435
+ * state, without mutating the registry. Used to reconcile a persisted
436
+ * `activeHook` whose session-local dispatch was lost across a pause/resume
437
+ * or crash-recovery, so the hook actually runs instead of being charged
438
+ * against the next unrelated unit's close-out (#1246).
439
+ */
440
+ getPendingHookDispatch(basePath: string): HookDispatchResult | null {
441
+ if (!this.activeHook) return null;
442
+ const config = resolvePostUnitHooks(basePath).find(
443
+ h => h.name === this.activeHook!.hookName,
444
+ );
445
+ if (!config) return null;
446
+ return this._buildHookDispatch(config, this.activeHook.triggerUnitId);
447
+ }
448
+
384
449
  private _assessHookCompletion(
385
450
  basePath: string,
386
451
  hook: HookExecutionState,
@@ -503,6 +568,30 @@ export class RuleRegistry {
503
568
  observedCleanExecution: boolean,
504
569
  ): HookDispatchResult | null {
505
570
  if (!observedCleanExecution) {
571
+ // The hook was charged a cycle at dispatch but never produced its own
572
+ // unit-end — its dispatch was lost/interrupted (pause/resume, crash
573
+ // recovery). Refund that dispatch-consumed cycle exactly once so the gate
574
+ // is re-dispatched at least once instead of hard-blocking on a hook that
575
+ // never actually ran.
576
+ //
577
+ // This refund is intentionally UNCONDITIONAL on the first lost dispatch —
578
+ // it must NOT be gated on `currentCycle >= max_cycles`. A lost dispatch is
579
+ // a non-run: charging its cycle at dispatch time must not consume the
580
+ // hook's real-run budget. Gating the refund on "budget already exhausted"
581
+ // lets a lost dispatch below max_cycles silently eat a genuine cycle, so
582
+ // the hook gets one fewer real run than configured — reintroducing #1244
583
+ // for max_cycles >= 2 (see the "does not steal a real-run cycle" test).
584
+ // The redispatchedGateKeys flag bounds this to a single refund per gate,
585
+ // so repeated lost dispatches still terminate (total dispatches never
586
+ // exceed max_cycles + 1) and a hook that keeps failing blocks normally.
587
+ const cycleKey = hookCycleKey(config, hook);
588
+ if (!this.redispatchedGateKeys.has(cycleKey)) {
589
+ this.redispatchedGateKeys.add(cycleKey);
590
+ const currentCycle = this.cycleCounts.get(cycleKey);
591
+ if (typeof currentCycle === "number" && currentCycle > 0) {
592
+ this.cycleCounts.set(cycleKey, currentCycle - 1);
593
+ }
594
+ }
506
595
  return this._rerunGateOrBlock(config, hook, basePath, {
507
596
  reason: `hook/${config.name} did not complete cleanly before the trigger unit resumed`,
508
597
  });
@@ -839,6 +928,7 @@ export class RuleRegistry {
839
928
  this.activeHook = null;
840
929
  this.hookQueue = [];
841
930
  this.cycleCounts.clear();
931
+ this.redispatchedGateKeys.clear();
842
932
  this.retryPending = false;
843
933
  this.retryTrigger = null;
844
934
  this.hookFailure = null;
@@ -855,6 +945,7 @@ export class RuleRegistry {
855
945
  persistState(basePath: string): void {
856
946
  const state: PersistedHookState = {
857
947
  cycleCounts: Object.fromEntries(this.cycleCounts),
948
+ redispatchedGateKeys: Array.from(this.redispatchedGateKeys),
858
949
  activeHook: this.activeHook ? { ...this.activeHook } : null,
859
950
  hookQueue: this.hookQueue.map(entry => ({
860
951
  hookName: entry.config.name,
@@ -888,6 +979,14 @@ export class RuleRegistry {
888
979
  }
889
980
  }
890
981
  }
982
+ this.redispatchedGateKeys.clear();
983
+ if (Array.isArray(state.redispatchedGateKeys)) {
984
+ for (const key of state.redispatchedGateKeys) {
985
+ if (typeof key === "string") {
986
+ this.redispatchedGateKeys.add(key);
987
+ }
988
+ }
989
+ }
891
990
  this.activeHook = state.activeHook && typeof state.activeHook === "object"
892
991
  ? { ...state.activeHook }
893
992
  : null;
@@ -918,7 +1017,7 @@ export class RuleRegistry {
918
1017
  if (existsSync(filePath)) {
919
1018
  writeFileSync(
920
1019
  filePath,
921
- JSON.stringify({ cycleCounts: {}, activeHook: null, hookQueue: [], savedAt: new Date().toISOString() }, null, 2),
1020
+ JSON.stringify({ cycleCounts: {}, redispatchedGateKeys: [], activeHook: null, hookQueue: [], savedAt: new Date().toISOString() }, null, 2),
922
1021
  "utf-8",
923
1022
  );
924
1023
  }
@@ -1014,7 +1113,10 @@ export class RuleRegistry {
1014
1113
  return {
1015
1114
  hookName: hook.name,
1016
1115
  prompt,
1017
- model: hook.model,
1116
+ // Model selection (including fallbacks[]) is resolved by dispatchHookUnit
1117
+ // via resolveModelWithFallbacksForUnit for the `hook/<name>` unit type,
1118
+ // matching the auto-mode path. Emitting the primary-only model here would
1119
+ // discard the configured fallback chain (#1229).
1018
1120
  unitType: `hook/${hook.name}`,
1019
1121
  unitId,
1020
1122
  };
@@ -213,21 +213,13 @@ function detectArtifactDbStatusDriftForMilestone(
213
213
 
214
214
  for (const task of tasks) {
215
215
  if (isClosedStatus(task.status)) continue;
216
- // Flat-phase: task summaries live in the phase dir as TID-SUMMARY.md
217
- let diskTaskSummary = resolveTaskFile(
216
+ const diskTaskSummary = resolveTaskFile(
218
217
  basePath,
219
218
  milestoneId,
220
219
  slice.id,
221
220
  task.id,
222
221
  "SUMMARY",
223
222
  );
224
- if (!diskTaskSummary) {
225
- const phaseDir = resolveMilestonePath(basePath, milestoneId);
226
- if (phaseDir) {
227
- const flatSummary = join(phaseDir, `${task.id}-SUMMARY.md`);
228
- if (existsSync(flatSummary)) diskTaskSummary = flatSummary;
229
- }
230
- }
231
223
  if (!diskTaskSummary || !existsSync(diskTaskSummary)) continue;
232
224
  addUniqueDrift(drifts, seen, {
233
225
  kind: "artifact-db-status-divergence",
@@ -20,11 +20,9 @@ import {
20
20
  } from "../../gsd-db.js";
21
21
  import {
22
22
  resolveMilestoneFile,
23
- resolveMilestonePath,
24
23
  resolveSliceFile,
25
24
  resolveTaskFile,
26
25
  } from "../../paths.js";
27
- import { join } from "node:path";
28
26
  import type { GSDState } from "../../types.js";
29
27
  import type { DriftContext, DriftHandler, DriftRecord } from "../types.js";
30
28
 
@@ -105,23 +103,13 @@ function collectMilestoneCompletionDrift(
105
103
  for (const task of getSliceTasks(mid, slice.id)) {
106
104
  if (!COMPLETE_STATUSES.has(task.status)) continue;
107
105
  if (task.completed_at !== null) continue;
108
- // Flat-phase: task summaries live in the phase dir as TID-SUMMARY.md.
109
- // Legacy: resolveTaskFile returns the tasks/TID/TID-SUMMARY.md path.
110
- let taskSummary = resolveTaskFile(
106
+ const taskSummary = resolveTaskFile(
111
107
  ctx.basePath,
112
108
  mid,
113
109
  slice.id,
114
110
  task.id,
115
111
  "SUMMARY",
116
112
  );
117
- // Flat-phase fallback: check the phase dir directly
118
- if (!taskSummary) {
119
- const phaseDir = resolveMilestonePath(ctx.basePath, mid);
120
- if (phaseDir) {
121
- const flatSummary = join(phaseDir, `${task.id}-SUMMARY.md`);
122
- if (existsSync(flatSummary)) taskSummary = flatSummary;
123
- }
124
- }
125
113
  if (taskSummary && existsSync(taskSummary)) {
126
114
  drifts.push({
127
115
  kind: "missing-completion-timestamp",
@@ -182,15 +170,7 @@ export function repairMissingCompletionTimestamp(
182
170
  task.completed_at !== null ||
183
171
  !COMPLETE_STATUSES.has(task.status)
184
172
  ) return;
185
- // Flat-phase: task summaries live in the phase dir as TID-SUMMARY.md
186
- let summary = resolveTaskFile(ctx.basePath, mid, sid, tid, "SUMMARY");
187
- if (!summary) {
188
- const phaseDir = resolveMilestonePath(ctx.basePath, mid);
189
- if (phaseDir) {
190
- const flatSummary = join(phaseDir, `${tid}-SUMMARY.md`);
191
- if (existsSync(flatSummary)) summary = flatSummary;
192
- }
193
- }
173
+ const summary = resolveTaskFile(ctx.basePath, mid, sid, tid, "SUMMARY");
194
174
  const ts = summary ? summaryMtimeIso(summary) : null;
195
175
  if (!ts) return;
196
176
  updateTaskStatus(mid, sid, tid, task.status, ts);
@@ -73,6 +73,20 @@ export async function reconcileBeforeDispatch(
73
73
  if (!deps.dryRun) {
74
74
  const { capturePlanningCompatIfNeeded } = await import("../compat/planning-compat.js");
75
75
  await capturePlanningCompatIfNeeded(basePath);
76
+
77
+ // Self-heal phantom projection entries (#1257): a renamed/removed phase
78
+ // directory leaves stale `.compat.json` projection paths that no detector
79
+ // ever prunes (they all skip missing files), so the marker drifts from disk
80
+ // reality permanently. Drop entries whose backing file is gone before the
81
+ // detect passes run. Gated on !dryRun to keep detection read-only.
82
+ const { pruneOrphanedProjectionEntries } = await import("../compat/compat-marker.js");
83
+ const prunedCount = pruneOrphanedProjectionEntries(basePath);
84
+ if (prunedCount > 0) {
85
+ logWarning(
86
+ "reconcile",
87
+ `pruned ${prunedCount} orphaned projection entr${prunedCount === 1 ? "y" : "ies"} from .compat.json (backing file no longer on disk)`,
88
+ );
89
+ }
76
90
  }
77
91
 
78
92
  for (let pass = 0; pass < MAX_PASSES; pass++) {
@@ -797,9 +797,11 @@ test("stuck-loop: empty ring on a freshly constructed orchestrator advances norm
797
797
  assert.equal(result.kind, "advanced");
798
798
  });
799
799
 
800
- test("stuck-loop: partial fill of mixed units does not block", async (t) => {
800
+ test("stuck-loop: distinct units making forward progress do not block", async (t) => {
801
+ // Healthy forward progress visits new units every advance — no key repeats,
802
+ // so no oscillation/repeat rule fires even once the window is full.
801
803
  let i = 0;
802
- const sequence = ["M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B", "M001/S01/A", "M001/S01/B"];
804
+ const sequence = ["M001/S01/A", "M001/S01/B", "M001/S01/C", "M001/S01/D", "M001/S01/E", "M001/S01/F"];
803
805
  const f = makeFixture({
804
806
  dispatch: () => ({ action: "dispatch", unitType: "execute-task", unitId: sequence[i++ % sequence.length], prompt: "p" }),
805
807
  });
@@ -811,6 +813,43 @@ test("stuck-loop: partial fill of mixed units does not block", async (t) => {
811
813
  }
812
814
  });
813
815
 
816
+ test("stuck-loop #1225: two-unit oscillation blocks once the window saturates", async (t) => {
817
+ // Reproduces the execute-task ↔ complete-slice loop: a slice gate keeps
818
+ // reopening the same task, so two keys alternate forever. Neither key ever
819
+ // saturates the window on its own, so the old matchingCount>=SIZE gate never
820
+ // consulted detect-stuck and the loop ran until an external kill. The window
821
+ // still fills, so detect-stuck's oscillation/repeat rules must now fire.
822
+ let i = 0;
823
+ const sequence: Array<{ unitType: string; unitId: string }> = [
824
+ { unitType: "execute-task", unitId: "M001/S01/T01" },
825
+ { unitType: "complete-slice", unitId: "M001/S01" },
826
+ ];
827
+ const f = makeFixture({
828
+ dispatch: () => {
829
+ const next = sequence[i++ % sequence.length];
830
+ return { action: "dispatch", unitType: next.unitType, unitId: next.unitId, prompt: "p" };
831
+ },
832
+ });
833
+ t.after(() => f.cleanup());
834
+
835
+ let blocked: Awaited<ReturnType<typeof f.orchestrator.advance>> | undefined;
836
+ for (let round = 0; round < STUCK_WINDOW_SIZE; round++) {
837
+ const result = await f.orchestrator.advance();
838
+ if (result.kind === "blocked") {
839
+ blocked = result;
840
+ break;
841
+ }
842
+ }
843
+
844
+ assert.ok(blocked, "oscillation must be detected once the window saturates");
845
+ if (!blocked || blocked.kind !== "blocked") return;
846
+ assert.equal(blocked.action, "stop");
847
+ assert.ok(
848
+ blocked.reason.startsWith("stuck-loop:"),
849
+ `expected stuck-loop verdict, got: ${blocked.reason}`,
850
+ );
851
+ });
852
+
814
853
  test("stuck-loop: ring saturated with same unit blocks with action 'stop' and stuck-loop reason", async (t) => {
815
854
  const f = makeFixture();
816
855
  t.after(() => f.cleanup());