gsd-pi 2.78.1-dev.84a383f51 → 2.78.1-dev.8a893322c

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 (147) hide show
  1. package/README.md +1 -0
  2. package/dist/bundled-resource-path.d.ts +7 -0
  3. package/dist/bundled-resource-path.js +34 -2
  4. package/dist/claude-cli-check.js +18 -6
  5. package/dist/headless-query.js +21 -6
  6. package/dist/loader.js +2 -3
  7. package/dist/resource-loader.js +2 -8
  8. package/dist/resources/.managed-resources-content-hash +1 -1
  9. package/dist/resources/extensions/claude-code-cli/readiness.js +19 -7
  10. package/dist/resources/extensions/google-search/index.js +2 -6
  11. package/dist/resources/extensions/gsd/auto/phases.js +3 -11
  12. package/dist/resources/extensions/gsd/auto/session.js +2 -6
  13. package/dist/resources/extensions/gsd/auto-dashboard.js +3 -2
  14. package/dist/resources/extensions/gsd/auto-dispatch.js +18 -6
  15. package/dist/resources/extensions/gsd/auto-prompts.js +63 -2
  16. package/dist/resources/extensions/gsd/auto-worktree.js +30 -13
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +19 -1
  18. package/dist/resources/extensions/gsd/bootstrap/subagent-input.js +22 -0
  19. package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -0
  20. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +84 -2
  21. package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
  22. package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
  23. package/dist/resources/extensions/gsd/commands/handlers/ops.js +8 -0
  24. package/dist/resources/extensions/gsd/commands-config.js +3 -2
  25. package/dist/resources/extensions/gsd/commands-extensions.js +46 -3
  26. package/dist/resources/extensions/gsd/commands-handlers.js +3 -2
  27. package/dist/resources/extensions/gsd/commands-worktree.js +309 -0
  28. package/dist/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  29. package/dist/resources/extensions/gsd/doctor-providers.js +2 -1
  30. package/dist/resources/extensions/gsd/forensics.js +8 -6
  31. package/dist/resources/extensions/gsd/guided-flow.js +2 -1
  32. package/dist/resources/extensions/gsd/home-dir.js +16 -0
  33. package/dist/resources/extensions/gsd/key-manager.js +2 -1
  34. package/dist/resources/extensions/gsd/migrate/command.js +3 -2
  35. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  36. package/dist/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  37. package/dist/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  38. package/dist/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  39. package/dist/resources/extensions/gsd/unit-context-manifest.js +29 -4
  40. package/dist/resources/extensions/gsd/worktree-manager.js +20 -1
  41. package/dist/resources/extensions/gsd/worktree-resolver.js +4 -13
  42. package/dist/resources/extensions/gsd/worktree-root.js +124 -0
  43. package/dist/resources/extensions/gsd/worktree.js +4 -115
  44. package/dist/resources/extensions/mcp-client/index.js +0 -6
  45. package/dist/resources/extensions/ollama/index.js +15 -2
  46. package/dist/resources/extensions/ollama/model-capabilities.js +31 -0
  47. package/dist/resources/extensions/ollama/ollama-client.js +40 -4
  48. package/dist/resources/extensions/subagent/index.js +324 -178
  49. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  50. package/dist/web/standalone/.next/BUILD_ID +1 -1
  51. package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
  52. package/dist/web/standalone/.next/build-manifest.json +2 -2
  53. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  54. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.html +1 -1
  71. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  76. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
  78. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  80. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  81. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  82. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  83. package/dist/welcome-screen.js +27 -1
  84. package/dist/worktree-cli.d.ts +1 -0
  85. package/dist/worktree-cli.js +9 -3
  86. package/package.json +1 -3
  87. package/packages/mcp-server/src/workflow-tools.test.ts +52 -0
  88. package/packages/native/tsconfig.tsbuildinfo +1 -1
  89. package/src/resources/extensions/claude-code-cli/readiness.ts +20 -7
  90. package/src/resources/extensions/google-search/index.ts +2 -9
  91. package/src/resources/extensions/gsd/auto/phases.ts +3 -11
  92. package/src/resources/extensions/gsd/auto/session.ts +2 -6
  93. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -2
  94. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -6
  95. package/src/resources/extensions/gsd/auto-prompts.ts +60 -2
  96. package/src/resources/extensions/gsd/auto-worktree.ts +44 -12
  97. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +19 -0
  98. package/src/resources/extensions/gsd/bootstrap/subagent-input.ts +20 -0
  99. package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -0
  100. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +103 -1
  101. package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
  102. package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
  103. package/src/resources/extensions/gsd/commands/handlers/ops.ts +10 -0
  104. package/src/resources/extensions/gsd/commands-config.ts +3 -2
  105. package/src/resources/extensions/gsd/commands-extensions.ts +43 -3
  106. package/src/resources/extensions/gsd/commands-handlers.ts +3 -2
  107. package/src/resources/extensions/gsd/commands-worktree.ts +383 -0
  108. package/src/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  109. package/src/resources/extensions/gsd/doctor-providers.ts +2 -1
  110. package/src/resources/extensions/gsd/forensics.ts +10 -5
  111. package/src/resources/extensions/gsd/guided-flow.ts +2 -1
  112. package/src/resources/extensions/gsd/home-dir.ts +19 -0
  113. package/src/resources/extensions/gsd/journal.ts +4 -1
  114. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  115. package/src/resources/extensions/gsd/migrate/command.ts +3 -2
  116. package/src/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  117. package/src/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  118. package/src/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  119. package/src/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  120. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +15 -0
  121. package/src/resources/extensions/gsd/tests/bundled-skill-triggers.test.ts +50 -27
  122. package/src/resources/extensions/gsd/tests/commands-extensions-version-compare.test.ts +58 -0
  123. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +48 -0
  124. package/src/resources/extensions/gsd/tests/google-search-stub.test.ts +25 -65
  125. package/src/resources/extensions/gsd/tests/home-dir.test.ts +52 -0
  126. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +50 -1
  127. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +18 -1
  128. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +34 -0
  129. package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +17 -1
  130. package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +38 -3
  131. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +34 -33
  132. package/src/resources/extensions/gsd/tests/worktree.test.ts +8 -0
  133. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +116 -1
  134. package/src/resources/extensions/gsd/unit-context-manifest.ts +36 -4
  135. package/src/resources/extensions/gsd/worktree-manager.ts +40 -1
  136. package/src/resources/extensions/gsd/worktree-resolver.ts +4 -14
  137. package/src/resources/extensions/gsd/worktree-root.ts +144 -0
  138. package/src/resources/extensions/gsd/worktree.ts +8 -119
  139. package/src/resources/extensions/mcp-client/index.ts +0 -7
  140. package/src/resources/extensions/ollama/index.ts +16 -2
  141. package/src/resources/extensions/ollama/model-capabilities.ts +34 -0
  142. package/src/resources/extensions/ollama/ollama-client.ts +41 -4
  143. package/src/resources/extensions/ollama/tests/model-capabilities.test.ts +96 -0
  144. package/src/resources/extensions/ollama/tests/ollama-client-timeout-env.test.ts +147 -0
  145. package/src/resources/extensions/subagent/index.ts +165 -7
  146. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_buildManifest.js +0 -0
  147. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_ssgManifest.js +0 -0
