gsd-pi 2.45.0-dev.6b9da3e → 2.45.0-dev.e0ee972

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 (104) hide show
  1. package/dist/help-text.js +1 -1
  2. package/dist/loader.js +34 -0
  3. package/dist/resources/extensions/gsd/auto/phases.js +16 -10
  4. package/dist/resources/extensions/gsd/auto/run-unit.js +6 -3
  5. package/dist/resources/extensions/gsd/auto-worktree.js +5 -4
  6. package/dist/resources/extensions/gsd/auto.js +4 -5
  7. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +13 -12
  8. package/dist/resources/extensions/gsd/db-writer.js +9 -9
  9. package/dist/resources/extensions/gsd/doctor-checks.js +1 -1
  10. package/dist/resources/extensions/gsd/doctor.js +2 -2
  11. package/dist/resources/extensions/gsd/gsd-db.js +5 -1
  12. package/dist/resources/extensions/gsd/preferences-types.js +2 -2
  13. package/dist/resources/extensions/gsd/preferences.js +8 -4
  14. package/dist/resources/extensions/gsd/workflow-logger.js +138 -0
  15. package/dist/resources/extensions/gsd/worktree-manager.js +4 -3
  16. package/dist/resources/extensions/gsd/worktree-resolver.js +37 -0
  17. package/dist/resources/extensions/voice/index.js +11 -16
  18. package/dist/resources/extensions/voice/linux-ready.js +67 -0
  19. package/dist/web/standalone/.next/BUILD_ID +1 -1
  20. package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
  21. package/dist/web/standalone/.next/build-manifest.json +2 -2
  22. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  23. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +19 -19
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/package.json +1 -1
  51. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js +2 -0
  53. package/packages/pi-coding-agent/dist/core/compaction-orchestrator.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -1
  55. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts +4 -0
  58. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js +10 -5
  60. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.js.map +1 -1
  61. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts +2 -0
  62. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.d.ts.map +1 -0
  63. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js +185 -0
  64. package/packages/pi-coding-agent/dist/core/lifecycle-hooks.test.js.map +1 -0
  65. package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js +239 -10
  66. package/packages/pi-coding-agent/dist/core/model-registry-auth-mode.test.js.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +2 -1
  68. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/model-registry.js +20 -2
  70. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  71. package/packages/pi-coding-agent/dist/core/package-commands.test.js +206 -195
  72. package/packages/pi-coding-agent/dist/core/package-commands.test.js.map +1 -1
  73. package/packages/pi-coding-agent/src/core/compaction-orchestrator.ts +2 -0
  74. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -1
  75. package/packages/pi-coding-agent/src/core/lifecycle-hooks.test.ts +227 -0
  76. package/packages/pi-coding-agent/src/core/lifecycle-hooks.ts +11 -5
  77. package/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts +297 -11
  78. package/packages/pi-coding-agent/src/core/model-registry.ts +30 -3
  79. package/packages/pi-coding-agent/src/core/package-commands.test.ts +227 -205
  80. package/src/resources/extensions/gsd/auto/phases.ts +16 -12
  81. package/src/resources/extensions/gsd/auto/run-unit.ts +6 -3
  82. package/src/resources/extensions/gsd/auto-worktree.ts +8 -5
  83. package/src/resources/extensions/gsd/auto.ts +3 -3
  84. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +13 -12
  85. package/src/resources/extensions/gsd/db-writer.ts +9 -17
  86. package/src/resources/extensions/gsd/doctor-checks.ts +1 -1
  87. package/src/resources/extensions/gsd/doctor.ts +2 -2
  88. package/src/resources/extensions/gsd/gsd-db.ts +5 -1
  89. package/src/resources/extensions/gsd/journal.ts +6 -1
  90. package/src/resources/extensions/gsd/preferences-types.ts +2 -2
  91. package/src/resources/extensions/gsd/preferences.ts +7 -3
  92. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +1 -1
  93. package/src/resources/extensions/gsd/tests/none-mode-gates.test.ts +42 -3
  94. package/src/resources/extensions/gsd/tests/preferences.test.ts +7 -9
  95. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +275 -0
  96. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +220 -0
  97. package/src/resources/extensions/gsd/workflow-logger.ts +193 -0
  98. package/src/resources/extensions/gsd/worktree-manager.ts +4 -9
  99. package/src/resources/extensions/gsd/worktree-resolver.ts +37 -0
  100. package/src/resources/extensions/voice/index.ts +11 -21
  101. package/src/resources/extensions/voice/linux-ready.ts +87 -0
  102. package/src/resources/extensions/voice/tests/linux-ready.test.ts +124 -0
  103. /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → dFMji9G1LZ-Tv36el9pRT}/_buildManifest.js +0 -0
  104. /package/dist/web/standalone/.next/static/{rzO54ZboyINyEt7cVM_uS → dFMji9G1LZ-Tv36el9pRT}/_ssgManifest.js +0 -0
