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
@@ -27,6 +27,7 @@ import { readFileSync } from "node:fs";
27
27
  import { join } from "node:path";
28
28
 
29
29
  import { buildFlatRateContext } from "../auto-model-selection.ts";
30
+ import { extractSourceRegion } from "./test-helpers.ts";
30
31
 
31
32
  // ─── Bug 2: this-binding regression ─────────────────────────────────────
32
33
 
@@ -69,7 +70,7 @@ test("isSamePath short-circuits ENOENT before logging a warning", () => {
69
70
  assert.ok(fnIdx !== -1, "isSamePath function exists");
70
71
 
71
72
  // Grab the function body (enough to cover the catch block).
72
- const fnBody = src.slice(fnIdx, fnIdx + 600);
73
+ const fnBody = extractSourceRegion(src, "function isSamePath", { fromIdx: fnIdx });
73
74
 
74
75
  const catchIdx = fnBody.indexOf("catch");
75
76
  assert.ok(catchIdx !== -1, "isSamePath has a catch block");
@@ -103,7 +104,7 @@ test("checkAutoStartAfterDiscuss guards DISCUSSION-MANIFEST.json unlink with exi
103
104
 
104
105
  // Everything from the comment to a short distance below should contain
105
106
  // the existsSync guard before the unlinkSync call.
106
- const block = src.slice(cleanupIdx, cleanupIdx + 400);
107
+ const block = extractSourceRegion(src, "remove discussion manifest after auto-start", { fromIdx: cleanupIdx });
107
108
 
108
109
  const existsIdx = block.indexOf("existsSync(manifestPath)");
109
110
  const unlinkIdx = block.indexOf("unlinkSync(manifestPath)");
@@ -2,6 +2,7 @@ import { describe, test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { extractSourceRegion } from "./test-helpers.ts";
5
6
 
6
7
  const systemContextSrc = readFileSync(
7
8
  join(import.meta.dirname, "..", "bootstrap", "system-context.ts"),
@@ -29,7 +30,7 @@ describe("bootstrap deriveState DB guards (#3844)", () => {
29
30
  test("register-hooks opens DB before deriveState in session_before_compact", () => {
30
31
  const compactIdx = registerHooksSrc.indexOf('pi.on("session_before_compact"');
31
32
  assert.ok(compactIdx > -1, "register-hooks should define session_before_compact");
32
- const compactSection = registerHooksSrc.slice(compactIdx, compactIdx + 1600);
33
+ const compactSection = extractSourceRegion(registerHooksSrc, 'pi.on("session_before_compact"');
33
34
  const ensureIdx = compactSection.indexOf("ensureDbOpen()");
34
35
  const deriveIdx = compactSection.indexOf("deriveState(basePath)");
35
36
  assert.ok(ensureIdx > -1, "session_before_compact should call ensureDbOpen()");
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for resolveCanonicalMilestoneRoot — the worktree-aware reader
3
+ * that fixes #4761 (worktree work stranded when auto-loop exits without
4
+ * milestone completion).
5
+ *
6
+ * Contract: given (basePath, milestoneId), return the worktree path if a
7
+ * live git worktree exists for that milestone at .gsd/worktrees/<MID>/;
8
+ * otherwise return basePath unchanged. A live worktree has a .git file
9
+ * (not directory) — a bare directory without .git is a stale leftover.
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ import { resolveCanonicalMilestoneRoot } from "../worktree-manager.ts";
20
+
21
+ function makeTmpBase(): string {
22
+ const base = join(tmpdir(), `gsd-canon-test-${randomUUID()}`);
23
+ mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
24
+ return base;
25
+ }
26
+
27
+ function cleanup(base: string): void {
28
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
29
+ }
30
+
31
+ /**
32
+ * Create a worktree directory shape that looks live: the .gsd/worktrees/<MID>/
33
+ * directory with a .git file containing a gitdir: pointer. We don't need a
34
+ * real git worktree — the resolver only checks for the .git file's presence.
35
+ */
36
+ function makeLiveWorktree(base: string, mid: string): string {
37
+ const wtPath = join(base, ".gsd", "worktrees", mid);
38
+ mkdirSync(wtPath, { recursive: true });
39
+ writeFileSync(
40
+ join(wtPath, ".git"),
41
+ `gitdir: ${join(base, ".git", "worktrees", mid)}\n`,
42
+ );
43
+ return wtPath;
44
+ }
45
+
46
+ function makeStaleWorktree(base: string, mid: string): string {
47
+ const wtPath = join(base, ".gsd", "worktrees", mid);
48
+ mkdirSync(wtPath, { recursive: true });
49
+ // No .git file — this is the stale-leftover shape createWorktree() sees
50
+ // and cleans up.
51
+ return wtPath;
52
+ }
53
+
54
+ test("returns worktree path when a live worktree exists for the milestone", () => {
55
+ const base = makeTmpBase();
56
+ try {
57
+ const wtPath = makeLiveWorktree(base, "M001");
58
+ const result = resolveCanonicalMilestoneRoot(base, "M001");
59
+ assert.equal(result, wtPath);
60
+ } finally {
61
+ cleanup(base);
62
+ }
63
+ });
64
+
65
+ test("returns basePath when no worktree directory exists", () => {
66
+ const base = makeTmpBase();
67
+ try {
68
+ const result = resolveCanonicalMilestoneRoot(base, "M001");
69
+ assert.equal(result, base);
70
+ } finally {
71
+ cleanup(base);
72
+ }
73
+ });
74
+
75
+ test("returns basePath when worktree directory exists but has no .git file (stale)", () => {
76
+ const base = makeTmpBase();
77
+ try {
78
+ makeStaleWorktree(base, "M001");
79
+ const result = resolveCanonicalMilestoneRoot(base, "M001");
80
+ assert.equal(result, base);
81
+ } finally {
82
+ cleanup(base);
83
+ }
84
+ });
85
+
86
+ test("returns basePath for invalid milestoneId (path separators)", () => {
87
+ const base = makeTmpBase();
88
+ try {
89
+ // Even if a worktree coincidentally exists, the guard should reject.
90
+ assert.equal(resolveCanonicalMilestoneRoot(base, "../evil"), base);
91
+ assert.equal(resolveCanonicalMilestoneRoot(base, "M001/subdir"), base);
92
+ assert.equal(resolveCanonicalMilestoneRoot(base, "M001\\subdir"), base);
93
+ assert.equal(resolveCanonicalMilestoneRoot(base, ""), base);
94
+ } finally {
95
+ cleanup(base);
96
+ }
97
+ });
98
+
99
+ test("only returns the worktree for the requested milestone, not siblings", () => {
100
+ const base = makeTmpBase();
101
+ try {
102
+ makeLiveWorktree(base, "M001");
103
+ const result = resolveCanonicalMilestoneRoot(base, "M002");
104
+ assert.equal(result, base, "M002 has no worktree → basePath");
105
+ } finally {
106
+ cleanup(base);
107
+ }
108
+ });
@@ -11,6 +11,7 @@ import { describe, it } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { resolve } from 'node:path'
14
+ import { extractSourceRegion } from "./test-helpers.ts";
14
15
 
15
16
  const src = readFileSync(
16
17
  resolve(process.cwd(), 'src', 'resources', 'extensions', 'gsd', 'tools', 'complete-slice.ts'),
@@ -59,7 +60,7 @@ describe('complete-slice verification gate (#3580)', () => {
59
60
  const gateIdx = src.indexOf('BLOCKED_SIGNALS.test(')
60
61
  assert.ok(gateIdx !== -1)
61
62
 
62
- const afterGate = src.slice(gateIdx, gateIdx + 500)
63
+ const afterGate = extractSourceRegion(src, 'BLOCKED_SIGNALS.test(')
63
64
  assert.ok(
64
65
  afterGate.includes('return { error:'),
65
66
  'blocked signal detection must return an error',
@@ -627,4 +627,83 @@ Integration tests mock external services.
627
627
 
628
628
  assert.strictEqual(result, '', 'empty content returns empty string');
629
629
  });
630
+
631
+ // ── Regression: issue #4719 — single-H2 with many H3 entries ──────────────
632
+ // A KNOWLEDGE.md structured as one top-level H2 with many H3 entries must
633
+ // filter at H3 granularity; otherwise one keyword match against the H2
634
+ // header or first paragraph returns the entire file.
635
+ test("single H2 with many H3 entries filters at H3 level (issue #4719)", async () => {
636
+ const singleH2Knowledge = `# Project Knowledge
637
+
638
+ ## Patterns
639
+
640
+ ### Database: prepared statements
641
+ Always use prepared statements with SQLite.
642
+
643
+ ### API: versioned paths
644
+ Use /v1/resource style versioning.
645
+
646
+ ### Testing: node:test
647
+ Prefer node:test over external frameworks.
648
+
649
+ ### Deployment: blue-green
650
+ Blue-green deployment for zero-downtime releases.
651
+ `;
652
+
653
+ const result = await queryKnowledge(singleH2Knowledge, ['database']);
654
+
655
+ // Should include only the matching H3 entry, not the whole file
656
+ assert.match(result, /Database: prepared statements/, 'includes matching H3 entry');
657
+ assert.ok(
658
+ !result.includes('API: versioned paths'),
659
+ 'does not include non-matching H3 entry',
660
+ );
661
+ assert.ok(
662
+ !result.includes('Testing: node:test'),
663
+ 'does not include non-matching H3 entry',
664
+ );
665
+ assert.ok(
666
+ !result.includes('Deployment: blue-green'),
667
+ 'does not include non-matching H3 entry',
668
+ );
669
+ // The returned payload must be dramatically smaller than the full content
670
+ assert.ok(
671
+ result.length < singleH2Knowledge.length / 2,
672
+ `scoped result (${result.length} chars) should be <50% of full content (${singleH2Knowledge.length} chars)`,
673
+ );
674
+ });
675
+
676
+ test("single H2 with H3 entries returns empty when no H3 matches (issue #4719)", async () => {
677
+ const singleH2Knowledge = `# Project Knowledge
678
+
679
+ ## Patterns
680
+
681
+ ### Database: prepared statements
682
+ Always use prepared statements with SQLite.
683
+
684
+ ### API: versioned paths
685
+ Use /v1/resource style versioning.
686
+ `;
687
+
688
+ const result = await queryKnowledge(singleH2Knowledge, ['nonexistent']);
689
+
690
+ assert.strictEqual(result, '', 'no H3 match returns empty string');
691
+ });
692
+
693
+ test("falls back to H2 when no H3 headings exist at all", async () => {
694
+ // Backwards-compat: files with only H2 topic headers must still filter.
695
+ const h2OnlyKnowledge = `# Project Knowledge
696
+
697
+ ## Database Patterns
698
+ Use prepared statements.
699
+
700
+ ## API Design
701
+ REST with OpenAPI.
702
+ `;
703
+
704
+ const result = await queryKnowledge(h2OnlyKnowledge, ['database']);
705
+
706
+ assert.match(result, /Database Patterns/, 'H2-only file falls back to H2 filtering');
707
+ assert.ok(!result.includes('API Design'), 'non-matching H2 section excluded');
708
+ });
630
709
  });
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { extractSourceRegion } from "./test-helpers.ts";
5
6
 
6
7
  test("copyPlanningArtifacts skips when source and destination .gsd resolve to the same path", () => {
7
8
  const srcPath = join(import.meta.dirname, "..", "auto-worktree.ts");
@@ -10,7 +11,7 @@ test("copyPlanningArtifacts skips when source and destination .gsd resolve to th
10
11
  const fnIdx = src.indexOf("function copyPlanningArtifacts");
11
12
  assert.ok(fnIdx !== -1, "copyPlanningArtifacts function exists");
12
13
 
13
- const fnBody = src.slice(fnIdx, fnIdx + 2400);
14
+ const fnBody = extractSourceRegion(src, "function copyPlanningArtifacts");
14
15
 
15
16
  const guardIdx = fnBody.indexOf("if (isSamePath(srcGsd, dstGsd)) return;");
16
17
  const copyIdx = fnBody.indexOf("safeCopyRecursive(join(srcGsd, \"milestones\")");
@@ -11,6 +11,7 @@ import { describe, it } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { resolve } from 'node:path'
14
+ import { extractSourceRegion } from "./test-helpers.ts";
14
15
 
15
16
  const template = readFileSync(
16
17
  resolve(process.cwd(), 'src', 'resources', 'extensions', 'gsd', 'prompts', 'guided-discuss-slice.md'),
@@ -37,7 +38,7 @@ describe('discuss-slice structuredQuestionsAvailable template variable', () => {
37
38
  const falseIdx = template.indexOf('`{{structuredQuestionsAvailable}}` is `false`')
38
39
  assert.ok(falseIdx !== -1)
39
40
 
40
- const afterFalse = template.slice(falseIdx, falseIdx + 300)
41
+ const afterFalse = extractSourceRegion(template, '`{{structuredQuestionsAvailable}}` is `false`')
41
42
  assert.ok(
42
43
  afterFalse.includes('plain text'),
43
44
  'when structuredQuestionsAvailable is false, questions should be in plain text',
@@ -22,6 +22,7 @@ import { join, dirname } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
 
24
24
  import { DISCUSS_TOOLS_ALLOWLIST } from "../constants.ts";
25
+ import { extractSourceRegion } from "./test-helpers.ts";
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const guidedFlowSource = readFileSync(join(__dirname, "..", "guided-flow.ts"), "utf-8");
@@ -58,7 +59,7 @@ describe("#3616 — discuss tool scoping must not leak across sessions", () => {
58
59
  );
59
60
  const newSessionStart = agentSessionSource.indexOf("async newSession(options?:");
60
61
  assert.ok(newSessionStart >= 0, "should find newSession");
61
- const body = agentSessionSource.slice(newSessionStart, newSessionStart + 3000);
62
+ const body = extractSourceRegion(agentSessionSource, "async newSession(options?:");
62
63
 
63
64
  // Both branches (cwd-changed and cwd-unchanged) must include extension tools
64
65
  assert.ok(
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { AutoSession } from "../auto/session.ts";
7
+ import { extractSourceRegion } from "./test-helpers.ts";
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
 
@@ -20,7 +21,7 @@ describe("double mergeAndExit guard (#2645)", () => {
20
21
  const completeIdx = phasesSrc.indexOf('state.phase === "complete"');
21
22
  assert.ok(completeIdx > 0, "phases.ts should have a 'complete' phase check");
22
23
 
23
- const afterComplete = phasesSrc.slice(completeIdx, completeIdx + 600);
24
+ const afterComplete = extractSourceRegion(phasesSrc, 'state.phase === "complete"');
24
25
  const mergeIdx = afterComplete.indexOf("deps.resolver.mergeAndExit");
25
26
  const flagIdx = afterComplete.indexOf("s.milestoneMergedInPhases = true");
26
27
 
@@ -42,7 +43,7 @@ describe("double mergeAndExit guard (#2645)", () => {
42
43
  const allCompleteIdx = phasesSrc.indexOf("incomplete.length === 0");
43
44
  assert.ok(allCompleteIdx > 0, "phases.ts should have an all-milestones-complete check");
44
45
 
45
- const afterAllComplete = phasesSrc.slice(allCompleteIdx, allCompleteIdx + 800);
46
+ const afterAllComplete = extractSourceRegion(phasesSrc, "incomplete.length === 0");
46
47
  const mergeIdx = afterAllComplete.indexOf("deps.resolver.mergeAndExit");
47
48
  const flagIdx = afterAllComplete.indexOf("s.milestoneMergedInPhases = true");
48
49
 
@@ -64,7 +65,7 @@ describe("double mergeAndExit guard (#2645)", () => {
64
65
  const step4Idx = autoSrc.indexOf("Step 4: Auto-worktree exit");
65
66
  assert.ok(step4Idx > 0, "auto.ts should have Step 4 worktree exit");
66
67
 
67
- const step4Block = autoSrc.slice(step4Idx, step4Idx + 600);
68
+ const step4Block = extractSourceRegion(autoSrc, "Step 4: Auto-worktree exit");
68
69
  assert.ok(
69
70
  step4Block.includes("milestoneMergedInPhases"),
70
71
  "stopAuto Step 4 must check milestoneMergedInPhases before merging",
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join, dirname } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { extractSourceRegion } from "./test-helpers.ts";
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  const RECOVERY_PATH = join(__dirname, "..", "bootstrap", "agent-end-recovery.ts");
@@ -30,7 +31,7 @@ test("agent-end-recovery.ts does not pause on aborted messages with empty conten
30
31
  assert.ok(abortIdx > -1, "abort handler must exist in agent-end-recovery.ts");
31
32
 
32
33
  // Extract the region around the abort handler (enough to see the guard logic)
33
- const abortRegion = source.slice(Math.max(0, abortIdx - 200), abortIdx + 600);
34
+ const abortRegion = extractSourceRegion(source, 'stopReason === "aborted"', { fromIdx: abortIdx });
34
35
 
35
36
  // Must check for empty content before pausing
36
37
  assert.ok(
@@ -48,7 +49,7 @@ test("agent-end-recovery.ts routes empty-content aborted messages to resolveAgen
48
49
  assert.ok(abortIdx > -1, "abort handler must exist");
49
50
 
50
51
  // Get the full abort handling block (from the if to the next stopReason check or success path)
51
- const afterAbort = source.slice(abortIdx, abortIdx + 800);
52
+ const afterAbort = extractSourceRegion(source, 'stopReason === "aborted"');
52
53
 
53
54
  // The abort block must have a code path that calls resolveAgentEnd (for empty-content case)
54
55
  assert.ok(
@@ -63,7 +64,7 @@ test("agent-end-recovery.ts checks for errorMessage presence in abort handler (#
63
64
  const abortIdx = source.indexOf('stopReason === "aborted"');
64
65
  assert.ok(abortIdx > -1, "abort handler must exist");
65
66
 
66
- const abortRegion = source.slice(abortIdx, abortIdx + 600);
67
+ const abortRegion = extractSourceRegion(source, 'stopReason === "aborted"');
67
68
 
68
69
  // Fatal aborts should have error context (errorMessage field).
69
70
  // The handler should check for this to distinguish fatal from non-fatal aborts.
@@ -16,7 +16,7 @@
16
16
  * isolated unit testing.
17
17
  */
18
18
 
19
- import { createTestContext } from "./test-helpers.ts";
19
+ import {createTestContext, extractSourceRegion } from "./test-helpers.ts";
20
20
  import {
21
21
  withTimeout,
22
22
  FINALIZE_PRE_TIMEOUT_MS,
@@ -163,7 +163,7 @@ function getRunFinalizeBody(phasesSource: string): string {
163
163
  assertTrue(preVerIdx > 0, "postUnitPreVerification should appear in runFinalize");
164
164
 
165
165
  // The first withTimeout should wrap postUnitPreVerification (not postUnitPostVerification)
166
- const firstWithTimeout = fnBody.slice(preTimeoutIdx, preTimeoutIdx + 200);
166
+ const firstWithTimeout = extractSourceRegion(fnBody, "withTimeout(");
167
167
  assertTrue(
168
168
  firstWithTimeout.includes("postUnitPreVerification"),
169
169
  "first withTimeout in runFinalize should wrap postUnitPreVerification",
@@ -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
  const gsdDir = join(__dirname, "..");
@@ -43,7 +44,7 @@ describe("forensics hook compound key parsing (#2826)", () => {
43
44
  it("detectMissingArtifacts delegates to splitCompletedKey", () => {
44
45
  const fnStart = forensicsSrc.indexOf("function detectMissingArtifacts(");
45
46
  assert.ok(fnStart !== -1, "detectMissingArtifacts must exist in forensics.ts");
46
- const fnBody = forensicsSrc.slice(fnStart, fnStart + 1000);
47
+ const fnBody = extractSourceRegion(forensicsSrc, "function detectMissingArtifacts(");
47
48
  assert.ok(
48
49
  fnBody.includes("splitCompletedKey("),
49
50
  "detectMissingArtifacts must call splitCompletedKey() rather than inline the split logic",
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for the #4764 forensics integration — verifies that
3
+ * buildForensicReport picks up worktree telemetry aggregates and emits
4
+ * anomalies for orphans and auto-exits with unmerged work.
5
+ */
6
+
7
+ import { describe, it } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { readFileSync, mkdirSync, rmSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { randomUUID } from "node:crypto";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import {
16
+ emitWorktreeOrphaned,
17
+ emitAutoExit,
18
+ emitWorktreeCreated,
19
+ emitWorktreeMerged,
20
+ } from "../worktree-telemetry.ts";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const gsdDir = join(__dirname, "..");
24
+
25
+ function makeTmpBase(): string {
26
+ const base = join(tmpdir(), `gsd-for-tel-test-${randomUUID()}`);
27
+ mkdirSync(join(base, ".gsd"), { recursive: true });
28
+ return base;
29
+ }
30
+
31
+ function cleanup(base: string): void {
32
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
33
+ }
34
+
35
+ describe("#4764 forensics + worktree telemetry integration", () => {
36
+ const forensicsSrc = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
37
+
38
+ it("forensics.ts imports the telemetry summarizer", () => {
39
+ assert.ok(
40
+ forensicsSrc.includes("summarizeWorktreeTelemetry"),
41
+ "forensics must consume the telemetry aggregator",
42
+ );
43
+ });
44
+
45
+ it("forensics.ts does NOT call queryJournal directly (memory guard)", () => {
46
+ // The same invariant guarded by forensics-journal.test.ts — re-asserted
47
+ // here so this feature change doesn't regress it.
48
+ assert.ok(
49
+ !forensicsSrc.includes("queryJournal("),
50
+ "forensics.ts must route journal reads through aggregators, not queryJournal",
51
+ );
52
+ });
53
+
54
+ it("ForensicReport includes worktreeTelemetry field", () => {
55
+ assert.ok(
56
+ forensicsSrc.includes("worktreeTelemetry"),
57
+ "report shape must include the telemetry summary",
58
+ );
59
+ });
60
+
61
+ it("formatReportForPrompt gates Worktree Telemetry section on signal", () => {
62
+ assert.ok(
63
+ forensicsSrc.includes("Worktree Telemetry"),
64
+ "prompt formatter must include a Worktree Telemetry section",
65
+ );
66
+ });
67
+
68
+ it("new anomaly types worktree-orphan and worktree-unmerged-exit exist on the union", () => {
69
+ assert.ok(forensicsSrc.includes('"worktree-orphan"'));
70
+ assert.ok(forensicsSrc.includes('"worktree-unmerged-exit"'));
71
+ });
72
+
73
+ it("buildForensicReport surfaces worktree-orphan anomaly when the journal shows an in-progress orphan", async () => {
74
+ const base = makeTmpBase();
75
+ try {
76
+ // Seed the journal with one in-progress orphan event
77
+ emitWorktreeOrphaned(base, "M001", {
78
+ reason: "in-progress-unmerged",
79
+ commitsAhead: 3,
80
+ worktreeDirExists: true,
81
+ });
82
+
83
+ const { buildForensicReport } = await import("../forensics.ts");
84
+ const report = await buildForensicReport(base);
85
+
86
+ const orphanAnomalies = report.anomalies.filter(a => a.type === "worktree-orphan");
87
+ assert.ok(orphanAnomalies.length >= 1, `expected a worktree-orphan anomaly; got ${JSON.stringify(report.anomalies.map(a => a.type))}`);
88
+ assert.equal(orphanAnomalies[0].severity, "warning", "in-progress orphan should be a warning");
89
+
90
+ // Aggregate fields surface in the telemetry summary
91
+ assert.ok(report.worktreeTelemetry, "report should carry the telemetry summary");
92
+ assert.equal(report.worktreeTelemetry!.orphansDetected, 1);
93
+ assert.equal(report.worktreeTelemetry!.orphansByReason["in-progress-unmerged"], 1);
94
+ } finally { cleanup(base); }
95
+ });
96
+
97
+ it("buildForensicReport surfaces worktree-unmerged-exit anomaly when auto-exit left work unmerged", async () => {
98
+ const base = makeTmpBase();
99
+ try {
100
+ emitAutoExit(base, { reason: "pause", milestoneId: "M002", milestoneMerged: false });
101
+ emitAutoExit(base, { reason: "stop", milestoneId: "M002", milestoneMerged: false });
102
+ emitAutoExit(base, { reason: "all-complete", milestoneId: "M001", milestoneMerged: true });
103
+
104
+ const { buildForensicReport } = await import("../forensics.ts");
105
+ const report = await buildForensicReport(base);
106
+
107
+ const unmergedExitAnomalies = report.anomalies.filter(a => a.type === "worktree-unmerged-exit");
108
+ assert.equal(unmergedExitAnomalies.length, 1, "exactly one aggregate unmerged-exit anomaly");
109
+ assert.equal(unmergedExitAnomalies[0].severity, "warning");
110
+ assert.ok(
111
+ unmergedExitAnomalies[0].summary.includes("2"),
112
+ "summary should mention the count (2 unmerged exits out of 3 total)",
113
+ );
114
+
115
+ assert.equal(report.worktreeTelemetry!.exitsWithUnmergedWork, 2);
116
+ } finally { cleanup(base); }
117
+ });
118
+
119
+ it("buildForensicReport emits no telemetry anomalies when there are no signals", async () => {
120
+ const base = makeTmpBase();
121
+ try {
122
+ // Healthy path — worktree created and merged without incident
123
+ emitWorktreeCreated(base, "M001");
124
+ emitWorktreeMerged(base, "M001", {
125
+ reason: "milestone-complete",
126
+ durationMs: 250,
127
+ conflict: false,
128
+ });
129
+ emitAutoExit(base, { reason: "all-complete", milestoneId: "M001", milestoneMerged: true });
130
+
131
+ const { buildForensicReport } = await import("../forensics.ts");
132
+ const report = await buildForensicReport(base);
133
+
134
+ const telemetryAnomalies = report.anomalies.filter(a =>
135
+ a.type === "worktree-orphan" || a.type === "worktree-unmerged-exit"
136
+ );
137
+ assert.deepStrictEqual(telemetryAnomalies, [], "no orphans, no unmerged exits → no telemetry anomalies");
138
+
139
+ assert.equal(report.worktreeTelemetry!.worktreesCreated, 1);
140
+ assert.equal(report.worktreeTelemetry!.worktreesMerged, 1);
141
+ assert.equal(report.worktreeTelemetry!.orphansDetected, 0);
142
+ assert.equal(report.worktreeTelemetry!.exitsWithUnmergedWork, 0);
143
+ } finally { cleanup(base); }
144
+ });
145
+ });
@@ -18,6 +18,7 @@ import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { test, describe } from "node:test";
20
20
  import assert from "node:assert/strict";
21
+ import { extractSourceRegion } from "./test-helpers.ts";
21
22
 
22
23
  const TIMERS_SRC = readFileSync(
23
24
  join(import.meta.dirname, "..", "auto-timers.ts"),
@@ -97,7 +98,11 @@ describe("#2527 Bug 2: null guard after async recovery prevents crash", () => {
97
98
 
98
99
  // The null guard must appear between the recovery call and the next
99
100
  // writeUnitRuntimeRecord that accesses s.currentUnit.startedAt
100
- const afterRecovery = TIMERS_SRC.slice(idleRecovery, idleRecovery + 400);
101
+ const afterRecovery = extractSourceRegion(
102
+ TIMERS_SRC,
103
+ 'recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle"',
104
+ { fromIdx: idleRecovery },
105
+ );
101
106
  assert.ok(
102
107
  afterRecovery.includes("if (!s.currentUnit) return"),
103
108
  "null guard for s.currentUnit must exist after idle recoverTimedOutUnit",
@@ -10,6 +10,7 @@ import assert from 'node:assert/strict';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { dirname, join } from 'node:path';
13
+ import { extractSourceRegion } from "./test-helpers.ts";
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
@@ -30,7 +31,7 @@ describe('import done milestones as complete (#3699)', () => {
30
31
  // Find the all-done guard and verify it sets 'complete'
31
32
  const everyIdx = importerSrc.indexOf('roadmap.slices.every(s => s.done)');
32
33
  assert.ok(everyIdx > -1, 'all-slices-done check should exist');
33
- const afterCheck = importerSrc.slice(everyIdx, everyIdx + 200);
34
+ const afterCheck = extractSourceRegion(importerSrc, 'roadmap.slices.every(s => s.done)');
34
35
  assert.match(afterCheck, /milestoneStatus\s*=\s*'complete'/,
35
36
  'should set milestoneStatus to complete when all slices are done');
36
37
  });
@@ -12,6 +12,7 @@
12
12
 
13
13
  import test from "node:test";
14
14
  import assert from "node:assert/strict";
15
+ import { extractSourceRegion } from "../test-helpers.ts";
15
16
  import {
16
17
  mkdtempSync,
17
18
  mkdirSync,
@@ -115,7 +116,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", (
115
116
  helperIdx > -1,
116
117
  "WorktreeResolver.mergeAndExit helper should exist",
117
118
  );
118
- const helperBlock = resolverSrc.slice(helperIdx, helperIdx + 2600);
119
+ const helperBlock = extractSourceRegion(resolverSrc, "mergeAndExit(milestoneId");
119
120
  assert.ok(
120
121
  helperBlock.includes('mode === "worktree"') ||
121
122
  helperBlock.includes('mode: "worktree"'),
@@ -8,6 +8,7 @@ import assert from "node:assert/strict";
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { extractSourceRegion } from "./test-helpers.ts";
11
12
 
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
14
 
@@ -62,7 +63,7 @@ describe("interactive routing bypass (#3962)", () => {
62
63
  );
63
64
  // The function should check isAutoMode before routing synthesis
64
65
  const fnIdx = modelSelectionSrc.indexOf("function resolvePreferredModelConfig");
65
- const fnBody = modelSelectionSrc.slice(fnIdx, fnIdx + 900);
66
+ const fnBody = extractSourceRegion(modelSelectionSrc, "function resolvePreferredModelConfig");
66
67
  assert.ok(
67
68
  fnBody.includes("isAutoMode"),
68
69
  "resolvePreferredModelConfig should accept isAutoMode parameter",
@@ -137,8 +138,13 @@ describe("model downgrade notifications always visible (#3962)", () => {
137
138
  const escalatedIdx = modelSelectionSrc.indexOf("if (escalated)");
138
139
  assert.ok(escalatedIdx > 0, "escalation block should exist");
139
140
 
140
- // Get the block from "if (escalated)" to the next closing brace pattern
141
- const block = modelSelectionSrc.slice(escalatedIdx, escalatedIdx + 400);
141
+ // Get the block from "if (escalated)" to the next distinctive marker
142
+ // (the capability-override loading that immediately follows).
143
+ const block = extractSourceRegion(
144
+ modelSelectionSrc,
145
+ "if (escalated)",
146
+ "// Load user capability overrides",
147
+ );
142
148
  assert.ok(
143
149
  block.includes("Tier escalation:"),
144
150
  "escalation block should contain the notification",