gsd-pi 2.37.1 → 2.38.0-dev.4d4d14a

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 (222) hide show
  1. package/README.md +1 -1
  2. package/dist/app-paths.js +1 -1
  3. package/dist/cli.js +9 -0
  4. package/dist/extension-discovery.d.ts +5 -3
  5. package/dist/extension-discovery.js +14 -9
  6. package/dist/extension-registry.js +2 -2
  7. package/dist/onboarding.js +1 -0
  8. package/dist/remote-questions-config.js +2 -2
  9. package/dist/resources/extensions/browser-tools/package.json +3 -1
  10. package/dist/resources/extensions/cmux/index.js +55 -1
  11. package/dist/resources/extensions/context7/package.json +1 -1
  12. package/dist/resources/extensions/env-utils.js +29 -0
  13. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  14. package/dist/resources/extensions/github-sync/cli.js +284 -0
  15. package/dist/resources/extensions/github-sync/index.js +73 -0
  16. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  17. package/dist/resources/extensions/github-sync/sync.js +424 -0
  18. package/dist/resources/extensions/github-sync/templates.js +118 -0
  19. package/dist/resources/extensions/github-sync/types.js +7 -0
  20. package/dist/resources/extensions/google-search/package.json +3 -1
  21. package/dist/resources/extensions/gsd/auto/session.js +6 -23
  22. package/dist/resources/extensions/gsd/auto-dispatch.js +74 -9
  23. package/dist/resources/extensions/gsd/auto-loop.js +149 -170
  24. package/dist/resources/extensions/gsd/auto-post-unit.js +105 -68
  25. package/dist/resources/extensions/gsd/auto-prompts.js +98 -33
  26. package/dist/resources/extensions/gsd/auto-recovery.js +37 -1
  27. package/dist/resources/extensions/gsd/auto-start.js +13 -2
  28. package/dist/resources/extensions/gsd/auto-worktree-sync.js +13 -5
  29. package/dist/resources/extensions/gsd/auto.js +143 -96
  30. package/dist/resources/extensions/gsd/captures.js +9 -1
  31. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  32. package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
  33. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  34. package/dist/resources/extensions/gsd/commands.js +22 -2
  35. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  36. package/dist/resources/extensions/gsd/detection.js +1 -2
  37. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  38. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  39. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  40. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  41. package/dist/resources/extensions/gsd/doctor-providers.js +62 -12
  42. package/dist/resources/extensions/gsd/doctor.js +184 -11
  43. package/dist/resources/extensions/gsd/export.js +1 -1
  44. package/dist/resources/extensions/gsd/files.js +43 -2
  45. package/dist/resources/extensions/gsd/forensics.js +1 -1
  46. package/dist/resources/extensions/gsd/git-service.js +8 -1
  47. package/dist/resources/extensions/gsd/index.js +24 -20
  48. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  49. package/dist/resources/extensions/gsd/observability-validator.js +24 -0
  50. package/dist/resources/extensions/gsd/package.json +1 -1
  51. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  52. package/dist/resources/extensions/gsd/preferences-types.js +3 -2
  53. package/dist/resources/extensions/gsd/preferences-validation.js +101 -11
  54. package/dist/resources/extensions/gsd/preferences.js +8 -5
  55. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  56. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
  57. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  58. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  59. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  60. package/dist/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  61. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  62. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +44 -0
  63. package/dist/resources/extensions/gsd/prompts/run-uat.md +25 -10
  64. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  65. package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
  66. package/dist/resources/extensions/gsd/repo-identity.js +21 -4
  67. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  68. package/dist/resources/extensions/gsd/state.js +1 -1
  69. package/dist/resources/extensions/gsd/templates/task-plan.md +11 -3
  70. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  71. package/dist/resources/extensions/gsd/worktree.js +35 -16
  72. package/dist/resources/extensions/remote-questions/status.js +2 -1
  73. package/dist/resources/extensions/remote-questions/store.js +2 -1
  74. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  75. package/dist/resources/extensions/subagent/index.js +12 -3
  76. package/dist/resources/extensions/subagent/isolation.js +2 -1
  77. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  78. package/dist/resources/extensions/universal-config/package.json +1 -1
  79. package/dist/welcome-screen.d.ts +12 -0
  80. package/dist/welcome-screen.js +53 -0
  81. package/package.json +2 -1
  82. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  83. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  84. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  85. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  86. package/packages/pi-ai/dist/models.generated.js +172 -0
  87. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  88. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  89. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  90. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  91. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  92. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  93. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  94. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  95. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  96. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  97. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  98. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  99. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  100. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  101. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  102. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  103. package/packages/pi-ai/dist/types.d.ts +2 -2
  104. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/types.js.map +1 -1
  106. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  107. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  108. package/packages/pi-ai/package.json +1 -0
  109. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  110. package/packages/pi-ai/src/models.generated.ts +172 -0
  111. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  112. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  113. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  114. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  115. package/packages/pi-ai/src/types.ts +2 -0
  116. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  117. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  119. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  122. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  123. package/packages/pi-coding-agent/package.json +1 -1
  124. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  125. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  126. package/pkg/package.json +1 -1
  127. package/src/resources/extensions/cmux/index.ts +57 -1
  128. package/src/resources/extensions/env-utils.ts +31 -0
  129. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  130. package/src/resources/extensions/github-sync/cli.ts +364 -0
  131. package/src/resources/extensions/github-sync/index.ts +93 -0
  132. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  133. package/src/resources/extensions/github-sync/sync.ts +556 -0
  134. package/src/resources/extensions/github-sync/templates.ts +183 -0
  135. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  136. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  137. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  138. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  139. package/src/resources/extensions/github-sync/types.ts +47 -0
  140. package/src/resources/extensions/gsd/auto/session.ts +7 -25
  141. package/src/resources/extensions/gsd/auto-dispatch.ts +99 -8
  142. package/src/resources/extensions/gsd/auto-loop.ts +207 -252
  143. package/src/resources/extensions/gsd/auto-post-unit.ts +82 -39
  144. package/src/resources/extensions/gsd/auto-prompts.ts +132 -36
  145. package/src/resources/extensions/gsd/auto-recovery.ts +42 -0
  146. package/src/resources/extensions/gsd/auto-start.ts +18 -2
  147. package/src/resources/extensions/gsd/auto-worktree-sync.ts +15 -4
  148. package/src/resources/extensions/gsd/auto.ts +139 -101
  149. package/src/resources/extensions/gsd/captures.ts +10 -1
  150. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  151. package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
  152. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  153. package/src/resources/extensions/gsd/commands.ts +24 -2
  154. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  155. package/src/resources/extensions/gsd/detection.ts +2 -2
  156. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  157. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  158. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  159. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  160. package/src/resources/extensions/gsd/doctor-providers.ts +64 -10
  161. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  162. package/src/resources/extensions/gsd/doctor.ts +177 -13
  163. package/src/resources/extensions/gsd/export.ts +1 -1
  164. package/src/resources/extensions/gsd/files.ts +47 -2
  165. package/src/resources/extensions/gsd/forensics.ts +1 -1
  166. package/src/resources/extensions/gsd/git-service.ts +13 -1
  167. package/src/resources/extensions/gsd/index.ts +24 -17
  168. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  169. package/src/resources/extensions/gsd/observability-validator.ts +27 -0
  170. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  171. package/src/resources/extensions/gsd/preferences-types.ts +9 -5
  172. package/src/resources/extensions/gsd/preferences-validation.ts +92 -11
  173. package/src/resources/extensions/gsd/preferences.ts +8 -5
  174. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  175. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  176. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  177. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  178. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  179. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  180. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  181. package/src/resources/extensions/gsd/prompts/reactive-execute.md +44 -0
  182. package/src/resources/extensions/gsd/prompts/run-uat.md +25 -10
  183. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  184. package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
  185. package/src/resources/extensions/gsd/repo-identity.ts +23 -4
  186. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  187. package/src/resources/extensions/gsd/state.ts +1 -1
  188. package/src/resources/extensions/gsd/templates/task-plan.md +11 -3
  189. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  190. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +16 -37
  191. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  192. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  193. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +191 -3
  194. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
  195. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  196. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  197. package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +511 -0
  198. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
  199. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  200. package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
  201. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  202. package/src/resources/extensions/gsd/types.ts +43 -1
  203. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  204. package/src/resources/extensions/gsd/worktree.ts +35 -15
  205. package/src/resources/extensions/remote-questions/status.ts +3 -1
  206. package/src/resources/extensions/remote-questions/store.ts +3 -1
  207. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  208. package/src/resources/extensions/subagent/index.ts +12 -3
  209. package/src/resources/extensions/subagent/isolation.ts +3 -1
  210. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
  211. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  212. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  213. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  214. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  215. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  216. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  217. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  218. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  219. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  220. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  221. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  222. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -42,11 +42,19 @@ estimated_files: {{estimatedFiles}}
