gsd-pi 2.16.0 → 2.17.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 (89) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.ts +9 -3
  3. package/dist/resources/extensions/gsd/auto-prompts.ts +71 -41
  4. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -2
  5. package/dist/resources/extensions/gsd/auto.ts +54 -15
  6. package/dist/resources/extensions/gsd/commands.ts +20 -2
  7. package/dist/resources/extensions/gsd/complexity.ts +236 -0
  8. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  9. package/dist/resources/extensions/gsd/files.ts +6 -2
  10. package/dist/resources/extensions/gsd/git-service.ts +19 -8
  11. package/dist/resources/extensions/gsd/gitignore.ts +41 -2
  12. package/dist/resources/extensions/gsd/guided-flow.ts +10 -6
  13. package/dist/resources/extensions/gsd/metrics.ts +44 -0
  14. package/dist/resources/extensions/gsd/native-git-bridge.ts +5 -0
  15. package/dist/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  16. package/dist/resources/extensions/gsd/preferences.ts +122 -1
  17. package/dist/resources/extensions/gsd/routing-history.ts +290 -0
  18. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  19. package/dist/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  20. package/dist/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  21. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  22. package/dist/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  23. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  25. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  26. package/dist/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  27. package/dist/resources/extensions/gsd/types.ts +28 -0
  28. package/dist/resources/extensions/gsd/worktree.ts +2 -2
  29. package/package.json +1 -1
  30. package/packages/pi-ai/dist/models.generated.d.ts +493 -13
  31. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  32. package/packages/pi-ai/dist/models.generated.js +422 -62
  33. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  34. package/packages/pi-ai/dist/providers/google-shared.d.ts +12 -0
  35. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  36. package/packages/pi-ai/dist/providers/google-shared.js +9 -22
  37. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  38. package/packages/pi-ai/dist/providers/google-shared.test.d.ts +2 -0
  39. package/packages/pi-ai/dist/providers/google-shared.test.d.ts.map +1 -0
  40. package/packages/pi-ai/dist/providers/google-shared.test.js +125 -0
  41. package/packages/pi-ai/dist/providers/google-shared.test.js.map +1 -0
  42. package/packages/pi-ai/src/models.generated.ts +422 -62
  43. package/packages/pi-ai/src/providers/google-shared.test.ts +137 -0
  44. package/packages/pi-ai/src/providers/google-shared.ts +10 -19
  45. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +7 -7
  46. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +209 -13
  48. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts +2 -0
  50. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts.map +1 -0
  51. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +67 -0
  52. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -0
  53. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js +10 -0
  55. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +85 -0
  57. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +245 -17
  58. package/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +13 -0
  59. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  60. package/pkg/dist/modes/interactive/theme/theme.js +10 -0
  61. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -1
  62. package/src/resources/extensions/gsd/auto-dashboard.ts +4 -0
  63. package/src/resources/extensions/gsd/auto-dispatch.ts +9 -3
  64. package/src/resources/extensions/gsd/auto-prompts.ts +71 -41
  65. package/src/resources/extensions/gsd/auto-recovery.ts +7 -2
  66. package/src/resources/extensions/gsd/auto.ts +54 -15
  67. package/src/resources/extensions/gsd/commands.ts +20 -2
  68. package/src/resources/extensions/gsd/complexity.ts +236 -0
  69. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  70. package/src/resources/extensions/gsd/files.ts +6 -2
  71. package/src/resources/extensions/gsd/git-service.ts +19 -8
  72. package/src/resources/extensions/gsd/gitignore.ts +41 -2
  73. package/src/resources/extensions/gsd/guided-flow.ts +10 -6
  74. package/src/resources/extensions/gsd/metrics.ts +44 -0
  75. package/src/resources/extensions/gsd/native-git-bridge.ts +5 -0
  76. package/src/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  77. package/src/resources/extensions/gsd/preferences.ts +122 -1
  78. package/src/resources/extensions/gsd/routing-history.ts +290 -0
  79. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  80. package/src/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  81. package/src/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  82. package/src/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  84. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  85. package/src/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  86. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  87. package/src/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  88. package/src/resources/extensions/gsd/types.ts +28 -0
  89. package/src/resources/extensions/gsd/worktree.ts +2 -2
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Context Compression — unit tests for M004/S02.
3
+ *
4
+ * Verifies that prompt builders respect inlineLevel parameter by
5
+ * inspecting the auto-prompts.ts source for level-aware gating.
6
+ * Cannot call builders directly due to @gsd/pi-coding-agent import
7
+ * resolution — uses source-level structural verification instead.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { readFileSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8");
18
+
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+ // inlineLevel Parameter Presence
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+
23
+ const BUILDERS_WITH_LEVEL = [
24
+ "buildPlanMilestonePrompt",
25
+ "buildPlanSlicePrompt",
26
+ "buildExecuteTaskPrompt",
27
+ "buildCompleteSlicePrompt",
28
+ "buildCompleteMilestonePrompt",
29
+ "buildReassessRoadmapPrompt",
30
+ ];
31
+
32
+ for (const builder of BUILDERS_WITH_LEVEL) {
33
+ test(`compression: ${builder} accepts inlineLevel parameter`, () => {
34
+ // Find the function signature
35
+ const sigRegex = new RegExp(`export async function ${builder}\\([^)]*level\\?: InlineLevel`);
36
+ assert.ok(
37
+ sigRegex.test(promptsSrc),
38
+ `${builder} should have level?: InlineLevel parameter`,
39
+ );
40
+ });
41
+ }
42
+
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+ // Default Level Resolution
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+
47
+ test("compression: builders default to resolveInlineLevel() when no level passed", () => {
48
+ const defaultPattern = /const inlineLevel = level \?\? resolveInlineLevel\(\)/g;
49
+ const matches = promptsSrc.match(defaultPattern);
50
+ assert.ok(matches, "should have resolveInlineLevel() fallback");
51
+ assert.ok(
52
+ matches.length >= BUILDERS_WITH_LEVEL.length,
53
+ `should have ${BUILDERS_WITH_LEVEL.length} fallback instances, found ${matches?.length}`,
54
+ );
55
+ });
56
+
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+ // Minimal Level — Template Reduction
59
+ // ═══════════════════════════════════════════════════════════════════════════
60
+
61
+ test("compression: buildExecuteTaskPrompt minimal drops decisions template", () => {
62
+ // In the execute-task builder, minimal should only inline task-summary, not decisions
63
+ assert.ok(
64
+ promptsSrc.includes('inlineLevel === "minimal"') &&
65
+ promptsSrc.includes('inlineTemplate("task-summary"'),
66
+ "execute-task should conditionally include decisions template based on level",
67
+ );
68
+ });
69
+
70
+ test("compression: buildExecuteTaskPrompt minimal truncates prior summaries", () => {
71
+ assert.ok(
72
+ promptsSrc.includes('inlineLevel === "minimal" && priorSummaries.length > 1'),
73
+ "execute-task should limit prior summaries for minimal level",
74
+ );
75
+ });
76
+
77
+ test("compression: buildPlanMilestonePrompt minimal drops project/requirements/decisions files", () => {
78
+ // The plan-milestone builder should gate root file inlining on inlineLevel
79
+ assert.ok(
80
+ promptsSrc.includes('inlineLevel !== "minimal"') &&
81
+ promptsSrc.includes('inlineGsdRootFile(base, "project.md"'),
82
+ "plan-milestone should conditionally include project.md based on level",
83
+ );
84
+ });
85
+
86
+ test("compression: buildPlanMilestonePrompt minimal drops extra templates", () => {
87
+ // Full inlines 5 templates, minimal should inline fewer
88
+ assert.ok(
89
+ promptsSrc.includes('if (inlineLevel === "full")') &&
90
+ promptsSrc.includes('inlineTemplate("secrets-manifest"'),
91
+ "plan-milestone should only include secrets-manifest template at full level",
92
+ );
93
+ });
94
+
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+ // Complete-Slice Level Gating
97
+ // ═══════════════════════════════════════════════════════════════════════════
98
+
99
+ test("compression: buildCompleteSlicePrompt minimal drops requirements", () => {
100
+ // Find the complete-slice section and verify requirements gating
101
+ const completeSliceIdx = promptsSrc.indexOf("buildCompleteSlicePrompt");
102
+ const nextBuilder = promptsSrc.indexOf("buildCompleteMilestonePrompt");
103
+ const completeSliceBlock = promptsSrc.slice(completeSliceIdx, nextBuilder);
104
+ assert.ok(
105
+ completeSliceBlock.includes('inlineLevel !== "minimal"'),
106
+ "complete-slice should gate requirements inlining on level",
107
+ );
108
+ });
109
+
110
+ test("compression: buildCompleteSlicePrompt minimal drops UAT template", () => {
111
+ const completeSliceIdx = promptsSrc.indexOf("buildCompleteSlicePrompt");
112
+ const nextBuilder = promptsSrc.indexOf("buildCompleteMilestonePrompt");
113
+ const completeSliceBlock = promptsSrc.slice(completeSliceIdx, nextBuilder);
114
+ assert.ok(
115
+ completeSliceBlock.includes('inlineLevel !== "minimal"') &&
116
+ completeSliceBlock.includes('inlineTemplate("uat"'),
117
+ "complete-slice should conditionally include UAT template based on level",
118
+ );
119
+ });
120
+
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+ // Complete-Milestone Level Gating
123
+ // ═══════════════════════════════════════════════════════════════════════════
124
+
125
+ test("compression: buildCompleteMilestonePrompt minimal drops root GSD files", () => {
126
+ const completeMilestoneIdx = promptsSrc.indexOf("buildCompleteMilestonePrompt");
127
+ const nextBuilder = promptsSrc.indexOf("buildReplanSlicePrompt");
128
+ const block = promptsSrc.slice(completeMilestoneIdx, nextBuilder);
129
+ assert.ok(
130
+ block.includes('inlineLevel !== "minimal"') &&
131
+ block.includes('inlineGsdRootFile(base, "requirements.md"'),
132
+ "complete-milestone should gate root file inlining on level",
133
+ );
134
+ });
135
+
136
+ // ═══════════════════════════════════════════════════════════════════════════
137
+ // Reassess-Roadmap Level Gating
138
+ // ═══════════════════════════════════════════════════════════════════════════
139
+
140
+ test("compression: buildReassessRoadmapPrompt minimal drops project/requirements/decisions", () => {
141
+ const reassessIdx = promptsSrc.indexOf("buildReassessRoadmapPrompt");
142
+ const block = promptsSrc.slice(reassessIdx, reassessIdx + 1500);
143
+ assert.ok(
144
+ block.includes('inlineLevel !== "minimal"'),
145
+ "reassess-roadmap should gate file inlining on level",
146
+ );
147
+ });
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════
150
+ // Full Level — No Regression
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+
153
+ test("compression: full level preserves all templates and files (no regression)", () => {
154
+ // Verify the key template names are still present in the source
155
+ const expectedTemplates = [
156
+ "roadmap", "decisions", "plan", "task-plan", "secrets-manifest",
157
+ "task-summary", "slice-summary", "uat", "milestone-summary",
158
+ ];
159
+ for (const tpl of expectedTemplates) {
160
+ assert.ok(
161
+ promptsSrc.includes(`inlineTemplate("${tpl}"`),
162
+ `template "${tpl}" should still be present in auto-prompts.ts`,
163
+ );
164
+ }
165
+ });
166
+
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+ // Import Verification
169
+ // ═══════════════════════════════════════════════════════════════════════════
170
+
171
+ test("compression: auto-prompts.ts imports resolveInlineLevel and InlineLevel", () => {
172
+ assert.ok(
173
+ promptsSrc.includes("resolveInlineLevel"),
174
+ "should import resolveInlineLevel from preferences",
175
+ );
176
+ assert.ok(
177
+ promptsSrc.includes("InlineLevel"),
178
+ "should import InlineLevel type from types",
179
+ );
180
+ });
@@ -1020,6 +1020,138 @@ async function main(): Promise<void> {
1020
1020
  rmSync(repo, { recursive: true, force: true });
1021
1021
  }
1022
1022
 
1023
+ // ─── commit_docs: false — smartStage excludes .gsd/ ──────────────────
1024
+
1025
+ console.log("\n=== commit_docs: false — smartStage excludes .gsd/ ===");
1026
+
1027
+ {
1028
+ const repo = mkdtempSync(join(tmpdir(), "gsd-commit-docs-"));
1029
+ run("git init -b main", repo);
1030
+ run("git config user.email test@test.com", repo);
1031
+ run("git config user.name Test", repo);
1032
+ writeFileSync(join(repo, "README.md"), "init");
1033
+ run("git add -A && git commit -m init", repo);
1034
+
1035
+ // Create .gsd/ planning files + a normal source file
1036
+ mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
1037
+ writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap");
1038
+ writeFileSync(join(repo, ".gsd", "preferences.md"), "---\nversion: 1\n---");
1039
+ writeFileSync(join(repo, "src.ts"), "const x = 1;");
1040
+
1041
+ // With commit_docs: false, smartStage should exclude .gsd/
1042
+ const svc = new GitServiceImpl(repo, { commit_docs: false });
1043
+ const msg = svc.commit({ message: "test commit" });
1044
+ assertTrue(msg !== null, "commit_docs=false: commit succeeds with non-.gsd files");
1045
+
1046
+ // .gsd/ files should NOT be in the commit
1047
+ const committed = run("git show --name-only HEAD", repo);
1048
+ assertTrue(!committed.includes(".gsd/"), "commit_docs=false: .gsd/ files not in commit");
1049
+ assertTrue(committed.includes("src.ts"), "commit_docs=false: source files ARE in commit");
1050
+
1051
+ rmSync(repo, { recursive: true, force: true });
1052
+ }
1053
+
1054
+ // ─── commit_docs: true (default) — smartStage includes .gsd/ ────────
1055
+
1056
+ console.log("\n=== commit_docs: true — smartStage includes .gsd/ ===");
1057
+
1058
+ {
1059
+ const repo = mkdtempSync(join(tmpdir(), "gsd-commit-docs-default-"));
1060
+ run("git init -b main", repo);
1061
+ run("git config user.email test@test.com", repo);
1062
+ run("git config user.name Test", repo);
1063
+ writeFileSync(join(repo, "README.md"), "init");
1064
+ run("git add -A && git commit -m init", repo);
1065
+
1066
+ mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
1067
+ writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap");
1068
+ writeFileSync(join(repo, "src.ts"), "const x = 1;");
1069
+
1070
+ // Default behavior (commit_docs not set) — .gsd/ files ARE committed
1071
+ const svc = new GitServiceImpl(repo);
1072
+ const msg = svc.commit({ message: "test commit" });
1073
+ assertTrue(msg !== null, "commit_docs=default: commit succeeds");
1074
+
1075
+ const committed = run("git show --name-only HEAD", repo);
1076
+ assertTrue(committed.includes(".gsd/"), "commit_docs=default: .gsd/ files ARE in commit");
1077
+ assertTrue(committed.includes("src.ts"), "commit_docs=default: source files in commit");
1078
+
1079
+ rmSync(repo, { recursive: true, force: true });
1080
+ }
1081
+
1082
+ // ─── writeIntegrationBranch: commitDocs false skips commit ──────────
1083
+
1084
+ console.log("\n=== writeIntegrationBranch: commitDocs false skips commit ===");
1085
+
1086
+ {
1087
+ const repo = initBranchTestRepo();
1088
+ const commitsBefore = run("git rev-list --count HEAD", repo);
1089
+
1090
+ writeIntegrationBranch(repo, "M001", "f-123-new-thing", { commitDocs: false });
1091
+
1092
+ // File should still be written to disk
1093
+ assertEq(readIntegrationBranch(repo, "M001"), "f-123-new-thing",
1094
+ "commitDocs=false: metadata file exists on disk");
1095
+
1096
+ // But no new commit should have been created
1097
+ const commitsAfter = run("git rev-list --count HEAD", repo);
1098
+ assertEq(commitsBefore, commitsAfter,
1099
+ "commitDocs=false: no git commit created for integration branch");
1100
+
1101
+ rmSync(repo, { recursive: true, force: true });
1102
+ }
1103
+
1104
+ // ─── ensureGitignore: commit_docs false adds blanket .gsd/ ──────────
1105
+
1106
+ console.log("\n=== ensureGitignore: commit_docs false ===");
1107
+
1108
+ {
1109
+ const { ensureGitignore } = await import("../gitignore.ts");
1110
+ const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-commit-docs-"));
1111
+
1112
+ // When commit_docs is false, should add blanket .gsd/ to gitignore
1113
+ const modified = ensureGitignore(repo, { commitDocs: false });
1114
+ assertTrue(modified, "commit_docs=false: gitignore was modified");
1115
+
1116
+ const { readFileSync } = await import("node:fs");
1117
+ const content = readFileSync(join(repo, ".gitignore"), "utf-8");
1118
+ assertTrue(content.includes(".gsd/"), "commit_docs=false: .gitignore contains blanket .gsd/");
1119
+ assertTrue(content.includes("commit_docs: false"), "commit_docs=false: .gitignore contains explanatory comment");
1120
+
1121
+ // Should NOT contain individual runtime patterns (those are subsumed by blanket .gsd/)
1122
+ // But it's OK if it does — the blanket .gsd/ covers everything
1123
+
1124
+ // Idempotent — calling again doesn't add duplicates
1125
+ const modified2 = ensureGitignore(repo, { commitDocs: false });
1126
+ assertTrue(!modified2, "commit_docs=false: second call is idempotent");
1127
+
1128
+ rmSync(repo, { recursive: true, force: true });
1129
+ }
1130
+
1131
+ // ─── ensureGitignore: commit_docs true removes blanket .gsd/ ────────
1132
+
1133
+ console.log("\n=== ensureGitignore: commit_docs true self-heals ===");
1134
+
1135
+ {
1136
+ const { ensureGitignore } = await import("../gitignore.ts");
1137
+ const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-selfheal-"));
1138
+
1139
+ // Start with a gitignore that has a blanket .gsd/ (e.g., user switched setting)
1140
+ writeFileSync(join(repo, ".gitignore"), ".gsd/\n");
1141
+
1142
+ const modified = ensureGitignore(repo, { commitDocs: true });
1143
+ assertTrue(modified, "commit_docs=true: gitignore was modified");
1144
+
1145
+ const { readFileSync } = await import("node:fs");
1146
+ const content = readFileSync(join(repo, ".gitignore"), "utf-8");
1147
+ // Blanket .gsd/ should be removed
1148
+ const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
1149
+ assertTrue(!lines.includes(".gsd/"), "commit_docs=true: blanket .gsd/ was removed");
1150
+ assertTrue(!lines.includes(".gsd"), "commit_docs=true: blanket .gsd was removed");
1151
+
1152
+ rmSync(repo, { recursive: true, force: true });
1153
+ }
1154
+
1023
1155
  report();
1024
1156
  }
