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
@@ -178,6 +178,12 @@ export class AutoSession {
178
178
  * stopAuto does not attempt the same merge a second time (#2645). */
179
179
  milestoneMergedInPhases = false;
180
180
 
181
+ // #4765 — slice-cadence collapse: main-branch SHAs at the moment each
182
+ // milestone's first slice merge began. Used by resquashMilestoneOnMain at
183
+ // milestone completion to collapse N slice commits into one. Cleared when
184
+ // the milestone finishes (or resquash runs).
185
+ milestoneStartShas: Map<string, string> = new Map();
186
+
181
187
  // ── Dispatch circuit breakers ──────────────────────────────────────
182
188
  rewriteAttemptCount = 0;
183
189
  /** Tracks consecutive bootstrap attempts that found phase === "complete".
@@ -299,6 +305,7 @@ export class AutoSession {
299
305
  this.lastGitActionStatus = null;
300
306
  this.isolationDegraded = false;
301
307
  this.milestoneMergedInPhases = false;
308
+ this.milestoneStartShas = new Map();
302
309
  this.checkpointSha = null;
303
310
 
304
311
  // Signal handler
@@ -618,6 +618,87 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
618
618
  clearReactiveState(s.basePath, mid, sid);
619
619
  }
620
620
  });
621
+
622
+ // #4765 — slice-cadence collapse. When `git.collapse_cadence: "slice"`
623
+ // is set, squash-merge the slice's commits from the milestone branch
624
+ // onto main right here, so orphan risk shrinks from milestone-size to
625
+ // slice-size. Only runs in worktree isolation mode — the feature needs
626
+ // a milestone branch to squash from.
627
+ let sliceMergeStopped = false;
628
+ await runSafely("postUnit", "slice-cadence-merge", async () => {
629
+ const prefsResult = loadEffectiveGSDPreferences(s.basePath);
630
+ const prefs = prefsResult?.preferences;
631
+ const { getCollapseCadence, mergeSliceToMain } = await import("./slice-cadence.js");
632
+ if (getCollapseCadence(prefs) !== "slice") return;
633
+ if (prefs?.git?.isolation !== "worktree") return;
634
+ if (s.isolationDegraded) return;
635
+
636
+ const projectRoot = s.originalBasePath || s.basePath;
637
+ const { milestone: mid, slice: sid } = parseUnitId(unit.id);
638
+ if (!mid || !sid) return;
639
+
640
+ // Record the milestone start SHA before the first slice merge, so
641
+ // resquashMilestoneOnMain has a target at milestone completion.
642
+ // Resolve main branch dynamically — hard-coding "main" breaks repos
643
+ // that use "master" or a custom default branch.
644
+ if (!s.milestoneStartShas.has(mid)) {
645
+ try {
646
+ const { nativeDetectMainBranch } = await import("./native-git-bridge.js");
647
+ const mainBranch = nativeDetectMainBranch(projectRoot);
648
+ const { execFileSync } = await import("node:child_process");
649
+ const sha = execFileSync("git", ["rev-parse", mainBranch], {
650
+ cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
651
+ }).trim();
652
+ if (sha) s.milestoneStartShas.set(mid, sha);
653
+ } catch (err) {
654
+ logWarning("engine", `slice-cadence: failed to record milestone start SHA: ${err instanceof Error ? err.message : String(err)}`);
655
+ }
656
+ }
657
+
658
+ try {
659
+ const result = mergeSliceToMain(projectRoot, mid, sid);
660
+ if (result.skipped) {
661
+ logWarning("engine", `slice-cadence: merge skipped for ${sid} — ${result.skippedReason}`);
662
+ return;
663
+ }
664
+ ctx.ui.notify(
665
+ `slice-cadence: ${sid} merged to main (${result.durationMs}ms).`,
666
+ "info",
667
+ );
668
+ } catch (err) {
669
+ const { MergeConflictError } = await import("./git-service.js");
670
+ if (err instanceof MergeConflictError) {
671
+ ctx.ui.notify(
672
+ `slice-cadence merge conflict in ${sid}: ${err.conflictedFiles.join(", ")}. ` +
673
+ `Resolve manually on main and run \`/gsd auto\` to resume.`,
674
+ "error",
675
+ );
676
+ // Stop auto AND signal the outer postUnit flow to exit early.
677
+ // Without the flag, subsequent hooks (triage, rogue detection,
678
+ // DB writes) would keep running against a conflicted main
679
+ // checkout after the loop was already told to stop.
680
+ const { stopAuto } = await import("./auto.js");
681
+ await stopAuto(ctx, undefined, `slice-merge-conflict on ${sid}`);
682
+ sliceMergeStopped = true;
683
+ return;
684
+ }
685
+ logError("engine", `slice-cadence merge failed for ${sid}`, {
686
+ error: err instanceof Error ? err.message : String(err),
687
+ });
688
+ // Non-conflict failures (dirty main, rev-walk error, etc.) can
689
+ // leave the checkout in an unexpected state. Stop auto-mode so
690
+ // the next slice doesn't dispatch on top of it.
691
+ const { stopAuto } = await import("./auto.js");
692
+ await stopAuto(ctx, undefined, `slice-merge-error on ${sid}`);
693
+ sliceMergeStopped = true;
694
+ }
695
+ });
696
+ // Exit early after stopAuto so the rest of post-unit processing
697
+ // (triage, rogue detection, hook dispatch, DB writes) doesn't run
698
+ // against a conflicted main checkout. Return "dispatched" to match
699
+ // the convention used by other stop/pauseAuto paths in this function
700
+ // (see signal handling earlier: stop/pause also return "dispatched").
701
+ if (sliceMergeStopped) return "dispatched";
621
702
  }
622
703
 
623
704
  // Post-triage: execute actionable resolutions
@@ -514,6 +514,48 @@ export async function inlineKnowledgeScoped(
514
514
  return `### Project Knowledge (scoped)\nSource: \`${relGsdRootFile("KNOWLEDGE")}\`\n\n${scoped.trim()}`;
515
515
  }
516
516
 
517
+ /**
518
+ * Budget-capped knowledge inline for milestone-level prompt assembly.
519
+ *
520
+ * Addresses issue #4719: the six milestone-phase prompts (research-milestone,
521
+ * plan-milestone, complete-slice, complete-milestone, validate-milestone,
522
+ * reassess-roadmap) previously injected the full KNOWLEDGE.md (~226KB for a
523
+ * real project) on every invocation. This helper scopes by caller-supplied
524
+ * keywords and caps the payload at `maxChars` (default 30,000 chars).
525
+ *
526
+ * Returns null when no KNOWLEDGE.md exists or no entries match any keyword.
527
+ */
528
+ export async function inlineKnowledgeBudgeted(
529
+ base: string,
530
+ keywords: string[],
531
+ options?: { maxChars?: number },
532
+ ): Promise<string | null> {
533
+ const DEFAULT_MAX_CHARS = 30_000;
534
+ const HARD_MAX_CHARS = 100_000;
535
+ const raw = Number(options?.maxChars ?? DEFAULT_MAX_CHARS);
536
+ const maxChars = Number.isFinite(raw)
537
+ ? Math.max(0, Math.min(Math.floor(raw), HARD_MAX_CHARS))
538
+ : DEFAULT_MAX_CHARS;
539
+
540
+ const knowledgePath = resolveGsdRootFile(base, "KNOWLEDGE");
541
+ if (!existsSync(knowledgePath)) return null;
542
+
543
+ const content = await loadFile(knowledgePath);
544
+ if (!content) return null;
545
+
546
+ const { queryKnowledge } = await import("./context-store.js");
547
+ const scoped = await queryKnowledge(content, keywords);
548
+ if (!scoped) return null;
549
+
550
+ const trimmed = scoped.trim();
551
+ const truncated =
552
+ trimmed.length > maxChars
553
+ ? `${trimmed.slice(0, maxChars)}\n\n[...truncated ${trimmed.length - maxChars} chars; rerun with narrower scope if needed]`
554
+ : trimmed;
555
+
556
+ return `### Project Knowledge (scoped)\nSource: \`${relGsdRootFile("KNOWLEDGE")}\`\n\n${truncated}`;
557
+ }
558
+
517
559
  /**
518
560
  * Inline a roadmap excerpt for a specific slice.
519
561
  * Reads full roadmap, extracts minimal excerpt with header + predecessor + target row.
@@ -1095,7 +1137,8 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
1095
1137
  if (requirementsInline) inlined.push(requirementsInline);
1096
1138
  const decisionsInline = await inlineDecisionsFromDb(base, mid);
1097
1139
  if (decisionsInline) inlined.push(decisionsInline);
1098
- const knowledgeInlineRM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1140
+ // Scoped + budgeted see issue #4719
1141
+ const knowledgeInlineRM = await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
1099
1142
  if (knowledgeInlineRM) inlined.push(knowledgeInlineRM);
1100
1143
  inlined.push(inlineTemplate("research", "Research"));
1101
1144
 
@@ -1156,7 +1199,8 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
1156
1199
  );
1157
1200
  inlined.push(queueInline);
1158
1201
  }
1159
- const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1202
+ // Scoped + budgeted see issue #4719
1203
+ const knowledgeInlinePM = await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
1160
1204
  if (knowledgeInlinePM) inlined.push(knowledgeInlinePM);
1161
1205
  inlined.push(inlineTemplate("roadmap", "Roadmap"));
1162
1206
  if (inlineLevel === "full") {
@@ -1655,7 +1699,7 @@ export async function buildExecuteTaskPrompt(
1655
1699
  }
1656
1700
 
1657
1701
  export async function buildCompleteSlicePrompt(
1658
- mid: string, _midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel,
1702
+ mid: string, midTitle: string, sid: string, sTitle: string, base: string, level?: InlineLevel,
1659
1703
  ): Promise<string> {
1660
1704
  const inlineLevel = level ?? resolveInlineLevel();
1661
1705
 
@@ -1675,7 +1719,12 @@ export async function buildCompleteSlicePrompt(
1675
1719
  const requirementsInline = await inlineRequirementsFromDb(base, mid, sid, inlineLevel);
1676
1720
  if (requirementsInline) inlined.push(requirementsInline);
1677
1721
  }
1678
- const knowledgeInlineCS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1722
+ // Scoped + budgeted see issue #4719. Slice context is richer than
1723
+ // milestone context at complete-slice time, so combine both title sources.
1724
+ const knowledgeInlineCS = await inlineKnowledgeBudgeted(
1725
+ base,
1726
+ [...extractKeywords(midTitle), ...extractKeywords(sTitle)],
1727
+ );
1679
1728
  if (knowledgeInlineCS) inlined.push(knowledgeInlineCS);
1680
1729
 
1681
1730
  // Inline all task summaries for this slice
@@ -1778,7 +1827,8 @@ export async function buildCompleteMilestonePrompt(
1778
1827
  const projectInline = await inlineProjectFromDb(base);
1779
1828
  if (projectInline) inlined.push(projectInline);
1780
1829
  }
1781
- const knowledgeInlineCM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1830
+ // Scoped + budgeted see issue #4719
1831
+ const knowledgeInlineCM = await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
1782
1832
  if (knowledgeInlineCM) inlined.push(knowledgeInlineCM);
1783
1833
  // Inline milestone context file (milestone-level, not GSD root)
1784
1834
  const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
@@ -1914,7 +1964,8 @@ export async function buildValidateMilestonePrompt(
1914
1964
  const projectInline = await inlineProjectFromDb(base);
1915
1965
  if (projectInline) inlined.push(projectInline);
1916
1966
  }
1917
- const knowledgeInline = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1967
+ // Scoped + budgeted see issue #4719
1968
+ const knowledgeInline = await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
1918
1969
  if (knowledgeInline) inlined.push(knowledgeInline);
1919
1970
  // Inline milestone context file
1920
1971
  const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
@@ -2099,7 +2150,8 @@ export async function buildReassessRoadmapPrompt(
2099
2150
  const decisionsInline = await inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
2100
2151
  if (decisionsInline) inlined.push(decisionsInline);
2101
2152
  }
2102
- const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
2153
+ // Scoped + budgeted see issue #4719
2154
+ const knowledgeInlineRA = await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
2103
2155
  if (knowledgeInlineRA) inlined.push(knowledgeInlineRA);
2104
2156
 
2105
2157
  const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
@@ -45,6 +45,7 @@ import {
45
45
  nativeBranchListMerged,
46
46
  nativeBranchDelete,
47
47
  nativeWorktreeRemove,
48
+ nativeCommitCountBetween,
48
49
  } from "./native-git-bridge.js";
49
50
  import { GitServiceImpl } from "./git-service.js";
50
51
  import {
@@ -55,6 +56,7 @@ import {
55
56
  import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js";
56
57
  import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js";
57
58
  import { worktreePath as getWorktreeDir, isInsideWorktreesDir } from "./worktree-manager.js";
59
+ import { emitWorktreeOrphaned } from "./worktree-telemetry.js";
58
60
  import { initMetrics } from "./metrics.js";
59
61
  import { initRoutingHistory } from "./routing-history.js";
60
62
  import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
@@ -187,11 +189,61 @@ export function auditOrphanedMilestoneBranches(
187
189
  const milestoneId = branch.replace(/^milestone\//, "");
188
190
  const milestone = getMilestone(milestoneId);
189
191
 
190
- // Only audit completed milestones
191
- if (!milestone || milestone.status !== "complete") continue;
192
+ if (!milestone) continue;
192
193
 
193
194
  const isMerged = mergedBranches.has(branch);
194
195
 
196
+ // #4762 — in-progress milestone branch with unmerged commits ahead of
197
+ // main. This is the pre-completion orphan case: auto-mode exited without
198
+ // completing the milestone (pause, stop, crash, merge error, blocker) and
199
+ // work is stranded on the branch or in the worktree. Data safety first:
200
+ // we never delete or touch; we just surface a warning so the user knows
201
+ // where to look.
202
+ //
203
+ // Gate on isClosedStatus so we only warn about genuinely open milestones.
204
+ // Parked/other closed statuses go through the legacy complete/unmerged
205
+ // path below where appropriate.
206
+ if (!isClosedStatus(milestone.status)) {
207
+ if (isMerged) continue; // nothing to recover
208
+ let commitsAhead = 0;
209
+ try {
210
+ commitsAhead = nativeCommitCountBetween(basePath, mainBranch, branch);
211
+ } catch {
212
+ // Rev-walk failure — skip rather than noise
213
+ continue;
214
+ }
215
+ if (commitsAhead === 0) continue;
216
+
217
+ const wtDir = getWorktreeDir(basePath, milestoneId);
218
+ const wtDirExists = existsSync(wtDir);
219
+ const wtSuffix = wtDirExists
220
+ ? ` Worktree directory at .gsd/worktrees/${milestoneId}/ holds the live work.`
221
+ : "";
222
+ warnings.push(
223
+ `Branch ${branch} has ${commitsAhead} commit(s) ahead of ${mainBranch} for in-progress milestone ${milestoneId}.` +
224
+ wtSuffix +
225
+ ` Run \`/gsd auto\` to resume, or merge manually if abandoning.`,
226
+ );
227
+
228
+ // #4764 telemetry
229
+ try {
230
+ emitWorktreeOrphaned(basePath, milestoneId, {
231
+ reason: "in-progress-unmerged",
232
+ commitsAhead,
233
+ worktreeDirExists: wtDirExists,
234
+ });
235
+ } catch (err) {
236
+ logWarning("engine", `worktree-orphaned telemetry failed for ${milestoneId}: ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+
239
+ continue;
240
+ }
241
+
242
+ // Only the "complete" status participates in the merged/unmerged cleanup
243
+ // paths below — other closed statuses (parked, etc.) are intentionally
244
+ // left alone.
245
+ if (milestone.status !== "complete") continue;
246
+
195
247
  if (isMerged) {
196
248
  // Branch is merged — safe to delete branch and clean up worktree dir
197
249
  try {
@@ -236,6 +288,16 @@ export function auditOrphanedMilestoneBranches(
236
288
  `Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` +
237
289
  `This may contain unmerged work. Merge manually or run \`/gsd health --fix\` to resolve.`,
238
290
  );
291
+
292
+ // #4764 telemetry
293
+ try {
294
+ emitWorktreeOrphaned(basePath, milestoneId, {
295
+ reason: "complete-unmerged",
296
+ worktreeDirExists: existsSync(getWorktreeDir(basePath, milestoneId)),
297
+ });
298
+ } catch (err) {
299
+ logWarning("engine", `worktree-orphaned telemetry failed for ${milestoneId}: ${err instanceof Error ? err.message : String(err)}`);
300
+ }
239
301
  }
240
302
  }
241
303
 
@@ -796,6 +796,43 @@ export async function stopAuto(
796
796
  const loadedPreferences = loadEffectiveGSDPreferences(s.basePath || undefined)?.preferences;
797
797
  const reasonSuffix = reason ? ` — ${reason}` : "";
798
798
 
799
+ // #4764 — telemetry: record the exit reason and whether the current milestone
800
+ // was merged before we entered stopAuto. This is the producer-side signal for
801
+ // the #4761 orphan class: milestoneMerged=false + currentMilestoneId present
802
+ // is exactly the pattern that strands work.
803
+ try {
804
+ const { emitAutoExit } = await import("./worktree-telemetry.js");
805
+ type AutoExitReason =
806
+ | "pause" | "stop" | "blocked" | "merge-conflict" | "merge-failed"
807
+ | "slice-merge-conflict" | "all-complete" | "no-active-milestone" | "other";
808
+ // Normalize the free-form reason to a closed set so the telemetry
809
+ // aggregator buckets stably. Raw detail is preserved in the phases.ts
810
+ // notification and the notify'd error string.
811
+ const rawReason = reason ?? "stop";
812
+ const normalizedReason: AutoExitReason = rawReason.startsWith("Blocked:")
813
+ ? "blocked"
814
+ : rawReason.startsWith("Merge conflict")
815
+ ? "merge-conflict"
816
+ : rawReason.startsWith("Merge error") || rawReason.startsWith("Merge failed")
817
+ ? "merge-failed"
818
+ : rawReason.startsWith("slice-merge-conflict")
819
+ ? "slice-merge-conflict"
820
+ : rawReason === "All milestones complete"
821
+ ? "all-complete"
822
+ : rawReason === "No active milestone"
823
+ ? "no-active-milestone"
824
+ : rawReason === "stop" || rawReason === "pause"
825
+ ? rawReason
826
+ : "other";
827
+ emitAutoExit(s.originalBasePath || s.basePath, {
828
+ reason: normalizedReason,
829
+ milestoneId: s.currentMilestoneId ?? undefined,
830
+ milestoneMerged: s.milestoneMergedInPhases === true,
831
+ });
832
+ } catch (err) {
833
+ logWarning("engine", `auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`);
834
+ }
835
+
799
836
  try {
800
837
  // ── Step 1: Timers and locks ──
801
838
  try {
@@ -211,7 +211,13 @@ export function queryProject(): string | null {
211
211
 
212
212
  /**
213
213
  * Filter KNOWLEDGE.md sections by keyword matching.
214
- * Uses H2 sections, matches keywords case-insensitively against:
214
+ *
215
+ * Structure-adaptive (issue #4719): files that organise entries as H3 items
216
+ * under one or more H2 topics are filtered at H3 granularity. Files with only
217
+ * H2 topic headers (no H3) fall back to H2-level filtering for backwards
218
+ * compatibility.
219
+ *
220
+ * Matches keywords case-insensitively against:
215
221
  * 1. Section header text
216
222
  * 2. First paragraph of section content (up to first blank line or next heading)
217
223
  *
@@ -220,7 +226,7 @@ export function queryProject(): string | null {
220
226
  *
221
227
  * @param content - Full KNOWLEDGE.md content
222
228
  * @param keywords - Keywords to match (case-insensitive)
223
- * @returns Concatenated matching sections with H2 headers, or empty string
229
+ * @returns Concatenated matching sections with their original heading prefix, or empty string
224
230
  */
225
231
  export async function queryKnowledge(content: string, keywords: string[]): Promise<string> {
226
232
  if (!content || keywords.length === 0) return '';
@@ -228,11 +234,23 @@ export async function queryKnowledge(content: string, keywords: string[]): Promi
228
234
  // Lazy import to avoid circular dependency
229
235
  const { extractAllSections } = await import('./files.js');
230
236
 
231
- const sections = extractAllSections(content, 2);
237
+ // Prefer H3 granularity when available; fall back to H2 for H2-only files.
238
+ // This prevents single-H2-with-many-H3 layouts from returning the entire
239
+ // file on a keyword match against the H2 header or its first paragraph.
240
+ const h3Sections = extractAllSections(content, 3);
241
+ const useH3 = h3Sections.size > 0;
242
+ const sections = useH3 ? h3Sections : extractAllSections(content, 2);
232
243
  if (sections.size === 0) return '';
244
+ const prefix = useH3 ? '###' : '##';
233
245
 
234
- // Normalize keywords for case-insensitive matching
235
- const normalizedKeywords = keywords.map(k => k.toLowerCase());
246
+ // Trim, lowercase, drop empties, and de-dupe so callers can pass raw
247
+ // user-provided strings without risking empty-string / whitespace matches.
248
+ const normalizedKeywords = [...new Set(
249
+ keywords
250
+ .map(k => k.trim().toLowerCase())
251
+ .filter(k => k.length > 0),
252
+ )];
253
+ if (normalizedKeywords.length === 0) return '';
236
254
 
237
255
  const matchingSections: string[] = [];
238
256
 
@@ -240,16 +258,15 @@ export async function queryKnowledge(content: string, keywords: string[]): Promi
240
258
  // Extract first paragraph: everything up to first blank line or next heading
241
259
  const firstParagraph = body.split(/\n\s*\n|\n#/)[0] || '';
242
260
 
243
- // Check if any keyword matches header or first paragraph
244
261
  const headerLower = header.toLowerCase();
245
262
  const paragraphLower = firstParagraph.toLowerCase();
246
263
 
247
264
  const matches = normalizedKeywords.some(kw =>
248
- headerLower.includes(kw) || paragraphLower.includes(kw)
265
+ headerLower.includes(kw) || paragraphLower.includes(kw),
249
266
  );
250
267
 
251
268
  if (matches) {
252
- matchingSections.push(`## ${header}\n\n${body}`);
269
+ matchingSections.push(`${prefix} ${header}\n\n${body}`);
253
270
  }
254
271
  }
255
272
 
@@ -35,11 +35,12 @@ import { getAutoWorktreePath } from "./auto-worktree.js";
35
35
  import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
36
36
  import { showNextAction } from "../shared/tui.js";
37
37
  import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
38
+ import { summarizeWorktreeTelemetry, percentile, type WorktreeTelemetrySummary } from "./worktree-telemetry.js";
38
39
 
39
40
  // ─── Types ────────────────────────────────────────────────────────────────────
40
41
 
41
42
  export interface ForensicAnomaly {
42
- type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure";
43
+ type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure" | "worktree-orphan" | "worktree-unmerged-exit";
43
44
  severity: "info" | "warning" | "error";
44
45
  unitType?: string;
45
46
  unitId?: string;
@@ -113,6 +114,8 @@ interface ForensicReport {
113
114
  recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
114
115
  journalSummary: JournalSummary | null;
115
116
  activityLogMeta: ActivityLogMeta | null;
117
+ /** #4764 — worktree lifespan / divergence telemetry aggregates. */
118
+ worktreeTelemetry: WorktreeTelemetrySummary | null;
116
119
  }
117
120
 
118
121
  // ─── Duplicate Detection ──────────────────────────────────────────────────────
@@ -337,6 +340,16 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
337
340
  detectCrash(crashLock, anomalies);
338
341
  detectDoctorIssues(doctorIssues, anomalies);
339
342
  detectErrorTraces(unitTraces, anomalies);
343
+
344
+ // 11b. #4764 — worktree lifecycle telemetry
345
+ let worktreeTelemetry: WorktreeTelemetrySummary | null = null;
346
+ try {
347
+ worktreeTelemetry = summarizeWorktreeTelemetry(basePath);
348
+ detectWorktreeOrphans(worktreeTelemetry, anomalies);
349
+ } catch {
350
+ // Telemetry is best-effort — do not let an aggregator failure block the
351
+ // rest of the forensic report.
352
+ }
340
353
  detectJournalAnomalies(journalSummary, anomalies);
341
354
 
342
355
  return {
@@ -356,6 +369,7 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
356
369
  recentUnits,
357
370
  journalSummary,
358
371
  activityLogMeta,
372
+ worktreeTelemetry,
359
373
  };
360
374
  }
361
375
 
@@ -783,6 +797,51 @@ function detectMissingArtifacts(completedKeys: string[], basePath: string, activ
783
797
  }
784
798
  }
785
799
 
800
+ /**
801
+ * #4764 — surface worktree lifecycle and orphan signals in the forensic report.
802
+ *
803
+ * Consumes only the aggregated summary (not raw journal events) to respect
804
+ * the forensics memory-bloat guard in forensics-journal.test.ts — per-event
805
+ * detail stays in the journal itself where the LLM can query it on demand.
806
+ */
807
+ function detectWorktreeOrphans(
808
+ summary: WorktreeTelemetrySummary,
809
+ anomalies: ForensicAnomaly[],
810
+ ): void {
811
+ // 1. Orphan aggregate — severity depends on reason. In-progress orphans are
812
+ // the #4761 consumer-side signal (live work sitting on an unmerged branch).
813
+ for (const [reason, count] of Object.entries(summary.orphansByReason)) {
814
+ if (count <= 0) continue;
815
+ const severity: ForensicAnomaly["severity"] =
816
+ reason === "in-progress-unmerged" ? "warning" : "info";
817
+ anomalies.push({
818
+ type: "worktree-orphan",
819
+ severity,
820
+ summary: `${count} worktree orphan(s) detected (${reason})`,
821
+ details:
822
+ reason === "in-progress-unmerged"
823
+ ? "Auto-mode exited without completing a milestone; live work sits on an unmerged milestone branch. Run `/gsd auto` to resume, or merge manually."
824
+ : reason === "complete-unmerged"
825
+ ? "A completed milestone's branch was never merged back to main. Run `/gsd health --fix` to resolve."
826
+ : `Reason: ${reason}.`,
827
+ });
828
+ }
829
+
830
+ // 2. Auto-exit producer signal — #4761's upstream cause.
831
+ if (summary.exitsWithUnmergedWork > 0) {
832
+ const reasonBreakdown = Object.entries(summary.exitsByReason)
833
+ .filter(([, n]) => n > 0)
834
+ .map(([r, n]) => `${r}=${n}`)
835
+ .join(", ");
836
+ anomalies.push({
837
+ type: "worktree-unmerged-exit",
838
+ severity: "warning",
839
+ summary: `${summary.exitsWithUnmergedWork} auto-exit(s) left milestone work unmerged`,
840
+ details: `Exit reasons: ${reasonBreakdown || "(none)"} · Producer-side signal for #4761-class orphans. Inspect .gsd/journal/*.jsonl with eventType:"auto-exit" for per-exit detail.`,
841
+ });
842
+ }
843
+ }
844
+
786
845
  function detectCrash(crashLock: LockData | null, anomalies: ForensicAnomaly[]): void {
787
846
  if (!crashLock) return;
788
847
  if (isLockProcessAlive(crashLock)) return; // Process still running, not a crash
@@ -972,6 +1031,40 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes
972
1031
  sections.push(``);
973
1032
  }
974
1033
 
1034
+ // #4764 — Worktree telemetry summary
1035
+ if (report.worktreeTelemetry) {
1036
+ const t = report.worktreeTelemetry;
1037
+ const p50 = percentile(t.mergeDurationsMs, 0.5);
1038
+ const p95 = percentile(t.mergeDurationsMs, 0.95);
1039
+ sections.push(`## Worktree Telemetry`, ``);
1040
+ sections.push(`- Worktrees created: ${t.worktreesCreated}`);
1041
+ sections.push(`- Worktrees merged: ${t.worktreesMerged}`);
1042
+ sections.push(`- Orphans detected: ${t.orphansDetected}`);
1043
+ if (t.orphansDetected > 0) {
1044
+ const breakdown = Object.entries(t.orphansByReason)
1045
+ .map(([r, n]) => `${r}=${n}`).join(", ");
1046
+ sections.push(` - By reason: ${breakdown}`);
1047
+ }
1048
+ sections.push(`- Merge conflicts: ${t.mergeConflicts}`);
1049
+ if (t.mergeDurationsMs.length > 0) {
1050
+ sections.push(`- Merge duration p50 / p95: ${p50 ?? "-"} / ${p95 ?? "-"} ms (n=${t.mergeDurationsMs.length})`);
1051
+ }
1052
+ sections.push(`- Auto-exits leaving unmerged work: ${t.exitsWithUnmergedWork}`);
1053
+ if (Object.keys(t.exitsByReason).length > 0) {
1054
+ const breakdown = Object.entries(t.exitsByReason)
1055
+ .sort((a, b) => b[1] - a[1])
1056
+ .map(([r, n]) => `${r}=${n}`).join(", ");
1057
+ sections.push(` - Exit reasons: ${breakdown}`);
1058
+ }
1059
+ sections.push(`- Canonical-root redirects (#4761 fix fired): ${t.canonicalRedirects}`);
1060
+ // #4765 slice-cadence counters
1061
+ if (t.slicesMerged + t.sliceMergeConflicts + t.milestoneResquashes > 0) {
1062
+ sections.push(`- Slices merged: ${t.slicesMerged} · Slice merge conflicts: ${t.sliceMergeConflicts}`);
1063
+ sections.push(`- Milestone re-squashes: ${t.milestoneResquashes}`);
1064
+ }
1065
+ sections.push(``);
1066
+ }
1067
+
975
1068
  // Journal summary
976
1069
  if (report.journalSummary) {
977
1070
  const js = report.journalSummary;
@@ -1117,6 +1210,30 @@ function formatReportForPrompt(report: ForensicReport): string {
1117
1210
  sections.push("");
1118
1211
  }
1119
1212
 
1213
+ // #4764 — worktree telemetry (compact prompt form)
1214
+ if (report.worktreeTelemetry) {
1215
+ const t = report.worktreeTelemetry;
1216
+ const hasSignal =
1217
+ t.worktreesCreated + t.worktreesMerged + t.orphansDetected +
1218
+ t.exitsWithUnmergedWork + t.canonicalRedirects +
1219
+ t.slicesMerged + t.milestoneResquashes > 0;
1220
+ if (hasSignal) {
1221
+ sections.push("### Worktree Telemetry");
1222
+ sections.push(`- Created: ${t.worktreesCreated} · Merged: ${t.worktreesMerged} · Conflicts: ${t.mergeConflicts}`);
1223
+ sections.push(`- Orphans: ${t.orphansDetected} · Unmerged exits: ${t.exitsWithUnmergedWork} · Redirects (#4761): ${t.canonicalRedirects}`);
1224
+ if (t.orphansDetected > 0) {
1225
+ const breakdown = Object.entries(t.orphansByReason)
1226
+ .map(([r, n]) => `${r}=${n}`).join(", ");
1227
+ sections.push(`- Orphan reasons: ${breakdown}`);
1228
+ }
1229
+ // #4765 — slice-cadence counters (only shown when the feature was exercised)
1230
+ if (t.slicesMerged + t.sliceMergeConflicts + t.milestoneResquashes > 0) {
1231
+ sections.push(`- Slices merged: ${t.slicesMerged} · Slice conflicts: ${t.sliceMergeConflicts} · Re-squashes: ${t.milestoneResquashes}`);
1232
+ }
1233
+ sections.push("");
1234
+ }
1235
+ }
1236
+
1120
1237
  // Activity log metadata
1121
1238
  if (report.activityLogMeta) {
1122
1239
  const meta = report.activityLogMeta;
@@ -85,6 +85,22 @@ export interface GitPreferences {
85
85
  * for forensic inspection.
86
86
  */
87
87
  absorb_snapshot_commits?: boolean;
88
+ /** #4765 — when to collapse worktree commits back to main.
89
+ * - "milestone" (default): existing behavior — squash-merge happens once
90
+ * at milestone completion or transition.
91
+ * - "slice": squash-merge each slice's commits to main as soon as the
92
+ * slice passes validation. Shrinks the orphan window from
93
+ * milestone-size to slice-size and surfaces merge conflicts per slice
94
+ * rather than all at once at milestone end.
95
+ */
96
+ collapse_cadence?: "milestone" | "slice";
97
+ /** #4765 — when `collapse_cadence: "slice"`, optionally re-squash the per-
98
+ * slice commits on main into one milestone commit at milestone completion.
99
+ * Preserves the "one commit per milestone in main" history shape that
100
+ * `collapse_cadence: "milestone"` produces today.
101
+ * Default: true when collapse_cadence is "slice", ignored otherwise.
102
+ */
103
+ milestone_resquash?: boolean;
88
104
  }
89
105
 
90
106
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -50,7 +50,17 @@ export type JournalEventType =
50
50
  | "worktree-skip"
51
51
  | "worktree-merge-start"
52
52
  | "worktree-merge-failed"
53
- | "artifact-verification-retry";
53
+ | "artifact-verification-retry"
54
+ // #4764 — worktree lifespan / divergence telemetry
55
+ | "worktree-created"
56
+ | "worktree-merged"
57
+ | "worktree-orphaned"
58
+ | "auto-exit"
59
+ | "worktree-sync"
60
+ | "canonical-root-redirect"
61
+ // #4765 — slice-cadence collapse
62
+ | "slice-merged"
63
+ | "milestone-resquash";
54
64
 
55
65
  /** A single structured event in the journal. */
56
66
  export interface JournalEntry {