@gr8ful/spf 0.5.1 → 0.7.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 (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. package/dist/test/watch.test.js +0 -687
@@ -1,687 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import path from "node:path";
4
- import { branchNameFor, claimNewWork, claimSpecs, createWatchState, finishReviews, reconcileOrphans, reconcileRefining, refineBranchNameFor, tick, } from "../core/watch.js";
5
- import { escapeForMarkup, formatReviewDigest, truncateDigest } from "../cli/commands/watch.js";
6
- /** A minimal, schema-shaped ReviewOutputT — only the fields formatReviewDigest reads. */
7
- function reviewOutput(overrides = {}) {
8
- return {
9
- status: "success",
10
- summary: "",
11
- artifacts: [],
12
- notes_for_next_agent: "",
13
- approved: false,
14
- findings: [],
15
- blocking: [],
16
- ...overrides,
17
- };
18
- }
19
- /** In-memory fake — exactly the seam `provider.ts` exists for. */
20
- class FakeProvider {
21
- entries = new Map();
22
- ensureLabelsCalls = 0;
23
- transitions = [];
24
- claimCalls = [];
25
- addIssue(id, title, state = "ready", marker = null) {
26
- this.entries.set(id, { issue: { id, title, body: "", labels: [`spf:${state}`] }, state, marker });
27
- }
28
- async ensureLabels() {
29
- this.ensureLabelsCalls++;
30
- return { created: [], updated: [], unchanged: [] };
31
- }
32
- async listEligible() {
33
- return [...this.entries.values()].filter((e) => e.state === "ready").map((e) => e.issue);
34
- }
35
- async listInState(state) {
36
- return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
37
- }
38
- async claim(issue, opts) {
39
- this.claimCalls.push(issue.id);
40
- const entry = this.entries.get(issue.id);
41
- const from = opts?.from ?? "ready";
42
- const to = opts?.to ?? "working";
43
- if (entry.state !== from)
44
- return false;
45
- entry.state = to;
46
- return true;
47
- }
48
- async transition(issue, to, detail) {
49
- this.entries.get(issue.id).state = to;
50
- this.transitions.push({ id: issue.id, to, detail });
51
- }
52
- async comment(_issue, _body) { }
53
- async readMarker(issue) {
54
- return this.entries.get(issue.id)?.marker ?? null;
55
- }
56
- async writeMarker(issue, marker) {
57
- this.entries.get(issue.id).marker = marker;
58
- }
59
- }
60
- /** In-memory fake `CodeHostProvider` — separate from `FakeProvider`, mirroring the real split. */
61
- class FakeCodeHost {
62
- prs = new Map();
63
- openedPrs = [];
64
- nextPrNumber = 1000;
65
- async openPr(opts) {
66
- const number = this.nextPrNumber++;
67
- this.openedPrs.push({ title: opts.title, branch: opts.branch, body: opts.body });
68
- this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
69
- return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
70
- }
71
- async prStatus(pr) {
72
- return this.prs.get(pr.number) ?? { merged: false, state: "open", ciStatus: "pending" };
73
- }
74
- }
75
- function fakeGit(overrides = {}) {
76
- return {
77
- currentBranch: () => "main",
78
- createBranch: (n) => n,
79
- isRepo: () => true,
80
- commitAll: () => "abc123",
81
- changedFiles: () => [],
82
- refExists: () => true,
83
- rev: () => "abc123",
84
- shortSha: () => "abc123",
85
- mergeBase: () => "abc123",
86
- isDirty: () => false,
87
- untrackedFiles: () => [],
88
- diffFiles: () => [],
89
- diffStat: () => "",
90
- diffCounts: () => [0, 0],
91
- diffText: () => "",
92
- fetch: () => { },
93
- worktreeAdd: () => { },
94
- worktreeRemove: () => { },
95
- deleteLocalBranch: () => { },
96
- push: () => { },
97
- ...overrides,
98
- };
99
- }
100
- function makeDeps(provider, codeHost, overrides = {}) {
101
- return {
102
- provider,
103
- codeHost,
104
- git: fakeGit(),
105
- worktreeGit: () => fakeGit({ diffFiles: () => ["src/index.ts"] }), // a real commit landed, by default
106
- labelPrefix: "spf",
107
- chain: "plan-build-test",
108
- baseBranch: "main",
109
- concurrency: 2,
110
- // Off by default, like WatchRefineConfigSchema itself — a test that
111
- // doesn't override these never touches the refine lane at all.
112
- refineEnabled: false,
113
- refineConcurrency: 1,
114
- refineChain: "refine",
115
- runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [] }),
116
- worktreesDir: "/tmp/spf-watch-test-worktrees",
117
- linkDataDir: () => { },
118
- dryRun: false,
119
- runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
120
- log: () => { },
121
- notify: () => { },
122
- ...overrides,
123
- };
124
- }
125
- /** Collects every `deps.notify(...)` call, for asserting kinds/levels without a real channel. */
126
- function collectNotifications() {
127
- const events = [];
128
- return { events, notify: (event) => events.push(event) };
129
- }
130
- async function waitUntil(predicate, timeoutMs = 2000) {
131
- const start = Date.now();
132
- while (!predicate()) {
133
- if (Date.now() - start > timeoutMs)
134
- throw new Error("waitUntil timed out");
135
- await new Promise((r) => setTimeout(r, 5));
136
- }
137
- }
138
- test("branchNameFor: sanitizes a title into a safe branch name", () => {
139
- assert.equal(branchNameFor({ id: "42", title: "Add a /health endpoint!!", body: "", labels: [] }), "spf-watch/42-add-a-health-endpoint");
140
- assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
141
- assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
142
- });
143
- test("claimNewWork: clears a stale worktree/branch from a killed prior attempt before creating a fresh one", async () => {
144
- const provider = new FakeProvider();
145
- provider.addIssue("2", "Retry me");
146
- const codeHost = new FakeCodeHost();
147
- const state = createWatchState();
148
- const calls = [];
149
- // Simulates a `spf watch` process killed mid-run for this exact issue: a
150
- // real `git.worktreeAdd` would refuse outright with "fatal: a branch
151
- // named '...' already exists" until the leftover branch is cleared.
152
- let branchExists = true;
153
- const deps = makeDeps(provider, codeHost, {
154
- git: fakeGit({
155
- worktreeRemove: (p) => calls.push(`remove:${p}`),
156
- deleteLocalBranch: (n) => {
157
- calls.push(`delete-branch:${n}`);
158
- branchExists = false;
159
- },
160
- worktreeAdd: (p, b) => {
161
- if (branchExists)
162
- throw new Error(`fatal: a branch named '${b}' already exists`);
163
- calls.push(`add:${p}`);
164
- },
165
- }),
166
- });
167
- await claimNewWork(deps, state);
168
- await waitUntil(() => state.inflight.size === 0);
169
- const branchIdx = calls.findIndex((c) => c.startsWith("delete-branch:"));
170
- const addIdx = calls.findIndex((c) => c.startsWith("add:"));
171
- assert.ok(branchIdx !== -1 && addIdx !== -1 && branchIdx < addIdx, `expected the stale branch cleared before worktreeAdd, got: ${calls.join(", ")}`);
172
- // The retry then succeeds normally, same as any other claim.
173
- assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
174
- });
175
- test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
176
- const provider = new FakeProvider();
177
- provider.addIssue("1", "Add a /health endpoint");
178
- const codeHost = new FakeCodeHost();
179
- const state = createWatchState();
180
- const linkedWorktrees = [];
181
- const deps = makeDeps(provider, codeHost, { linkDataDir: (worktreePath) => linkedWorktrees.push(worktreePath) });
182
- await claimNewWork(deps, state);
183
- await waitUntil(() => state.inflight.size === 0);
184
- assert.deepEqual(provider.claimCalls, ["1"]);
185
- assert.equal(codeHost.openedPrs.length, 1);
186
- assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
187
- assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
188
- assert.equal(provider.entries.get("1").marker?.pr, 1000);
189
- // linkDataDir must run before the chain does — otherwise the run's own
190
- // session/trace data resolves into the worktree's throwaway .spf/data
191
- // instead of the main repo's persistent one (invisible in spf ui, and
192
- // deleted along with the worktree on cleanup).
193
- assert.deepEqual(linkedWorktrees, [path.join(deps.worktreesDir, "issue-1")]);
194
- });
195
- test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
196
- const provider = new FakeProvider();
197
- provider.addIssue("2", "Flaky feature");
198
- const codeHost = new FakeCodeHost();
199
- const state = createWatchState();
200
- const deps = makeDeps(provider, codeHost, {
201
- runChain: async () => ({ accepted: false, adwId: "issue-2", detail: "build-test failed at phase build" }),
202
- });
203
- await claimNewWork(deps, state);
204
- await waitUntil(() => state.inflight.size === 0);
205
- assert.deepEqual(provider.transitions, [{ id: "2", to: "blocked", detail: "build-test failed at phase build" }]);
206
- assert.equal(codeHost.openedPrs.length, 0);
207
- });
208
- test("claimNewWork: an accepted run with nothing committed also blocks, without opening a PR", async () => {
209
- const provider = new FakeProvider();
210
- provider.addIssue("3", "No-op request");
211
- const codeHost = new FakeCodeHost();
212
- const state = createWatchState();
213
- const deps = makeDeps(provider, codeHost, { worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
214
- await claimNewWork(deps, state);
215
- await waitUntil(() => state.inflight.size === 0);
216
- assert.equal(provider.transitions[0]?.to, "blocked");
217
- assert.match(provider.transitions[0]?.detail ?? "", /no committed changes/);
218
- assert.equal(codeHost.openedPrs.length, 0);
219
- });
220
- test("claimNewWork: never claims more than the concurrency budget in one tick", async () => {
221
- const provider = new FakeProvider();
222
- provider.addIssue("10", "one");
223
- provider.addIssue("11", "two");
224
- provider.addIssue("12", "three");
225
- const codeHost = new FakeCodeHost();
226
- const state = createWatchState();
227
- // A runChain that never resolves keeps every claimed issue "inflight" for this assertion.
228
- const deps = makeDeps(provider, codeHost, { concurrency: 2, runChain: () => new Promise(() => { }) });
229
- await claimNewWork(deps, state);
230
- assert.equal(state.inflight.size, 2);
231
- assert.equal(provider.claimCalls.length, 2);
232
- });
233
- test("claimNewWork: dry-run claims nothing and calls neither claim() nor runChain", async () => {
234
- const provider = new FakeProvider();
235
- provider.addIssue("20", "dry run me");
236
- const codeHost = new FakeCodeHost();
237
- const state = createWatchState();
238
- let runChainCalled = false;
239
- const deps = makeDeps(provider, codeHost, {
240
- dryRun: true,
241
- runChain: async () => {
242
- runChainCalled = true;
243
- return { accepted: true, adwId: "x", detail: "" };
244
- },
245
- });
246
- await claimNewWork(deps, state);
247
- assert.equal(provider.claimCalls.length, 0);
248
- assert.equal(runChainCalled, false);
249
- assert.equal(provider.entries.get("20").state, "ready");
250
- });
251
- test("reconcileOrphans: a working issue with an open PR in its marker resumes as review", async () => {
252
- const provider = new FakeProvider();
253
- provider.addIssue("30", "orphaned mid-review", "working", { pr: 500, branch: "spf-watch/30-x" });
254
- const codeHost = new FakeCodeHost();
255
- codeHost.prs.set(500, { merged: false, state: "open", ciStatus: "pending" });
256
- const state = createWatchState();
257
- await reconcileOrphans(makeDeps(provider, codeHost), state);
258
- assert.deepEqual(provider.transitions, [{ id: "30", to: "review", detail: undefined }]);
259
- });
260
- test("reconcileOrphans: a working issue with no marker retries up to the cap, then blocks", async () => {
261
- const provider = new FakeProvider();
262
- provider.addIssue("31", "orphaned, no marker", "working", null);
263
- const codeHost = new FakeCodeHost();
264
- const state = createWatchState();
265
- const deps = makeDeps(provider, codeHost);
266
- await reconcileOrphans(deps, state); // attempt 1 -> ready
267
- assert.equal(provider.entries.get("31").state, "ready");
268
- assert.equal(provider.entries.get("31").marker?.attempt, 1);
269
- provider.entries.get("31").state = "working"; // simulate it getting re-claimed and orphaned again
270
- await reconcileOrphans(deps, state); // attempt 2 -> ready
271
- assert.equal(provider.entries.get("31").state, "ready");
272
- provider.entries.get("31").state = "working";
273
- await reconcileOrphans(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
274
- assert.equal(provider.entries.get("31").state, "blocked");
275
- assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned attempts/);
276
- });
277
- test("reconcileOrphans: skips issues this process is already tracking as in-flight", async () => {
278
- const provider = new FakeProvider();
279
- provider.addIssue("32", "actually still running", "working", null);
280
- const codeHost = new FakeCodeHost();
281
- const state = createWatchState();
282
- state.inflight.add("32");
283
- await reconcileOrphans(makeDeps(provider, codeHost), state);
284
- assert.equal(provider.transitions.length, 0, "an in-flight issue must not be treated as orphaned");
285
- });
286
- test("finishReviews: a merged PR moves the issue to done", async () => {
287
- const provider = new FakeProvider();
288
- provider.addIssue("40", "shipped", "review", { pr: 600, branch: "spf-watch/40-x" });
289
- const codeHost = new FakeCodeHost();
290
- codeHost.prs.set(600, { merged: true, state: "closed", ciStatus: "success" });
291
- await finishReviews(makeDeps(provider, codeHost));
292
- assert.deepEqual(provider.transitions, [{ id: "40", to: "done", detail: undefined }]);
293
- });
294
- test("finishReviews: a closed-without-merging PR blocks the issue", async () => {
295
- const provider = new FakeProvider();
296
- provider.addIssue("41", "rejected", "review", { pr: 601, branch: "spf-watch/41-x" });
297
- const codeHost = new FakeCodeHost();
298
- codeHost.prs.set(601, { merged: false, state: "closed", ciStatus: "failure" });
299
- await finishReviews(makeDeps(provider, codeHost));
300
- assert.equal(provider.transitions[0]?.to, "blocked");
301
- assert.match(provider.transitions[0]?.detail ?? "", /closed without merging/);
302
- });
303
- test("finishReviews: a still-open PR leaves the issue in review", async () => {
304
- const provider = new FakeProvider();
305
- provider.addIssue("42", "still cooking", "review", { pr: 602, branch: "spf-watch/42-x" });
306
- const codeHost = new FakeCodeHost();
307
- codeHost.prs.set(602, { merged: false, state: "open", ciStatus: "pending" });
308
- await finishReviews(makeDeps(provider, codeHost));
309
- assert.equal(provider.transitions.length, 0);
310
- assert.equal(provider.entries.get("42").state, "review");
311
- });
312
- // ── notifications ────────────────────────────────────────────────────────────
313
- test("claim -> PR opened -> merged fires issue_claimed, pr_opened, then issue_done (all info-level)", async () => {
314
- const provider = new FakeProvider();
315
- provider.addIssue("50", "Add a /health endpoint");
316
- const codeHost = new FakeCodeHost();
317
- const state = createWatchState();
318
- const { events, notify } = collectNotifications();
319
- const deps = makeDeps(provider, codeHost, { notify });
320
- await claimNewWork(deps, state);
321
- await waitUntil(() => state.inflight.size === 0);
322
- assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened"]);
323
- assert.ok(events.every((e) => e.level === "info"));
324
- codeHost.prs.set(1000, { merged: true, state: "closed", ciStatus: "success" });
325
- await finishReviews(deps);
326
- assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened", "issue_done"]);
327
- assert.equal(events.at(-1).level, "info");
328
- });
329
- test("a rejected chain run fires issue_blocked at error level", async () => {
330
- const provider = new FakeProvider();
331
- provider.addIssue("51", "Flaky feature");
332
- const codeHost = new FakeCodeHost();
333
- const state = createWatchState();
334
- const { events, notify } = collectNotifications();
335
- const deps = makeDeps(provider, codeHost, {
336
- notify,
337
- runChain: async () => ({ accepted: false, adwId: "issue-51", detail: "build-test failed" }),
338
- });
339
- await claimNewWork(deps, state);
340
- await waitUntil(() => state.inflight.size === 0);
341
- const blocked = events.filter((e) => e.kind === "issue_blocked");
342
- assert.equal(blocked.length, 1);
343
- assert.equal(blocked[0].level, "error");
344
- assert.equal(blocked[0].detail, "build-test failed");
345
- });
346
- test("an accepted run with nothing committed fires issue_blocked", async () => {
347
- const provider = new FakeProvider();
348
- provider.addIssue("52", "No-op request");
349
- const codeHost = new FakeCodeHost();
350
- const state = createWatchState();
351
- const { events, notify } = collectNotifications();
352
- const deps = makeDeps(provider, codeHost, { notify, worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
353
- await claimNewWork(deps, state);
354
- await waitUntil(() => state.inflight.size === 0);
355
- assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "issue_blocked"]);
356
- assert.match(events[1].detail ?? "", /no committed changes/);
357
- });
358
- test("an exception mid-runIssue fires watch_error at error level", async () => {
359
- const provider = new FakeProvider();
360
- provider.addIssue("53", "Explodes");
361
- const codeHost = new FakeCodeHost();
362
- const state = createWatchState();
363
- const { events, notify } = collectNotifications();
364
- const deps = makeDeps(provider, codeHost, {
365
- notify,
366
- runChain: async () => {
367
- throw new Error("kaboom");
368
- },
369
- });
370
- await claimNewWork(deps, state);
371
- await waitUntil(() => state.inflight.size === 0);
372
- const errors = events.filter((e) => e.kind === "watch_error");
373
- assert.equal(errors.length, 1);
374
- assert.equal(errors[0].level, "error");
375
- assert.match(errors[0].detail ?? "", /kaboom/);
376
- });
377
- test("finishReviews: a closed-without-merging PR fires issue_blocked", async () => {
378
- const provider = new FakeProvider();
379
- provider.addIssue("54", "rejected", "review", { pr: 700, branch: "spf-watch/54-x" });
380
- const codeHost = new FakeCodeHost();
381
- codeHost.prs.set(700, { merged: false, state: "closed", ciStatus: "failure" });
382
- const { events, notify } = collectNotifications();
383
- await finishReviews(makeDeps(provider, codeHost, { notify }));
384
- assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
385
- assert.equal(events[0].level, "error");
386
- });
387
- test("reconcileOrphans: giving up past the retry cap fires issue_blocked", async () => {
388
- const provider = new FakeProvider();
389
- provider.addIssue("55", "orphaned, no marker", "working", { attempt: 2 }); // one more push exceeds MAX_ORPHAN_ATTEMPTS (2)
390
- const codeHost = new FakeCodeHost();
391
- const state = createWatchState();
392
- const { events, notify } = collectNotifications();
393
- await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
394
- assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
395
- assert.match(events[0].detail ?? "", /Gave up after 2 orphaned attempts/);
396
- });
397
- test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only the terminal give-up does", async () => {
398
- const provider = new FakeProvider();
399
- provider.addIssue("56", "orphaned, resumable", "working", { pr: 800, branch: "spf-watch/56-x" });
400
- const codeHost = new FakeCodeHost();
401
- codeHost.prs.set(800, { merged: false, state: "open", ciStatus: "pending" });
402
- const state = createWatchState();
403
- const { events, notify } = collectNotifications();
404
- await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
405
- assert.deepEqual(events, []);
406
- });
407
- // ── the review digest threaded into the PR + pr_opened notification ────────
408
- test("claimNewWork: a reviewSummary from runChain lands in the PR body and the pr_opened event's detail/fields", async () => {
409
- const provider = new FakeProvider();
410
- provider.addIssue("60", "Add a /health endpoint");
411
- const codeHost = new FakeCodeHost();
412
- const state = createWatchState();
413
- const { events, notify } = collectNotifications();
414
- const deps = makeDeps(provider, codeHost, {
415
- notify,
416
- runChain: async (opts) => ({
417
- accepted: true,
418
- adwId: opts.adwId,
419
- detail: "",
420
- reviewRequired: true,
421
- reviewSummary: "Reviewer verdict: changes requested.\nBlocking:\n- add a test",
422
- }),
423
- });
424
- await claimNewWork(deps, state);
425
- await waitUntil(() => state.inflight.size === 0);
426
- assert.match(codeHost.openedPrs[0].body, /Automated by `spf watch`/);
427
- assert.match(codeHost.openedPrs[0].body, /Reviewer verdict: changes requested\.\nBlocking:\n- add a test/);
428
- const prOpened = events.find((e) => e.kind === "pr_opened");
429
- assert.equal(prOpened.detail, "Reviewer verdict: changes requested.\nBlocking:\n- add a test");
430
- assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "reviewed"]);
431
- });
432
- test("claimNewWork: a reviewer-required chain with no readable verdict says so, distinctly from no reviewer at all", async () => {
433
- const provider = new FakeProvider();
434
- provider.addIssue("61", "Add a /health endpoint");
435
- const codeHost = new FakeCodeHost();
436
- const state = createWatchState();
437
- const { events, notify } = collectNotifications();
438
- const deps = makeDeps(provider, codeHost, {
439
- notify,
440
- runChain: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", reviewRequired: true }),
441
- });
442
- await claimNewWork(deps, state);
443
- await waitUntil(() => state.inflight.size === 0);
444
- assert.match(codeHost.openedPrs[0].body, /no verdict could be read back/);
445
- const prOpened = events.find((e) => e.kind === "pr_opened");
446
- assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "reviewer ran, no verdict"]);
447
- });
448
- test("claimNewWork: a chain with no reviewer step says nothing reviewed this change", async () => {
449
- const provider = new FakeProvider();
450
- provider.addIssue("62", "Add a /health endpoint");
451
- const codeHost = new FakeCodeHost();
452
- const state = createWatchState();
453
- const { events, notify } = collectNotifications();
454
- // makeDeps' default runChain returns neither reviewRequired nor reviewSummary.
455
- const deps = makeDeps(provider, codeHost, { notify });
456
- await claimNewWork(deps, state);
457
- await waitUntil(() => state.inflight.size === 0);
458
- assert.match(codeHost.openedPrs[0].body, /Nothing reviewed this change — chain `plan-build-test` has no reviewer step\./);
459
- const prOpened = events.find((e) => e.kind === "pr_opened");
460
- assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "not reviewed"]);
461
- });
462
- // ── the refine lane ──────────────────────────────────────────────────────
463
- test("refineBranchNameFor: same sanitizer as branchNameFor, different prefix", () => {
464
- assert.equal(refineBranchNameFor({ id: "9", title: "Team invitations spec", body: "", labels: [] }), "spf-refine/9-team-invitations-spec");
465
- });
466
- test("claimSpecs: a disabled refine lane never lists spec-ready issues or claims anything", async () => {
467
- const provider = new FakeProvider();
468
- provider.addIssue("100", "A spec", "spec-ready");
469
- const codeHost = new FakeCodeHost();
470
- const state = createWatchState();
471
- provider.listInState = async () => {
472
- throw new Error("listInState should never be called when refineEnabled is false");
473
- };
474
- await claimSpecs(makeDeps(provider, codeHost, { refineEnabled: false }), state);
475
- assert.equal(provider.claimCalls.length, 0);
476
- });
477
- test("claimSpecs: claims a spec-ready spec, publishes, and finishes it to done with a summary comment", async () => {
478
- const provider = new FakeProvider();
479
- provider.addIssue("101", "Team invitations", "spec-ready");
480
- const codeHost = new FakeCodeHost();
481
- const state = createWatchState();
482
- const comments = [];
483
- provider.comment = async (issue, body) => {
484
- comments.push({ id: issue.id, body });
485
- };
486
- const deps = makeDeps(provider, codeHost, {
487
- refineEnabled: true,
488
- runRefine: async (opts) => ({
489
- accepted: true,
490
- adwId: opts.adwId,
491
- detail: "",
492
- created: [
493
- { id: "200", title: "Owner invites by email", kind: "story", isLeaf: true },
494
- { id: "199", title: "Invitations feature", kind: "feature", isLeaf: false },
495
- ],
496
- }),
497
- });
498
- await claimSpecs(deps, state);
499
- await waitUntil(() => state.refining.size === 0);
500
- assert.deepEqual(provider.claimCalls, ["101"]);
501
- assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
502
- assert.equal(comments.length, 1);
503
- assert.match(comments[0].body, /#200 \(story\): Owner invites by email/);
504
- assert.deepEqual(provider.entries.get("101").marker?.refined, ["200", "199"]);
505
- });
506
- test("claimSpecs: never claims more than refineConcurrency in one tick, independent of the build lane's own budget", async () => {
507
- const provider = new FakeProvider();
508
- provider.addIssue("110", "one spec", "spec-ready");
509
- provider.addIssue("111", "two spec", "spec-ready");
510
- const codeHost = new FakeCodeHost();
511
- const state = createWatchState();
512
- const deps = makeDeps(provider, codeHost, {
513
- refineEnabled: true,
514
- refineConcurrency: 1,
515
- runRefine: () => new Promise(() => { }), // never resolves — keeps the claim "in flight" for this assertion
516
- });
517
- await claimSpecs(deps, state);
518
- assert.equal(state.refining.size, 1);
519
- assert.equal(provider.claimCalls.length, 1);
520
- });
521
- test("claimSpecs: dry-run claims no spec and never calls runRefine", async () => {
522
- const provider = new FakeProvider();
523
- provider.addIssue("120", "dry run spec", "spec-ready");
524
- const codeHost = new FakeCodeHost();
525
- const state = createWatchState();
526
- let called = false;
527
- const deps = makeDeps(provider, codeHost, {
528
- refineEnabled: true,
529
- dryRun: true,
530
- runRefine: async (opts) => {
531
- called = true;
532
- return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
533
- },
534
- });
535
- await claimSpecs(deps, state);
536
- assert.equal(provider.claimCalls.length, 0);
537
- assert.equal(called, false);
538
- assert.equal(provider.entries.get("120").state, "spec-ready");
539
- });
540
- test("claimSpecs: a rejected refine chain blocks the spec with the failure detail, without publishing anything", async () => {
541
- const provider = new FakeProvider();
542
- provider.addIssue("130", "unrefinable spec", "spec-ready");
543
- const codeHost = new FakeCodeHost();
544
- const state = createWatchState();
545
- const deps = makeDeps(provider, codeHost, {
546
- refineEnabled: true,
547
- runRefine: async (opts) => ({ accepted: false, adwId: opts.adwId, detail: "refiner failed gates", created: [] }),
548
- });
549
- await claimSpecs(deps, state);
550
- await waitUntil(() => state.refining.size === 0);
551
- assert.deepEqual(provider.transitions, [{ id: "130", to: "blocked", detail: "refiner failed gates" }]);
552
- });
553
- test("claimSpecs: a re-claimed spec whose marker already lists published issues skips runRefine entirely", async () => {
554
- const provider = new FakeProvider();
555
- provider.addIssue("140", "already published", "spec-ready", { refined: ["300", "301"] });
556
- const codeHost = new FakeCodeHost();
557
- const state = createWatchState();
558
- let called = false;
559
- const deps = makeDeps(provider, codeHost, {
560
- refineEnabled: true,
561
- runRefine: async (opts) => {
562
- called = true;
563
- return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
564
- },
565
- });
566
- await claimSpecs(deps, state);
567
- await waitUntil(() => state.refining.size === 0);
568
- assert.equal(called, false, "to-tickets itself has no idempotency guard — this is the one this lane adds");
569
- assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
570
- });
571
- test("reconcileRefining: a refining spec whose marker already lists published issues finishes it without re-running the refiner", async () => {
572
- const provider = new FakeProvider();
573
- provider.addIssue("150", "orphaned after publish", "refining", { refined: ["400"] });
574
- const codeHost = new FakeCodeHost();
575
- const state = createWatchState();
576
- await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: true }), state);
577
- assert.deepEqual(provider.transitions, [{ id: "150", to: "done", detail: undefined }]);
578
- });
579
- test("reconcileRefining: a refining spec with no marker retries up to the cap, then blocks", async () => {
580
- const provider = new FakeProvider();
581
- provider.addIssue("151", "orphaned, no marker", "refining", null);
582
- const codeHost = new FakeCodeHost();
583
- const state = createWatchState();
584
- const deps = makeDeps(provider, codeHost, { refineEnabled: true });
585
- await reconcileRefining(deps, state); // attempt 1 -> spec-ready
586
- assert.equal(provider.entries.get("151").state, "spec-ready");
587
- provider.entries.get("151").state = "refining";
588
- await reconcileRefining(deps, state); // attempt 2 -> spec-ready
589
- assert.equal(provider.entries.get("151").state, "spec-ready");
590
- provider.entries.get("151").state = "refining";
591
- await reconcileRefining(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
592
- assert.equal(provider.entries.get("151").state, "blocked");
593
- assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned refine attempts/);
594
- });
595
- test("reconcileRefining: a disabled refine lane is a complete no-op", async () => {
596
- const provider = new FakeProvider();
597
- provider.addIssue("152", "should be ignored", "refining", null);
598
- const codeHost = new FakeCodeHost();
599
- const state = createWatchState();
600
- provider.listInState = async () => {
601
- throw new Error("listInState should never be called when refineEnabled is false");
602
- };
603
- await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: false }), state);
604
- assert.equal(provider.transitions.length, 0);
605
- });
606
- test("claim -> refine -> published fires issue_claimed then spec_refined, both info-level", async () => {
607
- const provider = new FakeProvider();
608
- provider.addIssue("160", "A spec", "spec-ready");
609
- const codeHost = new FakeCodeHost();
610
- const state = createWatchState();
611
- const { events, notify } = collectNotifications();
612
- const deps = makeDeps(provider, codeHost, {
613
- refineEnabled: true,
614
- notify,
615
- runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [{ id: "500", title: "A story", kind: "story", isLeaf: true }] }),
616
- });
617
- await claimSpecs(deps, state);
618
- await waitUntil(() => state.refining.size === 0);
619
- assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "spec_refined"]);
620
- assert.ok(events.every((e) => e.level === "info"));
621
- });
622
- // ── escapeForMarkup / truncateDigest / formatReviewDigest ───────────────────
623
- // These three are what actually assembles the digest — the tests above only
624
- // exercise core/watch.ts's three-way reviewLine selection via a stubbed
625
- // runChain, so none of them ever call into these. Slack/GitHub markup
626
- // escaping is the one security-relevant behavior spec item B calls out by
627
- // name (a reviewer's findings are LLM-authored, hence untrusted), so it needs
628
- // direct coverage.
629
- test("escapeForMarkup: a Slack link-forgery attempt is neutralized, and & is escaped without double-escaping", () => {
630
- const escaped = escapeForMarkup("<https://evil.example/|click here> & enjoy");
631
- assert.equal(escaped, "&lt;https://evil.example/|click here&gt; &amp; enjoy");
632
- assert.ok(!escaped.includes("<") && !escaped.includes(">"), "no raw angle bracket must survive — that's what prevents a forged Slack mrkdwn link");
633
- });
634
- test("formatReviewDigest: an approved review's met findings are the actual signal, and are surfaced (not silently dropped)", () => {
635
- const review = reviewOutput({
636
- approved: true,
637
- findings: [
638
- { requirement: "endpoint returns 200", met: true, evidence: "curl output attached" },
639
- { requirement: "handles missing auth header", met: true, evidence: "" },
640
- { requirement: "logs the request id", met: true, evidence: "trace shows request_id field" },
641
- ],
642
- blocking: [],
643
- });
644
- const digest = formatReviewDigest(review);
645
- assert.match(digest, /^Reviewer verdict: approved\./);
646
- assert.match(digest, /Verified 3 requirement\(s\):/);
647
- assert.match(digest, /- endpoint returns 200 — curl output attached/);
648
- assert.match(digest, /- handles missing auth header/);
649
- assert.match(digest, /- logs the request id — trace shows request_id field/);
650
- });
651
- test("formatReviewDigest: blocking items and unmet findings are escaped before being joined in", () => {
652
- const review = reviewOutput({
653
- approved: false,
654
- blocking: ["contains a <https://evil.example/|forged link> & needs fixing"],
655
- findings: [{ requirement: "auth check <script>", met: false, evidence: "not implemented & untested" }],
656
- });
657
- const digest = formatReviewDigest(review);
658
- assert.match(digest, /^Reviewer verdict: changes requested\./);
659
- assert.match(digest, /- contains a &lt;https:\/\/evil\.example\/\|forged link&gt; &amp; needs fixing/);
660
- assert.match(digest, /- auth check &lt;script&gt; — not implemented &amp; untested/);
661
- assert.ok(!digest.includes("<script>") && !digest.includes("<https"), "raw markup from an unmet finding must not survive into the digest");
662
- });
663
- test("truncateDigest: a digest over the cap is cut at exactly the codepoint boundary, without splitting a surrogate pair", () => {
664
- // An astral-plane emoji (2 UTF-16 code units, 1 codepoint) placed exactly
665
- // at the 1200th codepoint — a naive `.slice(0, 1200)` on UTF-16 units would
666
- // land mid-pair and emit a lone, invalid surrogate instead of the emoji.
667
- const emoji = "\u{1F600}"; // grinning face, codepoint #1200
668
- const filler = "a".repeat(1199); // codepoints #1-1199
669
- const text = filler + emoji + "more text that must be cut off entirely";
670
- const result = truncateDigest(text);
671
- assert.equal(result, filler + emoji + "…", "must keep exactly 1200 whole codepoints (the emoji intact) then the ellipsis");
672
- assert.ok(result.endsWith("…"), "a truncated digest must end with the ellipsis marker");
673
- const short = "short digest, well under the cap";
674
- assert.equal(truncateDigest(short), short, "text under the cap must pass through unchanged, with no ellipsis appended");
675
- });
676
- test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
677
- const provider = new FakeProvider();
678
- const codeHost = new FakeCodeHost();
679
- const state = createWatchState();
680
- const { events, notify } = collectNotifications();
681
- provider.listEligible = async () => {
682
- throw new Error("listEligible exploded");
683
- };
684
- await tick(makeDeps(provider, codeHost, { notify }), state);
685
- assert.deepEqual(events.map((e) => e.kind), ["watch_error"]);
686
- assert.match(events[0].detail ?? "", /listEligible exploded/);
687
- });