1025
1157
 
@@ -68,6 +68,34 @@ async function main(): Promise<void> {
68
68
  assertTrue(warnings[0].includes("merge_to_main"), "warning mentions merge_to_main");
69
69
  }
70
70
 
71
+ console.log("\n=== git.commit_docs ===");
72
+
73
+ // Valid boolean values accepted
74
+ {
75
+ const { preferences, errors } = validatePreferences({ git: { commit_docs: false } });
76
+ assertEq(errors.length, 0, "commit_docs: false — no errors");
77
+ assertEq(preferences.git?.commit_docs, false, "commit_docs: false — value preserved");
78
+ }
79
+ {
80
+ const { preferences, errors } = validatePreferences({ git: { commit_docs: true } });
81
+ assertEq(errors.length, 0, "commit_docs: true — no errors");
82
+ assertEq(preferences.git?.commit_docs, true, "commit_docs: true — value preserved");
83
+ }
84
+
85
+ // Invalid type produces error
86
+ {
87
+ const { errors } = validatePreferences({ git: { commit_docs: "no" as any } });
88
+ assertTrue(errors.length > 0, "commit_docs: string — produces error");
89
+ assertTrue(errors[0].includes("commit_docs"), "commit_docs: string — error mentions commit_docs");
90
+ }
91
+
92
+ // Undefined passes through without issue
93
+ {
94
+ const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
95
+ assertEq(errors.length, 0, "commit_docs: undefined — no errors");
96
+ assertEq(preferences.git?.commit_docs, undefined, "commit_docs: undefined — not set");
97
+ }
98
+
71
99
  report();