@@ -2,12 +2,56 @@
2
2
  //
3
3
  // Guards the skill-trigger table in system-context.ts against accidental
4
4
  // regression. Every entry must have a non-empty trigger + skill, and the
5
- // skills added in PR #4505 must remain present.
5
+ // skills added in PR #4505 and PR #5060 must remain present.
6
6
  import { test } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
8
 
9
9
  import { BUNDLED_SKILL_TRIGGERS } from '../bootstrap/system-context.ts';
10
10
 
11
+ const PR_4505_BUNDLED_SKILLS = [
12
+ 'review',
13
+ 'test',
14
+ 'lint',
15
+ 'make-interfaces-feel-better',
16
+ 'accessibility',
17
+ 'grill-me',
18
+ 'design-an-interface',
19
+ 'tdd',
20
+ 'write-milestone-brief',
21
+ 'decompose-into-slices',
22
+ 'spike-wrap-up',
23
+ 'verify-before-complete',
24
+ 'create-mcp-server',
25
+ 'write-docs',
26
+ 'forensics',
27
+ 'handoff',
28
+ 'security-review',
29
+ 'api-design',
30
+ 'dependency-upgrade',
31
+ 'observability',
32
+ ] as const;
33
+
34
+ const PR_5060_BUNDLED_SKILLS = [
35
+ 'react-best-practices',
36
+ 'core-web-vitals',
37
+ 'github-workflows',
38
+ 'web-quality-audit',
39
+ 'agent-browser',
40
+ 'web-design-guidelines',
41
+ 'userinterface-wiki',
42
+ 'create-skill',
43
+ 'create-gsd-extension',
44
+ 'create-workflow',
45
+ 'code-optimizer',
46
+ ] as const;
47
+
48
+ function assertBundledSkillsRegistered(label: string, expectedSkills: readonly string[]): void {
49
+ const registered = new Set(BUNDLED_SKILL_TRIGGERS.map(e => e.skill));
50
+ for (const skill of expectedSkills) {
51
+ assert.ok(registered.has(skill), `${label}: expected bundled skill "${skill}" to be registered`);
52
+ }
53
+ }
54
+
11
55
  test('BUNDLED_SKILL_TRIGGERS: every entry has a non-empty trigger and skill', () => {
12
56
  assert.ok(BUNDLED_SKILL_TRIGGERS.length > 0, 'table should not be empty');
13
57
  for (const { trigger, skill } of BUNDLED_SKILL_TRIGGERS) {
@@ -17,32 +61,11 @@ test('BUNDLED_SKILL_TRIGGERS: every entry has a non-empty trigger and skill', ()
17
61
  });
18
62
 
19
63
  test('BUNDLED_SKILL_TRIGGERS: PR #4505 bundled skills are present', () => {
20
- const expected = [
21
- 'review',
22
- 'test',
23
- 'lint',
24
- 'make-interfaces-feel-better',
25
- 'accessibility',
26
- 'grill-me',
27
- 'design-an-interface',
28
- 'tdd',
29
- 'write-milestone-brief',
30
- 'decompose-into-slices',
31
- 'spike-wrap-up',
32
- 'verify-before-complete',
33
- 'create-mcp-server',
34
- 'write-docs',
35
- 'forensics',
36
- 'handoff',
37
- 'security-review',
38
- 'api-design',
39
- 'dependency-upgrade',
40
- 'observability',
41
- ];
42
- const registered = new Set(BUNDLED_SKILL_TRIGGERS.map(e => e.skill));
43
- for (const skill of expected) {
44
- assert.ok(registered.has(skill), `expected bundled skill "${skill}" to be registered`);
45
- }
64
+ assertBundledSkillsRegistered('PR #4505', PR_4505_BUNDLED_SKILLS);
65
+ });
66
+
67
+ test('BUNDLED_SKILL_TRIGGERS: PR #5060 previously-unexposed skills are present', () => {
68
+ assertBundledSkillsRegistered('PR #5060', PR_5060_BUNDLED_SKILLS);
46
69
  });
47
70
 
48
71
  test('BUNDLED_SKILL_TRIGGERS: skill ids are unique', () => {
@@ -0,0 +1,58 @@
1
+ // GSD2 — Regression test for Issue #4946 isVersionGreater (commands-extensions.ts)
2
+ //
3
+ // Covers the inline npm-version comparator that replaced the `semver` import in
4
+ // commands-extensions.ts. The original import broke `tsc -p tsconfig.json` whenever
5
+ // `@types/semver` failed to install — the file is type-checked transitively despite
6
+ // being under the `src/resources` exclude. The replacement keeps the same comparison
7
+ // semantics for the realistic input space (npm extension version strings) without
8
+ // pulling in the full semver type surface.
9
+
10
+ import test, { describe } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { isVersionGreater } from "../commands-extensions.ts";
13
+
14
+ describe("isVersionGreater — npm extension version comparison (#4946)", () => {
15
+ test("strictly greater patch beats lesser patch", () => {
16
+ assert.equal(isVersionGreater("1.2.4", "1.2.3"), true);
17
+ assert.equal(isVersionGreater("1.2.3", "1.2.4"), false);
18
+ });
19
+
20
+ test("equal versions are not strictly greater", () => {
21
+ assert.equal(isVersionGreater("1.2.3", "1.2.3"), false);
22
+ });
23
+
24
+ test("numeric (not lexicographic) component comparison", () => {
25
+ // Lexicographic compare would say "1.10.0" < "1.9.0".
26
+ assert.equal(isVersionGreater("1.10.0", "1.9.0"), true);
27
+ assert.equal(isVersionGreater("1.9.0", "1.10.0"), false);
28
+ });
29
+
30
+ test("major bump beats minor and patch differences", () => {
31
+ assert.equal(isVersionGreater("2.0.0", "1.99.99"), true);
32
+ assert.equal(isVersionGreater("1.99.99", "2.0.0"), false);
33
+ });
34
+
35
+ test("missing trailing components default to zero", () => {
36
+ assert.equal(isVersionGreater("1.2", "1.1.9"), true);
37
+ assert.equal(isVersionGreater("1", "0.9.9"), true);
38
+ assert.equal(isVersionGreater("1.2", "1.2.0"), false);
39
+ });
40
+
41
+ test("release version beats prerelease at the same release number", () => {
42
+ assert.equal(isVersionGreater("1.0.0", "1.0.0-beta.1"), true);
43
+ assert.equal(isVersionGreater("1.0.0-beta.1", "1.0.0"), false);
44
+ });
45
+
46
+ test("prerelease ordering: beta.2 > beta.1, rc.1 > beta.9", () => {
47
+ assert.equal(isVersionGreater("1.0.0-beta.2", "1.0.0-beta.1"), true);
48
+ assert.equal(isVersionGreater("1.0.0-rc.1", "1.0.0-beta.9"), true);
49
+ });
50
+
51
+ test("non-numeric junk in components doesn't crash — coerces to 0", () => {
52
+ // Defensive: we don't throw on garbage input; we treat unparseable
53
+ // components as 0. This matches the behaviour the extension installer
54
+ // can rely on without surfacing an error to the user.
55
+ assert.equal(isVersionGreater("1.x.0", "1.0.0"), false);
56
+ assert.equal(isVersionGreater("abc", "0.0.1"), false);
57
+ });
58
+ });
@@ -0,0 +1,48 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ formatCleanKeepReason,
6
+ type WorktreeStatus,
7
+ } from "../commands-worktree.ts";
8
+
9
+ function mkStatus(over: Partial<WorktreeStatus>): WorktreeStatus {
10
+ const name = over.name ?? "feat-x";
11
+ return {
12
+ name,
13
+ path: `/repo/.gsd/worktrees/${name}`,
14
+ branch: `gsd/${name}`,
15
+ exists: true,
16
+ filesChanged: 0,
17
+ linesAdded: 0,
18
+ linesRemoved: 0,
19
+ uncommitted: false,
20
+ commits: 0,
21
+ ...over,
22
+ };
23
+ }
24
+
25
+ test("clean keep reason shows uncommitted-only worktrees clearly", () => {
26
+ const reason = formatCleanKeepReason(mkStatus({ uncommitted: true }));
27
+ assert.equal(reason, "uncommitted changes");
28
+ });
29
+
30
+ test("clean keep reason includes uncommitted context with changed files", () => {
31
+ const reason = formatCleanKeepReason(mkStatus({ filesChanged: 2, uncommitted: true }));
32
+ assert.equal(reason, "2 changed files, uncommitted");
33
+ });
34
+
35
+ test("clean keep reason flags missing directory with prune hint", () => {
36
+ const reason = formatCleanKeepReason(mkStatus({ exists: false }));
37
+ assert.equal(reason, "directory missing — run 'git worktree prune' to unregister");
38
+ });
39
+
40
+ test("clean keep reason reports changed files without uncommitted suffix", () => {
41
+ const reason = formatCleanKeepReason(mkStatus({ filesChanged: 2, uncommitted: false }));
42
+ assert.equal(reason, "2 changed files");
43
+ });
44
+
45
+ test("clean keep reason uses singular form for a single changed file", () => {
46
+ const reason = formatCleanKeepReason(mkStatus({ filesChanged: 1, uncommitted: false }));
47
+ assert.equal(reason, "1 changed file");
48
+ });
@@ -11,26 +11,23 @@ test("google-search stub: default export is a function", async (_t) => {
11
11
  assert.equal(typeof stubFn, "function");
12
12
  });
13
13
 
14
- test("google-search stub: registers session_start handler", async (_t) => {
15
- // STUB-01: stub calls pi.on("session_start", ...)
14
+ test("google-search stub: registers no event handlers", async (_t) => {
15
+ // STUB-01: deprecation notice is suppressed — stub must not call pi.on() at all
16
16
  const mod = await import("../../google-search/index.ts");
17
17
  const stubFn = mod.default;
18
18
 
19
- let capturedEvent: string | undefined;
20
- let capturedHandler: unknown;
19
+ let onCallCount = 0;
21
20
 
22
21
  const mockPi = {
23
- on(event: string, handler: unknown) {
24
- capturedEvent = event;
25
- capturedHandler = handler;
22
+ on(_event: string, _handler: unknown) {
23
+ onCallCount++;
26
24
  },
27
25
  registerTool: () => {},
28
26
  };
29
27
 
30
28
  stubFn(mockPi as never);
31
29
 
32
- assert.equal(capturedEvent, "session_start");
33
- assert.equal(typeof capturedHandler, "function");
30
+ assert.equal(onCallCount, 0, "stub should not register any event handlers");
34
31
  });
35
32
 
36
33
  test("google-search stub: does NOT call registerTool", async (_t) => {
@@ -50,82 +47,45 @@ test("google-search stub: does NOT call registerTool", async (_t) => {
50
47
  assert.equal(registerToolCalled, false);
51
48
  });
52
49
 
53
- test("google-search stub: session_start warning contains package name", async (_t) => {
54
- // STUB-01: warning includes @gsd-extensions/google-search
50
+ test("google-search stub: does not emit any notifications", async (_t) => {
51
+ // STUB-01: deprecation notice is suppressed — stub must not call ctx.ui.notify()
55
52
  const mod = await import("../../google-search/index.ts");
56
53
  const stubFn = mod.default;
57
54
 
58
- let capturedHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
55
+ let notifyCallCount = 0;
59
56
 
60
57
  const mockPi = {
61
- on(_event: string, handler: (event: unknown, ctx: unknown) => Promise<void>) {
62
- capturedHandler = handler;
63
- },
58
+ on(_event: string, _handler: unknown) {},
64
59
  registerTool: () => {},
65
60
  };
66
61
 
62
+ // Verify no notify is emitted by the stub itself (it registers no handlers,
63
+ // so there is nothing to invoke — this simply confirms no top-level notify call).
67
64
  stubFn(mockPi as never);
68
65
 
69
- assert.ok(capturedHandler, "session_start handler should have been registered");
70
-
71
- let capturedMessage: string | undefined;
72
- const mockCtx = {
73
- ui: {
74
- notify(message: string, _level: string) {
75
- capturedMessage = message;
76
- },
77
- },
78
- };
79
-
80
- await capturedHandler!({}, mockCtx);
81
-
82
- assert.ok(
83
- capturedMessage?.includes("@gsd-extensions/google-search"),
84
- `Expected message to include "@gsd-extensions/google-search", got: "${capturedMessage}"`,
85
- );
66
+ assert.equal(notifyCallCount, 0, "stub should not emit any notifications");
86
67
  });
87
68
 
88
- test("google-search stub: session_start warning explains package is not yet published", async (_t) => {
89
- // STUB-01: stub must NOT advise `gsd extensions install` — the replacement
90
- // package is not yet on npm, so that command would 404. The message must
91
- // explain the extraction is in progress and no user action is required.
69
+ test("google-search stub: is a complete no-op (no handlers, no tools, no notifications)", async (_t) => {
70
+ // STUB-01: the deprecation notice is suppressed until @gsd-extensions/google-search
71
+ // ships. The stub must call nothing on the ExtensionAPI.
92
72
  const mod = await import("../../google-search/index.ts");
93
73
  const stubFn = mod.default;
94
74
 
95
- let capturedHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
75
+ let onCallCount = 0;
76
+ let registerToolCallCount = 0;
96
77
 
97
78
  const mockPi = {
98
- on(_event: string, handler: (event: unknown, ctx: unknown) => Promise<void>) {
99
- capturedHandler = handler;
79
+ on(_event: string, _handler: unknown) {
80
+ onCallCount++;
81
+ },
82
+ registerTool: () => {
83
+ registerToolCallCount++;
100
84
  },
101
- registerTool: () => {},
102
85
  };
103
86
 
104
87
  stubFn(mockPi as never);
105
88
 
106
- assert.ok(capturedHandler, "session_start handler should have been registered");
107
-
108
- let capturedMessage: string | undefined;
109
- const mockCtx = {
110
- ui: {
111
- notify(message: string, _level: string) {
112
- capturedMessage = message;
113
- },
114
- },
115
- };
116
-
117
- await capturedHandler!({}, mockCtx);
118
-
119
- assert.ok(
120
- !capturedMessage?.includes("gsd extensions install"),
121
- `Expected message NOT to include unpublished install command, got: "${capturedMessage}"`,
122
- );
123
- assert.ok(
124
- capturedMessage?.includes("not yet published"),
125
- `Expected message to include "not yet published", got: "${capturedMessage}"`,
126
- );
127
- assert.ok(
128
- capturedMessage?.includes("No action needed"),
129
- `Expected message to include "No action needed", got: "${capturedMessage}"`,
130
- );
89
+ assert.equal(onCallCount, 0, "stub should not register any event handlers");
90
+ assert.equal(registerToolCallCount, 0, "stub should not register any tools");
131
91
  });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tests for getHomeDir() — cross-platform home directory resolution.
3
+ *
4
+ * @see https://github.com/gsd-build/gsd-2/issues/5015
5
+ */
6
+ import { describe, it, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { homedir } from "node:os";
9
+
10
+ describe("getHomeDir", () => {
11
+ let savedHome: string | undefined;
12
+ let savedUserProfile: string | undefined;
13
+ let getHomeDir: () => string;
14
+
15
+ beforeEach(async () => {
16
+ savedHome = process.env.HOME;
17
+ savedUserProfile = process.env.USERPROFILE;
18
+ const mod = await import("../home-dir.ts");
19
+ getHomeDir = mod.getHomeDir;
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (savedHome !== undefined) {
24
+ process.env.HOME = savedHome;
25
+ } else {
26
+ delete process.env.HOME;
27
+ }
28
+ if (savedUserProfile !== undefined) {
29
+ process.env.USERPROFILE = savedUserProfile;
30
+ } else {
31
+ delete process.env.USERPROFILE;
32
+ }
33
+ });
34
+
35
+ it("returns HOME when set", () => {
36
+ process.env.HOME = "/test/home";
37
+ delete process.env.USERPROFILE;
38
+ assert.equal(getHomeDir(), "/test/home");
39
+ });
40
+
41
+ it("falls back to USERPROFILE when HOME is unset", () => {
42
+ delete process.env.HOME;
43
+ process.env.USERPROFILE = String.raw`C:\Users\test`;
44
+ assert.equal(getHomeDir(), String.raw`C:\Users\test`);
45
+ });
46
+
47
+ it("falls back to os.homedir() when both HOME and USERPROFILE are unset", () => {
48
+ delete process.env.HOME;
49
+ delete process.env.USERPROFILE;
50
+ assert.equal(getHomeDir(), homedir());
51
+ });
52
+ });
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { describe, test, afterEach } from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, symlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
  import { execSync } from "node:child_process";
@@ -21,6 +21,7 @@ import {
21
21
  getAutoWorktreeOriginalBase,
22
22
  getActiveAutoWorktreeContext,
23
23
  syncGsdStateToWorktree,
24
+ _resetAutoWorktreeOriginalBaseForTests,
24
25
  } from "../../auto-worktree.ts";
25
26
 
26
27
  // Note: execSync is used intentionally in tests for git operations with
@@ -142,6 +143,54 @@ describe("auto-worktree lifecycle", () => {
142
143
  teardownAutoWorktree(tempDir, "M003");
143
144
  });
144
145
 
146
+ test("symlink-resolved auto worktree is detected after module state reset", () => {
147
+ tempDir = createTempRepo();
148
+ const savedGsdHome = process.env.GSD_HOME;
149
+ const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "auto-wt-home-")));
150
+ const storage = join(fakeHome, ".gsd", "projects", "abc123def456");
151
+ mkdirSync(join(storage, "milestones", "M001"), { recursive: true });
152
+ writeFileSync(join(storage, "milestones", "M001", "CONTEXT.md"), "# M001\n");
153
+ symlinkSync(storage, join(tempDir, ".gsd"));
154
+ process.env.GSD_HOME = join(fakeHome, ".gsd");
155
+
156
+ try {
157
+ const wtPath = createAutoWorktree(tempDir, "M001");
158
+ const realWtPath = realpathSync(wtPath);
159
+ assert.ok(realWtPath.startsWith(storage), "git registered the symlink-resolved worktree path");
160
+
161
+ _resetAutoWorktreeOriginalBaseForTests();
162
+ process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
163
+
164
+ assert.ok(isInAutoWorktree(tempDir), "structural detection works without module originalBase");
165
+ const resolved = getAutoWorktreePath(realWtPath, "M001");
166
+ assert.ok(resolved, "existing worktree is found when basePath is the worktree path");
167
+ assert.equal(realpathSync(resolved!), realWtPath);
168
+ assert.equal(existsSync(join(realWtPath, ".gsd", "worktrees", "M001")), false);
169
+
170
+ enterAutoWorktree(tempDir, "M001");
171
+ process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
172
+ assert.deepStrictEqual(
173
+ getActiveAutoWorktreeContext(),
174
+ {
175
+ originalBase: tempDir,
176
+ worktreeName: "M001",
177
+ branch: "milestone/M001",
178
+ },
179
+ "active context is detected from a symlink-resolved worktree cwd",
180
+ );
181
+ } finally {
182
+ process.chdir(tempDir);
183
+ try {
184
+ teardownAutoWorktree(tempDir, "M001");
185
+ } catch {
186
+ // Best-effort cleanup for partially-created temp worktrees.
187
+ }
188
+ if (savedGsdHome === undefined) delete process.env.GSD_HOME;
189
+ else process.env.GSD_HOME = savedGsdHome;
190
+ rmSync(fakeHome, { recursive: true, force: true });
191
+ }
192
+ });
193
+
145
194
  test("coexistence with manual worktree", async () => {
146
195
  tempDir = createTempRepo();
147
196
  const msDir = join(tempDir, ".gsd", "milestones", "M003");
@@ -10,7 +10,7 @@
10
10
  import { describe, test } from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
 
13
- import { _resolveReportBasePath } from "../auto/phases.ts";
13
+ import { _resolveDispatchGuardBasePath, _resolveReportBasePath } from "../auto/phases.ts";
14
14
 
15
15
  describe("_resolveReportBasePath", () => {
16
16
  test("uses originalBasePath when set (worktree scenario)", () => {
@@ -48,4 +48,21 @@ describe("_resolveReportBasePath", () => {
48
48
 
49
49
  assert.equal(_resolveReportBasePath(session), "/home/user/repo");
50
50
  });
51
+
52
+ test("uses GSD_PROJECT_ROOT for symlink-resolved worktree paths", () => {
53
+ const savedProjectRoot = process.env.GSD_PROJECT_ROOT;
54
+ process.env.GSD_PROJECT_ROOT = "/real/project";
55
+ try {
56
+ const session = {
57
+ originalBasePath: "",
58
+ basePath: "/Users/dev/.gsd/projects/abc123/worktrees/M001/slices/S01",
59
+ };
60
+
61
+ assert.equal(_resolveReportBasePath(session), "/real/project");
62
+ assert.equal(_resolveDispatchGuardBasePath(session), "/real/project");
63
+ } finally {
64
+ if (savedProjectRoot === undefined) delete process.env.GSD_PROJECT_ROOT;
65
+ else process.env.GSD_PROJECT_ROOT = savedProjectRoot;
66
+ }
67
+ });
51
68
  });
@@ -18,9 +18,11 @@ import { shouldBlockQueueExecution } from "../bootstrap/write-gate.ts";
18
18
  import {
19
19
  resetEvidence,
20
20
  recordToolCall,
21
+ recordToolResult,
21
22
  getEvidence,
22
23
  saveEvidenceToDisk,
23
24
  loadEvidenceFromDisk,
25
+ type BashEvidence,
24
26
  } from "../safety/evidence-collector.ts";
25
27
  import { validateFileChanges } from "../safety/file-change-validator.ts";
26
28
 
@@ -110,6 +112,38 @@ test("safety-harness-bug2: loadEvidenceFromDisk returns empty array when no file
110
112
  assert.equal(getEvidence().length, 0, "no evidence on fresh unit is correct — not a false positive");
111
113
  });
112
114
 
115
+ test("safety-harness-bug2-race: bash evidence survives mid-unit reset between tool_call and tool_execution_end", (t) => {
116
+ // Reproduces the race where runUnitPhase re-fires (resetEvidence + loadEvidenceFromDisk)
117
+ // between a bash tool_call and its tool_execution_end. Pre-fix, the call entry lived
118
+ // only in memory until tool_execution_end; the reset wiped it and recordToolResult
119
+ // silently no-op'd, producing the "task complete with no bash calls" false positive.
120
+ // Post-fix, register-hooks.ts persists at tool_call time too — so the entry survives
121
+ // the reset via the disk round-trip.
122
+ const base = mkdtempSync(join(tmpdir(), "gsd-evidence-race-"));
123
+ t.after(() => rmSync(base, { recursive: true, force: true }));
124
+
125
+ resetEvidence();
126
+
127
+ // tool_call fires: record AND persist (post-fix register-hooks.ts behavior).
128
+ recordToolCall("tc-bash-1", "bash", { command: "grep -q saveTodos app.js" });
129
+ saveEvidenceToDisk(base, "M001", "S01", "T02");
130
+
131
+ // Mid-unit race: runUnitPhase re-fires, calling resetEvidence + loadEvidenceFromDisk.
132
+ resetEvidence();
133
+ assert.equal(getEvidence().length, 0, "memory cleared by mid-unit reset");
134
+ loadEvidenceFromDisk(base, "M001", "S01", "T02");
135
+ assert.equal(getEvidence().length, 1, "entry restored from disk-persisted tool_call");
136
+
137
+ // tool_execution_end fires: result must update the restored entry by toolCallId.
138
+ recordToolResult("tc-bash-1", "bash", "Command exited with code 0\nfound\n", false);
139
+
140
+ const bash = getEvidence().filter((e): e is BashEvidence => e.kind === "bash");
141
+ assert.equal(bash.length, 1, "bash entry must survive race + result update");
142
+ assert.equal(bash[0].exitCode, 0, "result populated the restored entry");
143
+ assert.equal(bash[0].command, "grep -q saveTodos app.js", "command preserved across race");
144
+ assert.ok(bash[0].outputSnippet.includes("found"), "output snippet captured");
145
+ });
146
+
113
147
  // ─── Bug 3: git diff HEAD~1 scope check ─────────────────────────────────────
114
148
 
115
149
  test("safety-harness-bug3: validateFileChanges works on initial commit (no HEAD~1)", (t) => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test, beforeEach, afterEach } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
  import { appendOverride, loadActiveOverrides } from "../files.ts";
@@ -73,6 +73,22 @@ describe("steer worktree path resolution (#3476)", () => {
73
73
  assert.equal(result, null, "returns null for worktree without .git file");
74
74
  });
75
75
 
76
+ test("getAutoWorktreePath returns null when .git is a directory", () => {
77
+ mkdirSync(join(worktreePath, ".git"), { recursive: true });
78
+
79
+ const result = getAutoWorktreePath(projectRoot, "M001");
80
+
81
+ assert.equal(result, null, "returns null for standalone .git directories");
82
+ });
83
+
84
+ test("getAutoWorktreePath returns null when .git file is not a gitdir pointer", () => {
85
+ writeFileSync(join(worktreePath, ".git"), "not-a-gitdir\n", "utf-8");
86
+
87
+ const result = getAutoWorktreePath(projectRoot, "M001");
88
+
89
+ assert.equal(result, null, "returns null for invalid .git files");
90
+ });
91
+
76
92
  test("override routing: inactive worktree directory should not receive overrides", async () => {
77
93
  // Simulate the handleSteer path-resolution logic:
78
94
  // When no auto-mode is running, even if a worktree dir exists,
@@ -199,8 +199,8 @@ test("#4934: every manifest declares a tools policy", () => {
199
199
  }
200
200
  });
201
201
 
202
- test("#4934: tools.mode is one of the four declared policies", () => {
203
- const validModes = new Set(["all", "read-only", "planning", "docs"]);
202
+ test("#4934: tools.mode is one of the declared policies", () => {
203
+ const validModes = new Set(["all", "read-only", "planning", "planning-dispatch", "docs"]);
204
204
  for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
205
205
  const mode = (manifest as { tools: { mode: string } }).tools.mode;
206
206
  assert.ok(
@@ -219,7 +219,42 @@ test('#4934: only execute-task and reactive-execute may use tools.mode "all" (fu
219
219
  allowedAllUnits.has(unitType),
220
220
  `manifest "${unitType}" declares tools.mode = "all" but is not on the execute-track. ` +
221
221
  'Only execute-task and reactive-execute should have full source write access; ' +
222
- 'planning/discuss/research units must use "planning" (or "docs" for rewrite-docs).',
222
+ 'planning/discuss/research units must use "planning" or "planning-dispatch" (or "docs" for rewrite-docs).',
223
+ );
224
+ }
225
+ }
226
+ });
227
+
228
+ test('planning-dispatch mode is reserved for slice-level decomposition and completion units', () => {
229
+ const allowedDispatchUnits = new Set([
230
+ "plan-slice",
231
+ "refine-slice",
232
+ "complete-slice",
233
+ "complete-milestone",
234
+ ]);
235
+ for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
236
+ const mode = (manifest as { tools: { mode: string } }).tools.mode;
237
+ if (mode === "planning-dispatch") {
238
+ assert.ok(
239
+ allowedDispatchUnits.has(unitType),
240
+ `manifest "${unitType}" declares tools.mode = "planning-dispatch" but is not on the dispatch-allowed allowlist. ` +
241
+ 'planning-dispatch is intentionally narrow — extend the allowlist consciously when a new unit type genuinely benefits from subagent delegation.',
242
+ );
243
+ }
244
+ }
245
+ });
246
+
247
+ test('planning-dispatch manifests declare non-empty allowedSubagents lists', () => {
248
+ for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
249
+ if (manifest.tools.mode !== "planning-dispatch") continue;
250
+ assert.ok(
251
+ Array.isArray(manifest.tools.allowedSubagents) && manifest.tools.allowedSubagents.length > 0,
252
+ `manifest "${unitType}" has planning-dispatch policy but no allowedSubagents — explicit allowlist is required for runtime dispatch gating`,
253
+ );
254
+ for (const agent of manifest.tools.allowedSubagents) {
255
+ assert.ok(
256
+ typeof agent === "string" && agent.length > 0,
257
+ `manifest "${unitType}" has empty/invalid allowedSubagents entry: ${JSON.stringify(agent)}`,
223
258
  );
224
259
  }
225
260
  }