42
42
 
43
43
  ## Inputs
44
44
 
45
+ <!-- Every input MUST be a backtick-wrapped file path. These paths are machine-parsed to
46
+ derive task dependencies — vague descriptions without paths break dependency detection.
47
+ For the first task in a slice with no prior task outputs, list the existing source files
48
+ this task reads or modifies. -->
49
+
45
50
  - `{{filePath}}` — {{whatThisTaskNeedsFromPriorWork}}
46
- - {{priorTaskSummaryInsight}}
47
51
 
48
52
  ## Expected Output
49
53
 
50
- <!-- This task should produce a real increment toward making the slice goal/demo true. A full slice plan should not be able to mark every task complete while the claimed slice behavior still does not work at the stated proof level. -->
54
+ <!-- Every output MUST be a backtick-wrapped file path the specific files this task creates
55
+ or modifies. These paths are machine-parsed to derive task dependencies.
56
+ This task should produce a real increment toward making the slice goal/demo true. A full
57
+ slice plan should not be able to mark every task complete while the claimed slice behavior
58
+ still does not work at the stated proof level. -->
51
59
 
52
- - `{{filePath}}` — {{whatThisTaskShouldProduceOrModify}}
60
+ - `{{filePath}}` — {{whatThisTaskCreatesOrModifies}}
@@ -1,9 +1,9 @@
1
1
  /**
2
- * agent-end-retry.test.ts — Regression checks for the post-#1419 agent_end model.
2
+ * agent-end-retry.test.ts — Regression checks for the agent_end model.
3
3
  *
4
- * The old recursive handleAgentEnd retry path is gone. The loop now keeps
5
- * pendingResolve + pendingAgentEndQueue on AutoSession, and handleAgentEnd is
6
- * only a thin compatibility wrapper around resolveAgentEnd().
4
+ * The per-unit one-shot resolve function lives at module level in auto-loop.ts
5
+ * (_currentResolve). handleAgentEnd is a thin compatibility wrapper around
6
+ * resolveAgentEnd().
7
7
  */
