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
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Slice-cadence collapse — #4765.
3
+ *
4
+ * When `git.collapse_cadence: "slice"` is set, each slice's commits are
5
+ * squash-merged from the milestone branch to main as soon as the slice
6
+ * passes validation. Shrinks the orphan window (#4761) from milestone-size
7
+ * to slice-size and surfaces merge conflicts per-slice rather than all at
8
+ * once at milestone end.
9
+ *
10
+ * This module is deliberately focused and narrower than mergeMilestoneToMain:
11
+ * - No worktree teardown (worktree is reused for the next slice)
12
+ * - No DB reconciliation (modern worktrees share the main DB via path resolver)
13
+ * - No roadmap/summary/gate handling (that's still the milestone's job)
14
+ * - Fails loudly on dirty main — caller is responsible for cleanliness
15
+ *
16
+ * Kernighan: the v1 surface handles the happy path + conflict. Edge cases
17
+ * that mergeMilestoneToMain covers (concurrent merges, shared DB paths,
18
+ * submodules) are explicit non-goals; users opt in via preference and early-
19
+ * adopter scenarios are scoped narrow.
20
+ */
21
+ import { existsSync, unlinkSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { execFileSync } from "node:child_process";
24
+ import { GSDError, GSD_GIT_ERROR } from "./errors.js";
25
+ import { MergeConflictError } from "./git-service.js";
26
+ import { nativeBranchForceReset, nativeCheckoutBranch, nativeCommit, nativeCommitCountBetween, nativeConflictFiles, nativeDetectMainBranch, nativeMergeSquash, } from "./native-git-bridge.js";
27
+ import { resolveGitDir } from "./worktree-manager.js";
28
+ import { logWarning } from "./workflow-logger.js";
29
+ import { emitSliceMerged, emitMilestoneResquash } from "./worktree-telemetry.js";
30
+ /**
31
+ * Auto-worktree milestone branch name. Must match autoWorktreeBranch() in
32
+ * auto-worktree.ts; duplicated here to avoid a cyclic import.
33
+ */
34
+ function milestoneBranchName(milestoneId) {
35
+ return `milestone/${milestoneId}`;
36
+ }
37
+ function cleanupMergeArtifacts(projectRoot) {
38
+ try {
39
+ const gitDir = resolveGitDir(projectRoot);
40
+ for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
41
+ const p = join(gitDir, f);
42
+ if (existsSync(p))
43
+ unlinkSync(p);
44
+ }
45
+ }
46
+ catch (err) {
47
+ logWarning("worktree", `merge artifact cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+ /**
51
+ * Squash-merge one slice's commits from the milestone branch to main.
52
+ *
53
+ * Preconditions:
54
+ * - Caller is on the milestone branch inside the worktree
55
+ * - `projectRoot` points at the real project root (not the worktree)
56
+ *
57
+ * Post-conditions on success:
58
+ * - Slice's commits are a single squash commit on main
59
+ * - `milestone/<MID>` is fast-forwarded to main (so next slice's work
60
+ * starts from a clean base)
61
+ * - caller's process.cwd is restored
62
+ *
63
+ * Throws MergeConflictError on conflicts; caller should surface and stop.
64
+ * Throws GSDError on dirty main / detection failures.
65
+ */
66
+ export function mergeSliceToMain(projectRoot, milestoneId, sliceId) {
67
+ const started = Date.now();
68
+ const worktreeCwd = process.cwd();
69
+ const milestoneBranch = milestoneBranchName(milestoneId);
70
+ const mainBranch = nativeDetectMainBranch(projectRoot);
71
+ // Fast path: if the milestone branch has no commits ahead of main, there
72
+ // is nothing to merge. Return a skip result instead of no-op'ing silently
73
+ // so the caller's telemetry shows the decision.
74
+ let commitsAhead = 0;
75
+ try {
76
+ commitsAhead = nativeCommitCountBetween(projectRoot, mainBranch, milestoneBranch);
77
+ }
78
+ catch {
79
+ // If we can't count, assume there's work and let the merge proceed —
80
+ // a failing merge is more informative than a silent skip.
81
+ commitsAhead = 1;
82
+ }
83
+ if (commitsAhead === 0) {
84
+ // Do NOT emit slice-merged here — this is a no-op, not a merge. Emitting
85
+ // would inflate slicesMerged in telemetry/forensics and distort the
86
+ // conflict rate denominator.
87
+ return {
88
+ commitSha: null,
89
+ mainBranch,
90
+ milestoneBranch,
91
+ durationMs: Date.now() - started,
92
+ skipped: true,
93
+ skippedReason: "no-commits-ahead",
94
+ };
95
+ }
96
+ process.chdir(projectRoot);
97
+ try {
98
+ // Dirty-main check — v1 fails loudly rather than auto-stashing. Users
99
+ // running slice-cadence opt in knowing main stays clean between merges.
100
+ const status = execFileSync("git", ["status", "--porcelain"], {
101
+ cwd: projectRoot,
102
+ stdio: ["ignore", "pipe", "pipe"],
103
+ encoding: "utf-8",
104
+ }).trim();
105
+ if (status) {
106
+ throw new GSDError(GSD_GIT_ERROR, `slice-cadence merge requires a clean project root; uncommitted changes detected. ` +
107
+ `Commit or stash at ${projectRoot} before retrying. Status:\n${status}`);
108
+ }
109
+ nativeCheckoutBranch(projectRoot, mainBranch);
110
+ // Clean any stale merge artifacts before attempting the squash (#2912 pattern)
111
+ cleanupMergeArtifacts(projectRoot);
112
+ const mergeResult = nativeMergeSquash(projectRoot, milestoneBranch);
113
+ if (!mergeResult.success) {
114
+ const conflictedFiles = mergeResult.conflicts.length > 0
115
+ ? mergeResult.conflicts
116
+ : nativeConflictFiles(projectRoot);
117
+ cleanupMergeArtifacts(projectRoot);
118
+ try {
119
+ emitSliceMerged(projectRoot, milestoneId, sliceId, {
120
+ durationMs: Date.now() - started,
121
+ conflict: true,
122
+ });
123
+ }
124
+ catch { /* silent */ }
125
+ throw new MergeConflictError(conflictedFiles, "squash", milestoneBranch, mainBranch);
126
+ }
127
+ // Commit the squash with a slice-scoped message
128
+ const commitSha = nativeCommit(projectRoot, `gsd: merge ${sliceId} of ${milestoneId} (slice-cadence)`);
129
+ // Advance the milestone branch to main so the next slice's commits start
130
+ // from a clean base. Force-reset is safe because we just merged this
131
+ // branch's entire delta.
132
+ nativeBranchForceReset(projectRoot, milestoneBranch, mainBranch);
133
+ const durationMs = Date.now() - started;
134
+ try {
135
+ emitSliceMerged(projectRoot, milestoneId, sliceId, {
136
+ durationMs,
137
+ conflict: false,
138
+ commitSha: commitSha ?? undefined,
139
+ });
140
+ }
141
+ catch { /* silent */ }
142
+ return {
143
+ commitSha,
144
+ mainBranch,
145
+ milestoneBranch,
146
+ durationMs,
147
+ skipped: false,
148
+ };
149
+ }
150
+ finally {
151
+ // Always restore cwd even if anything above threw.
152
+ try {
153
+ process.chdir(worktreeCwd);
154
+ }
155
+ catch { /* best-effort */ }
156
+ }
157
+ }
158
+ /**
159
+ * Re-squash per-slice commits on main into a single milestone commit.
160
+ *
161
+ * Runs at milestone completion when `collapse_cadence: "slice"` AND
162
+ * `milestone_resquash: true`. The `startSha` is the SHA of main immediately
163
+ * before the milestone's first slice merge — the caller is responsible for
164
+ * recording this (AutoSession field, git ref, or DB row).
165
+ *
166
+ * Strategy: soft-reset main to startSha, then commit the net diff. The
167
+ * N slice commits between startSha and HEAD are collapsed into one.
168
+ *
169
+ * No-op (returns false) if startSha equals HEAD (nothing to re-squash).
170
+ */
171
+ export function resquashMilestoneOnMain(projectRoot, milestoneId, startSha) {
172
+ const mainBranch = nativeDetectMainBranch(projectRoot);
173
+ const worktreeCwd = process.cwd();
174
+ process.chdir(projectRoot);
175
+ try {
176
+ nativeCheckoutBranch(projectRoot, mainBranch);
177
+ // Verify the startSha..HEAD range contains ONLY this milestone's slice-
178
+ // cadence commits. If any unrelated commits landed on main since the
179
+ // milestone started (e.g. concurrent work, cherry-picks, hotfixes), a
180
+ // blind `git reset --soft` would fold them into the re-squash and rewrite
181
+ // their attribution. Fail closed — the user can resolve manually.
182
+ const expectedSuffix = `(slice-cadence)`;
183
+ const expectedMilestoneToken = ` of ${milestoneId} `;
184
+ let subjectsRaw = "";
185
+ try {
186
+ subjectsRaw = execFileSync("git", ["log", "--format=%s", `${startSha}..HEAD`], { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" });
187
+ }
188
+ catch {
189
+ return { resquashed: false, newSha: null };
190
+ }
191
+ const subjects = subjectsRaw.split("\n").filter((s) => s.length > 0);
192
+ const sliceCount = subjects.length;
193
+ if (sliceCount === 0) {
194
+ return { resquashed: false, newSha: null };
195
+ }
196
+ const foreign = subjects.filter((s) => !(s.endsWith(expectedSuffix) && s.includes(expectedMilestoneToken)));
197
+ if (foreign.length > 0) {
198
+ logWarning("worktree", `slice-cadence: skipping milestone resquash for ${milestoneId} — ` +
199
+ `${foreign.length} non-slice-cadence commit(s) in ${startSha}..HEAD ` +
200
+ `would be folded in. First: "${foreign[0]}". Resolve history manually.`);
201
+ return { resquashed: false, newSha: null };
202
+ }
203
+ // Safe to collapse: all commits in the range are this milestone's slices.
204
+ execFileSync("git", ["reset", "--soft", startSha], {
205
+ cwd: projectRoot,
206
+ stdio: ["ignore", "pipe", "pipe"],
207
+ encoding: "utf-8",
208
+ });
209
+ const newSha = nativeCommit(projectRoot, `gsd: complete milestone ${milestoneId} (${sliceCount} slices re-squashed)`, { allowEmpty: true });
210
+ try {
211
+ emitMilestoneResquash(projectRoot, milestoneId, {
212
+ sliceCount,
213
+ startSha,
214
+ endSha: newSha ?? undefined,
215
+ });
216
+ }
217
+ catch { /* silent */ }
218
+ return { resquashed: true, newSha };
219
+ }
220
+ finally {
221
+ try {
222
+ process.chdir(worktreeCwd);
223
+ }
224
+ catch { /* best-effort */ }
225
+ }
226
+ }
227
+ /**
228
+ * Read the effective collapse cadence from validated preferences. Accepts
229
+ * a raw preferences object (the shape loadEffectiveGSDPreferences returns).
230
+ */
231
+ export function getCollapseCadence(prefs) {
232
+ return prefs?.git?.collapse_cadence ?? "milestone";
233
+ }
234
+ export function getMilestoneResquash(prefs) {
235
+ // Default true when cadence is slice — resquash preserves the milestone-
236
+ // level history shape users expect.
237
+ return prefs?.git?.milestone_resquash !== false;
238
+ }
@@ -11,6 +11,7 @@
11
11
  import { join } from "node:path";
12
12
  import { transaction, insertAssessment, deleteAssessmentByScope, getMilestoneSlices, } from "../gsd-db.js";
13
13
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
14
+ import { resolveCanonicalMilestoneRoot } from "../worktree-manager.js";
14
15
  import { saveFile, clearParseCache } from "../files.js";
15
16
  import { invalidateStateCache } from "../state.js";
16
17
  import { VALIDATION_VERDICTS, isValidMilestoneVerdict } from "../verdict-parser.js";
@@ -59,14 +60,18 @@ export async function handleValidateMilestone(params, basePath, opts) {
59
60
  return { error: `verdict must be one of: ${VALIDATION_VERDICTS.join(", ")}` };
60
61
  }
61
62
  // ── Resolve paths and render markdown ────────────────────────────────
63
+ // #4761: route through the canonical-root resolver so that when a live
64
+ // worktree exists for this milestone, validation reads/writes the
65
+ // worktree's artifacts instead of stale project-root state.
62
66
  const validationMd = renderValidationMarkdown(params);
67
+ const canonicalBase = resolveCanonicalMilestoneRoot(basePath, params.milestoneId);
63
68
  let validationPath;
64
- const milestoneDir = resolveMilestonePath(basePath, params.milestoneId);
69
+ const milestoneDir = resolveMilestonePath(canonicalBase, params.milestoneId);
65
70
  if (milestoneDir) {
66
71
  validationPath = join(milestoneDir, `${params.milestoneId}-VALIDATION.md`);
67
72
  }
68
73
  else {
69
- const gsdDir = join(basePath, ".gsd");
74
+ const gsdDir = join(canonicalBase, ".gsd");
70
75
  const manualDir = join(gsdDir, "milestones", params.milestoneId);
71
76
  validationPath = join(manualDir, `${params.milestoneId}-VALIDATION.md`);
72
77
  }
@@ -20,6 +20,7 @@ import { join, resolve, sep } from "node:path";
20
20
  import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
21
21
  import { logWarning } from "./workflow-logger.js";
22
22
  import { nativeBranchDelete, nativeBranchExists, nativeBranchForceReset, nativeCommit, nativeDetectMainBranch, nativeDiffContent, nativeDiffNameStatus, nativeDiffNumstat, nativeGetCurrentBranch, nativeLogOneline, nativeMergeSquash, nativeWorktreeAdd, nativeWorktreeList, nativeWorktreePrune, nativeWorktreeRemove, } from "./native-git-bridge.js";
23
+ import { emitCanonicalRootRedirect } from "./worktree-telemetry.js";
23
24
  // ─── Path Helpers ──────────────────────────────────────────────────────────
24
25
  function normalizePathForComparison(path) {
25
26
  const normalized = path
@@ -84,6 +85,56 @@ export function isInsideWorktreesDir(basePath, targetPath) {
84
85
  // not merely be a prefix match (e.g. ".gsd/worktrees-extra" must not match).
85
86
  return resolved === wtDir || resolved.startsWith(wtDir + sep);
86
87
  }
88
+ /**
89
+ * Return the canonical path from which a milestone's artifacts should be read.
90
+ *
91
+ * If a live git worktree exists for this milestone at `.gsd/worktrees/<MID>/`
92
+ * (directory present AND a `.git` file indicating a registered worktree),
93
+ * returns that worktree path. Otherwise returns `basePath` unchanged.
94
+ *
95
+ * Readers that cross the session/worktree boundary (validators, the bootstrap
96
+ * audit, cross-session state queries) should route through this helper so they
97
+ * don't silently read stale project-root state while live work sits in the
98
+ * worktree. Writers and tools whose contract is "operate on the path I was
99
+ * given" should NOT use this helper — they preserve the legacy behavior.
100
+ *
101
+ * A stale worktree directory (no `.git` file) is treated as absent. The
102
+ * createWorktree() path already cleans these up, but readers must not trust
103
+ * them in the window before cleanup runs.
104
+ *
105
+ * Fixes #4761. Used by the #4762 audit for the pre-completion orphan case.
106
+ */
107
+ export function resolveCanonicalMilestoneRoot(basePath, milestoneId) {
108
+ if (!milestoneId || /[\/\\]|\.\./.test(milestoneId))
109
+ return basePath;
110
+ const wtPath = worktreePath(basePath, milestoneId);
111
+ if (!existsSync(wtPath))
112
+ return basePath;
113
+ // A registered git worktree has a .git *file* (not directory) containing
114
+ // "gitdir: <path>". A standalone .git directory indicates a copied repo
115
+ // or nested standalone repo — not a worktree registered with this project —
116
+ // and must not be treated as the canonical root.
117
+ const gitPath = join(wtPath, ".git");
118
+ if (!existsSync(gitPath))
119
+ return basePath;
120
+ try {
121
+ const stat = lstatSync(gitPath);
122
+ if (!stat.isFile())
123
+ return basePath;
124
+ }
125
+ catch {
126
+ return basePath;
127
+ }
128
+ // #4764 — record the redirect so we can measure how often the #4761 fix
129
+ // would have mattered. Best-effort; emit is silent on any failure.
130
+ try {
131
+ emitCanonicalRootRedirect(basePath, milestoneId, wtPath);
132
+ }
133
+ catch (err) {
134
+ logWarning("worktree", `canonical-root-redirect telemetry failed: ${err instanceof Error ? err.message : String(err)}`);
135
+ }
136
+ return wtPath;
137
+ }
87
138
  // ─── Core Operations ───────────────────────────────────────────────────────
88
139
  /**
89
140
  * Create a new git worktree under .gsd/worktrees/<name>/ with branch worktree/<name>.
@@ -17,6 +17,9 @@ import { randomUUID } from "node:crypto";
17
17
  import { join } from "node:path";
18
18
  import { debugLog } from "./debug-logger.js";
19
19
  import { emitJournalEvent } from "./journal.js";
20
+ import { emitWorktreeCreated, emitWorktreeMerged } from "./worktree-telemetry.js";
21
+ import { getCollapseCadence, getMilestoneResquash, resquashMilestoneOnMain } from "./slice-cadence.js";
22
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
20
23
  // ─── Path Helpers ──────────────────────────────────────────────────────────
21
24
  /**
22
25
  * Worktree marker segment — present in any path produced by worktreePath().
@@ -194,6 +197,20 @@ export class WorktreeResolver {
194
197
  eventType: "worktree-enter",
195
198
  data: { milestoneId, wtPath, created: !existingPath },
196
199
  });
200
+ // #4764 — record creation/enter as a lifecycle event so the telemetry
201
+ // aggregator can pair it with the eventual worktree-merged event.
202
+ try {
203
+ emitWorktreeCreated(this.s.originalBasePath || this.s.basePath, milestoneId, {
204
+ reason: existingPath ? "enter-milestone" : "create-milestone",
205
+ });
206
+ }
207
+ catch (telemetryErr) {
208
+ debugLog("WorktreeResolver", {
209
+ action: "enterMilestone",
210
+ phase: "telemetry-emit",
211
+ error: telemetryErr instanceof Error ? telemetryErr.message : String(telemetryErr),
212
+ });
213
+ }
197
214
  ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info");
198
215
  }
199
216
  catch (err) {
@@ -289,6 +306,9 @@ export class WorktreeResolver {
289
306
  */
290
307
  mergeAndExit(milestoneId, ctx) {
291
308
  this.validateMilestoneId(milestoneId);
309
+ // #4764 — telemetry: record start timestamp so we can emit merge duration.
310
+ const mergeStartedAt = new Date().toISOString();
311
+ const mergeStartMs = Date.now();
292
312
  // If worktree creation failed earlier, skip merge — work is on current branch (#2483)
293
313
  if (this.s.isolationDegraded) {
294
314
  debugLog("WorktreeResolver", {
@@ -328,14 +348,68 @@ export class WorktreeResolver {
328
348
  });
329
349
  return;
330
350
  }
351
+ let actuallyMerged = false;
331
352
  if (mode === "worktree" || inWorktree) {
332
- this._mergeWorktreeMode(milestoneId, ctx);
353
+ actuallyMerged = this._mergeWorktreeMode(milestoneId, ctx);
333
354
  }
334
355
  else if (mode === "branch") {
335
- this._mergeBranchMode(milestoneId, ctx);
356
+ actuallyMerged = this._mergeBranchMode(milestoneId, ctx);
357
+ }
358
+ // The remainder of this function emits telemetry and runs re-squash.
359
+ // Both are gated on actuallyMerged — if the _merge* helper took a
360
+ // no-merge path (missing originalBase, no roadmap, wrong branch) the
361
+ // milestone branch was intentionally left unmerged and we must not
362
+ // emit a worktree-merged event or collapse commits on main.
363
+ if (!actuallyMerged) {
364
+ // Always clear the start-SHA tracker to avoid leaking across sessions.
365
+ this.s.milestoneStartShas.delete(milestoneId);
366
+ return;
367
+ }
368
+ // #4765 — when collapse_cadence=slice AND milestone_resquash=true, the
369
+ // N per-slice commits on main should be collapsed into one milestone
370
+ // commit. Done AFTER the primary merge-and-teardown so the branch and
371
+ // worktree are already cleaned up; we operate on main directly.
372
+ try {
373
+ const startSha = this.s.milestoneStartShas.get(milestoneId);
374
+ if (startSha) {
375
+ const prefs = loadEffectiveGSDPreferences(this.s.originalBasePath || this.s.basePath)?.preferences;
376
+ if (getCollapseCadence(prefs) === "slice" && getMilestoneResquash(prefs)) {
377
+ const result = resquashMilestoneOnMain(this.s.originalBasePath || this.s.basePath, milestoneId, startSha);
378
+ if (result.resquashed) {
379
+ ctx.notify(`slice-cadence: re-squashed slice commits for ${milestoneId} into a single milestone commit.`, "info");
380
+ }
381
+ }
382
+ this.s.milestoneStartShas.delete(milestoneId);
383
+ }
384
+ }
385
+ catch (err) {
386
+ debugLog("WorktreeResolver", {
387
+ action: "mergeAndExit",
388
+ milestoneId,
389
+ phase: "resquash",
390
+ error: err instanceof Error ? err.message : String(err),
391
+ });
392
+ }
393
+ // #4764 — record merge completion. Only reaches here when an actual
394
+ // merge ran; failure paths throw out of _merge* before this point and
395
+ // no-merge paths returned above.
396
+ try {
397
+ emitWorktreeMerged(this.s.originalBasePath || this.s.basePath, milestoneId, {
398
+ reason: "milestone-complete",
399
+ startedAt: mergeStartedAt,
400
+ durationMs: Date.now() - mergeStartMs,
401
+ });
402
+ }
403
+ catch (telemetryErr) {
404
+ debugLog("WorktreeResolver", {
405
+ action: "mergeAndExit",
406
+ phase: "telemetry-emit",
407
+ error: telemetryErr instanceof Error ? telemetryErr.message : String(telemetryErr),
408
+ });
336
409
  }
337
410
  }
338
- /** Worktree-mode merge: read roadmap, merge, teardown, reset paths. */
411
+ /** Worktree-mode merge: read roadmap, merge, teardown, reset paths.
412
+ * Returns true when a squash-merge actually ran (false on skip paths). */
339
413
  _mergeWorktreeMode(milestoneId, ctx) {
340
414
  const originalBase = this.s.originalBasePath;
341
415
  if (!originalBase) {
@@ -346,8 +420,9 @@ export class WorktreeResolver {
346
420
  skipped: true,
347
421
  reason: "missing-original-base",
348
422
  });
349
- return;
423
+ return false;
350
424
  }
425
+ let merged = false;
351
426
  try {
352
427
  const { synced } = this.deps.syncWorktreeStateBack(originalBase, this.s.basePath, milestoneId);
353
428
  if (synced.length > 0) {
@@ -378,6 +453,7 @@ export class WorktreeResolver {
378
453
  if (roadmapPath) {
379
454
  const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
380
455
  const mergeResult = this.deps.mergeMilestoneToMain(originalBase, milestoneId, roadmapContent);
456
+ merged = true;
381
457
  // #2945 Bug 3: mergeMilestoneToMain performs best-effort worktree
382
458
  // cleanup internally (step 12), but it can silently fail on Windows
383
459
  // or when the worktree directory is locked. Perform a secondary
@@ -470,8 +546,10 @@ export class WorktreeResolver {
470
546
  result: "done",
471
547
  basePath: this.s.basePath,
472
548
  });
549
+ return merged;
473
550
  }
474
- /** Branch-mode merge: check current branch, merge if on milestone branch. */
551
+ /** Branch-mode merge: check current branch, merge if on milestone branch.
552
+ * Returns true when a merge actually ran (false on skip paths). */
475
553
  _mergeBranchMode(milestoneId, ctx) {
476
554
  try {
477
555
  const currentBranch = this.deps.getCurrentBranch(this.s.basePath);
@@ -486,7 +564,7 @@ export class WorktreeResolver {
486
564
  currentBranch,
487
565
  milestoneBranch,
488
566
  });
489
- return;
567
+ return false;
490
568
  }
491
569
  const roadmapPath = this.deps.resolveMilestoneFile(this.s.basePath, milestoneId, "ROADMAP");
492
570
  if (!roadmapPath) {
@@ -497,7 +575,7 @@ export class WorktreeResolver {
497
575
  skipped: true,
498
576
  reason: "no-roadmap",
499
577
  });
500
- return;
578
+ return false;
501
579
  }
502
580
  const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8");
503
581
  const mergeResult = this.deps.mergeMilestoneToMain(this.s.basePath, milestoneId, roadmapContent);
@@ -516,6 +594,7 @@ export class WorktreeResolver {
516
594
  mode: "branch",
517
595
  result: "success",
518
596
  });
597
+ return true;
519
598
  }
520
599
  catch (err) {
521
600
  const msg = err instanceof Error ? err.message : String(err);