gsd-pi 2.77.0-dev.1d17f366c → 2.77.0-dev.58d3d4d6c

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 (128) hide show
  1. package/dist/resources/extensions/gsd/auto/session.js +6 -0
  2. package/dist/resources/extensions/gsd/auto-post-unit.js +79 -0
  3. package/dist/resources/extensions/gsd/auto-prompts.js +48 -7
  4. package/dist/resources/extensions/gsd/auto-start.js +62 -3
  5. package/dist/resources/extensions/gsd/auto.js +34 -0
  6. package/dist/resources/extensions/gsd/context-store.js +23 -7
  7. package/dist/resources/extensions/gsd/forensics.js +106 -0
  8. package/dist/resources/extensions/gsd/preferences-validation.js +23 -0
  9. package/dist/resources/extensions/gsd/prompt-cache-optimizer.js +4 -0
  10. package/dist/resources/extensions/gsd/slice-cadence.js +238 -0
  11. package/dist/resources/extensions/gsd/tools/validate-milestone.js +7 -2
  12. package/dist/resources/extensions/gsd/worktree-manager.js +51 -0
  13. package/dist/resources/extensions/gsd/worktree-resolver.js +86 -7
  14. package/dist/resources/extensions/gsd/worktree-telemetry.js +198 -0
  15. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  44. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  45. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js +36 -5
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +30 -12
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  55. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts +49 -3
  56. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +48 -9
  57. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  58. package/src/resources/extensions/gsd/auto/session.ts +7 -0
  59. package/src/resources/extensions/gsd/auto-post-unit.ts +81 -0
  60. package/src/resources/extensions/gsd/auto-prompts.ts +59 -7
  61. package/src/resources/extensions/gsd/auto-start.ts +64 -2
  62. package/src/resources/extensions/gsd/auto.ts +37 -0
  63. package/src/resources/extensions/gsd/context-store.ts +25 -8
  64. package/src/resources/extensions/gsd/forensics.ts +118 -1
  65. package/src/resources/extensions/gsd/git-service.ts +16 -0
  66. package/src/resources/extensions/gsd/journal.ts +11 -1
  67. package/src/resources/extensions/gsd/preferences-validation.ts +21 -0
  68. package/src/resources/extensions/gsd/prompt-cache-optimizer.ts +4 -0
  69. package/src/resources/extensions/gsd/slice-cadence.ts +299 -0
  70. package/src/resources/extensions/gsd/tests/artifacts-table-preserved-on-cache-invalidate.test.ts +2 -1
  71. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +5 -8
  72. package/src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts +4 -1
  73. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +12 -9
  74. package/src/resources/extensions/gsd/tests/auto-start-clean-runtime-db-gated.test.ts +3 -2
  75. package/src/resources/extensions/gsd/tests/auto-start-cold-db-bootstrap.test.ts +2 -2
  76. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +4 -3
  77. package/src/resources/extensions/gsd/tests/auto-start-worktree-db-path.test.ts +2 -2
  78. package/src/resources/extensions/gsd/tests/auto-thinking-restore.test.ts +3 -2
  79. package/src/resources/extensions/gsd/tests/auto-warning-noise-regression.test.ts +3 -2
  80. package/src/resources/extensions/gsd/tests/bootstrap-derive-state-db-open.test.ts +2 -1
  81. package/src/resources/extensions/gsd/tests/canonical-milestone-root.test.ts +108 -0
  82. package/src/resources/extensions/gsd/tests/complete-slice-verification-gate.test.ts +2 -1
  83. package/src/resources/extensions/gsd/tests/context-store.test.ts +79 -0
  84. package/src/resources/extensions/gsd/tests/copy-planning-artifacts-samepath.test.ts +2 -1
  85. package/src/resources/extensions/gsd/tests/discuss-slice-structured-questions.test.ts +2 -1
  86. package/src/resources/extensions/gsd/tests/discuss-tool-scope-leak.test.ts +2 -1
  87. package/src/resources/extensions/gsd/tests/double-merge-guard.test.ts +4 -3
  88. package/src/resources/extensions/gsd/tests/empty-content-abort-loop.test.ts +4 -3
  89. package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +2 -2
  90. package/src/resources/extensions/gsd/tests/forensics-hook-key-parse.test.ts +2 -1
  91. package/src/resources/extensions/gsd/tests/forensics-worktree-telemetry.test.ts +145 -0
  92. package/src/resources/extensions/gsd/tests/idle-watchdog-stall-override.test.ts +6 -1
  93. package/src/resources/extensions/gsd/tests/import-done-milestones.test.ts +2 -1
  94. package/src/resources/extensions/gsd/tests/integration/all-milestones-complete-merge.test.ts +2 -1
  95. package/src/resources/extensions/gsd/tests/interactive-routing-bypass.test.ts +9 -3
  96. package/src/resources/extensions/gsd/tests/knowledge.test.ts +93 -1
  97. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +10 -3
  98. package/src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts +2 -1
  99. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +59 -2
  100. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +4 -1
  101. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +2 -1
  102. package/src/resources/extensions/gsd/tests/prompt-cache-optimizer.test.ts +12 -0
  103. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +15 -4
  104. package/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts +3 -2
  105. package/src/resources/extensions/gsd/tests/queued-discuss-fast-path.test.ts +4 -5
  106. package/src/resources/extensions/gsd/tests/restore-tools-after-discuss.test.ts +6 -3
  107. package/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +3 -2
  108. package/src/resources/extensions/gsd/tests/slice-cadence.test.ts +242 -0
  109. package/src/resources/extensions/gsd/tests/slice-context-injection.test.ts +3 -2
  110. package/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +2 -1
  111. package/src/resources/extensions/gsd/tests/stop-auto-race-null-unit.test.ts +3 -3
  112. package/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +7 -6
  113. package/src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts +4 -3
  114. package/src/resources/extensions/gsd/tests/test-helpers.test.ts +147 -0
  115. package/src/resources/extensions/gsd/tests/test-helpers.ts +140 -0
  116. package/src/resources/extensions/gsd/tests/token-profile.test.ts +8 -1
  117. package/src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts +6 -5
  118. package/src/resources/extensions/gsd/tests/worktree-health-monorepo.test.ts +2 -2
  119. package/src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts +2 -2
  120. package/src/resources/extensions/gsd/tests/worktree-submodule-safety.test.ts +2 -2
  121. package/src/resources/extensions/gsd/tests/worktree-telemetry.test.ts +210 -0
  122. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +3 -3
  123. package/src/resources/extensions/gsd/tools/validate-milestone.ts +8 -2
  124. package/src/resources/extensions/gsd/worktree-manager.ts +53 -0
  125. package/src/resources/extensions/gsd/worktree-resolver.ts +96 -9
  126. package/src/resources/extensions/gsd/worktree-telemetry.ts +322 -0
  127. /package/dist/web/standalone/.next/static/{vidAVJkURvTJ0_V2-64ro → Cev5xrAYA3ZGTRLyjR2fX}/_buildManifest.js +0 -0
  128. /package/dist/web/standalone/.next/static/{vidAVJkURvTJ0_V2-64ro → Cev5xrAYA3ZGTRLyjR2fX}/_ssgManifest.js +0 -0
