gsd-pi 2.11.0 → 2.12.0

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 (113) hide show
  1. package/dist/onboarding.js +3 -0
  2. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  3. package/dist/resources/extensions/gsd/auto.ts +159 -2
  4. package/dist/resources/extensions/gsd/commands.ts +9 -3
  5. package/dist/resources/extensions/gsd/doctor.ts +60 -3
  6. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  7. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  8. package/dist/resources/extensions/gsd/preferences.ts +192 -0
  9. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  10. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  11. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  12. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  13. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  14. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  15. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  16. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  17. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  18. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  19. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  20. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  22. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  23. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  26. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  27. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  28. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  29. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  30. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  31. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  32. package/dist/resources/extensions/gsd/types.ts +109 -0
  33. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  34. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  35. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  36. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  37. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  38. package/dist/wizard.js +1 -0
  39. package/package.json +1 -1
  40. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  41. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  42. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  43. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  44. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  45. package/packages/pi-agent-core/dist/agent.js +16 -0
  46. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  47. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  48. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  49. package/packages/pi-agent-core/dist/types.js.map +1 -1
  50. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  51. package/packages/pi-agent-core/src/agent.ts +24 -0
  52. package/packages/pi-agent-core/src/types.ts +98 -0
  53. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  54. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  55. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  56. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  57. package/packages/pi-ai/dist/models.generated.js +236 -0
  58. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  59. package/packages/pi-ai/dist/types.d.ts +1 -1
  60. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  61. package/packages/pi-ai/dist/types.js.map +1 -1
  62. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  63. package/packages/pi-ai/src/models.generated.ts +236 -0
  64. package/packages/pi-ai/src/types.ts +2 -1
  65. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/cli/args.js +1 -0
  67. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  69. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  71. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  74. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  75. package/packages/pi-coding-agent/src/cli/args.ts +1 -0
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  77. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  78. package/src/resources/extensions/bg-shell/index.ts +51 -7
  79. package/src/resources/extensions/gsd/auto.ts +159 -2
  80. package/src/resources/extensions/gsd/commands.ts +9 -3
  81. package/src/resources/extensions/gsd/doctor.ts +60 -3
  82. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  83. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  84. package/src/resources/extensions/gsd/preferences.ts +192 -0
  85. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  86. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  87. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  88. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  89. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  90. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  91. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  92. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  93. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  94. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  95. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  96. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  97. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  98. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  99. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  100. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  101. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  102. package/src/resources/extensions/gsd/templates/context.md +1 -1
  103. package/src/resources/extensions/gsd/templates/state.md +3 -3
  104. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  105. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  106. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  107. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  108. package/src/resources/extensions/gsd/types.ts +109 -0
  109. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  110. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  111. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  112. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  113. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
 
5
- import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope } from "../doctor.js";
5
+ import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope, validateTitle } from "../doctor.js";
6
6
  import { createTestContext } from './test-helpers.ts';
7
7
 
8
8
  const { assertEq, assertTrue, report } = createTestContext();
@@ -471,6 +471,120 @@ Discovered an issue.
471
471
  rmSync(mhBase, { recursive: true, force: true });
472
472
  }
473
473
 
