gsd-pi 2.63.0-dev.351157b → 2.63.0-dev.786f0ff

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 (99) hide show
  1. package/dist/cli.js +4 -0
  2. package/dist/headless-query.js +11 -1
  3. package/dist/resources/extensions/gsd/auto/detect-stuck.js +27 -0
  4. package/dist/resources/extensions/gsd/auto/phases.js +34 -0
  5. package/dist/resources/extensions/gsd/auto/session.js +4 -0
  6. package/dist/resources/extensions/gsd/auto-model-selection.js +32 -0
  7. package/dist/resources/extensions/gsd/auto-post-unit.js +79 -0
  8. package/dist/resources/extensions/gsd/auto-timers.js +2 -1
  9. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +87 -28
  10. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +23 -0
  11. package/dist/resources/extensions/gsd/bootstrap/system-context.js +30 -2
  12. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.js +7 -0
  14. package/dist/resources/extensions/gsd/prompts/system.md +3 -7
  15. package/dist/resources/extensions/gsd/safety/content-validator.js +73 -0
  16. package/dist/resources/extensions/gsd/safety/destructive-guard.js +34 -0
  17. package/dist/resources/extensions/gsd/safety/evidence-collector.js +109 -0
  18. package/dist/resources/extensions/gsd/safety/evidence-cross-ref.js +83 -0
  19. package/dist/resources/extensions/gsd/safety/file-change-validator.js +71 -0
  20. package/dist/resources/extensions/gsd/safety/git-checkpoint.js +91 -0
  21. package/dist/resources/extensions/gsd/safety/safety-harness.js +64 -0
  22. package/dist/resources/extensions/ollama/index.js +22 -10
  23. package/dist/resources/extensions/ollama/ollama-chat-provider.js +1 -1
  24. package/dist/update-cmd.js +4 -2
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +18 -18
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +18 -18
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/packages/pi-coding-agent/dist/core/extensions/provider-registration.test.d.ts +2 -0
  58. package/packages/pi-coding-agent/dist/core/extensions/provider-registration.test.d.ts.map +1 -0
  59. package/packages/pi-coding-agent/dist/core/extensions/provider-registration.test.js +46 -0
  60. package/packages/pi-coding-agent/dist/core/extensions/provider-registration.test.js.map +1 -0
  61. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/core/model-registry.js +11 -0
  63. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/sdk.js +2 -3
  66. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  67. package/packages/pi-coding-agent/src/core/extensions/provider-registration.test.ts +81 -0
  68. package/packages/pi-coding-agent/src/core/model-registry.ts +12 -0
  69. package/packages/pi-coding-agent/src/core/sdk.ts +2 -3
  70. package/src/resources/extensions/gsd/auto/detect-stuck.ts +27 -0
  71. package/src/resources/extensions/gsd/auto/phases.ts +39 -0
  72. package/src/resources/extensions/gsd/auto/session.ts +5 -0
  73. package/src/resources/extensions/gsd/auto-model-selection.ts +36 -0
  74. package/src/resources/extensions/gsd/auto-post-unit.ts +88 -0
  75. package/src/resources/extensions/gsd/auto-timers.ts +2 -1
  76. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +86 -28
  77. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +27 -0
  78. package/src/resources/extensions/gsd/bootstrap/system-context.ts +31 -2
  79. package/src/resources/extensions/gsd/preferences-types.ts +13 -0
  80. package/src/resources/extensions/gsd/prompt-loader.ts +8 -0
  81. package/src/resources/extensions/gsd/prompts/system.md +3 -7
  82. package/src/resources/extensions/gsd/safety/content-validator.ts +98 -0
  83. package/src/resources/extensions/gsd/safety/destructive-guard.ts +49 -0
  84. package/src/resources/extensions/gsd/safety/evidence-collector.ts +151 -0
  85. package/src/resources/extensions/gsd/safety/evidence-cross-ref.ts +120 -0
  86. package/src/resources/extensions/gsd/safety/file-change-validator.ts +108 -0
  87. package/src/resources/extensions/gsd/safety/git-checkpoint.ts +106 -0
  88. package/src/resources/extensions/gsd/safety/safety-harness.ts +105 -0
  89. package/src/resources/extensions/gsd/tests/complete-slice-string-coercion.test.ts +211 -0
  90. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +50 -0
  91. package/src/resources/extensions/gsd/tests/git-checkpoint.test.ts +94 -0
  92. package/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts +42 -0
  93. package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
  94. package/src/resources/extensions/ollama/index.ts +20 -11
  95. package/src/resources/extensions/ollama/ollama-auth-mode.test.ts +20 -0
  96. package/src/resources/extensions/ollama/ollama-chat-provider.ts +1 -1
  97. package/src/resources/extensions/ollama/tests/ollama-chat-provider-stream.test.ts +82 -0
  98. /package/dist/web/standalone/.next/static/{QmuF-eAbuU_2MQ03t38qr → SDB1T-4NqkMjYirjjqQhr}/_buildManifest.js +0 -0
  99. /package/dist/web/standalone/.next/static/{QmuF-eAbuU_2MQ03t38qr → SDB1T-4NqkMjYirjjqQhr}/_ssgManifest.js +0 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Safety Harness — central module for LLM damage control during auto-mode.