@@ -59,3 +59,143 @@ export function createTestContext() {
59
59
 
60
60
  return { assertEq, assertTrue, assertMatch, assertNoMatch, report };
61
61
  }
62
+
63
+ // ─── Source-inspection helpers ────────────────────────────────────────────────
64
+ //
65
+ // Replace brittle fixed-byte slice patterns like `src.slice(idx, idx + 6000)`
66
+ // with structural boundary detection. See #4773, #4774.
67
+
68
+ /**
69
+ * Extract a region of source between a start anchor and either an explicit
70
+ * end anchor or, if none is given, a set of reasonable structural
71
+ * terminators (next `private `/`export `/`function `/`class `/`interface `/
72
+ * `//` section separator). Falls back to end-of-source if none match.
73
+ *
74
+ * Use this instead of `src.slice(startIdx, startIdx + N)` when searching
75
+ * for patterns within a specific method or region — the start anchor is
76
+ * what the caller already has, and the end is determined by structure,
77
+ * not by a magic byte count that breaks under refactors.
78
+ *
79
+ * @param src The source text.
80
+ * @param startAnchor Literal substring that marks the start of the region.
81
+ * Typically a function name, a section header comment, or
82
+ * a distinctive statement.
83
+ * @param endAnchor Optional. Either a literal substring that marks the end,
84
+ * or `{ fromIdx: number }` to find the *next* occurrence
85
+ * of `startAnchor` at or after `fromIdx` (useful when the
86
+ * anchor appears multiple times and a positional search
87
+ * is required). When omitted, structural terminators are
88
+ * used.
89
+ *
90
+ * Returns the extracted region (including the start anchor), or an empty
91
+ * string if `startAnchor` is not found.
92
+ */
93
+ export function extractSourceRegion(
94
+ src: string,
95
+ startAnchor: string,
96
+ endAnchor?: string | { fromIdx: number },
97
+ ): string {
98
+ const fromIdx = typeof endAnchor === "object" && endAnchor !== null
99
+ ? endAnchor.fromIdx
100
+ : 0;
101
+ const endLiteral = typeof endAnchor === "string" ? endAnchor : undefined;
102
+
103
+ const startIdx = src.indexOf(startAnchor, fromIdx);
104
+ if (startIdx < 0) return "";
105
+
106
+ if (endLiteral) {
107
+ const endIdx = src.indexOf(endLiteral, startIdx + startAnchor.length);
108
+ return endIdx > 0 ? src.slice(startIdx, endIdx) : src.slice(startIdx);
109
+ }
110
+
111
+ // Heuristic terminators — the next sibling declaration/section.
112
+ const terminators = [
113
+ "\n private ",
114
+ "\n public ",
115
+ "\n protected ",
116
+ "\n static ",
117
+ "\nexport function ",
118
+ "\nexport async function ",
119
+ "\nfunction ",
120
+ "\nasync function ",
121
+ "\nexport class ",
122
+ "\nclass ",
123
+ "\nexport interface ",
124
+ "\ninterface ",
125
+ "\n// ─", // section separator comments
126
+ "\n/** ", // next docblock
127
+ ];
128
+
129
+ let earliestEnd = -1;
130
+ for (const t of terminators) {
131
+ const idx = src.indexOf(t, startIdx + startAnchor.length);
132
+ if (idx > 0 && (earliestEnd < 0 || idx < earliestEnd)) earliestEnd = idx;
133
+ }
134
+
135
+ return earliestEnd > 0 ? src.slice(startIdx, earliestEnd) : src.slice(startIdx);
136
+ }
137
+
138
+ /**
139
+ * Poll `condition()` until it returns truthy or `timeoutMs` elapses.
140
+ * Returns the truthy value from `condition()`, or throws on timeout.
141
+ *
142
+ * Use this instead of `await new Promise(r => setTimeout(r, <magic-ms>))`
143
+ * when waiting for a state change that tests produce. The fixed-sleep
144
+ * pattern is flaky: too short → race; too long → slow tests.
145
+ *
146
+ * @param condition Predicate. Returns truthy when done. May be async.
147
+ * @param opts.timeoutMs Max total wait. Defaults to 2000ms.
148
+ * @param opts.intervalMs Poll interval. Defaults to 10ms.
149
+ * @param opts.description Included in the timeout error for debuggability.
150
+ */
151
+ export async function waitForCondition<T>(
152
+ condition: () => T | Promise<T>,
153
+ opts: { timeoutMs?: number; intervalMs?: number; description?: string } = {},
154
+ ): Promise<T> {
155
+ const timeoutMs = opts.timeoutMs ?? 2000;
156
+ const intervalMs = opts.intervalMs ?? 10;
157
+ const deadline = Date.now() + timeoutMs;
158
+ let lastErr: unknown;
159
+ while (Date.now() < deadline) {
160
+ try {
161
+ const result = await condition();
162
+ if (result) return result;
163
+ } catch (e) {
164
+ lastErr = e;
165
+ }
166
+ await new Promise((r) => setTimeout(r, intervalMs));
167
+ }
168
+ const desc = opts.description ?? "condition";
169
+ const errSuffix = lastErr instanceof Error ? ` (last error: ${lastErr.message})` : "";
170
+ throw new Error(`waitForCondition timed out after ${timeoutMs}ms waiting for ${desc}${errSuffix}`);
171
+ }
172
+
173
+ /**
174
+ * Find the first line in rendered output that matches a predicate. Returns
175
+ * the line's index and text, or throws if no line matches.
176
+ *
177
+ * Use this instead of `lines[N]` indexing when the N is positional and
178
+ * could shift under formatting changes.
179
+ */
180
+ export function findLine(
181
+ output: string,
182
+ predicate: RegExp | ((line: string) => boolean),
183
+ ): { index: number; text: string } {
184
+ const lines = output.split("\n");
185
+ const fn = predicate instanceof RegExp
186
+ ? (l: string) => {
187
+ // RegExp.test is stateful when the pattern has /g or /y flags
188
+ // (maintains lastIndex across calls). Reset before each test so
189
+ // matches on different lines don't silently skip.
190
+ if (predicate.global || predicate.sticky) predicate.lastIndex = 0;
191
+ return predicate.test(l);
192
+ }
193
+ : predicate;
194
+ for (let i = 0; i < lines.length; i++) {
195
+ if (fn(lines[i]!)) return { index: i, text: lines[i]! };
196
+ }
197
+ const preview = lines.slice(0, 10).join("\n");
198
+ throw new Error(
199
+ `findLine: no line matched. First 10 lines were:\n${preview}`,
200
+ );
201
+ }
@@ -14,6 +14,7 @@ import assert from "node:assert/strict";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
+ import { extractSourceRegion } from "./test-helpers.ts";
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
 