@@ -0,0 +1,275 @@
1
+ // GSD Extension — Workflow Logger Tests
2
+ // Tests for the centralized warning/error accumulator.
3
+
4
+ import { describe, test, beforeEach } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import {
7
+ logWarning,
8
+ logError,
9
+ drainLogs,
10
+ drainAndSummarize,
11
+ peekLogs,
12
+ hasErrors,
13
+ hasWarnings,
14
+ hasAnyIssues,
15
+ summarizeLogs,
16
+ formatForNotification,
17
+ _resetLogs,
18
+ } from "../workflow-logger.ts";
19
+
20
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
21
+
22
+ describe("workflow-logger", () => {
23
+ beforeEach(() => {
24
+ _resetLogs();
25
+ });
26
+
27
+ describe("accumulation", () => {
28
+ test("logWarning adds an entry with severity warn", () => {
29
+ logWarning("engine", "test warning");
30
+ const entries = peekLogs();
31
+ assert.equal(entries.length, 1);
32
+ assert.equal(entries[0].severity, "warn");
33
+ assert.equal(entries[0].component, "engine");
34
+ assert.equal(entries[0].message, "test warning");
35
+ assert.match(entries[0].ts, ISO_RE);
36
+ });
37
+
38
+ test("logError adds an entry with severity error", () => {
39
+ logError("intercept", "blocked write", { path: "/foo/STATE.md" });
40
+ const entries = peekLogs();
41
+ assert.equal(entries.length, 1);
42
+ assert.equal(entries[0].severity, "error");
43
+ assert.equal(entries[0].component, "intercept");
44
+ assert.deepEqual(entries[0].context, { path: "/foo/STATE.md" });
45
+ });
46
+
47
+ test("accumulates multiple entries in order", () => {
48
+ logWarning("projection", "render failed");
49
+ logError("intercept", "blocked write");
50
+ logWarning("manifest", "write failed");
51
+ assert.equal(peekLogs().length, 3);
52
+ assert.equal(peekLogs()[0].component, "projection");
53
+ assert.equal(peekLogs()[1].component, "intercept");
54
+ assert.equal(peekLogs()[2].component, "manifest");
55
+ });
56
+
57
+ test("omits context field when not provided", () => {
58
+ logWarning("engine", "no context");
59
+ assert.equal("context" in peekLogs()[0], false);
60
+ });
61
+
62
+ test("omits context field when undefined is passed", () => {
63
+ logWarning("engine", "no context", undefined);
64
+ assert.equal("context" in peekLogs()[0], false);
65
+ });
66
+
67
+ test("context with special characters is stored as-is", () => {
68
+ logError("tool", "failed", { path: '/foo/"quoted".md', msg: "line1\nline2" });
69
+ assert.deepEqual(peekLogs()[0].context, {
70
+ path: '/foo/"quoted".md',
71
+ msg: "line1\nline2",
72
+ });
73
+ });
74
+
75
+ test("ts field is a valid ISO 8601 timestamp", () => {
76
+ logWarning("engine", "ts check");
77
+ assert.match(peekLogs()[0].ts, ISO_RE);
78
+ });
79
+ });
80
+
81
+ describe("drain", () => {
82
+ test("returns all entries and clears buffer", () => {
83
+ logWarning("engine", "w1");
84
+ logError("engine", "e1");
85
+ const drained = drainLogs();
86
+ assert.equal(drained.length, 2);
87
+ assert.equal(peekLogs().length, 0);
88
+ });
89
+
90
+ test("returns empty array when no entries", () => {
91
+ assert.deepEqual(drainLogs(), []);
92
+ });
93
+
94
+ test("second drain returns empty array", () => {
95
+ logWarning("engine", "w1");
96
+ drainLogs();
97
+ assert.deepEqual(drainLogs(), []);
98
+ });
99
+ });
100
+
101
+ describe("drainAndSummarize", () => {
102
+ test("returns summary and clears buffer atomically", () => {
103
+ logError("intercept", "blocked");
104
+ logWarning("projection", "render failed");
105
+ const { logs, summary } = drainAndSummarize();
106
+ assert.equal(logs.length, 2);
107
+ assert.equal(peekLogs().length, 0);
108
+ assert.ok(summary?.includes("1 error(s)"));
109
+ assert.ok(summary?.includes("1 warning(s)"));
110
+ });
111
+
112
+ test("returns null summary when buffer is empty", () => {
113
+ const { logs, summary } = drainAndSummarize();
114
+ assert.deepEqual(logs, []);
115
+ assert.equal(summary, null);
116
+ });
117
+ });
118
+
119
+ describe("hasErrors / hasWarnings / hasAnyIssues", () => {
120
+ test("hasErrors returns false when only warnings", () => {
121
+ logWarning("engine", "just a warning");
122
+ assert.equal(hasErrors(), false);
123
+ assert.equal(hasWarnings(), true);
124
+ });
125
+
126
+ test("hasErrors returns true when errors present", () => {
127
+ logWarning("engine", "warning");
128
+ logError("intercept", "error");
129
+ assert.equal(hasErrors(), true);
130
+ });
131
+
132
+ test("hasWarnings returns false when buffer empty", () => {
133
+ assert.equal(hasWarnings(), false);
134
+ });
135
+
136
+ test("hasWarnings returns false when buffer contains only errors", () => {
137
+ logError("intercept", "only an error");
138
+ assert.equal(hasWarnings(), false);
139
+ assert.equal(hasErrors(), true);
140
+ });
141
+
142
+ test("hasAnyIssues returns true for warnings only", () => {
143
+ logWarning("engine", "warn");
144
+ assert.equal(hasAnyIssues(), true);
145
+ });
146
+
147
+ test("hasAnyIssues returns true for errors only", () => {
148
+ logError("engine", "err");
149
+ assert.equal(hasAnyIssues(), true);
150
+ });
151
+
152
+ test("hasAnyIssues returns false when buffer empty", () => {
153
+ assert.equal(hasAnyIssues(), false);
154
+ });
155
+ });
156
+
157
+ describe("summarizeLogs", () => {
158
+ test("returns null when empty", () => {
159
+ assert.equal(summarizeLogs(), null);
160
+ });
161
+
162
+ test("summarizes errors and warnings separately", () => {
163
+ logError("intercept", "blocked STATE.md");
164
+ logWarning("projection", "render failed");
165
+ logWarning("manifest", "write failed");
166
+ const summary = summarizeLogs()!;
167
+ assert.ok(summary.includes("1 error(s)"));
168
+ assert.ok(summary.includes("blocked STATE.md"));
169
+ assert.ok(summary.includes("2 warning(s)"));
170
+ });
171
+
172
+ test("only shows errors section when no warnings", () => {
173
+ logError("intercept", "blocked");
174
+ const summary = summarizeLogs()!;
175
+ assert.ok(summary.includes("1 error(s)"));
176
+ assert.ok(!summary.includes("warning"));
177
+ });
178
+
179
+ test("only shows warnings section when no errors", () => {
180
+ logWarning("projection", "render degraded");
181
+ logWarning("manifest", "write slow");
182
+ const summary = summarizeLogs()!;
183
+ assert.ok(summary.includes("2 warning(s)"));
184
+ assert.ok(!summary.includes("error"));
185
+ });
186
+
187
+ test("does not clear buffer", () => {
188
+ logError("intercept", "blocked");
189
+ summarizeLogs();
190
+ assert.equal(peekLogs().length, 1);
191
+ });
192
+ });
193
+
194
+ describe("formatForNotification", () => {
195
+ test("returns empty string for empty array", () => {
196
+ assert.equal(formatForNotification([]), "");
197
+ });
198
+
199
+ test("formats single entry without line breaks", () => {
200
+ logError("intercept", "blocked write");
201
+ const entries = drainLogs();
202
+ const formatted = formatForNotification(entries);
203
+ assert.equal(formatted, "[intercept] blocked write");
204
+ });
205
+
206
+ test("formats multiple entries with line breaks", () => {
207
+ logWarning("projection", "render failed");
208
+ logError("intercept", "blocked write");
209
+ const entries = drainLogs();
210
+ const formatted = formatForNotification(entries);
211
+ assert.ok(formatted.includes("[projection] render failed"));
212
+ assert.ok(formatted.includes("[intercept] blocked write"));
213
+ assert.ok(formatted.includes("\n"));
214
+ });
215
+
216
+ test("does not include context in formatted output", () => {
217
+ logError("tool", "failed", { cmd: "complete_task" });
218
+ const entries = drainLogs();
219
+ const formatted = formatForNotification(entries);
220
+ assert.equal(formatted, "[tool] failed");
221
+ assert.ok(!formatted.includes("complete_task"));
222
+ });
223
+ });
224
+
225
+ describe("buffer limit", () => {
226
+ test("caps at MAX_BUFFER entries, dropping oldest", () => {
227
+ const OVER = 110;
228
+ const MAX = 100;
229
+ for (let i = 0; i < OVER; i++) {
230
+ logWarning("engine", `msg-${i}`);
231
+ }
232
+ const entries = peekLogs();
233
+ assert.equal(entries.length, MAX);
234
+ // First MAX entries dropped; oldest surviving = msg-(OVER-MAX)
235
+ assert.equal(entries[0].message, `msg-${OVER - MAX}`);
236
+ assert.equal(entries[MAX - 1].message, `msg-${OVER - 1}`);
237
+ });
238
+ });
239
+
240
+ describe("stderr output", () => {
241
+ test("writes WARN prefix to stderr for warnings", (t) => {
242
+ const written: string[] = [];
243
+ const orig = process.stderr.write.bind(process.stderr);
244
+ // @ts-ignore — patching for test
245
+ process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
246
+ t.after(() => { process.stderr.write = orig; });
247
+
248
+ logWarning("engine", "test warn");
249
+ assert.equal(written.length, 1);
250
+ assert.ok(written[0].includes("[gsd:engine] WARN: test warn"));
251
+ });
252
+
253
+ test("writes ERROR prefix to stderr for errors", (t) => {
254
+ const written: string[] = [];
255
+ const orig = process.stderr.write.bind(process.stderr);
256
+ // @ts-ignore — patching for test
257
+ process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
258
+ t.after(() => { process.stderr.write = orig; });
259
+
260
+ logError("intercept", "blocked");
261
+ assert.ok(written[0].includes("[gsd:intercept] ERROR: blocked"));
262
+ });
263
+
264
+ test("includes serialized context in stderr output", (t) => {
265
+ const written: string[] = [];
266
+ const orig = process.stderr.write.bind(process.stderr);
267
+ // @ts-ignore — patching for test
268
+ process.stderr.write = (chunk: string) => { written.push(chunk); return true; };
269
+ t.after(() => { process.stderr.write = orig; });
270
+
271
+ logError("tool", "failed", { cmd: "complete_task" });
272
+ assert.ok(written[0].includes('"cmd":"complete_task"'));
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ WorktreeResolver,
8
+ type WorktreeResolverDeps,
9
+ type NotifyCtx,
10
+ } from "../worktree-resolver.js";
11
+ import { AutoSession } from "../auto/session.js";
12
+ import type { JournalEntry } from "../journal.js";
13
+
14
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
15
+
16
+ function makeSession(
17
+ overrides?: Partial<{ basePath: string; originalBasePath: string }>,
18
+ ): AutoSession {
19
+ const s = new AutoSession();
20
+ s.basePath = overrides?.basePath ?? "/project";
21
+ s.originalBasePath = overrides?.originalBasePath ?? "/project";
22
+ return s;
23
+ }
24
+
25
+ function makeDeps(
26
+ overrides?: Partial<WorktreeResolverDeps>,
27
+ ): WorktreeResolverDeps {
28
+ const deps: WorktreeResolverDeps = {
29
+ isInAutoWorktree: () => false,
30
+ shouldUseWorktreeIsolation: () => true,
31
+ getIsolationMode: () => "worktree",
32
+ mergeMilestoneToMain: () => ({ pushed: false, codeFilesChanged: true }),
33
+ syncWorktreeStateBack: () => ({ synced: [] }),
34
+ teardownAutoWorktree: () => {},
35
+ createAutoWorktree: (_basePath: string, milestoneId: string) =>
36
+ `/project/.gsd/worktrees/${milestoneId}`,
37
+ enterAutoWorktree: (_basePath: string, milestoneId: string) =>
38
+ `/project/.gsd/worktrees/${milestoneId}`,
39
+ getAutoWorktreePath: () => null,
40
+ autoCommitCurrentBranch: () => {},
41
+ getCurrentBranch: () => "main",
42
+ autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
43
+ resolveMilestoneFile: (_basePath: string, milestoneId: string) =>
44
+ `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`,
45
+ readFileSync: () => "# Roadmap\n- [x] S01: Slice one\n",
46
+ GitServiceImpl: class {
47
+ constructor() {}
48
+ } as unknown as WorktreeResolverDeps["GitServiceImpl"],
49
+ loadEffectiveGSDPreferences: () => ({ preferences: { git: {} } }),
50
+ invalidateAllCaches: () => {},
51
+ captureIntegrationBranch: () => {},
52
+ ...overrides,
53
+ };
54
+ return deps;
55
+ }
56
+
57
+ function makeNotifyCtx(): NotifyCtx {
58
+ return {
59
+ notify: () => {},
60
+ };
61
+ }
62
+
63
+ /** Read all journal entries from a temp .gsd/journal directory. */
64
+ function readJournalEntries(basePath: string): JournalEntry[] {
65
+ const journalDir = join(basePath, ".gsd", "journal");
66
+ try {
67
+ const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
68
+ const entries: JournalEntry[] = [];
69
+ for (const file of files) {
70
+ const raw = readFileSync(join(journalDir, file), "utf-8");
71
+ for (const line of raw.split("\n")) {
72
+ if (!line.trim()) continue;
73
+ entries.push(JSON.parse(line) as JournalEntry);
74
+ }
75
+ }
76
+ return entries;
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // ─── Tests ───────────────────────────────────────────────────────────────────
83
+
84
+ describe("worktree journal events", () => {
85
+ let tmp: string;
86
+ const originalCwd = process.cwd();
87
+
88
+ beforeEach(() => {
89
+ tmp = mkdtempSync(join(tmpdir(), "wt-journal-"));
90
+ });
91
+ afterEach(() => {
92
+ // Restore cwd before cleanup — on Windows, rmSync fails with EPERM
93
+ // if the process cwd is inside the directory being deleted.
94
+ try { process.chdir(originalCwd); } catch { /* best-effort */ }
95
+ rmSync(tmp, { recursive: true, force: true });
96
+ });
97
+
98
+ test("enterMilestone emits worktree-enter on success (new worktree)", () => {
99
+ const s = makeSession({ basePath: tmp, originalBasePath: tmp });
100
+ const deps = makeDeps({ getAutoWorktreePath: () => null });
101
+ const resolver = new WorktreeResolver(s, deps);
102
+
103
+ resolver.enterMilestone("M001", makeNotifyCtx());
104
+
105
+ const entries = readJournalEntries(tmp);
106
+ const enter = entries.find(e => e.eventType === "worktree-enter");
107
+ assert.ok(enter, "worktree-enter event should be emitted");
108
+ assert.equal(enter!.data?.milestoneId, "M001");
109
+ assert.equal(enter!.data?.created, true);
110
+ assert.ok(enter!.data?.wtPath);
111
+ });
112
+
113
+ test("enterMilestone emits worktree-enter with created=false for existing worktree", () => {
114
+ const s = makeSession({ basePath: tmp, originalBasePath: tmp });
115
+ const deps = makeDeps({
116
+ getAutoWorktreePath: () => "/project/.gsd/worktrees/M001",
117
+ });
118
+ const resolver = new WorktreeResolver(s, deps);
119
+
120
+ resolver.enterMilestone("M001", makeNotifyCtx());
121
+
122
+ const entries = readJournalEntries(tmp);
123
+ const enter = entries.find(e => e.eventType === "worktree-enter");
124
+ assert.ok(enter, "worktree-enter event should be emitted");
125
+ assert.equal(enter!.data?.created, false);
126
+ });
127
+
128
+ test("enterMilestone emits worktree-skip when isolation disabled", () => {
129
+ const s = makeSession({ basePath: tmp, originalBasePath: tmp });
130
+ const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
131
+ const resolver = new WorktreeResolver(s, deps);
132
+
133
+ resolver.enterMilestone("M001", makeNotifyCtx());
134
+
135
+ const entries = readJournalEntries(tmp);
136
+ const skip = entries.find(e => e.eventType === "worktree-skip");
137
+ assert.ok(skip, "worktree-skip event should be emitted");
138
+ assert.equal(skip!.data?.milestoneId, "M001");
139
+ assert.equal(skip!.data?.reason, "isolation-disabled");
140
+ });
141
+
142
+ test("enterMilestone emits worktree-create-failed on error", () => {
143
+ const s = makeSession({ basePath: tmp, originalBasePath: tmp });
144
+ const deps = makeDeps({
145
+ getAutoWorktreePath: () => null,
146
+ createAutoWorktree: () => { throw new Error("disk full"); },
147
+ });
148
+ const resolver = new WorktreeResolver(s, deps);
149
+
150
+ resolver.enterMilestone("M001", makeNotifyCtx());
151
+
152
+ const entries = readJournalEntries(tmp);
153
+ const failed = entries.find(e => e.eventType === "worktree-create-failed");
154
+ assert.ok(failed, "worktree-create-failed event should be emitted");
155
+ assert.equal(failed!.data?.milestoneId, "M001");
156
+ assert.equal(failed!.data?.error, "disk full");
157
+ assert.equal(failed!.data?.fallback, "project-root");
158
+ });
159
+
160
+ test("mergeAndExit emits worktree-merge-start", () => {
161
+ const s = makeSession({
162
+ basePath: join(tmp, "worktree"),
163
+ originalBasePath: tmp,
164
+ });
165
+ const deps = makeDeps({
166
+ isInAutoWorktree: () => true,
167
+ getIsolationMode: () => "worktree",
168
+ });
169
+ const resolver = new WorktreeResolver(s, deps);
170
+
171
+ resolver.mergeAndExit("M001", makeNotifyCtx());
172
+
173
+ const entries = readJournalEntries(tmp);
174
+ const start = entries.find(e => e.eventType === "worktree-merge-start");
175
+ assert.ok(start, "worktree-merge-start event should be emitted");
176
+ assert.equal(start!.data?.milestoneId, "M001");
177
+ assert.equal(start!.data?.mode, "worktree");
178
+ });
179
+
180
+ test("mergeAndExit emits worktree-merge-failed on error", () => {
181
+ const s = makeSession({
182
+ basePath: join(tmp, "worktree"),
183
+ originalBasePath: tmp,
184
+ });
185
+ const deps = makeDeps({
186
+ isInAutoWorktree: () => true,
187
+ getIsolationMode: () => "worktree",
188
+ mergeMilestoneToMain: () => { throw new Error("conflict in main"); },
189
+ });
190
+ const resolver = new WorktreeResolver(s, deps);
191
+
192
+ resolver.mergeAndExit("M001", makeNotifyCtx());
193
+
194
+ const entries = readJournalEntries(tmp);
195
+ const failed = entries.find(e => e.eventType === "worktree-merge-failed");
196
+ assert.ok(failed, "worktree-merge-failed event should be emitted");
197
+ assert.equal(failed!.data?.milestoneId, "M001");
198
+ assert.equal(failed!.data?.error, "conflict in main");
199
+ });
200
+
201
+ test("journal entries have valid flowId, seq, and ts fields", () => {
202
+ const s = makeSession({ basePath: tmp, originalBasePath: tmp });
203
+ const deps = makeDeps({ shouldUseWorktreeIsolation: () => false });
204
+ const resolver = new WorktreeResolver(s, deps);
205
+
206
+ resolver.enterMilestone("M001", makeNotifyCtx());
207
+
208
+ const entries = readJournalEntries(tmp);
209
+ assert.ok(entries.length > 0, "at least one entry should exist");
210
+ const entry = entries[0];
211
+ assert.ok(entry.flowId, "flowId should be set");
212
+ assert.ok(
213
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(entry.flowId),
214
+ "flowId should be a valid UUID",
215
+ );
216
+ assert.equal(entry.seq, 0);
217
+ assert.ok(entry.ts, "ts should be set");
218
+ assert.ok(!isNaN(Date.parse(entry.ts)), "ts should be a valid ISO date");
219
+ });
220
+ });
@@ -0,0 +1,193 @@
1
+ // GSD Extension — Workflow Logger
2
+ // Centralized warning/error accumulator for the workflow engine pipeline.
3
+ // Captures structured entries that the auto-loop can drain after each unit
4
+ // to surface root causes for stuck loops, silent degradation, and blocked writes.
5
+ //
6
+ // Stderr policy: every logWarning/logError call writes immediately to stderr
7
+ // for terminal visibility. This is intentional — unlike debug-logger (which is
8
+ // opt-in and zero-overhead when disabled), workflow-logger covers operational
9
+ // warnings/errors that should always be visible. There is no disable flag.
10
+ //
11
+ // Singleton safety: _buffer is module-level and shared across all calls within
12
+ // a process. The auto-loop must call _resetLogs() (or drainAndSummarize()) at
13
+ // the start of each unit to prevent log bleed between units running in the same
14
+ // Node process.
15
+
16
+ // ─── Types ──────────────────────────────────────────────────────────────
17
+
18
+ export type LogSeverity = "warn" | "error";
19
+
20
+ export type LogComponent =
21
+ | "engine" // WorkflowEngine afterCommand side effects
22
+ | "projection" // Projection rendering
23
+ | "manifest" // Manifest write
24
+ | "event-log" // Event append
25
+ | "intercept" // Write intercept / tool-call blocks
26
+ | "migration" // Auto-migration from markdown
27
+ | "state" // deriveState fallback/degradation
28
+ | "tool" // Tool handler errors
29
+ | "compaction" // Event compaction
30
+ | "reconcile"; // Worktree reconciliation
31
+
32
+ export interface LogEntry {
33
+ ts: string;
34
+ severity: LogSeverity;
35
+ component: LogComponent;
36
+ message: string;
37
+ /** Optional structured context (file path, command name, etc.) */
38
+ context?: Record<string, string>;
39
+ }
40
+
41
+ // ─── Buffer ─────────────────────────────────────────────────────────────
42
+
43
+ const MAX_BUFFER = 100;
44
+ let _buffer: LogEntry[] = [];
45
+
46
+ // ─── Public API ─────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Record a warning. Also writes to stderr for terminal visibility.
50
+ */
51
+ export function logWarning(
52
+ component: LogComponent,
53
+ message: string,
54
+ context?: Record<string, string>,
55
+ ): void {
56
+ _push("warn", component, message, context);
57
+ }
58
+
59
+ /**
60
+ * Record an error. Also writes to stderr for terminal visibility.
61
+ */
62
+ export function logError(
63
+ component: LogComponent,
64
+ message: string,
65
+ context?: Record<string, string>,
66
+ ): void {
67
+ _push("error", component, message, context);
68
+ }
69
+
70
+ /**
71
+ * Drain all accumulated entries and clear the buffer.
72
+ * Returns entries oldest-first.
73
+ *
74
+ * WARNING: Call summarizeLogs() or drainAndSummarize() BEFORE calling this
75
+ * if you need a summary — drainLogs() clears the buffer immediately.
76
+ */
77
+ export function drainLogs(): LogEntry[] {
78
+ const entries = _buffer;
79
+ _buffer = [];
80
+ return entries;
81
+ }
82
+
83
+ /**
84
+ * Atomically summarize then drain — the safe way to consume logs.
85
+ * Use this in the auto-loop instead of calling summarizeLogs() + drainLogs()
86
+ * separately to avoid the ordering footgun.
87
+ */
88
+ export function drainAndSummarize(): { logs: LogEntry[]; summary: string | null } {
89
+ const summary = summarizeLogs();
90
+ const logs = drainLogs();
91
+ return { logs, summary };
92
+ }
93
+
94
+ /**
95
+ * Peek at current entries without clearing.
96
+ */
97
+ export function peekLogs(): readonly LogEntry[] {
98
+ return _buffer;
99
+ }
100
+
101
+ /**
102
+ * Returns true if the buffer contains any error-severity entries.
103
+ */
104
+ export function hasErrors(): boolean {
105
+ return _buffer.some((e) => e.severity === "error");
106
+ }
107
+
108
+ /**
109
+ * Returns true if the buffer contains any warn-severity entries.
110
+ * Use hasAnyIssues() if you want to check for either severity.
111
+ */
112
+ export function hasWarnings(): boolean {
113
+ return _buffer.some((e) => e.severity === "warn");
114
+ }
115
+
116
+ /**
117
+ * Returns true if the buffer contains any entries (warn or error).
118
+ */
119
+ export function hasAnyIssues(): boolean {
120
+ return _buffer.length > 0;
121
+ }
122
+
123
+ /**
124
+ * Get a one-line summary of accumulated issues for stuck detection messages.
125
+ * Returns null if no entries.
126
+ *
127
+ * Must be called BEFORE drainLogs() — use drainAndSummarize() for safe ordering.
128
+ */
129
+ export function summarizeLogs(): string | null {
130
+ if (_buffer.length === 0) return null;
131
+ const errors = _buffer.filter((e) => e.severity === "error");
132
+ const warns = _buffer.filter((e) => e.severity === "warn");
133
+
134
+ const parts: string[] = [];
135
+ if (errors.length > 0) {
136
+ parts.push(`${errors.length} error(s): ${errors.map((e) => e.message).join("; ")}`);
137
+ }
138
+ if (warns.length > 0) {
139
+ parts.push(`${warns.length} warning(s): ${warns.map((e) => e.message).join("; ")}`);
140
+ }
141
+ return parts.join(" | ");
142
+ }
143
+
144
+ /**
145
+ * Format entries for display (used by auto-loop post-unit notification).
146
+ * Note: context fields are not included in the formatted output.
147
+ */
148
+ export function formatForNotification(entries: readonly LogEntry[]): string {
149
+ if (entries.length === 0) return "";
150
+ if (entries.length === 1) {
151
+ const e = entries[0];
152
+ return `[${e.component}] ${e.message}`;
153
+ }
154
+ return entries
155
+ .map((e) => `[${e.component}] ${e.message}`)
156
+ .join("\n");
157
+ }
158
+
159
+ /**
160
+ * Reset buffer. Call at the start of each auto-loop unit to prevent log bleed
161
+ * between units running in the same process. Also used in tests via _resetLogs().
162
+ */
163
+ export function _resetLogs(): void {
164
+ _buffer = [];
165
+ }
166
+
167
+ // ─── Internal ───────────────────────────────────────────────────────────
168
+
169
+ function _push(
170
+ severity: LogSeverity,
171
+ component: LogComponent,
172
+ message: string,
173
+ context?: Record<string, string>,
174
+ ): void {
175
+ const entry: LogEntry = {
176
+ ts: new Date().toISOString(),
177
+ severity,
178
+ component,
179
+ message,
180
+ ...(context ? { context } : {}),
181
+ };
182
+
183
+ // Always forward to stderr so terminal watchers see it (see module header for policy)
184
+ const prefix = severity === "error" ? "ERROR" : "WARN";
185
+ const ctxStr = context ? ` ${JSON.stringify(context)}` : "";
186
+ process.stderr.write(`[gsd:${component}] ${prefix}: ${message}${ctxStr}\n`);
187
+
188
+ // Buffer for auto-loop to drain
189
+ _buffer.push(entry);
190
+ if (_buffer.length > MAX_BUFFER) {
191
+ _buffer.shift();
192
+ }
193
+ }