72
100
  }
73
101
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Routing History — structural tests for adaptive learning module.
3
+ *
4
+ * Verifies routing-history.ts exports and structure from #579.
5
+ * Uses source-level checks to avoid @gsd/pi-coding-agent import chain.
6
+ */
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const historySrc = readFileSync(join(__dirname, "..", "routing-history.ts"), "utf-8");
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // Module Exports
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ test("routing-history: exports initRoutingHistory", () => {
22
+ assert.ok(historySrc.includes("export function initRoutingHistory"), "should export initRoutingHistory");
23
+ });
24
+
25
+ test("routing-history: exports recordOutcome", () => {
26
+ assert.ok(historySrc.includes("export function recordOutcome"), "should export recordOutcome");
27
+ });
28
+
29
+ test("routing-history: exports recordFeedback", () => {
30
+ assert.ok(historySrc.includes("export function recordFeedback"), "should export recordFeedback");
31
+ });
32
+
33
+ test("routing-history: exports getAdaptiveTierAdjustment", () => {
34
+ assert.ok(historySrc.includes("export function getAdaptiveTierAdjustment"), "should export getAdaptiveTierAdjustment");
35
+ });
36
+
37
+ test("routing-history: exports resetRoutingHistory", () => {
38
+ assert.ok(historySrc.includes("export function resetRoutingHistory"), "should export resetRoutingHistory");
39
+ });
40
+
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // Design Constants
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
45
+ test("routing-history: uses rolling window of 50 entries", () => {
46
+ assert.ok(historySrc.includes("ROLLING_WINDOW = 50"), "should use 50-entry rolling window");
47
+ });
48
+
49
+ test("routing-history: failure threshold is 20%", () => {
50
+ assert.ok(historySrc.includes("FAILURE_THRESHOLD = 0.20"), "should use 20% failure threshold");
51
+ });
52
+
53
+ test("routing-history: feedback weight is 2x", () => {
54
+ assert.ok(historySrc.includes("FEEDBACK_WEIGHT = 2"), "feedback should count 2x");
55
+ });
56
+
57
+ // ═══════════════════════════════════════════════════════════════════════════
58
+ // Type Structure
59
+ // ═══════════════════════════════════════════════════════════════════════════
60
+
61
+ test("routing-history: imports ComplexityTier from types.ts", () => {
62
+ assert.ok(
63
+ historySrc.includes('from "./types.js"') && historySrc.includes("ComplexityTier"),
64
+ "should import ComplexityTier from types.ts",
65
+ );
66
+ });
67
+
68
+ test("routing-history: defines RoutingHistoryData interface", () => {
69
+ assert.ok(historySrc.includes("interface RoutingHistoryData"), "should define RoutingHistoryData");
70
+ });
71
+
72
+ test("routing-history: defines FeedbackEntry interface", () => {
73
+ assert.ok(historySrc.includes("interface FeedbackEntry"), "should define FeedbackEntry");
74
+ });
75
+
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+ // Persistence
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+
80
+ test("routing-history: persists to routing-history.json", () => {
81
+ assert.ok(historySrc.includes("routing-history.json"), "should persist to routing-history.json");
82
+ });
83
+
84
+ test("routing-history: has save and load functions", () => {
85
+ assert.ok(historySrc.includes("saveHistory") || historySrc.includes("function save"), "should have save");
86
+ assert.ok(historySrc.includes("loadHistory") || historySrc.includes("function load"), "should have load");
87
+ });
@@ -0,0 +1,130 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { fork } from "node:child_process";
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import {
11
+ writeLock,
12
+ readCrashLock,
13
+ clearLock,
14
+ isLockProcessAlive,
15
+ } from "../crash-recovery.ts";
16
+ import { stopAutoRemote } from "../auto.ts";
17
+
18
+ function makeTmpBase(): string {
19
+ const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
20
+ mkdirSync(join(base, ".gsd"), { recursive: true });
21
+ return base;
22
+ }
23
+
24
+ function cleanup(base: string): void {
25
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
26
+ }
27
+
28
+ // ─── stopAutoRemote ──────────────────────────────────────────────────────
29
+
30
+ test("stopAutoRemote returns found:false when no lock file exists", () => {
31
+ const base = makeTmpBase();
32
+ try {
33
+ const result = stopAutoRemote(base);
34
+ assert.equal(result.found, false);
35
+ assert.equal(result.pid, undefined);
36
+ assert.equal(result.error, undefined);
37
+ } finally {
38
+ cleanup(base);
39
+ }
40
+ });
41
+
42
+ test("stopAutoRemote cleans up stale lock (dead PID) and returns found:false", () => {
43
+ const base = makeTmpBase();
44
+ try {
45
+ // Write a lock with a PID that doesn't exist
46
+ writeLock(base, "execute-task", "M001/S01/T01", 3);
47
+ // Overwrite PID to a dead one
48
+ const lock = readCrashLock(base)!;
49
+ const staleData = { ...lock, pid: 999999999 };
50
+ writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(staleData, null, 2), "utf-8");
51
+
52
+ const result = stopAutoRemote(base);
53
+ assert.equal(result.found, false, "stale lock should not be found as running");
54
+
55
+ // Lock should be cleaned up
56
+ assert.equal(readCrashLock(base), null, "stale lock should be removed");
57
+ } finally {
58
+ cleanup(base);
59
+ }
60
+ });
61
+
62
+ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", async () => {
63
+ const base = makeTmpBase();
64
+
65
+ // Spawn a child process that sleeps, acting as a fake auto-mode session
66
+ const child = fork(
67
+ "-e",
68
+ ["process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"],
69
+ { stdio: "ignore", detached: false },
70
+ );
71
+
72
+ try {
73
+ // Wait for child to be ready
74
+ await new Promise((resolve) => setTimeout(resolve, 200));
75
+
76
+ // Write lock with child's PID
77
+ const lockData = {
78
+ pid: child.pid,
79
+ startedAt: new Date().toISOString(),
80
+ unitType: "execute-task",
81
+ unitId: "M001/S01/T01",
82
+ unitStartedAt: new Date().toISOString(),
83
+ completedUnits: 0,
84
+ };
85
+ writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2), "utf-8");
86
+
87
+ const result = stopAutoRemote(base);
88
+ assert.equal(result.found, true, "should find running auto-mode");
89
+ assert.equal(result.pid, child.pid, "should return the PID");
90
+
91
+ // Wait for child to exit (it should receive SIGTERM)
92
+ const exitCode = await new Promise<number | null>((resolve) => {
93
+ child.on("exit", (code) => resolve(code));
94
+ setTimeout(() => resolve(null), 5000);
95
+ });
96
+ // On Windows, SIGTERM is not interceptable — the process exits with code 1
97
+ // rather than running the handler. Accept either clean exit (0) or forced (1).
98
+ assert.ok(exitCode !== null, "child should have exited after SIGTERM");
99
+ if (process.platform !== "win32") {
100
+ assert.equal(exitCode, 0, "child should have exited cleanly via SIGTERM");
101
+ }
102
+ } finally {
103
+ try { child.kill("SIGKILL"); } catch { /* already dead */ }
104
+ cleanup(base);
105
+ }
106
+ });
107
+
108
+ // ─── Lock path: original project root vs worktree ────────────────────────
109
+
110
+ test("lock file should be discoverable at project root, not worktree path", () => {
111
+ const projectRoot = makeTmpBase();
112
+ const worktreePath = join(projectRoot, ".gsd", "worktrees", "M001");
113
+ mkdirSync(join(worktreePath, ".gsd"), { recursive: true });
114
+
115
+ try {
116
+ // Simulate: auto-mode writes lock to project root (the fix)
117
+ writeLock(projectRoot, "execute-task", "M001/S01/T01", 0);
118
+
119
+ // Second terminal checks project root — should find the lock
120
+ const lock = readCrashLock(projectRoot);
121
+ assert.ok(lock, "lock should be found at project root");
122
+ assert.equal(lock!.unitType, "execute-task");
123
+
124
+ // Worktree path should NOT have a lock
125
+ const worktreeLock = readCrashLock(worktreePath);
126
+ assert.equal(worktreeLock, null, "lock should NOT exist at worktree path");
127
+ } finally {
128
+ cleanup(projectRoot);
129
+ }
130
+ });