474
+ // ─── validateTitle: em dash and slash detection ────────────────────────
475
+ console.log("\n=== validateTitle: returns null for clean titles ===");
476
+ {
477
+ assertEq(validateTitle("Foundation"), null, "clean title passes");
478
+ assertEq(validateTitle("Build Core Systems"), null, "clean title with spaces passes");
479
+ assertEq(validateTitle("API v2 Integration"), null, "clean title with version passes");
480
+ assertEq(validateTitle(""), null, "empty title passes");
481
+ }
482
+
483
+ console.log("\n=== validateTitle: detects em dash ===");
484
+ {
485
+ const result = validateTitle("Foundation — Build Core");
486
+ assertTrue(result !== null, "detects em dash in title");
487
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
488
+ }
489
+
490
+ console.log("\n=== validateTitle: detects en dash ===");
491
+ {
492
+ const result = validateTitle("Phase 1 – Phase 2");
493
+ assertTrue(result !== null, "detects en dash in title");
494
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash for en dash");
495
+ }
496
+
497
+ console.log("\n=== validateTitle: detects forward slash ===");
498
+ {
499
+ const result = validateTitle("Client/Server");
500
+ assertTrue(result !== null, "detects forward slash in title");
501
+ assertTrue(result!.includes("forward slash"), "message mentions forward slash");
502
+ }
503
+
504
+ console.log("\n=== validateTitle: detects both em dash and slash ===");
505
+ {
506
+ const result = validateTitle("Client — Server/API");
507
+ assertTrue(result !== null, "detects both delimiters");
508
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
509
+ assertTrue(result!.includes("forward slash"), "message mentions forward slash");
510
+ }
511
+
512
+ // ─── doctor detects delimiter_in_title for milestone ───────────────────
513
+ console.log("\n=== doctor detects em dash in milestone title ===");
514
+ {
515
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-test-"));
516
+ const dtGsd = join(dtBase, ".gsd");
517
+ const dtMDir = join(dtGsd, "milestones", "M001");
518
+ const dtSDir = join(dtMDir, "slices", "S01");
519
+ const dtTDir = join(dtSDir, "tasks");
520
+ mkdirSync(dtTDir, { recursive: true });
521
+
522
+ // Roadmap with em dash in milestone title
523
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation — Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
524
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
525
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
526
+
527
+ const report = await runGSDDoctor(dtBase, { fix: false });
528
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
529
+ assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for milestone with em dash");
530
+ const milestoneIssue = dtIssues.find(i => i.scope === "milestone");
531
+ assertTrue(milestoneIssue !== undefined, "delimiter issue has milestone scope");
532
+ assertEq(milestoneIssue?.severity, "warning", "delimiter issue has warning severity");
533
+ assertEq(milestoneIssue?.unitId, "M001", "delimiter issue unitId is M001");
534
+ assertTrue(milestoneIssue?.message?.includes("em/en dash") ?? false, "issue message mentions em/en dash");
535
+ assertEq(milestoneIssue?.fixable, false, "delimiter issue is not auto-fixable");
536
+
537
+ rmSync(dtBase, { recursive: true, force: true });
538
+ }
539
+
540
+ // ─── doctor detects delimiter_in_title for slice ────────────────────────
541
+ console.log("\n=== doctor detects em dash in slice title ===");
542
+ {
543
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-slice-"));
544
+ const dtGsd = join(dtBase, ".gsd");
545
+ const dtMDir = join(dtGsd, "milestones", "M001");
546
+ const dtSDir = join(dtMDir, "slices", "S01");
547
+ const dtTDir = join(dtSDir, "tasks");
548
+ mkdirSync(dtTDir, { recursive: true });
549
+
550
+ // Roadmap with em dash in slice title (milestone title is clean)
551
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Clean Milestone\n\n## Slices\n- [ ] **S01: Core — Foundation** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
552
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Core — Foundation\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
553
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
554
+
555
+ const report = await runGSDDoctor(dtBase, { fix: false });
556
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
557
+ assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for slice with em dash");
558
+ const sliceIssue = dtIssues.find(i => i.scope === "slice");
559
+ assertTrue(sliceIssue !== undefined, "delimiter issue has slice scope");
560
+ assertEq(sliceIssue?.severity, "warning", "slice delimiter issue has warning severity");
561
+ assertEq(sliceIssue?.unitId, "M001/S01", "slice delimiter issue unitId is M001/S01");
562
+
563
+ rmSync(dtBase, { recursive: true, force: true });
564
+ }
565
+
566
+ // ─── doctor does NOT flag clean titles ──────────────────────────────────
567
+ console.log("\n=== doctor does NOT flag milestone with clean title ===");
568
+ {
569
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-clean-"));
570
+ const dtGsd = join(dtBase, ".gsd");
571
+ const dtMDir = join(dtGsd, "milestones", "M001");
572
+ const dtSDir = join(dtMDir, "slices", "S01");
573
+ const dtTDir = join(dtSDir, "tasks");
574
+ mkdirSync(dtTDir, { recursive: true });
575
+
576
+ // Roadmap with clean titles (no delimiters)
577
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
578
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
579
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
580
+
581
+ const report = await runGSDDoctor(dtBase, { fix: false });
582
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
583
+ assertEq(dtIssues.length, 0, "no delimiter_in_title issues for clean titles");
584
+
585
+ rmSync(dtBase, { recursive: true, force: true });
586
+ }
587
+
474
588
  report();