@@ -127,7 +128,13 @@ test("profile: balanced profile skips research, reassess, and slice research (AD
127
128
 
128
129
  test("profile: quality profile skips research, slice research, and reassess (ADR-003)", () => {
129
130
  const qualityIdx = preferencesSrc.indexOf('case "quality":');
130
- const qualityBlock = preferencesSrc.slice(qualityIdx, qualityIdx + 300);
131
+ // preferencesSrc is concatenated from multiple modules — bound the region
132
+ // with the next case marker so the assertion stays tightly scoped.
133
+ const qualityBlock = extractSourceRegion(
134
+ preferencesSrc,
135
+ 'case "quality":',
136
+ 'case "burn-max":',
137
+ );
131
138
  assert.ok(qualityBlock.includes("skip_research: true"), "quality should skip research");
132
139
  assert.ok(qualityBlock.includes("skip_slice_research: true"), "quality should skip slice research");
133
140
  assert.ok(qualityBlock.includes("skip_reassess: true"), "quality should skip reassess");
@@ -15,6 +15,7 @@ import { describe, it } from 'node:test'
15
15
  import assert from 'node:assert/strict'
16
16
  import { readFileSync } from 'node:fs'
17
17
  import { resolve } from 'node:path'
18
+ import { extractSourceRegion } from "./test-helpers.ts";
18
19
 
19
20
  const src = readFileSync(
20
21
  resolve(process.cwd(), 'src', 'resources', 'extensions', 'gsd', 'auto-recovery.ts'),
@@ -28,7 +29,7 @@ describe('verifyExpectedArtifact legacy branch tightened (#3607)', () => {
28
29
  assert.ok(legacyIdx !== -1, 'LEGACY comment must exist')
29
30
 
30
31
  // Check the code within a reasonable window after the LEGACY comment
31
- const legacyBlock = src.slice(legacyIdx, legacyIdx + 600)
32
+ const legacyBlock = extractSourceRegion(src, 'LEGACY: Pre-migration fallback')
32
33
 
33
34
  assert.ok(
34
35
  !legacyBlock.includes('hdRe'),
@@ -40,7 +41,7 @@ describe('verifyExpectedArtifact legacy branch tightened (#3607)', () => {
40
41
  const legacyIdx = src.indexOf('LEGACY: Pre-migration fallback')
41
42
  assert.ok(legacyIdx !== -1)
42
43
 
43
- const legacyBlock = src.slice(legacyIdx, legacyIdx + 600)
44
+ const legacyBlock = extractSourceRegion(src, 'LEGACY: Pre-migration fallback')
44
45
 
45
46
  assert.ok(
46
47
  legacyBlock.includes('cbRe'),
@@ -58,7 +59,7 @@ describe('verifyExpectedArtifact legacy branch tightened (#3607)', () => {
58
59
  const legacyIdx = src.indexOf('LEGACY: Pre-migration fallback')
59
60
  assert.ok(legacyIdx !== -1)
60
61
 
61
- const legacyBlock = src.slice(legacyIdx, legacyIdx + 1000)
62
+ const legacyBlock = extractSourceRegion(src, 'LEGACY: Pre-migration fallback')
62
63
 
63
64
  // The else branch: no plan file means cannot verify
64
65
  assert.ok(
@@ -71,7 +72,7 @@ describe('verifyExpectedArtifact legacy branch tightened (#3607)', () => {
71
72
  const legacyIdx = src.indexOf('LEGACY: Pre-migration fallback')
72
73
  assert.ok(legacyIdx !== -1)
73
74
 
74
- const legacyBlock = src.slice(legacyIdx, legacyIdx + 1000)
75
+ const legacyBlock = extractSourceRegion(src, 'LEGACY: Pre-migration fallback')
75
76
 
76
77
  assert.ok(
77
78
  legacyBlock.includes('DB available but task row not found'),
@@ -80,7 +81,7 @@ describe('verifyExpectedArtifact legacy branch tightened (#3607)', () => {
80
81
 
81
82
  // The comment should be followed by a return false
82
83
  const commentIdx = legacyBlock.indexOf('DB available but task row not found')
83
- const afterComment = legacyBlock.slice(commentIdx, commentIdx + 200)
84
+ const afterComment = extractSourceRegion(legacyBlock, 'DB available but task row not found')
84
85
  assert.ok(
85
86
  afterComment.includes('return false'),
86
87
  'missing task row when DB available must return false',
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { createTestContext } from "./test-helpers.ts";
11
+ import {createTestContext, extractSourceRegion } from "./test-helpers.ts";
12
12
 
13
13
  const { assertTrue, report } = createTestContext();
14
14
 
@@ -22,7 +22,7 @@ console.log("\n=== #2347: Worktree health check supports monorepos ===");
22
22
  const healthCheckIdx = src.indexOf("Worktree health check");
23
23
  assertTrue(healthCheckIdx > 0, "auto/phases.ts has worktree health check section");
24
24
 
25
- const healthCheckRegion = src.slice(healthCheckIdx, healthCheckIdx + 2000);
25
+ const healthCheckRegion = extractSourceRegion(src, "Worktree health check");
26
26
 
27
27
  // ── Test 2: The check walks parent directories for project markers ──────
28
28
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { createTestContext } from "./test-helpers.ts";
16
+ import {createTestContext, extractSourceRegion } from "./test-helpers.ts";
17
17
 
18
18
  const { assertTrue, report } = createTestContext();
19
19
 
@@ -27,7 +27,7 @@ console.log("\n=== #2616: Worktree cleanup detects nested .git directories ===")
27
27
  const removeWorktreeIdx = src.indexOf("export function removeWorktree");
28
28
  assertTrue(removeWorktreeIdx > 0, "worktree-manager.ts exports removeWorktree");
29
29
 
30
- const fnBody = src.slice(removeWorktreeIdx, removeWorktreeIdx + 5000);
30
+ const fnBody = extractSourceRegion(src, "export function removeWorktree");
31
31
 
32
32
  const detectsNestedGit =
33
33
  fnBody.includes("nested") && fnBody.includes(".git") ||
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { createTestContext } from "./test-helpers.ts";
11
+ import {createTestContext, extractSourceRegion } from "./test-helpers.ts";
12
12
 
13
13
  const { assertTrue, report } = createTestContext();
14
14
 
@@ -22,7 +22,7 @@ console.log("\n=== #2337: Worktree teardown preserves submodule state ===");
22
22
  const removeWorktreeIdx = src.indexOf("export function removeWorktree");
23
23
  assertTrue(removeWorktreeIdx > 0, "worktree-manager.ts exports removeWorktree");
24
24
 
25
- const fnBody = src.slice(removeWorktreeIdx, removeWorktreeIdx + 6000);
25
+ const fnBody = extractSourceRegion(src, "export function removeWorktree");
26
26
 
27
27
  // ── Test 2: The function checks for submodules before force removal ─────
28
28
 
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Tests for worktree telemetry — #4764.
3
+ *
4
+ * Covers emit helpers (writing to the journal) and the aggregator
5
+ * (summarizeWorktreeTelemetry).
6
+ */
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ import {
16
+ emitWorktreeCreated,
17
+ emitWorktreeMerged,
18
+ emitWorktreeOrphaned,
19
+ emitAutoExit,
20
+ emitCanonicalRootRedirect,
21
+ summarizeWorktreeTelemetry,
22
+ percentile,
23
+ } from "../worktree-telemetry.ts";
24
+ import { resolveCanonicalMilestoneRoot } from "../worktree-manager.ts";
25
+ import { queryJournal } from "../journal.ts";
26
+
27
+ function makeTmpBase(): string {
28
+ const base = join(tmpdir(), `gsd-tel-test-${randomUUID()}`);
29
+ mkdirSync(join(base, ".gsd"), { recursive: true });
30
+ return base;
31
+ }
32
+
33
+ function cleanup(base: string): void {
34
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
35
+ }
36
+
37
+ test("emitWorktreeCreated writes a worktree-created journal event", () => {
38
+ const base = makeTmpBase();
39
+ try {
40
+ emitWorktreeCreated(base, "M001", { reason: "create-milestone" });
41
+ const entries = queryJournal(base, { eventType: "worktree-created" });
42
+ assert.equal(entries.length, 1);
43
+ assert.equal(entries[0].data?.milestoneId, "M001");
44
+ assert.equal(entries[0].data?.reason, "create-milestone");
45
+ assert.ok(typeof entries[0].data?.startedAt === "string");
46
+ } finally { cleanup(base); }
47
+ });
48
+
49
+ test("emitWorktreeMerged records duration and conflict fields", () => {
50
+ const base = makeTmpBase();
51
+ try {
52
+ emitWorktreeMerged(base, "M001", {
53
+ reason: "milestone-complete",
54
+ durationMs: 1234,
55
+ sliceCount: 3,
56
+ taskCount: 9,
57
+ conflict: false,
58
+ });
59
+ const entries = queryJournal(base, { eventType: "worktree-merged" });
60
+ assert.equal(entries.length, 1);
61
+ assert.equal(entries[0].data?.milestoneId, "M001");
62
+ assert.equal(entries[0].data?.durationMs, 1234);
63
+ assert.equal(entries[0].data?.sliceCount, 3);
64
+ assert.equal(entries[0].data?.conflict, false);
65
+ } finally { cleanup(base); }
66
+ });
67
+
68
+ test("emitWorktreeOrphaned captures reason and commits-ahead", () => {
69
+ const base = makeTmpBase();
70
+ try {
71
+ emitWorktreeOrphaned(base, "M002", {
72
+ reason: "in-progress-unmerged",
73
+ commitsAhead: 4,
74
+ worktreeDirExists: true,
75
+ });
76
+ const entries = queryJournal(base, { eventType: "worktree-orphaned" });
77
+ assert.equal(entries.length, 1);
78
+ assert.equal(entries[0].data?.milestoneId, "M002");
79
+ assert.equal(entries[0].data?.reason, "in-progress-unmerged");
80
+ assert.equal(entries[0].data?.commitsAhead, 4);
81
+ assert.equal(entries[0].data?.worktreeDirExists, true);
82
+ } finally { cleanup(base); }
83
+ });
84
+
85
+ test("emitAutoExit records reason and unmerged-work signal", () => {
86
+ const base = makeTmpBase();
87
+ try {
88
+ emitAutoExit(base, {
89
+ reason: "pause",
90
+ milestoneId: "M003",
91
+ milestoneMerged: false,
92
+ });
93
+ const entries = queryJournal(base, { eventType: "auto-exit" });
94
+ assert.equal(entries.length, 1);
95
+ assert.equal(entries[0].data?.reason, "pause");
96
+ assert.equal(entries[0].data?.milestoneMerged, false);
97
+ } finally { cleanup(base); }
98
+ });
99
+
100
+ test("summarizeWorktreeTelemetry aggregates events correctly", () => {
101
+ const base = makeTmpBase();
102
+ try {
103
+ // Two created, one merged, two orphans (different reasons), three exits,
104
+ // two of which left work unmerged.
105
+ emitWorktreeCreated(base, "M001");
106
+ emitWorktreeCreated(base, "M002");
107
+
108
+ emitWorktreeMerged(base, "M001", { reason: "milestone-complete", durationMs: 500, conflict: false });
109
+
110
+ emitWorktreeOrphaned(base, "M002", { reason: "in-progress-unmerged", commitsAhead: 2 });
111
+ emitWorktreeOrphaned(base, "M003", { reason: "complete-unmerged" });
112
+
113
+ emitAutoExit(base, { reason: "pause", milestoneId: "M002", milestoneMerged: false });
114
+ emitAutoExit(base, { reason: "stop", milestoneId: "M002", milestoneMerged: false });
115
+ emitAutoExit(base, { reason: "all-complete", milestoneId: "M001", milestoneMerged: true });
116
+
117
+ const summary = summarizeWorktreeTelemetry(base);
118
+ assert.equal(summary.worktreesCreated, 2);
119
+ assert.equal(summary.worktreesMerged, 1);
120
+ assert.equal(summary.orphansDetected, 2);
121
+ assert.deepStrictEqual(summary.orphansByReason, {
122
+ "in-progress-unmerged": 1,
123
+ "complete-unmerged": 1,
124
+ });
125
+ assert.deepStrictEqual(summary.mergeDurationsMs, [500]);
126
+ assert.equal(summary.mergeConflicts, 0);
127
+ assert.deepStrictEqual(summary.exitsByReason, {
128
+ "pause": 1,
129
+ "stop": 1,
130
+ "all-complete": 1,
131
+ });
132
+ assert.equal(summary.exitsWithUnmergedWork, 2, "pause and stop each left work unmerged");
133
+ } finally { cleanup(base); }
134
+ });
135
+
136
+ test("summarizeWorktreeTelemetry counts merge conflicts", () => {
137
+ const base = makeTmpBase();
138
+ try {
139
+ emitWorktreeMerged(base, "M001", { reason: "milestone-complete", durationMs: 100, conflict: false });
140
+ emitWorktreeMerged(base, "M002", { reason: "milestone-complete", durationMs: 200, conflict: true, conflictedFiles: 3 });
141
+ emitWorktreeMerged(base, "M003", { reason: "milestone-complete", durationMs: 150, conflict: false });
142
+
143
+ const summary = summarizeWorktreeTelemetry(base);
144
+ assert.equal(summary.worktreesMerged, 3);
145
+ assert.equal(summary.mergeConflicts, 1);
146
+ // Durations are sorted
147
+ assert.deepStrictEqual(summary.mergeDurationsMs, [100, 150, 200]);
148
+ } finally { cleanup(base); }
149
+ });
150
+
151
+ test("resolveCanonicalMilestoneRoot emits canonical-root-redirect on redirect", () => {
152
+ const base = makeTmpBase();
153
+ try {
154
+ // Create the live-worktree shape the resolver looks for
155
+ const wtDir = join(base, ".gsd", "worktrees", "M001");
156
+ mkdirSync(wtDir, { recursive: true });
157
+ writeFileSync(join(wtDir, ".git"), `gitdir: ${join(base, ".git", "worktrees", "M001")}\n`);
158
+
159
+ const result = resolveCanonicalMilestoneRoot(base, "M001");
160
+ assert.equal(result, wtDir);
161
+
162
+ const summary = summarizeWorktreeTelemetry(base);
163
+ assert.equal(summary.canonicalRedirects, 1, "redirect should emit exactly one event");
164
+ } finally { cleanup(base); }
165
+ });
166
+
167
+ test("resolveCanonicalMilestoneRoot emits nothing when it doesn't redirect", () => {
168
+ const base = makeTmpBase();
169
+ try {
170
+ const result = resolveCanonicalMilestoneRoot(base, "M999");
171
+ assert.equal(result, base);
172
+
173
+ const summary = summarizeWorktreeTelemetry(base);
174
+ assert.equal(summary.canonicalRedirects, 0, "no worktree → no redirect event");
175
+ } finally { cleanup(base); }
176
+ });
177
+
178
+ test("percentile helper returns quantiles of a sorted array (nearest-rank)", () => {
179
+ assert.equal(percentile([], 0.5), null);
180
+ assert.equal(percentile([10], 0.5), 10);
181
+ // Boundary behavior
182
+ assert.equal(percentile([10, 20, 30, 40], 0), 10);
183
+ assert.equal(percentile([10, 20, 30, 40], 1), 40);
184
+ // Nearest-rank: idx = ceil(q*n) - 1
185
+ // q=0.5, n=4 → idx = 2-1 = 1 → 20
186
+ assert.equal(percentile([10, 20, 30, 40], 0.5), 20);
187
+ // p95 on 20 values = idx ceil(0.95*20)-1 = 19-1 = 18 → value at index 18 (19th sample)
188
+ const twenty = Array.from({ length: 20 }, (_, i) => (i + 1) * 10); // [10..200]
189
+ assert.equal(percentile(twenty, 0.95), 190, "p95 should be the 19th of 20 sorted values, not the max");
190
+ });
191
+
192
+ test("summarizeWorktreeTelemetry supports time-window filtering", () => {
193
+ const base = makeTmpBase();
194
+ try {
195
+ emitWorktreeCreated(base, "M001");
196
+ const midpoint = new Date().toISOString();
197
+ // Brief delay to ensure the next event has a later ts
198
+ const start = Date.now();
199
+ while (Date.now() - start < 10) { /* spin */ }
200
+ emitWorktreeCreated(base, "M002");
201
+
202
+ const beforeOnly = summarizeWorktreeTelemetry(base, { before: midpoint });
203
+ const afterOnly = summarizeWorktreeTelemetry(base, { after: midpoint });
204
+ // The sum of the two partitions covers all events (may overlap by 1 at
205
+ // exact-ts boundary — assert each partition is a proper subset).
206
+ assert.ok(beforeOnly.worktreesCreated >= 1);
207
+ assert.ok(afterOnly.worktreesCreated >= 1);
208
+ assert.ok(beforeOnly.worktreesCreated + afterOnly.worktreesCreated >= 2);
209
+ } finally { cleanup(base); }
210
+ });
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
- import { createTestContext } from "./test-helpers.ts";
4
+ import {createTestContext, extractSourceRegion } from "./test-helpers.ts";
5
5
 
6
6
  const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
7
7
 
@@ -26,7 +26,7 @@ assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry");
26
26
 
27
27
  // Extract the region between showSmartEntry and the first showProjectInit call
28
28
  // This is where the init wizard gate lives.
29
- const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : "";
29
+ const afterSmartEntry = smartEntryIdx >= 0 ? extractSourceRegion(guidedFlowSrc, "export async function showSmartEntry(") : "";
30
30
 
31
31
  // The gate must NOT be a bare `!existsSync(gsdRoot(basePath))` check.
32
32
  // It must also verify that bootstrap artifacts (PREFERENCES.md or milestones/) exist.
@@ -48,7 +48,7 @@ assertTrue(
48
48
  // Find the specific init wizard gate pattern — the detection preamble block.
49
49
  const detectionPreambleIdx = afterSmartEntry.indexOf("Detection preamble");
50
50
  const detectionRegion = detectionPreambleIdx >= 0
51
- ? afterSmartEntry.slice(detectionPreambleIdx, detectionPreambleIdx + 600)
51
+ ? extractSourceRegion(afterSmartEntry, "Detection preamble")
52
52
  : afterSmartEntry.slice(0, 1500);
53
53
 
54
54
  // The gate condition must reference PREFERENCES.md or milestones (bootstrap artifacts)
@@ -18,6 +18,7 @@ import {
18
18
  getMilestoneSlices,
19
19
  } from "../gsd-db.js";
20
20
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
21
+ import { resolveCanonicalMilestoneRoot } from "../worktree-manager.js";
21
22
  import { saveFile, clearParseCache } from "../files.js";
22
23
  import { invalidateStateCache } from "../state.js";
23
24
  import { VALIDATION_VERDICTS, isValidMilestoneVerdict } from "../verdict-parser.js";
@@ -100,14 +101,19 @@ export async function handleValidateMilestone(
100
101
  }
101
102
 
102
103
  // ── Resolve paths and render markdown ────────────────────────────────
104
+ // #4761: route through the canonical-root resolver so that when a live
105
+ // worktree exists for this milestone, validation reads/writes the
106
+ // worktree's artifacts instead of stale project-root state.
103
107
  const validationMd = renderValidationMarkdown(params);
104
108
 
109
+ const canonicalBase = resolveCanonicalMilestoneRoot(basePath, params.milestoneId);
110
+
105
111
  let validationPath: string;
106
- const milestoneDir = resolveMilestonePath(basePath, params.milestoneId);
112
+ const milestoneDir = resolveMilestonePath(canonicalBase, params.milestoneId);
107
113
  if (milestoneDir) {
108
114
  validationPath = join(milestoneDir, `${params.milestoneId}-VALIDATION.md`);
109
115
  } else {
110
- const gsdDir = join(basePath, ".gsd");
116
+ const gsdDir = join(canonicalBase, ".gsd");
111
117
  const manualDir = join(gsdDir, "milestones", params.milestoneId);
112
118
  validationPath = join(manualDir, `${params.milestoneId}-VALIDATION.md`);
113
119
  }
@@ -37,6 +37,7 @@ import {
37
37
  nativeWorktreePrune,
38
38
  nativeWorktreeRemove,
39
39
  } from "./native-git-bridge.js";
40
+ import { emitCanonicalRootRedirect } from "./worktree-telemetry.js";
40
41
 
41
42
  // ─── Types ─────────────────────────────────────────────────────────────────
42
43
 
@@ -132,6 +133,58 @@ export function isInsideWorktreesDir(basePath: string, targetPath: string): bool
132
133
  return resolved === wtDir || resolved.startsWith(wtDir + sep);
133
134
  }
134
135
 
136
+ /**
137
+ * Return the canonical path from which a milestone's artifacts should be read.
138
+ *
139
+ * If a live git worktree exists for this milestone at `.gsd/worktrees/<MID>/`
140
+ * (directory present AND a `.git` file indicating a registered worktree),
141
+ * returns that worktree path. Otherwise returns `basePath` unchanged.
142
+ *
143
+ * Readers that cross the session/worktree boundary (validators, the bootstrap
144
+ * audit, cross-session state queries) should route through this helper so they
145
+ * don't silently read stale project-root state while live work sits in the
146
+ * worktree. Writers and tools whose contract is "operate on the path I was
147
+ * given" should NOT use this helper — they preserve the legacy behavior.
148
+ *
149
+ * A stale worktree directory (no `.git` file) is treated as absent. The
150
+ * createWorktree() path already cleans these up, but readers must not trust
151
+ * them in the window before cleanup runs.
152
+ *
153
+ * Fixes #4761. Used by the #4762 audit for the pre-completion orphan case.
154
+ */
155
+ export function resolveCanonicalMilestoneRoot(
156
+ basePath: string,
157
+ milestoneId: string,
158
+ ): string {
159
+ if (!milestoneId || /[\/\\]|\.\./.test(milestoneId)) return basePath;
160
+
161
+ const wtPath = worktreePath(basePath, milestoneId);
162
+ if (!existsSync(wtPath)) return basePath;
163
+
164
+ // A registered git worktree has a .git *file* (not directory) containing
165
+ // "gitdir: <path>". A standalone .git directory indicates a copied repo
166
+ // or nested standalone repo — not a worktree registered with this project —
167
+ // and must not be treated as the canonical root.
168
+ const gitPath = join(wtPath, ".git");
169
+ if (!existsSync(gitPath)) return basePath;
170
+ try {
171
+ const stat = lstatSync(gitPath);
172
+ if (!stat.isFile()) return basePath;
173
+ } catch {
174
+ return basePath;
175
+ }
176
+
177
+ // #4764 — record the redirect so we can measure how often the #4761 fix
178
+ // would have mattered. Best-effort; emit is silent on any failure.
179
+ try {
180
+ emitCanonicalRootRedirect(basePath, milestoneId, wtPath);
181
+ } catch (err) {
182
+ logWarning("worktree", `canonical-root-redirect telemetry failed: ${err instanceof Error ? err.message : String(err)}`);
183
+ }
184
+
185
+ return wtPath;
186
+ }
187
+
135
188
  // ─── Core Operations ───────────────────────────────────────────────────────
136
189
 
137
190
  /**