3
+ * Provides types, preference resolution, and orchestration for all safety components.
4
+ *
5
+ * Components:
6
+ * - evidence-collector.ts: Real-time tool call tracking
7
+ * - destructive-guard.ts: Bash command classification
8
+ * - file-change-validator.ts: Post-unit git diff vs plan
9
+ * - evidence-cross-ref.ts: Claimed vs actual verification evidence
10
+ * - git-checkpoint.ts: Pre-unit checkpoints + rollback
11
+ * - content-validator.ts: Output quality validation
12
+ *
13
+ * Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
14
+ */
15
+
16
+ // ─── Types ──────────────────────────────────────────────────────────────────
17
+
18
+ export interface SafetyHarnessConfig {
19
+ enabled: boolean;
20
+ evidence_collection: boolean;
21
+ file_change_validation: boolean;
22
+ evidence_cross_reference: boolean;
23
+ destructive_command_warnings: boolean;
24
+ content_validation: boolean;
25
+ checkpoints: boolean;
26
+ auto_rollback: boolean;
27
+ timeout_scale_cap: number;
28
+ }
29
+
30
+ // ─── Defaults ───────────────────────────────────────────────────────────────
31
+
32
+ const DEFAULTS: SafetyHarnessConfig = {
33
+ enabled: true,
34
+ evidence_collection: true,
35
+ file_change_validation: true,
36
+ evidence_cross_reference: true,
37
+ destructive_command_warnings: true,
38
+ content_validation: true,
39
+ checkpoints: true,
40
+ auto_rollback: false,
41
+ timeout_scale_cap: 6,
42
+ };
43
+
44
+ // ─── Public API ─────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Resolve safety harness configuration from raw preferences.
48
+ * Missing fields fall back to defaults.
49
+ */
50
+ export function resolveSafetyHarnessConfig(
51
+ raw: Record<string, unknown> | undefined,
52
+ ): SafetyHarnessConfig {
53
+ if (!raw) return { ...DEFAULTS };
54
+
55
+ return {
56
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled,
57
+ evidence_collection: typeof raw.evidence_collection === "boolean" ? raw.evidence_collection : DEFAULTS.evidence_collection,
58
+ file_change_validation: typeof raw.file_change_validation === "boolean" ? raw.file_change_validation : DEFAULTS.file_change_validation,
59
+ evidence_cross_reference: typeof raw.evidence_cross_reference === "boolean" ? raw.evidence_cross_reference : DEFAULTS.evidence_cross_reference,
60
+ destructive_command_warnings: typeof raw.destructive_command_warnings === "boolean" ? raw.destructive_command_warnings : DEFAULTS.destructive_command_warnings,
61
+ content_validation: typeof raw.content_validation === "boolean" ? raw.content_validation : DEFAULTS.content_validation,
62
+ checkpoints: typeof raw.checkpoints === "boolean" ? raw.checkpoints : DEFAULTS.checkpoints,
63
+ auto_rollback: typeof raw.auto_rollback === "boolean" ? raw.auto_rollback : DEFAULTS.auto_rollback,
64
+ timeout_scale_cap: typeof raw.timeout_scale_cap === "number" ? raw.timeout_scale_cap : DEFAULTS.timeout_scale_cap,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Check if the safety harness is enabled.
70
+ * Used as a fast gate at hook registration and phase integration points.
71
+ */
72
+ export function isHarnessEnabled(
73
+ raw: Record<string, unknown> | undefined,
74
+ ): boolean {
75
+ if (!raw) return DEFAULTS.enabled;
76
+ if (typeof raw.enabled === "boolean") return raw.enabled;
77
+ return DEFAULTS.enabled;
78
+ }
79
+
80
+ // ─── Re-exports ─────────────────────────────────────────────────────────────
81
+
82
+ export {
83
+ resetEvidence,
84
+ getEvidence,
85
+ getBashEvidence,
86
+ getFilePaths,
87
+ recordToolCall,
88
+ recordToolResult,
89
+ } from "./evidence-collector.js";
90
+
91
+ export type { EvidenceEntry, BashEvidence, FileWriteEvidence, FileEditEvidence } from "./evidence-collector.js";
92
+
93
+ export { classifyCommand } from "./destructive-guard.js";
94
+ export type { CommandClassification } from "./destructive-guard.js";
95
+
96
+ export { validateFileChanges } from "./file-change-validator.js";
97
+ export type { FileChangeAudit, FileViolation } from "./file-change-validator.js";
98
+
99
+ export { crossReferenceEvidence } from "./evidence-cross-ref.js";
100
+ export type { ClaimedEvidence, EvidenceMismatch } from "./evidence-cross-ref.js";
101
+
102
+ export { createCheckpoint, rollbackToCheckpoint, cleanupCheckpoint } from "./git-checkpoint.js";
103
+
104
+ export { validateContent } from "./content-validator.js";
105
+ export type { ContentViolation } from "./content-validator.js";
@@ -0,0 +1,211 @@
1
+ // GSD Extension — String coercion regression tests for complete-slice/task tools
2
+
3
+ import { describe, test, beforeEach, afterEach } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import {
9
+ openDatabase,
10
+ closeDatabase,
11
+ insertMilestone,
12
+ insertSlice,
13
+ insertTask,
14
+ } from "../gsd-db.ts";
15
+ import { handleCompleteSlice } from "../tools/complete-slice.ts";
16
+ import type { CompleteSliceParams } from "../types.ts";
17
+
18
+ // ─── Helpers ─────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * The splitPair coercion logic extracted from db-tools.ts sliceCompleteExecute.
22
+ * Duplicated here so we can unit-test it directly.
23
+ */
24
+ function splitPair(s: string): [string, string] {
25
+ const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
26
+ return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
27
+ }
28
+
29
+ function makeValidSliceParams(): CompleteSliceParams {
30
+ return {
31
+ sliceId: "S01",
32
+ milestoneId: "M001",
33
+ sliceTitle: "Test Slice",
34
+ oneLiner: "Implemented test slice",
35
+ narrative: "Built and tested.",
36
+ verification: "All tests pass.",
37
+ deviations: "None.",
38
+ knownLimitations: "None.",
39
+ followUps: "None.",
40
+ keyFiles: ["src/foo.ts"],
41
+ keyDecisions: ["D001"],
42
+ patternsEstablished: [],
43
+ observabilitySurfaces: [],
44
+ provides: ["test handler"],
45
+ requirementsSurfaced: [],
46
+ drillDownPaths: [],
47
+ affects: [],
48
+ requirementsAdvanced: [{ id: "R001", how: "Handler validates" }],
49
+ requirementsValidated: [],
50
+ requirementsInvalidated: [],
51
+ filesModified: [{ path: "src/foo.ts", description: "Handler" }],
52
+ requires: [],
53
+ uatContent: "## Smoke Test\n\nVerify all assertions pass.",
54
+ };
55
+ }
56
+
57
+ // ─── splitPair unit tests ────────────────────────────────────────────────
58
+
59
+ describe("splitPair coercion helper (#3565)", () => {
60
+ test("plain string without delimiter returns string + empty", () => {
61
+ const [a, b] = splitPair("src/foo.ts");
62
+ assert.equal(a, "src/foo.ts");
63
+ assert.equal(b, "");
64
+ });
65
+
66
+ test("em-dash delimiter parses both parts", () => {
67
+ const [id, how] = splitPair("R001 — Handler validates task completion");
68
+ assert.equal(id, "R001");
69
+ assert.equal(how, "Handler validates task completion");
70
+ });
71
+
72
+ test("hyphen delimiter parses both parts", () => {
73
+ const [id, proof] = splitPair("R002 - Tests pass");
74
+ assert.equal(id, "R002");
75
+ assert.equal(proof, "Tests pass");
76
+ });
77
+
78
+ test("string with no space around hyphen is treated as plain", () => {
79
+ // e.g. a file path like "src/foo-bar.ts" should not split
80
+ const [a, b] = splitPair("src/foo-bar.ts");
81
+ assert.equal(a, "src/foo-bar.ts");
82
+ assert.equal(b, "");
83
+ });
84
+
85
+ test("whitespace is trimmed from both parts", () => {
86
+ const [id, how] = splitPair(" R003 — Trimmed value ");
87
+ assert.equal(id, "R003");
88
+ assert.equal(how, "Trimmed value");
89
+ });
90
+ });
91
+
92
+ // ─── verificationEvidence sentinel tests ─────────────────────────────────
93
+
94
+ describe("verificationEvidence sentinel coercion (#3565)", () => {
95
+ function coerceEvidence(v: any) {
96
+ return typeof v === "string"
97
+ ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 }
98
+ : v;
99
+ }
100
+
101
+ test("string input produces non-passing sentinel", () => {
102
+ const result = coerceEvidence("npm test");
103
+ assert.equal(result.command, "npm test");
104
+ assert.equal(result.exitCode, -1);
105
+ assert.equal(result.verdict, "unknown (coerced from string)");
106
+ assert.equal(result.durationMs, 0);
107
+ });
108
+
109
+ test("object input passes through unchanged", () => {
110
+ const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 };
111
+ const result = coerceEvidence(obj);
112
+ assert.equal(result.exitCode, 0);
113
+ assert.equal(result.verdict, "pass");
114
+ assert.equal(result.durationMs, 1234);
115
+ });
116
+
117
+ test("sentinel exitCode is not 0 (must not fabricate success)", () => {
118
+ const result = coerceEvidence("anything");
119
+ assert.notEqual(result.exitCode, 0, "exitCode must not be 0 for coerced strings");
120
+ assert.ok(
121
+ !result.verdict.includes("pass"),
122
+ "verdict must not contain 'pass' for coerced strings",
123
+ );
124
+ });
125
+ });
126
+
127
+ // ─── Handler integration with coerced params ─────────────────────────────
128
+
129
+ describe("handleCompleteSlice with coerced string arrays (#3565)", () => {
130
+ let dbPath: string;
131
+ let basePath: string;
132
+
133
+ beforeEach(() => {
134
+ dbPath = path.join(
135
+ fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-")),
136
+ "test.db",
137
+ );
138
+ openDatabase(dbPath);
139
+
140
+ basePath = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-handler-"));
141
+ const sliceDir = path.join(basePath, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
142
+ fs.mkdirSync(sliceDir, { recursive: true });
143
+
144
+ const roadmapPath = path.join(basePath, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
145
+ fs.writeFileSync(
146
+ roadmapPath,
147
+ [
148
+ "# M001: Test Milestone",
149
+ "",
150
+ "## Slices",
151
+ "",
152
+ '- [ ] **S01: Test Slice** `risk:medium` `depends:[]`',
153
+ " - After this: basic functionality works",
154
+ ].join("\n"),
155
+ );
156
+
157
+ insertMilestone({ id: "M001" });
158
+ insertSlice({ id: "S01", milestoneId: "M001" });
159
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
160
+ });
161
+
162
+ afterEach(() => {
163
+ closeDatabase();
164
+ fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
165
+ fs.rmSync(basePath, { recursive: true, force: true });
166
+ });
167
+
168
+ test("handler succeeds with coerced filesModified and requirementsAdvanced", async () => {
169
+ const params = makeValidSliceParams();
170
+ // Simulate coercion from plain strings
171
+ params.filesModified = ["src/foo.ts", "src/bar.ts"].map((f) => {
172
+ const [p, d] = splitPair(f);
173
+ return { path: p, description: d };
174
+ });
175
+ params.requirementsAdvanced = ["R001 — Handler validates task completion"].map((r) => {
176
+ const [id, how] = splitPair(r);
177
+ return { id, how };
178
+ });
179
+
180
+ const result = await handleCompleteSlice(params, basePath);
181
+ assert.ok(!("error" in result), "handler should succeed");
182
+ if (!("error" in result)) {
183
+ const summary = fs.readFileSync(result.summaryPath, "utf-8");
184
+ assert.match(summary, /src\/foo\.ts/);
185
+ assert.match(summary, /R001/);
186
+ assert.match(summary, /Handler validates task completion/);
187
+ }
188
+ });
189
+
190
+ test("handler succeeds with coerced requires and requirementsValidated", async () => {
191
+ const params = makeValidSliceParams();
192
+ params.requires = ["S00 — Provided base infrastructure"].map((r) => {
193
+ const [slice, provides] = splitPair(r);
194
+ return { slice, provides };
195
+ });
196
+ params.requirementsValidated = ["R002 - Tests pass"].map((r) => {
197
+ const [id, proof] = splitPair(r);
198
+ return { id, proof };
199
+ });
200
+
201
+ const result = await handleCompleteSlice(params, basePath);
202
+ assert.ok(!("error" in result), "handler should succeed");
203
+ if (!("error" in result)) {
204
+ const summary = fs.readFileSync(result.summaryPath, "utf-8");
205
+ assert.match(summary, /S00/);
206
+ assert.match(summary, /Provided base infrastructure/);
207
+ assert.match(summary, /R002/);
208
+ assert.match(summary, /Tests pass/);
209
+ }
210
+ });
211
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Regression test for #3453: dynamic model routing must be disabled for
3
+ * flat-rate providers like GitHub Copilot where all models cost the same
4
+ * per request — routing only degrades quality with no cost benefit.
5
+ */
6
+
7
+ import { describe, test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { isFlatRateProvider, resolvePreferredModelConfig } from "../auto-model-selection.ts";
10
+
11
+ describe("flat-rate provider routing guard (#3453)", () => {
12
+
13
+ test("isFlatRateProvider returns true for github-copilot", () => {
14
+ assert.equal(isFlatRateProvider("github-copilot"), true);
15
+ });
16
+
17
+ test("isFlatRateProvider returns true for copilot alias", () => {
18
+ assert.equal(isFlatRateProvider("copilot"), true);
19
+ });
20
+
21
+ test("isFlatRateProvider is case-insensitive", () => {
22
+ assert.equal(isFlatRateProvider("GitHub-Copilot"), true);
23
+ assert.equal(isFlatRateProvider("GITHUB-COPILOT"), true);
24
+ assert.equal(isFlatRateProvider("Copilot"), true);
25
+ });
26
+
27
+ test("isFlatRateProvider returns false for anthropic", () => {
28
+ assert.equal(isFlatRateProvider("anthropic"), false);
29
+ });
30
+
31
+ test("isFlatRateProvider returns false for openai", () => {
32
+ assert.equal(isFlatRateProvider("openai"), false);
33
+ });
34
+
35
+ test("resolvePreferredModelConfig returns undefined for copilot start model", () => {
36
+ // When the user's start model is on a flat-rate provider,
37
+ // resolvePreferredModelConfig should not synthesize a routing
38
+ // config from tier_models — it should return undefined so the
39
+ // user's selected model is preserved.
40
+ const result = resolvePreferredModelConfig("execute-task", {
41
+ provider: "github-copilot",
42
+ id: "claude-sonnet-4",
43
+ });
44
+
45
+ // Should be undefined (no routing config created for flat-rate)
46
+ // Note: this only tests the guard — if explicit per-unit config exists
47
+ // in preferences, that takes precedence regardless.
48
+ assert.equal(result, undefined, "Should not create routing config for copilot");
49
+ });
50
+ });
@@ -0,0 +1,94 @@
1
+ // GSD2 — Regression tests for git-checkpoint rollback (#3576)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { execFileSync } from "node:child_process";
10
+ import { createCheckpoint, rollbackToCheckpoint, cleanupCheckpoint } from "../safety/git-checkpoint.js";
11
+
12
+ function git(args: string[], cwd: string): string {
13
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
14
+ }
15
+
16
+ function createTempRepo(): string {
17
+ const dir = mkdtempSync(join(tmpdir(), "ckpt-test-"));
18
+ git(["init"], dir);
19
+ git(["config", "user.email", "test@test.com"], dir);
20
+ git(["config", "user.name", "Test"], dir);
21
+ writeFileSync(join(dir, "file.txt"), "initial\n");
22
+ git(["add", "."], dir);
23
+ git(["commit", "-m", "init"], dir);
24
+ git(["branch", "-M", "main"], dir);
25
+ return dir;
26
+ }
27
+
28
+ describe("git-checkpoint rollback", () => {
29
+ it("rolls back to checkpoint on checked-out branch", (t) => {
30
+ const repo = createTempRepo();
31
+ t.after(() => rmSync(repo, { recursive: true, force: true }));
32
+
33
+ // Create checkpoint at initial commit
34
+ const sha = createCheckpoint(repo, "unit-1");
35
+ assert.ok(sha, "checkpoint should return a SHA");
36
+
37
+ // Make a second commit
38
+ writeFileSync(join(repo, "file.txt"), "modified\n");
39
+ git(["add", "."], repo);
40
+ git(["commit", "-m", "second"], repo);
41
+
42
+ const headBefore = git(["rev-parse", "HEAD"], repo);
43
+ assert.notEqual(headBefore, sha, "HEAD should have advanced");
44
+
45
+ // Rollback — this must work on the checked-out branch
46
+ const result = rollbackToCheckpoint(repo, "unit-1", sha);
47
+ assert.equal(result, true, "rollback should succeed");
48
+
49
+ const headAfter = git(["rev-parse", "HEAD"], repo);
50
+ assert.equal(headAfter, sha, "HEAD should match checkpoint SHA after rollback");
51
+ });
52
+
53
+ it("returns false on detached HEAD", (t) => {
54
+ const repo = createTempRepo();
55
+ t.after(() => rmSync(repo, { recursive: true, force: true }));
56
+
57
+ const sha = git(["rev-parse", "HEAD"], repo);
58
+ git(["checkout", "--detach", sha], repo);
59
+
60
+ const result = rollbackToCheckpoint(repo, "unit-2", sha);
61
+ assert.equal(result, false, "rollback should fail on detached HEAD");
62
+ });
63
+
64
+ it("cleans up checkpoint ref after rollback", (t) => {
65
+ const repo = createTempRepo();
66
+ t.after(() => rmSync(repo, { recursive: true, force: true }));
67
+
68
+ const sha = createCheckpoint(repo, "unit-3");
69
+ assert.ok(sha);
70
+
71
+ // Ref should exist
72
+ const refBefore = git(["for-each-ref", "refs/gsd/checkpoints/unit-3", "--format=%(objectname)"], repo);
73
+ assert.equal(refBefore, sha);
74
+
75
+ rollbackToCheckpoint(repo, "unit-3", sha);
76
+
77
+ // Ref should be cleaned up
78
+ const refAfter = git(["for-each-ref", "refs/gsd/checkpoints/unit-3", "--format=%(objectname)"], repo);
79
+ assert.equal(refAfter, "", "checkpoint ref should be removed after rollback");
80
+ });
81
+
82
+ it("cleanupCheckpoint removes the ref without error", (t) => {
83
+ const repo = createTempRepo();
84
+ t.after(() => rmSync(repo, { recursive: true, force: true }));
85
+
86
+ const sha = createCheckpoint(repo, "unit-4");
87
+ assert.ok(sha);
88
+
89
+ cleanupCheckpoint(repo, "unit-4");
90
+
91
+ const ref = git(["for-each-ref", "refs/gsd/checkpoints/unit-4", "--format=%(objectname)"], repo);
92
+ assert.equal(ref, "", "ref should be gone");
93
+ });
94
+ });
@@ -123,6 +123,48 @@ test("Rule 3: A-A-A-A triggers Rule 2 not Rule 3", () => {
123
123
  );
124
124
  });
125
125
 
126
+ // ─── Rule 4: ENOENT same path twice in window (#3575) ───────────────────────
127
+
128
+ test("Rule 4: same ENOENT path in two entries triggers stuck", () => {
129
+ const result = detectStuck([
130
+ { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" },
131
+ { key: "B" },
132
+ { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" },
133
+ ]);
134
+ assert.notEqual(result, null);
135
+ assert.equal(result!.stuck, true);
136
+ assert.ok(result!.reason.includes("Missing file"), `reason was: ${result!.reason}`);
137
+ assert.ok(result!.reason.includes("ENOENT"), `reason was: ${result!.reason}`);
138
+ });
139
+
140
+ test("Rule 4: different ENOENT paths do not trigger stuck", () => {
141
+ const result = detectStuck([
142
+ { key: "A", error: "ENOENT: no such file or directory, access '/path/a'" },
143
+ { key: "B", error: "ENOENT: no such file or directory, access '/path/b'" },
144
+ ]);
145
+ assert.equal(result, null);
146
+ });
147
+
148
+ test("Rule 4: single ENOENT does not trigger stuck", () => {
149
+ const result = detectStuck([
150
+ { key: "A", error: "ENOENT: no such file or directory, access '/path/a'" },
151
+ { key: "B" },
152
+ ]);
153
+ assert.equal(result, null);
154
+ });
155
+
156
+ test("Rule 4: ENOENT paths non-consecutive still triggers", () => {
157
+ const result = detectStuck([
158
+ { key: "A", error: "ENOENT: no such file or directory, access '/missing/skill'" },
159
+ { key: "B" },
160
+ { key: "C" },
161
+ { key: "D", error: "ENOENT: no such file or directory, access '/missing/skill'" },
162
+ ]);
163
+ assert.notEqual(result, null);
164
+ assert.equal(result!.stuck, true);
165
+ assert.ok(result!.reason.includes("/missing/skill"), `reason was: ${result!.reason}`);
166
+ });
167
+
126
168
  // ─── Gap documentation: 3-unit cycle evades detection ────────────────────────
127
169
 
128
170
  test("Three-unit cycle A-B-C-A-B-C does NOT trigger stuck (documents gap L13)", () => {
@@ -48,7 +48,8 @@ export type LogComponent =
48
48
  | "bootstrap" // Extension bootstrap (system-context, agent-end)
49
49
  | "guided" // Guided flow (discuss, plan wizards)
50
50
  | "registry" // Rule registry hook state
51
- | "renderer"; // Markdown renderer and projections
51
+ | "renderer" // Markdown renderer and projections
52
+ | "safety"; // LLM safety harness
52
53
 
53
54
  export interface LogEntry {
54
55
  ts: string;
@@ -61,8 +61,13 @@ async function probeAndRegister(pi: ExtensionAPI): Promise<boolean> {
61
61
 
62
62
  const baseUrl = client.getOllamaHost();
63
63
 
64
+ // Use authMode "apiKey" with a dummy key (#3440).
65
+ // authMode "none" requires a custom streamSimple handler, but Ollama uses
66
+ // the standard OpenAI-compatible streaming endpoint. Ollama ignores the
67
+ // Authorization header so the dummy key is harmless.
64
68
  pi.registerProvider("ollama", {
65
- authMode: "none",
69
+ authMode: "apiKey",
70
+ apiKey: "ollama",
66
71
  baseUrl,
67
72
  api: "ollama-chat",
68
73
  streamSimple: streamOllamaChat,
@@ -100,16 +105,20 @@ export default function ollama(pi: ExtensionAPI) {
100
105
  await registerOllamaTools(pi);
101
106
  }
102
107
 
103
- // Async probe don't block startup
104
- probeAndRegister(pi)
105
- .then((found) => {
106
- if (found && ctx.hasUI) {
107
- ctx.ui.setStatus("ollama", "Ollama");
108
- }
109
- })
110
- .catch(() => {
111
- // Silently ignore probe failures
112
- });
108
+ // In headless/auto mode, await the probe so the fallback resolver can
109
+ // see Ollama before the first LLM call (#3531 race condition).
110
+ // In interactive mode, keep it async for fast startup.
111
+ if (!ctx.hasUI) {
112
+ try {
113
+ await probeAndRegister(pi);
114
+ } catch { /* non-fatal */ }
115
+ } else {
116
+ probeAndRegister(pi)
117
+ .then((found) => {
118
+ if (found) ctx.ui.setStatus("ollama", "Ollama");
119
+ })
120
+ .catch(() => {});
121
+ }
113
122
  });
114
123
 
115
124
  pi.on("session_shutdown", async () => {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Regression test for #3440: Ollama extension must register with
3
+ * authMode "apiKey" (not "none") to avoid streamSimple requirement.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ test("Ollama registers with authMode apiKey, not none (#3440)", () => {
14
+ const src = readFileSync(join(__dirname, "index.ts"), "utf-8");
15
+ // Find the registerProvider call
16
+ const registerBlock = src.slice(src.indexOf("pi.registerProvider(\"ollama\""));
17
+ const authLine = registerBlock.match(/authMode:\s*"(\w+)"/);
18
+ assert.ok(authLine, "registerProvider must specify authMode");
19
+ assert.equal(authLine![1], "apiKey", "authMode must be apiKey, not none");
20
+ });
@@ -149,7 +149,7 @@ export function streamOllamaChat(
149
149
  // Handle text content — process independently of tool_calls
150
150
  // (a chunk may contain both content and tool_calls)
151
151
  const content = chunk.message?.content ?? "";
152
- if (content && !chunk.done) {
152
+ if (content) {
153
153
  if (thinkParser) {
154
154
  processChunks(thinkParser.push(content));
155
155
  } else {