8
8
 
9
9
  import test from "node:test";
@@ -14,40 +14,43 @@ import { fileURLToPath } from "node:url";
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  const AUTO_TS_PATH = join(__dirname, "..", "auto.ts");
17
+ const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts");
17
18
  const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts");
18
19
 
19
20
  function getAutoTsSource(): string {
20
21
  return readFileSync(AUTO_TS_PATH, "utf-8");
21
22
  }
22
23
 
24
+ function getAutoLoopTsSource(): string {
25
+ return readFileSync(AUTO_LOOP_TS_PATH, "utf-8");
26
+ }
27
+
23
28
  function getSessionTsSource(): string {
24
29
  return readFileSync(SESSION_TS_PATH, "utf-8");
25
30
  }
26
31
 
27
- test("AutoSession declares pending agent_end queue state", () => {
28
- const source = getSessionTsSource();
32
+ test("auto-loop.ts declares _currentResolve for per-unit one-shot promises", () => {
33
+ const source = getAutoLoopTsSource();
29
34
  assert.ok(
30
- source.includes("pendingResolve"),
31
- "AutoSession must declare pendingResolve for the in-flight unit promise",
35
+ source.includes("_currentResolve"),
36
+ "auto-loop.ts must declare _currentResolve for the per-unit resolve function",
32
37
  );
33
38
  assert.ok(
34
- source.includes("pendingAgentEndQueue"),
35
- "AutoSession must declare pendingAgentEndQueue for between-iteration agent_end events",
39
+ source.includes("_sessionSwitchInFlight"),
40
+ "auto-loop.ts must declare _sessionSwitchInFlight guard",
36
41
  );
37
42
  });
38
43
 
39
- test("AutoSession reset clears pending agent_end queue state", () => {
44
+ test("AutoSession no longer holds promise state (moved to auto-loop.ts module scope)", () => {
40
45
  const source = getSessionTsSource();
41
- const resetIdx = source.indexOf("reset(): void");
42
- assert.ok(resetIdx > -1, "AutoSession must have a reset() method");
43
- const resetBlock = source.slice(resetIdx, resetIdx + 4000);
46
+ // Properties should NOT exist as class fields
44
47
  assert.ok(
45
- resetBlock.includes("this.pendingResolve = null"),
46
- "reset() must clear pendingResolve",
48
+ !source.includes("pendingResolve:"),
49
+ "AutoSession must not declare pendingResolve (moved to auto-loop.ts)",
47
50
  );
48
51
  assert.ok(
49
- resetBlock.includes("this.pendingAgentEndQueue = []"),
50
- "reset() must clear pendingAgentEndQueue",
52
+ !source.includes("pendingAgentEndQueue:"),
53
+ "AutoSession must not declare pendingAgentEndQueue (removed — events are dropped)",
51
54
  );
52
55
  });
53
56
 
@@ -37,9 +37,6 @@ function makeMockSession(opts?: {
37
37
  const session = {
38
38
  active: true,
39
39
  verbose: false,
40
- sessionSwitchInFlight: false,
41
- pendingResolve: null,
42
- pendingAgentEndQueue: [],
43
40
  cmdCtx: {
44
41
  newSession: () => {
45
42
  opts?.onNewSessionStart?.(session);
@@ -96,7 +93,6 @@ test("resolveAgentEnd resolves a pending runUnit promise", async () => {
96
93
  const ctx = makeMockCtx();
97
94
  const pi = makeMockPi();
98
95
  const s = makeMockSession();
99
- _setActiveSession(s);
100
96
  const event = makeEvent();
101
97
 
102
98
  // Start runUnit — it will create the promise and send a message,
@@ -108,7 +104,6 @@ test("resolveAgentEnd resolves a pending runUnit promise", async () => {
108
104
  "task",
109
105
  "T01",
110
106
  "do stuff",
111
- undefined,
112
107
  );
113
108
 
114
109
  // Give the microtask queue a tick so runUnit reaches the await
@@ -122,44 +117,35 @@ test("resolveAgentEnd resolves a pending runUnit promise", async () => {
122
117
  assert.deepEqual(result.event, event);
123
118
  });
124
119
 
125
- test("resolveAgentEnd queues event when no promise is pending", () => {
120
+ test("resolveAgentEnd drops event when no promise is pending", () => {
126
121
  _resetPendingResolve();
127
- const s = makeMockSession();
128
- _setActiveSession(s);
129
122
 
130
- // Should not throw — queues the event for the next runUnit
123
+ // Should not throw — event is dropped (logged as warning)
131
124
  assert.doesNotThrow(() => {
132
125
  resolveAgentEnd(makeEvent());
133
126
  });
134
- assert.equal(s.pendingAgentEndQueue.length, 1, "event should be queued");
135
127
  });
136
128
 
137
- test("double resolveAgentEnd only resolves once (second is queued)", async () => {
129
+ test("double resolveAgentEnd only resolves once (second is dropped)", async () => {
138
130
  _resetPendingResolve();
139
131
 
140
132
  const ctx = makeMockCtx();
141
133
  const pi = makeMockPi();
142
134
  const s = makeMockSession();
143
- _setActiveSession(s);
144
135
  const event1 = makeEvent([{ id: 1 }]);
145
136
  const event2 = makeEvent([{ id: 2 }]);
146
137
 
147
- const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
138
+ const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt");
148
139
 
149
140
  await new Promise((r) => setTimeout(r, 10));
150
141
 
151
142
  // First resolve — should work
152
143
  resolveAgentEnd(event1);
153
144
 
154
- // Second resolve — should be queued (no pending promise)
145
+ // Second resolve — should be dropped (no pending resolver)
155
146
  assert.doesNotThrow(() => {
156
147
  resolveAgentEnd(event2);
157
148
  });
158
- assert.equal(
159
- s.pendingAgentEndQueue.length,
160
- 1,
161
- "second event should be queued",
162
- );
163
149
 
164
150
  const result = await resultPromise;
165
151
  assert.equal(result.status, "completed");
@@ -174,7 +160,7 @@ test("runUnit returns cancelled when session creation fails", async () => {
174
160
  const pi = makeMockPi();
175
161
  const s = makeMockSession({ newSessionThrows: "connection refused" });
176
162
 
177
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
163
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
178
164
 
179
165
  assert.equal(result.status, "cancelled");
180
166
  assert.equal(result.event, undefined);
@@ -190,7 +176,7 @@ test("runUnit returns cancelled when session creation times out", async () => {
190
176
  // Session returns cancelled: true (simulates the timeout race outcome)
191
177
  const s = makeMockSession({ newSessionResult: { cancelled: true } });
192
178
 
193
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
179
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
194
180
 
195
181
  assert.equal(result.status, "cancelled");
196
182
  assert.equal(result.event, undefined);
@@ -205,35 +191,31 @@ test("runUnit returns cancelled when s.active is false before sendMessage", asyn
205
191
  const s = makeMockSession();
206
192
  s.active = false;
207
193
 
208
- const result = await runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
194
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
209
195
 
210
196
  assert.equal(result.status, "cancelled");
211
197
  assert.equal(pi.calls.length, 0);
212
198
  });
213
199
 
214
- test("runUnit only arms pendingResolve after newSession completes", async () => {
200
+ test("runUnit only arms resolve after newSession completes", async () => {
215
201
  _resetPendingResolve();
216
202
 
217
203
  let sawSwitchFlag = false;
218
- let sawPendingResolve: unknown = "unset";
219
204
 
220
205
  const ctx = makeMockCtx();
221
206
  const pi = makeMockPi();
222
207
  const s = makeMockSession({
223
208
  newSessionDelayMs: 20,
224
- onNewSessionStart: (session) => {
225
- sawSwitchFlag = session.sessionSwitchInFlight;
226
- sawPendingResolve = session.pendingResolve;
209
+ onNewSessionStart: () => {
210
+ sawSwitchFlag = isSessionSwitchInFlight();
227
211
  },
228
212
  });
229
- _setActiveSession(s);
230
213
 
231
- const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt", undefined);
214
+ const resultPromise = runUnit(ctx, pi, s, "task", "T01", "prompt");
232
215
 
233
216
  await new Promise((r) => setTimeout(r, 30));
234
217
 
235
218
  assert.equal(sawSwitchFlag, true, "session switch guard should be active during newSession");
236
- assert.equal(sawPendingResolve, null, "pendingResolve should not be armed before newSession completes");
237
219
  assert.equal(isSessionSwitchInFlight(), false, "session switch guard should clear after newSession settles");
238
220
 
239
221
  resolveAgentEnd(makeEvent());
@@ -275,24 +257,23 @@ test("auto-loop.ts contains a while keyword", () => {
275
257
  );
276
258
  });
277
259
 
278
- test("auto-loop.ts one-shot pattern: pendingResolve is nulled before calling resolver", () => {
260
+ test("auto-loop.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => {
279
261
  const src = readFileSync(
280
262
  resolve(import.meta.dirname, "..", "auto-loop.ts"),
281
263
  "utf-8",
282
264
  );
283
265
  // The one-shot pattern requires: save ref, null the variable, then call
284
- // Look for the pattern: s.pendingResolve = null appearing before r(
285
266
  const resolveBlock = src.slice(
286
267
  src.indexOf("export function resolveAgentEnd"),
287
268
  src.indexOf("export function resolveAgentEnd") + 600,
288
269
  );
289
- const nullIdx = resolveBlock.indexOf("pendingResolve = null");
270
+ const nullIdx = resolveBlock.indexOf("_currentResolve = null");
290
271
  const callIdx = resolveBlock.indexOf("r({");
291
- assert.ok(nullIdx > 0, "should null pendingResolve in resolveAgentEnd");
272
+ assert.ok(nullIdx > 0, "should null _currentResolve in resolveAgentEnd");
292
273
  assert.ok(callIdx > 0, "should call resolver in resolveAgentEnd");
293
274
  assert.ok(
294
275
  nullIdx < callIdx,
295
- "pendingResolve should be nulled before calling the resolver (one-shot)",
276
+ "_currentResolve should be nulled before calling the resolver (one-shot)",
296
277
  );
297
278
  });
298
279
 
@@ -462,8 +443,6 @@ function makeLoopSession(overrides?: Partial<Record<string, unknown>>) {
462
443
  pendingQuickTasks: [],
463
444
  sidecarQueue: [],
464
445
  autoModeStartModel: null,
465
- pendingResolve: null,
466
- pendingAgentEndQueue: [],
467
446
  unitDispatchCount: new Map<string, number>(),
468
447
  unitLifetimeDispatches: new Map<string, number>(),
469
448
  unitRecoveryCount: new Map<string, number>(),
@@ -100,6 +100,99 @@ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
100
100
  assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
101
101
  });
102
102
 
103
+ describe("createGridLayout", () => {
104
+ // Create a mock CmuxClient that tracks createSplitFrom calls
105
+ function makeMockClient() {
106
+ let nextId = 1;
107
+ const calls: Array<{ source: string | undefined; direction: string }> = [];
108
+
109
+ const client = {
110
+ calls,
111
+ async createGridLayout(count: number) {
112
+ // Simulate the grid layout logic with a fake client
113
+ if (count <= 0) return [];
114
+ const surfaces: string[] = [];
115
+
116
+ const createSplitFrom = async (source: string | undefined, direction: string) => {
117
+ calls.push({ source, direction });
118
+ return `surface-${nextId++}`;
119
+ };
120
+
121
+ const rightCol = await createSplitFrom("gsd-surface", "right");
122
+ surfaces.push(rightCol);
123
+ if (count === 1) return surfaces;
124
+
125
+ const bottomRight = await createSplitFrom(rightCol, "down");
126
+ surfaces.push(bottomRight);
127
+ if (count === 2) return surfaces;
128
+
129
+ const bottomLeft = await createSplitFrom("gsd-surface", "down");
130
+ surfaces.push(bottomLeft);
131
+ if (count === 3) return surfaces;
132
+
133
+ let lastSurface = bottomRight;
134
+ for (let i = 3; i < count; i++) {
135
+ const next = await createSplitFrom(lastSurface, "down");
136
+ surfaces.push(next);
137
+ lastSurface = next;
138
+ }
139
+
140
+ return surfaces;
141
+ },
142
+ };
143
+ return client;
144
+ }
145
+
146
+ test("1 agent creates single right split", async () => {
147
+ const mock = makeMockClient();
148
+ const surfaces = await mock.createGridLayout(1);
149
+ assert.equal(surfaces.length, 1);
150
+ assert.deepEqual(mock.calls, [
151
+ { source: "gsd-surface", direction: "right" },
152
+ ]);
153
+ });
154
+
155
+ test("2 agents creates right column then splits it down", async () => {
156
+ const mock = makeMockClient();
157
+ const surfaces = await mock.createGridLayout(2);
158
+ assert.equal(surfaces.length, 2);
159
+ assert.deepEqual(mock.calls, [
160
+ { source: "gsd-surface", direction: "right" },
161
+ { source: "surface-1", direction: "down" },
162
+ ]);
163
+ });
164
+
165
+ test("3 agents creates 2x2 grid (gsd + 3 agent surfaces)", async () => {
166
+ const mock = makeMockClient();
167
+ const surfaces = await mock.createGridLayout(3);
168
+ assert.equal(surfaces.length, 3);
169
+ assert.deepEqual(mock.calls, [
170
+ { source: "gsd-surface", direction: "right" },
171
+ { source: "surface-1", direction: "down" },
172
+ { source: "gsd-surface", direction: "down" },
173
+ ]);
174
+ });
175
+
176
+ test("4 agents creates 2x2 grid with extra split", async () => {
177
+ const mock = makeMockClient();
178
+ const surfaces = await mock.createGridLayout(4);
179
+ assert.equal(surfaces.length, 4);
180
+ assert.deepEqual(mock.calls, [
181
+ { source: "gsd-surface", direction: "right" },
182
+ { source: "surface-1", direction: "down" },
183
+ { source: "gsd-surface", direction: "down" },
184
+ { source: "surface-2", direction: "down" },
185
+ ]);
186
+ });
187
+
188
+ test("0 agents returns empty", async () => {
189
+ const mock = makeMockClient();
190
+ const surfaces = await mock.createGridLayout(0);
191
+ assert.equal(surfaces.length, 0);
192
+ assert.equal(mock.calls.length, 0);
193
+ });
194
+ });
195
+
103
196
  describe("cmux extension discovery opt-out", () => {
104
197
  test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
105
198
  const cmuxDir = path.resolve(
@@ -0,0 +1,266 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import { runGSDDoctor } from "../doctor.js";
6
+ import { formatDoctorReportJson } from "../doctor-format.js";
7
+ import { createTestContext } from "./test-helpers.ts";
8
+
9
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
10
+
11
+ // ── Helpers ─────────────────────────────────────────────────────────────────
12
+
13
+ function makeBase(): { base: string; gsd: string; mDir: string } {
14
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-enh-"));
15
+ const gsd = join(base, ".gsd");
16
+ const mDir = join(gsd, "milestones", "M001");
17
+ mkdirSync(join(mDir, "slices"), { recursive: true });
18
+ return { base, gsd, mDir };
19
+ }
20
+
21
+ function writeRoadmap(mDir: string, content: string): void {
22
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), content);
23
+ }
24
+
25
+ function writeSlice(mDir: string, sliceId: string, planContent: string): string {
26
+ const sDir = join(mDir, "slices", sliceId);
27
+ const tDir = join(sDir, "tasks");
28
+ mkdirSync(tDir, { recursive: true });
29
+ writeFileSync(join(sDir, `${sliceId}-PLAN.md`), planContent);
30
+ return sDir;
31
+ }
32
+
33
+ async function main(): Promise<void> {
34
+ // ── 1. Circular dependency detection ──────────────────────────────────────
35
+ console.log("\n=== circular dependency detection ===");
36
+ {
37
+ const { base, mDir } = makeBase();
38
+ writeRoadmap(mDir, `# M001: Circular Test\n\n## Slices\n- [ ] **S01: Slice A** \`risk:low\` \`depends:[S02]\`\n > After this: done\n- [ ] **S02: Slice B** \`risk:low\` \`depends:[S01]\`\n > After this: done\n`);
39
+ writeSlice(mDir, "S01", "# S01: Slice A\n\n**Goal:** A\n**Demo:** A\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
40
+ writeSlice(mDir, "S02", "# S02: Slice B\n\n**Goal:** B\n**Demo:** B\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
41
+
42
+ const result = await runGSDDoctor(base, { fix: false });
43
+ assertTrue(
44
+ result.issues.some(i => i.code === "circular_slice_dependency"),
45
+ "detects circular dependency S01 → S02 → S01",
46
+ );
47
+ rmSync(base, { recursive: true, force: true });
48
+ }
49
+
50
+ // ── 2. Duplicate task IDs ──────────────────────────────────────────────────
51
+ console.log("\n=== duplicate task IDs ===");
52
+ {
53
+ const { base, mDir } = makeBase();
54
+ writeRoadmap(mDir, `# M001: Dup Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
55
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: First** `est:10m`\n Task one.\n- [ ] **T01: Duplicate** `est:10m`\n Task dup.\n");
56
+
57
+ const result = await runGSDDoctor(base, { fix: false });
58
+ assertTrue(
59
+ result.issues.some(i => i.code === "duplicate_task_id"),
60
+ "detects duplicate task ID T01",
61
+ );
62
+ rmSync(base, { recursive: true, force: true });
63
+ }
64
+
65
+ // ── 3. Orphaned slice directory ──────────────────────────────────────────
66
+ console.log("\n=== orphaned slice directory ===");
67
+ {
68
+ const { base, mDir } = makeBase();
69
+ writeRoadmap(mDir, `# M001: Orphan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
70
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
71
+ // Create an extra slice directory not in roadmap
72
+ mkdirSync(join(mDir, "slices", "S99"), { recursive: true });
73
+
74
+ const result = await runGSDDoctor(base, { fix: false });
75
+ assertTrue(
76
+ result.issues.some(i => i.code === "orphaned_slice_directory" && i.message.includes("S99")),
77
+ "detects orphaned slice directory S99",
78
+ );
79
+ rmSync(base, { recursive: true, force: true });
80
+ }
81
+
82
+ // ── 4. Task file not in plan ───────────────────────────────────────────────
83
+ console.log("\n=== task file not in plan ===");
84
+ {
85
+ const { base, mDir } = makeBase();
86
+ writeRoadmap(mDir, `# M001: Extra Task Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
87
+ const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
88
+ // T01 summary (matches plan)
89
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), "---\nstatus: done\n---\n# T01\nDone.\n");
90
+ // T99 summary (NOT in plan)
91
+ writeFileSync(join(sDir, "tasks", "T99-SUMMARY.md"), "---\nstatus: done\n---\n# T99\nExtra.\n");
92
+
93
+ const result = await runGSDDoctor(base, { fix: false });
94
+ assertTrue(
95
+ result.issues.some(i => i.code === "task_file_not_in_plan" && i.message.includes("T99")),
96
+ "detects task summary T99 not in plan",
97
+ );
98
+ rmSync(base, { recursive: true, force: true });
99
+ }
100
+
101
+ // ── 5. Stale REPLAN file ────────────────────────────────────────────────────
102
+ console.log("\n=== stale REPLAN detection ===");
103
+ {
104
+ const { base, mDir } = makeBase();
105
+ writeRoadmap(mDir, `# M001: Replan Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
106
+ const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
107
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), "---\nstatus: done\ncompleted_at: 2026-01-01T00:00:00Z\n---\n# T01\nDone.\n");
108
+ // Add a REPLAN file even though all tasks are done
109
+ writeFileSync(join(sDir, "S01-REPLAN.md"), "# S01 REPLAN\nSomething changed.\n");
110
+
111
+ const result = await runGSDDoctor(base, { fix: false });
112
+ assertTrue(
113
+ result.issues.some(i => i.code === "stale_replan_file"),
114
+ "detects stale REPLAN when all tasks are done",
115
+ );
116
+ rmSync(base, { recursive: true, force: true });
117
+ }
118
+
119
+ // ── 6. Metrics ledger corrupt ───────────────────────────────────────────────
120
+ console.log("\n=== metrics ledger corrupt ===");
121
+ {
122
+ const { base, gsd, mDir } = makeBase();
123
+ writeRoadmap(mDir, `# M001: Metrics Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
124
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
125
+ // Write invalid metrics.json
126
+ writeFileSync(join(gsd, "metrics.json"), '{"version":2,"data":[]}');
127
+
128
+ const result = await runGSDDoctor(base, { fix: false });
129
+ assertTrue(
130
+ result.issues.some(i => i.code === "metrics_ledger_corrupt"),
131
+ "detects corrupt metrics ledger (version != 1)",
132
+ );
133
+ rmSync(base, { recursive: true, force: true });
134
+ }
135
+
136
+ // ── 7. Large planning file ──────────────────────────────────────────────────
137
+ console.log("\n=== large planning file ===");
138
+ {
139
+ const { base, mDir } = makeBase();
140
+ writeRoadmap(mDir, `# M001: Large File Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
141
+ const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
142
+ // Write a 101KB .md file
143
+ const bigContent = "# Big File\n" + "x".repeat(101 * 1024);
144
+ writeFileSync(join(sDir, "BIGFILE.md"), bigContent);
145
+
146
+ const result = await runGSDDoctor(base, { fix: false });
147
+ assertTrue(
148
+ result.issues.some(i => i.code === "large_planning_file"),
149
+ "detects large planning file over 100KB",
150
+ );
151
+ rmSync(base, { recursive: true, force: true });
152
+ }
153
+
154
+ // ── 8. Future timestamp ─────────────────────────────────────────────────────
155
+ console.log("\n=== future timestamp ===");
156
+ {
157
+ const { base, mDir } = makeBase();
158
+ writeRoadmap(mDir, `# M001: Timestamp Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
159
+ const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
160
+ // completed_at is 2 days in the future
161
+ const futureDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
162
+ writeFileSync(
163
+ join(sDir, "tasks", "T01-SUMMARY.md"),
164
+ `---\nstatus: done\ncompleted_at: ${futureDate}\n---\n# T01\nDone.\n`,
165
+ );
166
+
167
+ const result = await runGSDDoctor(base, { fix: false });
168
+ assertTrue(
169
+ result.issues.some(i => i.code === "future_timestamp"),
170
+ "detects future completed_at timestamp",
171
+ );
172
+ rmSync(base, { recursive: true, force: true });
173
+ }
174
+
175
+ // ── 9. JSON output format ───────────────────────────────────────────────────
176
+ console.log("\n=== JSON output format ===");
177
+ {
178
+ const { base, mDir } = makeBase();
179
+ writeRoadmap(mDir, `# M001: JSON Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
180
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
181
+
182
+ const result = await runGSDDoctor(base, { fix: false });
183
+ const json = formatDoctorReportJson(result);
184
+
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(json);
188
+ } catch {
189
+ parsed = null;
190
+ }
191
+
192
+ assertTrue(parsed !== null, "formatDoctorReportJson produces valid JSON");
193
+ assertTrue(typeof (parsed as Record<string, unknown>)?.ok === "boolean", "JSON has ok field");
194
+ assertTrue(Array.isArray((parsed as Record<string, unknown>)?.issues), "JSON has issues array");
195
+ assertTrue(Array.isArray((parsed as Record<string, unknown>)?.fixesApplied), "JSON has fixesApplied array");
196
+ assertTrue(typeof (parsed as Record<string, unknown>)?.generatedAt === "string", "JSON has generatedAt field");
197
+ assertTrue(typeof (parsed as Record<string, unknown>)?.summary === "object", "JSON has summary object");
198
+
199
+ rmSync(base, { recursive: true, force: true });
200
+ }
201
+
202
+ // ── 10. Dry-run mode ────────────────────────────────────────────────────────
203
+ console.log("\n=== dry-run mode ===");
204
+ {
205
+ const { base, mDir } = makeBase();
206
+ writeRoadmap(mDir, `# M001: Dry Run Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
207
+ const sDir = writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [x] **T01: Task** `est:10m`\n Done.\n");
208
+
209
+ const result = await runGSDDoctor(base, { fix: true, dryRun: true });
210
+ // In dry-run mode, no actual files should be created
211
+ assertTrue(!existsSync(join(sDir, "S01-SUMMARY.md")), "dry-run does not create slice summary");
212
+ assertTrue(
213
+ result.fixesApplied.some(f => f.startsWith("[dry-run]")),
214
+ "dry-run mode reports would-fix entries",
215
+ );
216
+
217
+ rmSync(base, { recursive: true, force: true });
218
+ }
219
+
220
+ // ── 11. Per-check timing ─────────────────────────────────────────────────────
221
+ console.log("\n=== per-check timing ===");
222
+ {
223
+ const { base, mDir } = makeBase();
224
+ writeRoadmap(mDir, `# M001: Timing Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
225
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
226
+
227
+ const result = await runGSDDoctor(base, { fix: false });
228
+ assertTrue(result.timing !== undefined, "report includes timing");
229
+ assertTrue(typeof result.timing?.git === "number", "timing.git is a number");
230
+ assertTrue(typeof result.timing?.runtime === "number", "timing.runtime is a number");
231
+ assertTrue(typeof result.timing?.environment === "number", "timing.environment is a number");
232
+ assertTrue(typeof result.timing?.gsdState === "number", "timing.gsdState is a number");
233
+
234
+ rmSync(base, { recursive: true, force: true });
235
+ }
236
+
237
+ // ── 12. Doctor history ───────────────────────────────────────────────────────
238
+ console.log("\n=== doctor history ===");
239
+ {
240
+ const { base, gsd, mDir } = makeBase();
241
+ writeRoadmap(mDir, `# M001: History Test\n\n## Slices\n- [ ] **S01: Slice** \`risk:low\` \`depends:[]\`\n > After this: done\n`);
242
+ writeSlice(mDir, "S01", "# S01: Slice\n\n**Goal:** G\n**Demo:** D\n\n## Tasks\n- [ ] **T01: Task** `est:10m`\n Pending.\n");
243
+
244
+ await runGSDDoctor(base, { fix: false });
245
+
246
+ const historyPath = join(gsd, "doctor-history.jsonl");
247
+ assertTrue(existsSync(historyPath), "doctor-history.jsonl is created after run");
248
+
249
+ const { readDoctorHistory } = await import("../doctor.js");
250
+ const history = await readDoctorHistory(base);
251
+ assertTrue(history.length >= 1, "history has at least one entry");
252
+ assertTrue(typeof history[0]?.ts === "string", "history entry has ts field");
253
+ assertTrue(typeof history[0]?.ok === "boolean", "history entry has ok field");
254
+ assertTrue(typeof history[0]?.errors === "number", "history entry has errors count");
255
+ assertTrue(Array.isArray(history[0]?.codes), "history entry has codes array");
256
+
257
+ rmSync(base, { recursive: true, force: true });
258
+ }
259
+
260
+ report();
261
+ }
262
+
263
+ main().catch(err => {
264
+ console.error(err);
265
+ process.exit(1);
266
+ });