475
589
  }
476
590
 
@@ -0,0 +1,297 @@
1
+ // GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { createTestContext } from "./test-helpers.ts";
8
+ import {
9
+ checkPostUnitHooks,
10
+ getActiveHook,
11
+ resetHookState,
12
+ isRetryPending,
13
+ consumeRetryTrigger,
14
+ resolveHookArtifactPath,
15
+ runPreDispatchHooks,
16
+ persistHookState,
17
+ restoreHookState,
18
+ clearPersistedHookState,
19
+ getHookStatus,
20
+ formatHookStatus,
21
+ } from "../post-unit-hooks.ts";
22
+
23
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
24
+
25
+ // ─── Fixture Helpers ───────────────────────────────────────────────────────
26
+
27
+ function createFixtureBase(): string {
28
+ const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-"));
29
+ mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true });
30
+ return base;
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // Phase 1: Post-Unit Hook Tests
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ // ─── resolveHookArtifactPath ───────────────────────────────────────────────
38
+
39
+ console.log("\n=== resolveHookArtifactPath ===");
40
+
41
+ {
42
+ const base = "/project";
43
+
44
+ // Task-level
45
+ const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
46
+ assertEq(
47
+ taskPath,
48
+ join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
49
+ "task-level artifact path",
50
+ );
51
+
52
+ // Slice-level
53
+ const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
54
+ assertEq(
55
+ slicePath,
56
+ join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"),
57
+ "slice-level artifact path",
58
+ );
59
+
60
+ // Milestone-level
61
+ const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
62
+ assertEq(
63
+ milestonePath,
64
+ join(base, ".gsd", "M001", "REVIEW-PASS.md"),
65
+ "milestone-level artifact path",
66
+ );
67
+ }
68
+
69
+ // ─── resetHookState ────────────────────────────────────────────────────────
70
+
71
+ console.log("\n=== resetHookState ===");
72
+
73
+ {
74
+ resetHookState();
75
+ assertEq(getActiveHook(), null, "no active hook after reset");
76
+ assertTrue(!isRetryPending(), "no retry pending after reset");
77
+ assertEq(consumeRetryTrigger(), null, "no retry trigger after reset");
78
+ }
79
+
80
+ // ─── checkPostUnitHooks with no hooks configured ───────────────────────────
81
+
82
+ console.log("\n=== No hooks configured ===");
83
+
84
+ {
85
+ resetHookState();
86
+ const base = createFixtureBase();
87
+ try {
88
+ const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
89
+ assertEq(result, null, "returns null when no hooks configured");
90
+ } finally {
91
+ rmSync(base, { recursive: true, force: true });
92
+ }
93
+ }
94
+
95
+ // ─── Hook units don't trigger hooks (no hook-on-hook) ──────────────────────
96
+
97
+ console.log("\n=== Hook-on-hook prevention ===");
98
+
99
+ {
100
+ resetHookState();
101
+ const base = createFixtureBase();
102
+ try {
103
+ const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base);
104
+ assertEq(result, null, "hook units don't trigger other hooks");
105
+ } finally {
106
+ rmSync(base, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ // ─── consumeRetryTrigger clears state ──────────────────────────────────────
111
+
112
+ console.log("\n=== consumeRetryTrigger clears state ===");
113
+
114
+ {
115
+ resetHookState();
116
+ assertEq(consumeRetryTrigger(), null, "no trigger initially");
117
+ assertTrue(!isRetryPending(), "no retry initially");
118
+ }
119
+
120
+ // ─── Variable substitution in prompts ──────────────────────────────────────
121
+
122
+ console.log("\n=== Variable substitution ===");
123
+
124
+ {
125
+ const base = "/project";
126
+
127
+ // 3-part ID
128
+ const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md");
129
+ assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
130
+ assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
131
+ assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
132
+
133
+ // 2-part ID
134
+ const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
135
+ assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
136
+ assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
137
+
138
+ // 1-part ID
139
+ const path1 = resolveHookArtifactPath(base, "M002", "result.md");
140
+ assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // Phase 2: Pre-Dispatch Hook Tests
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ console.log("\n=== Pre-dispatch: no hooks configured ===");
148
+
149
+ {
150
+ const base = createFixtureBase();
151
+ try {
152
+ const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base);
153
+ assertEq(result.action, "proceed", "proceeds when no hooks");
154
+ assertEq(result.prompt, "original prompt", "prompt unchanged");
155
+ assertEq(result.firedHooks.length, 0, "no hooks fired");
156
+ } finally {
157
+ rmSync(base, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ console.log("\n=== Pre-dispatch: hook units bypass ===");
162
+
163
+ {
164
+ const base = createFixtureBase();
165
+ try {
166
+ const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base);
167
+ assertEq(result.action, "proceed", "hook units always proceed");
168
+ assertEq(result.prompt, "hook prompt", "hook prompt unchanged");
169
+ assertEq(result.firedHooks.length, 0, "no hooks fired for hook units");
170
+ } finally {
171
+ rmSync(base, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // Phase 3: State Persistence Tests
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+
179
+ console.log("\n=== State persistence: persist and restore ===");
180
+
181
+ {
182
+ const base = createFixtureBase();
183
+ try {
184
+ resetHookState();
185
+
186
+ // Persist empty state
187
+ persistHookState(base);
188
+ const filePath = join(base, ".gsd", "hook-state.json");
189
+ assertTrue(existsSync(filePath), "hook-state.json created");
190
+
191
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
192
+ assertEq(typeof content.savedAt, "string", "savedAt is a string");
193
+ assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
194
+ } finally {
195
+ rmSync(base, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ console.log("\n=== State persistence: restore from disk ===");
200
+
201
+ {
202
+ const base = createFixtureBase();
203
+ try {
204
+ resetHookState();
205
+
206
+ // Write a state file with some cycle counts
207
+ const stateFile = join(base, ".gsd", "hook-state.json");
208
+ writeFileSync(stateFile, JSON.stringify({
209
+ cycleCounts: {
210
+ "review/execute-task/M001/S01/T01": 2,
211
+ "simplify/execute-task/M001/S01/T02": 1,
212
+ },
213
+ savedAt: new Date().toISOString(),
214
+ }), "utf-8");
215
+
216
+ // Restore
217
+ restoreHookState(base);
218
+
219
+ // Verify by persisting and reading back
220
+ persistHookState(base);
221
+ const restored = JSON.parse(readFileSync(stateFile, "utf-8"));
222
+ assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
223
+ assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
224
+ } finally {
225
+ rmSync(base, { recursive: true, force: true });
226
+ }
227
+ }
228
+
229
+ console.log("\n=== State persistence: clear ===");
230
+
231
+ {
232
+ const base = createFixtureBase();
233
+ try {
234
+ resetHookState();
235
+
236
+ // Write then clear
237
+ const stateFile = join(base, ".gsd", "hook-state.json");
238
+ writeFileSync(stateFile, JSON.stringify({
239
+ cycleCounts: { "review/execute-task/M001/S01/T01": 3 },
240
+ savedAt: new Date().toISOString(),
241
+ }), "utf-8");
242
+
243
+ clearPersistedHookState(base);
244
+
245
+ const cleared = JSON.parse(readFileSync(stateFile, "utf-8"));
246
+ assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
247
+ } finally {
248
+ rmSync(base, { recursive: true, force: true });
249
+ }
250
+ }
251
+
252
+ console.log("\n=== State persistence: restore handles missing file ===");
253
+
254
+ {
255
+ const base = createFixtureBase();
256
+ try {
257
+ resetHookState();
258
+ // Should not throw
259
+ restoreHookState(base);
260
+ assertEq(getActiveHook(), null, "no active hook after restore from missing file");
261
+ } finally {
262
+ rmSync(base, { recursive: true, force: true });
263
+ }
264
+ }
265
+
266
+ console.log("\n=== State persistence: restore handles corrupt file ===");
267
+
268
+ {
269
+ const base = createFixtureBase();
270
+ try {
271
+ resetHookState();
272
+ writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8");
273
+ // Should not throw
274
+ restoreHookState(base);
275
+ assertEq(getActiveHook(), null, "no active hook after corrupt restore");
276
+ } finally {
277
+ rmSync(base, { recursive: true, force: true });
278
+ }
279
+ }
280
+
281
+ // ═══════════════════════════════════════════════════════════════════════════
282
+ // Phase 3: Hook Status Reporting Tests
283
+ // ═══════════════════════════════════════════════════════════════════════════
284
+
285
+ console.log("\n=== Hook status: no hooks ===");
286
+
287
+ {
288
+ resetHookState();
289
+ const entries = getHookStatus();
290
+ // No preferences file = no hooks
291
+ assertEq(entries.length, 0, "no entries when no hooks configured");
292
+
293
+ const formatted = formatHookStatus();
294
+ assertMatch(formatted, /No hooks configured/, "status message says no hooks");
295
+ }
296
+
297
+ report();
@@ -0,0 +1,226 @@
1
+ // GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { createTestContext } from "./test-helpers.ts";
5
+
6
+ const { assertEq, assertTrue, report } = createTestContext();
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ // Phase 1: Post-Unit Hook Config Tests
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+
12
+ console.log("\n=== Post-unit hook config validation ===");
13
+
14
+ {
15
+ const validHook = {
16
+ name: "test-hook",
17
+ after: ["execute-task"],
18
+ prompt: "Test prompt",
19
+ max_cycles: 2,
20
+ model: "claude-sonnet-4-6",
21
+ artifact: "TEST-RESULT.md",
22
+ retry_on: "TEST-ISSUES.md",
23
+ enabled: true,
24
+ };
25
+
26
+ assertEq(validHook.name, "test-hook", "valid hook has name");
27
+ assertEq(validHook.after.length, 1, "valid hook has one after entry");
28
+ assertEq(validHook.after[0], "execute-task", "valid hook triggers after execute-task");
29
+ assertTrue(validHook.max_cycles! <= 10, "max_cycles within limit");
30
+ assertTrue(validHook.max_cycles! >= 1, "max_cycles above minimum");
31
+ }
32
+
33
+ console.log("\n=== max_cycles clamping ===");
34
+
35
+ {
36
+ const clampedHigh = Math.max(1, Math.min(10, Math.round(15)));
37
+ assertEq(clampedHigh, 10, "max_cycles above 10 clamped to 10");
38
+
39
+ const clampedLow = Math.max(1, Math.min(10, Math.round(0)));
40
+ assertEq(clampedLow, 1, "max_cycles below 1 clamped to 1");
41
+
42
+ const clampedNeg = Math.max(1, Math.min(10, Math.round(-5)));
43
+ assertEq(clampedNeg, 1, "negative max_cycles clamped to 1");
44
+
45
+ const normal = Math.max(1, Math.min(10, Math.round(3)));
46
+ assertEq(normal, 3, "normal max_cycles passes through");
47
+ }
48
+
49
+ console.log("\n=== Post-unit hook merging ===");
50
+
51
+ {
52
+ const baseHooks = [
53
+ { name: "review", after: ["execute-task"], prompt: "base prompt" },
54
+ { name: "lint", after: ["plan-slice"], prompt: "lint code" },
55
+ ];
56
+
57
+ const overrideHooks = [
58
+ { name: "review", after: ["execute-task", "complete-slice"], prompt: "override prompt" },
59
+ { name: "security", after: ["execute-task"], prompt: "security check" },
60
+ ];
61
+
62
+ const merged = [...baseHooks];
63
+ for (const hook of overrideHooks) {
64
+ const idx = merged.findIndex(h => h.name === hook.name);
65
+ if (idx >= 0) {
66
+ merged[idx] = hook;
67
+ } else {
68
+ merged.push(hook);
69
+ }
70
+ }
71
+
72
+ assertEq(merged.length, 3, "merged has 3 hooks");
73
+ assertEq(merged[0].prompt, "override prompt", "review hook was overridden");
74
+ assertEq(merged[0].after.length, 2, "overridden review has 2 after entries");
75
+ assertEq(merged[1].name, "lint", "lint kept from base");
76
+ assertEq(merged[2].name, "security", "security added from override");
77
+ }
78
+
79
+ // ═══════════════════════════════════════════════════════════════════════════
80
+ // Phase 2: Pre-Dispatch Hook Config Tests
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+
83
+ console.log("\n=== Pre-dispatch hook config shape ===");
84
+
85
+ {
86
+ const modifyHook = {
87
+ name: "inject-context",
88
+ before: ["execute-task"],
89
+ action: "modify" as const,
90
+ prepend: "Remember to follow coding conventions.",
91
+ append: "Run tests after making changes.",
92
+ enabled: true,
93
+ };
94
+
95
+ assertEq(modifyHook.name, "inject-context", "modify hook has name");
96
+ assertEq(modifyHook.action, "modify", "action is modify");
97
+ assertTrue(!!modifyHook.prepend, "has prepend text");
98
+ assertTrue(!!modifyHook.append, "has append text");
99
+ }
100
+
101
+ {
102
+ const skipHook = {
103
+ name: "skip-research",
104
+ before: ["research-slice"],
105
+ action: "skip" as const,
106
+ skip_if: "RESEARCH-DONE.md",
107
+ enabled: true,
108
+ };
109
+
110
+ assertEq(skipHook.action, "skip", "action is skip");
111
+ assertEq(skipHook.skip_if, "RESEARCH-DONE.md", "has skip condition");
112
+ }
113
+
114
+ {
115
+ const replaceHook = {
116
+ name: "custom-planning",
117
+ before: ["plan-slice"],
118
+ action: "replace" as const,
119
+ prompt: "Use custom planning approach for {sliceId}",
120
+ unit_type: "custom-plan",
121
+ model: "claude-opus-4-6",
122
+ enabled: true,
123
+ };
124
+
125
+ assertEq(replaceHook.action, "replace", "action is replace");
126
+ assertTrue(!!replaceHook.prompt, "replace hook has prompt");
127
+ assertEq(replaceHook.unit_type, "custom-plan", "has unit_type override");
128
+ }
129
+
130
+ console.log("\n=== Pre-dispatch action validation ===");
131
+
132
+ {
133
+ const validActions = new Set(["modify", "skip", "replace"]);
134
+ assertTrue(validActions.has("modify"), "modify is valid");
135
+ assertTrue(validActions.has("skip"), "skip is valid");
136
+ assertTrue(validActions.has("replace"), "replace is valid");
137
+ assertTrue(!validActions.has("delete"), "delete is not valid");
138
+ assertTrue(!validActions.has(""), "empty string is not valid");
139
+ }
140
+
141
+ console.log("\n=== Pre-dispatch hook merging ===");
142
+
143
+ {
144
+ const baseHooks = [
145
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "base" },
146
+ ];
147
+
148
+ const overrideHooks = [
149
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "override" },
150
+ { name: "gate", before: ["plan-slice"], action: "skip" as const },
151
+ ];
152
+
153
+ const merged = [...baseHooks];
154
+ for (const hook of overrideHooks) {
155
+ const idx = merged.findIndex(h => h.name === hook.name);
156
+ if (idx >= 0) {
157
+ merged[idx] = hook;
158
+ } else {
159
+ merged.push(hook);
160
+ }
161
+ }
162
+
163
+ assertEq(merged.length, 2, "merged has 2 pre-dispatch hooks");
164
+ assertEq(merged[0].prepend, "override", "inject hook overridden");
165
+ assertEq(merged[1].name, "gate", "gate hook added");
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // Known unit types validation
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ console.log("\n=== Known unit types ===");
173
+
174
+ {
175
+ const knownUnitTypes = new Set([
176
+ "research-milestone", "plan-milestone", "research-slice", "plan-slice",
177
+ "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
178
+ "run-uat", "fix-merge", "complete-milestone",
179
+ ]);
180
+
181
+ assertTrue(knownUnitTypes.has("execute-task"), "execute-task is known");
182
+ assertTrue(knownUnitTypes.has("complete-slice"), "complete-slice is known");
183
+ assertTrue(knownUnitTypes.has("plan-slice"), "plan-slice is known");
184
+ assertTrue(!knownUnitTypes.has("hook/review"), "hook types are not in known set");
185
+ assertTrue(!knownUnitTypes.has("invalid-type"), "invalid types are not in known set");
186
+ }
187
+
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+ // Preferences YAML format verification
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+
192
+ console.log("\n=== Preferences YAML format ===");
193
+
194
+ {
195
+ const prefsContent = [
196
+ "---",
197
+ "version: 1",
198
+ "post_unit_hooks:",
199
+ " - name: code-review",
200
+ " after:",
201
+ " - execute-task",
202
+ " prompt: Review the changes",
203
+ " max_cycles: 3",
204
+ " artifact: REVIEW-PASS.md",
205
+ " retry_on: REVIEW-ISSUES.md",
206
+ "pre_dispatch_hooks:",
207
+ " - name: inject-conventions",
208
+ " before:",
209
+ " - execute-task",
210
+ " action: modify",
211
+ " append: Follow project coding conventions",
212
+ " - name: custom-research",
213
+ " before:",
214
+ " - research-slice",
215
+ " action: replace",
216
+ " prompt: Custom research prompt",
217
+ "---",
218
+ ].join("\n");
219
+
220
+ assertTrue(prefsContent.includes("post_unit_hooks:"), "has post_unit_hooks key");
221
+ assertTrue(prefsContent.includes("pre_dispatch_hooks:"), "has pre_dispatch_hooks key");
222
+ assertTrue(prefsContent.includes("action: modify"), "has modify action");
223
+ assertTrue(prefsContent.includes("action: replace"), "has replace action");
224
+ }
225
+
226
+ report();
@@ -67,6 +67,18 @@ async function main(): Promise<void> {
67
67
  assertEq('M001-abc123: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001-abc123: Title → Title');
68
68
  assertEq('M042-z9a8b7: Dashboard'.replace(TITLE_STRIP_RE, ''), 'Dashboard', 'strips M042-z9a8b7: Dashboard');
69
69
 
70
+ // Em dash in title — current format (M001: Title) correctly preserves em dash in title body
71
+ assertEq(
72
+ 'M001: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
73
+ 'Foundation — Build Core',
74
+ 'strips M001: prefix and preserves em dash in title body',
75
+ );
76
+ assertEq(
77
+ 'M001-abc123: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
78
+ 'Foundation — Build Core',
79
+ 'strips M001-abc123: prefix and preserves em dash in title body (unique format)',
80
+ );
81
+
70
82
  // Edge case: dash-style separator (M001 — Title: Subtitle preserves colon in body)
71
83
  assertEq(
72
84
  'M001 — Unique Milestone IDs: Foo'.replace(TITLE_STRIP_RE, ''),