@gr8ful/spf 0.2.1 → 0.4.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 (60) hide show
  1. package/README.md +106 -6
  2. package/assets/defaults/spf.config.yaml +16 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/skill/references/config.md +83 -3
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +22 -2
  8. package/dist/chains/context.d.ts +9 -0
  9. package/dist/chains/index.js +5 -0
  10. package/dist/chains/steps.d.ts +24 -0
  11. package/dist/chains/steps.js +55 -4
  12. package/dist/cli/commands/doctor.js +18 -0
  13. package/dist/cli/commands/init.js +44 -3
  14. package/dist/cli/commands/install-skill.js +5 -2
  15. package/dist/cli/commands/list.js +1 -0
  16. package/dist/cli/commands/run.js +5 -1
  17. package/dist/cli/commands/watch.js +86 -8
  18. package/dist/cli/index.js +7 -3
  19. package/dist/cli/interview.d.ts +2 -0
  20. package/dist/cli/interview.js +107 -3
  21. package/dist/core/agents.js +4 -1
  22. package/dist/core/console.d.ts +13 -1
  23. package/dist/core/console.js +51 -1
  24. package/dist/core/data_types.d.ts +133 -0
  25. package/dist/core/data_types.js +72 -0
  26. package/dist/core/gates.d.ts +13 -0
  27. package/dist/core/gates.js +103 -0
  28. package/dist/core/issues/github_provider.d.ts +35 -9
  29. package/dist/core/issues/github_provider.js +76 -28
  30. package/dist/core/issues/jira_provider.d.ts +14 -1
  31. package/dist/core/issues/jira_provider.js +9 -7
  32. package/dist/core/issues/provider.d.ts +77 -15
  33. package/dist/core/issues/provider.js +7 -4
  34. package/dist/core/notify/channel.d.ts +32 -0
  35. package/dist/core/notify/channel.js +14 -0
  36. package/dist/core/notify/notifier.d.ts +42 -0
  37. package/dist/core/notify/notifier.js +100 -0
  38. package/dist/core/notify/slack_channel.d.ts +13 -0
  39. package/dist/core/notify/slack_channel.js +30 -0
  40. package/dist/core/notify/teams_channel.d.ts +17 -0
  41. package/dist/core/notify/teams_channel.js +38 -0
  42. package/dist/core/notify/webhook_channel.d.ts +13 -0
  43. package/dist/core/notify/webhook_channel.js +19 -0
  44. package/dist/core/refine.d.ts +39 -0
  45. package/dist/core/refine.js +144 -0
  46. package/dist/core/runner.d.ts +7 -0
  47. package/dist/core/runner.js +4 -1
  48. package/dist/core/session.js +3 -0
  49. package/dist/core/watch.d.ts +66 -1
  50. package/dist/core/watch.js +267 -15
  51. package/dist/test/chains.test.js +1 -0
  52. package/dist/test/data_types.test.js +34 -1
  53. package/dist/test/init_command.test.js +17 -0
  54. package/dist/test/interview.test.js +119 -0
  55. package/dist/test/notify.test.d.ts +1 -0
  56. package/dist/test/notify.test.js +174 -0
  57. package/dist/test/refine.test.d.ts +1 -0
  58. package/dist/test/refine.test.js +126 -0
  59. package/dist/test/watch.test.js +286 -5
  60. package/package.json +1 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The notifier: the `events` filter matrix, per-channel scope override, a
3
+ * real webhook POST against a local `node:http` receiver (not a `fetch`
4
+ * mock — proves the actual bytes on the wire), a rejecting channel never
5
+ * propagating, `flush()` actually awaiting pending sends, and an unset env
6
+ * var degrading to a warning instead of a crash.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { createServer } from "node:http";
11
+ import * as v from "valibot";
12
+ import { Notifier, resolveNotifier, DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
13
+ import { SFConfigSchema } from "../core/data_types.js";
14
+ function baseConfig(overrides = {}) {
15
+ return v.parse(SFConfigSchema, { notifications: { events: "errors", channels: [], ...overrides } });
16
+ }
17
+ function infoEvent() {
18
+ return { kind: "run_started", level: "info", title: "run started", fields: [] };
19
+ }
20
+ function errorEvent() {
21
+ return { kind: "run_failed", level: "error", title: "run failed", fields: [] };
22
+ }
23
+ class RecordingChannel {
24
+ received = [];
25
+ label = "recording";
26
+ async send(event) {
27
+ this.received.push(event);
28
+ }
29
+ }
30
+ class RejectingChannel {
31
+ label = "rejecting";
32
+ async send() {
33
+ throw new Error("boom");
34
+ }
35
+ }
36
+ class BlockedChannel {
37
+ label = "blocked";
38
+ release;
39
+ gate = new Promise((resolve) => (this.release = resolve));
40
+ async send() {
41
+ await this.gate;
42
+ }
43
+ }
44
+ test("events: off never sends, regardless of level", () => {
45
+ const channel = new RecordingChannel();
46
+ const notifier = new Notifier([{ channel, scope: "off" }], 1000, false, () => { });
47
+ notifier.send(infoEvent());
48
+ notifier.send(errorEvent());
49
+ assert.deepEqual(channel.received, []);
50
+ });
51
+ test("events: errors sends only level=error", () => {
52
+ const channel = new RecordingChannel();
53
+ const notifier = new Notifier([{ channel, scope: "errors" }], 1000, false, () => { });
54
+ notifier.send(infoEvent());
55
+ notifier.send(errorEvent());
56
+ assert.equal(channel.received.length, 1);
57
+ assert.equal(channel.received[0].level, "error");
58
+ });
59
+ test("events: all sends both info and error", () => {
60
+ const channel = new RecordingChannel();
61
+ const notifier = new Notifier([{ channel, scope: "all" }], 1000, false, () => { });
62
+ notifier.send(infoEvent());
63
+ notifier.send(errorEvent());
64
+ assert.equal(channel.received.length, 2);
65
+ });
66
+ test("a per-channel scope overrides the top-level scope", () => {
67
+ const loud = new RecordingChannel();
68
+ const quiet = new RecordingChannel();
69
+ const notifier = new Notifier([
70
+ { channel: loud, scope: "all" },
71
+ { channel: quiet, scope: "errors" },
72
+ ], 1000, false, () => { });
73
+ notifier.send(infoEvent());
74
+ assert.equal(loud.received.length, 1);
75
+ assert.equal(quiet.received.length, 0);
76
+ });
77
+ test("dry-run logs and sends nothing to any channel", () => {
78
+ const channel = new RecordingChannel();
79
+ const logs = [];
80
+ const notifier = new Notifier([{ channel, scope: "all" }], 1000, true, (m) => logs.push(m));
81
+ notifier.send(infoEvent());
82
+ assert.deepEqual(channel.received, []);
83
+ assert.equal(logs.length, 1);
84
+ assert.match(logs[0], /would notify/);
85
+ });
86
+ test("a rejecting channel logs one line and never throws or blocks other channels", async () => {
87
+ const rejecting = new RejectingChannel();
88
+ const ok = new RecordingChannel();
89
+ const logs = [];
90
+ const notifier = new Notifier([
91
+ { channel: rejecting, scope: "all" },
92
+ { channel: ok, scope: "all" },
93
+ ], 1000, false, (m) => logs.push(m));
94
+ assert.doesNotThrow(() => notifier.send(infoEvent()));
95
+ await notifier.flush();
96
+ assert.equal(ok.received.length, 1);
97
+ assert.equal(logs.length, 1);
98
+ assert.match(logs[0], /rejecting notification failed: boom/);
99
+ });
100
+ test("flush() actually awaits pending sends before returning", async () => {
101
+ const blocked = new BlockedChannel();
102
+ const notifier = new Notifier([{ channel: blocked, scope: "all" }], 1000, false, () => { });
103
+ notifier.send(infoEvent());
104
+ let flushed = false;
105
+ const flushPromise = notifier.flush().then(() => {
106
+ flushed = true;
107
+ });
108
+ await new Promise((r) => setTimeout(r, 20));
109
+ assert.equal(flushed, false, "flush must not resolve before the pending send does");
110
+ blocked.release();
111
+ await flushPromise;
112
+ assert.equal(flushed, true);
113
+ });
114
+ test("resolveNotifier returns null when events is off", () => {
115
+ const cfg = baseConfig({ events: "off", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK" }] });
116
+ assert.equal(resolveNotifier(cfg), null);
117
+ });
118
+ test("resolveNotifier returns null when no channels are configured", () => {
119
+ const cfg = baseConfig({ events: "all", channels: [] });
120
+ assert.equal(resolveNotifier(cfg), null);
121
+ });
122
+ test("resolveNotifier skips a channel whose env var is unset, with one warning naming the key — never throws", () => {
123
+ delete process.env["SPF_TEST_WEBHOOK_UNSET"];
124
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: "SPF_TEST_WEBHOOK_UNSET" }] });
125
+ const logs = [];
126
+ const notifier = resolveNotifier(cfg, { log: (m) => logs.push(m) });
127
+ assert.equal(notifier, null);
128
+ assert.equal(logs.length, 1);
129
+ assert.match(logs[0], /SPF_TEST_WEBHOOK_UNSET is not set/);
130
+ });
131
+ test("resolveNotifier falls back to the kind's default env key when webhook_url_env is empty", () => {
132
+ process.env["SLACK_WEBHOOK_URL"] = "http://127.0.0.1:1/unused";
133
+ try {
134
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "slack" }] });
135
+ const notifier = resolveNotifier(cfg);
136
+ assert.notEqual(notifier, null);
137
+ assert.equal(DEFAULT_NOTIFY_ENV_KEY["slack"], "SLACK_WEBHOOK_URL");
138
+ }
139
+ finally {
140
+ delete process.env["SLACK_WEBHOOK_URL"];
141
+ }
142
+ });
143
+ test("a webhook channel posts the real NotifyEvent JSON over the wire to a local receiver", async () => {
144
+ let receivedBody = "";
145
+ let receivedContentType = "";
146
+ const server = createServer((req, res) => {
147
+ receivedContentType = req.headers["content-type"] ?? "";
148
+ let body = "";
149
+ req.on("data", (chunk) => (body += chunk));
150
+ req.on("end", () => {
151
+ receivedBody = body;
152
+ res.writeHead(200);
153
+ res.end("ok");
154
+ });
155
+ });
156
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
157
+ const port = server.address().port;
158
+ const envKey = "SPF_TEST_WEBHOOK_LIVE";
159
+ process.env[envKey] = `http://127.0.0.1:${port}/hook`;
160
+ try {
161
+ const cfg = baseConfig({ events: "all", channels: [{ kind: "webhook", webhook_url_env: envKey }] });
162
+ const notifier = resolveNotifier(cfg);
163
+ assert.notEqual(notifier, null);
164
+ const event = { kind: "issue_claimed", level: "info", title: "issue 42 claimed", fields: [["issue", "42"]] };
165
+ notifier.send(event);
166
+ await notifier.flush();
167
+ assert.equal(receivedContentType, "application/json");
168
+ assert.deepEqual(JSON.parse(receivedBody), event);
169
+ }
170
+ finally {
171
+ delete process.env[envKey];
172
+ await new Promise((resolve) => server.close(() => resolve()));
173
+ }
174
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { publish } from "../core/refine.js";
4
+ import { refinementWellFormed } from "../core/gates.js";
5
+ /** In-memory fake — exactly the seam `IssueAuthoringProvider` exists for. */
6
+ class FakeTracker {
7
+ created = [];
8
+ links = [];
9
+ nextId = 1;
10
+ async createIssue(input) {
11
+ this.created.push(input);
12
+ const id = String(this.nextId++);
13
+ return { id, internal_id: `db-${id}`, title: input.title, body: input.body, labels: input.labels };
14
+ }
15
+ async linkChild(parent, child) {
16
+ this.links.push({ parent: parent.id, child: child.id });
17
+ }
18
+ }
19
+ function node(overrides) {
20
+ return { body: "", parent: "", blocked_by: [], ...overrides };
21
+ }
22
+ // ── publish() ────────────────────────────────────────────────────────────
23
+ test("publish: creates a container before its children, and links them via linkChild", async () => {
24
+ const tracker = new FakeTracker();
25
+ const issues = [
26
+ node({ key: "S1", kind: "story", title: "Owner invites by email", parent: "F1" }),
27
+ node({ key: "F1", kind: "feature", title: "Team invitations" }),
28
+ ];
29
+ const created = await publish(tracker, issues, { labelPrefix: "spf" });
30
+ assert.deepEqual(tracker.created.map((c) => c.title), ["Team invitations", "Owner invites by email"], "the parent must be created before its child");
31
+ assert.deepEqual(tracker.links, [{ parent: "1", child: "2" }]);
32
+ assert.deepEqual(created.map((c) => ({ key: c.key, isLeaf: c.isLeaf })), [
33
+ { key: "F1", isLeaf: false },
34
+ { key: "S1", isLeaf: true },
35
+ ]);
36
+ });
37
+ test("publish: labels a leaf with its type AND spf:refined; a container gets only its type label", async () => {
38
+ const tracker = new FakeTracker();
39
+ const issues = [node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })];
40
+ await publish(tracker, issues, { labelPrefix: "spf" });
41
+ const feature = tracker.created.find((c) => c.title === "A feature");
42
+ const leaf = tracker.created.find((c) => c.title === "A leaf");
43
+ assert.deepEqual(feature.labels, ["spf:type:feature"]);
44
+ assert.deepEqual(leaf.labels, ["spf:type:story", "spf:refined"]);
45
+ });
46
+ test("publish: creates a blocker before what it blocks, and renders a real #n reference in the body", async () => {
47
+ const tracker = new FakeTracker();
48
+ const issues = [
49
+ node({ key: "S2", kind: "bug", title: "Fix the expiry check", blocked_by: ["S1"] }),
50
+ node({ key: "S1", kind: "story", title: "Owner invites by email" }),
51
+ ];
52
+ const created = await publish(tracker, issues, { labelPrefix: "spf" });
53
+ const s1Id = created.find((c) => c.key === "S1").issue.id;
54
+ const s2 = tracker.created.find((c) => c.title === "Fix the expiry check");
55
+ assert.equal(created[0].key, "S1", "the blocker publishes first");
56
+ assert.match(s2.body, new RegExp(`## Blocked by\\n\\n- #${s1Id}`));
57
+ });
58
+ test("publish: a leaf with no blockers gets the 'None (can start immediately)' text", async () => {
59
+ const tracker = new FakeTracker();
60
+ const created = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
61
+ assert.match(created[0].issue.body, /## Blocked by\n\nNone \(can start immediately\)\./);
62
+ });
63
+ test("publish: renders '## Parent: #<id>' when a spec issue id is given, and omits it otherwise", async () => {
64
+ const tracker = new FakeTracker();
65
+ const withSpec = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf", specIssueId: "42" });
66
+ assert.match(withSpec[0].issue.body, /## Parent\n\nDecomposed from #42\./);
67
+ const tracker2 = new FakeTracker();
68
+ const withoutSpec = await publish(tracker2, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
69
+ assert.doesNotMatch(withoutSpec[0].issue.body, /## Parent/);
70
+ });
71
+ // ── gates.refinementWellFormed ───────────────────────────────────────────
72
+ function envelope(issues) {
73
+ return { status: "success", summary: "", artifacts: [], notes_for_next_agent: "", issues };
74
+ }
75
+ test("refinementWellFormed: an empty issues list fails", () => {
76
+ const report = refinementWellFormed(envelope([]), { repo_root: "/repo" });
77
+ assert.equal(report.passed, false);
78
+ });
79
+ test("refinementWellFormed: a well-formed feature/story tree with no blockers passes clean", () => {
80
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
81
+ assert.equal(report.passed, true);
82
+ });
83
+ test("refinementWellFormed: a container mislabeled as a leaf kind fails", () => {
84
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "story", title: "Should be a feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
85
+ assert.equal(report.passed, false);
86
+ assert.ok(report.violations.some((v) => v.includes("F1.kind")));
87
+ });
88
+ test("refinementWellFormed: a two-node parent cycle has no leaves AND fails the dependency-graph check", () => {
89
+ // A finite, ACYCLIC parent forest always has at least one leaf by
90
+ // construction (some node's children set is empty) — the only way to
91
+ // drive leafCount to zero is a cycle, which the dependency-graph check
92
+ // already independently rejects. This test exercises that overlap
93
+ // directly rather than asserting an "all containers, no leaves" shape
94
+ // that isn't otherwise reachable.
95
+ const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A", parent: "F2" }), node({ key: "F2", kind: "feature", title: "B", parent: "F1" })]), { repo_root: "/repo" });
96
+ assert.equal(report.passed, false);
97
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
98
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("leaves")));
99
+ });
100
+ test("refinementWellFormed: an unresolved parent key fails", () => {
101
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "Orphaned", parent: "nonexistent" })]), {
102
+ repo_root: "/repo",
103
+ });
104
+ assert.equal(report.passed, false);
105
+ assert.ok(report.violations.some((v) => v.includes("parent")));
106
+ });
107
+ test("refinementWellFormed: an unresolved blocked_by key fails", () => {
108
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A leaf", blocked_by: ["nonexistent"] })]), {
109
+ repo_root: "/repo",
110
+ });
111
+ assert.equal(report.passed, false);
112
+ assert.ok(report.violations.some((v) => v.includes("blocked_by")));
113
+ });
114
+ test("refinementWellFormed: a blocked_by cycle fails", () => {
115
+ const report = refinementWellFormed(envelope([
116
+ node({ key: "S1", kind: "story", title: "A", blocked_by: ["S2"] }),
117
+ node({ key: "S2", kind: "story", title: "B", blocked_by: ["S1"] }),
118
+ ]), { repo_root: "/repo" });
119
+ assert.equal(report.passed, false);
120
+ assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
121
+ });
122
+ test("refinementWellFormed: duplicate keys fail", () => {
123
+ const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A" }), node({ key: "S1", kind: "bug", title: "B" })]), { repo_root: "/repo" });
124
+ assert.equal(report.passed, false);
125
+ assert.ok(report.violations.some((v) => v.includes("duplicate")));
126
+ });
@@ -1,7 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
- import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
4
+ import { branchNameFor, claimNewWork, claimSpecs, createWatchState, finishReviews, reconcileOrphans, reconcileRefining, refineBranchNameFor, tick, } from "../core/watch.js";
5
5
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
6
6
  class FakeProvider {
7
7
  entries = new Map();
@@ -21,19 +21,21 @@ class FakeProvider {
21
21
  async listInState(state) {
22
22
  return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
23
23
  }
24
- async claim(issue) {
24
+ async claim(issue, opts) {
25
25
  this.claimCalls.push(issue.id);
26
26
  const entry = this.entries.get(issue.id);
27
- if (entry.state !== "ready")
27
+ const from = opts?.from ?? "ready";
28
+ const to = opts?.to ?? "working";
29
+ if (entry.state !== from)
28
30
  return false;
29
- entry.state = "working";
31
+ entry.state = to;
30
32
  return true;
31
33
  }
32
34
  async transition(issue, to, detail) {
33
35
  this.entries.get(issue.id).state = to;
34
36
  this.transitions.push({ id: issue.id, to, detail });
35
37
  }
36
- async comment() { }
38
+ async comment(_issue, _body) { }
37
39
  async readMarker(issue) {
38
40
  return this.entries.get(issue.id)?.marker ?? null;
39
41
  }
@@ -91,14 +93,26 @@ function makeDeps(provider, codeHost, overrides = {}) {
91
93
  chain: "plan-build-test",
92
94
  baseBranch: "main",
93
95
  concurrency: 2,
96
+ // Off by default, like WatchRefineConfigSchema itself — a test that
97
+ // doesn't override these never touches the refine lane at all.
98
+ refineEnabled: false,
99
+ refineConcurrency: 1,
100
+ refineChain: "refine",
101
+ runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [] }),
94
102
  worktreesDir: "/tmp/spf-watch-test-worktrees",
95
103
  linkDataDir: () => { },
96
104
  dryRun: false,
97
105
  runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
98
106
  log: () => { },
107
+ notify: () => { },
99
108
  ...overrides,
100
109
  };
101
110
  }
111
+ /** Collects every `deps.notify(...)` call, for asserting kinds/levels without a real channel. */
112
+ function collectNotifications() {
113
+ const events = [];
114
+ return { events, notify: (event) => events.push(event) };
115
+ }
102
116
  async function waitUntil(predicate, timeoutMs = 2000) {
103
117
  const start = Date.now();
104
118
  while (!predicate()) {
@@ -281,3 +295,270 @@ test("finishReviews: a still-open PR leaves the issue in review", async () => {
281
295
  assert.equal(provider.transitions.length, 0);
282
296
  assert.equal(provider.entries.get("42").state, "review");
283
297
  });
298
+ // ── notifications ────────────────────────────────────────────────────────────
299
+ test("claim -> PR opened -> merged fires issue_claimed, pr_opened, then issue_done (all info-level)", async () => {
300
+ const provider = new FakeProvider();
301
+ provider.addIssue("50", "Add a /health endpoint");
302
+ const codeHost = new FakeCodeHost();
303
+ const state = createWatchState();
304
+ const { events, notify } = collectNotifications();
305
+ const deps = makeDeps(provider, codeHost, { notify });
306
+ await claimNewWork(deps, state);
307
+ await waitUntil(() => state.inflight.size === 0);
308
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened"]);
309
+ assert.ok(events.every((e) => e.level === "info"));
310
+ codeHost.prs.set(1000, { merged: true, state: "closed", ciStatus: "success" });
311
+ await finishReviews(deps);
312
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "pr_opened", "issue_done"]);
313
+ assert.equal(events.at(-1).level, "info");
314
+ });
315
+ test("a rejected chain run fires issue_blocked at error level", async () => {
316
+ const provider = new FakeProvider();
317
+ provider.addIssue("51", "Flaky feature");
318
+ const codeHost = new FakeCodeHost();
319
+ const state = createWatchState();
320
+ const { events, notify } = collectNotifications();
321
+ const deps = makeDeps(provider, codeHost, {
322
+ notify,
323
+ runChain: async () => ({ accepted: false, adwId: "issue-51", detail: "build-test failed" }),
324
+ });
325
+ await claimNewWork(deps, state);
326
+ await waitUntil(() => state.inflight.size === 0);
327
+ const blocked = events.filter((e) => e.kind === "issue_blocked");
328
+ assert.equal(blocked.length, 1);
329
+ assert.equal(blocked[0].level, "error");
330
+ assert.equal(blocked[0].detail, "build-test failed");
331
+ });
332
+ test("an accepted run with nothing committed fires issue_blocked", async () => {
333
+ const provider = new FakeProvider();
334
+ provider.addIssue("52", "No-op request");
335
+ const codeHost = new FakeCodeHost();
336
+ const state = createWatchState();
337
+ const { events, notify } = collectNotifications();
338
+ const deps = makeDeps(provider, codeHost, { notify, worktreeGit: () => fakeGit({ diffFiles: () => [] }) });
339
+ await claimNewWork(deps, state);
340
+ await waitUntil(() => state.inflight.size === 0);
341
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "issue_blocked"]);
342
+ assert.match(events[1].detail ?? "", /no committed changes/);
343
+ });
344
+ test("an exception mid-runIssue fires watch_error at error level", async () => {
345
+ const provider = new FakeProvider();
346
+ provider.addIssue("53", "Explodes");
347
+ const codeHost = new FakeCodeHost();
348
+ const state = createWatchState();
349
+ const { events, notify } = collectNotifications();
350
+ const deps = makeDeps(provider, codeHost, {
351
+ notify,
352
+ runChain: async () => {
353
+ throw new Error("kaboom");
354
+ },
355
+ });
356
+ await claimNewWork(deps, state);
357
+ await waitUntil(() => state.inflight.size === 0);
358
+ const errors = events.filter((e) => e.kind === "watch_error");
359
+ assert.equal(errors.length, 1);
360
+ assert.equal(errors[0].level, "error");
361
+ assert.match(errors[0].detail ?? "", /kaboom/);
362
+ });
363
+ test("finishReviews: a closed-without-merging PR fires issue_blocked", async () => {
364
+ const provider = new FakeProvider();
365
+ provider.addIssue("54", "rejected", "review", { pr: 700, branch: "spf-watch/54-x" });
366
+ const codeHost = new FakeCodeHost();
367
+ codeHost.prs.set(700, { merged: false, state: "closed", ciStatus: "failure" });
368
+ const { events, notify } = collectNotifications();
369
+ await finishReviews(makeDeps(provider, codeHost, { notify }));
370
+ assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
371
+ assert.equal(events[0].level, "error");
372
+ });
373
+ test("reconcileOrphans: giving up past the retry cap fires issue_blocked", async () => {
374
+ const provider = new FakeProvider();
375
+ provider.addIssue("55", "orphaned, no marker", "working", { attempt: 2 }); // one more push exceeds MAX_ORPHAN_ATTEMPTS (2)
376
+ const codeHost = new FakeCodeHost();
377
+ const state = createWatchState();
378
+ const { events, notify } = collectNotifications();
379
+ await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
380
+ assert.deepEqual(events.map((e) => e.kind), ["issue_blocked"]);
381
+ assert.match(events[0].detail ?? "", /Gave up after 2 orphaned attempts/);
382
+ });
383
+ test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only the terminal give-up does", async () => {
384
+ const provider = new FakeProvider();
385
+ provider.addIssue("56", "orphaned, resumable", "working", { pr: 800, branch: "spf-watch/56-x" });
386
+ const codeHost = new FakeCodeHost();
387
+ codeHost.prs.set(800, { merged: false, state: "open", ciStatus: "pending" });
388
+ const state = createWatchState();
389
+ const { events, notify } = collectNotifications();
390
+ await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
391
+ assert.deepEqual(events, []);
392
+ });
393
+ // ── the refine lane ──────────────────────────────────────────────────────
394
+ test("refineBranchNameFor: same sanitizer as branchNameFor, different prefix", () => {
395
+ assert.equal(refineBranchNameFor({ id: "9", title: "Team invitations spec", body: "", labels: [] }), "spf-refine/9-team-invitations-spec");
396
+ });
397
+ test("claimSpecs: a disabled refine lane never lists spec-ready issues or claims anything", async () => {
398
+ const provider = new FakeProvider();
399
+ provider.addIssue("100", "A spec", "spec-ready");
400
+ const codeHost = new FakeCodeHost();
401
+ const state = createWatchState();
402
+ provider.listInState = async () => {
403
+ throw new Error("listInState should never be called when refineEnabled is false");
404
+ };
405
+ await claimSpecs(makeDeps(provider, codeHost, { refineEnabled: false }), state);
406
+ assert.equal(provider.claimCalls.length, 0);
407
+ });
408
+ test("claimSpecs: claims a spec-ready spec, publishes, and finishes it to done with a summary comment", async () => {
409
+ const provider = new FakeProvider();
410
+ provider.addIssue("101", "Team invitations", "spec-ready");
411
+ const codeHost = new FakeCodeHost();
412
+ const state = createWatchState();
413
+ const comments = [];
414
+ provider.comment = async (issue, body) => {
415
+ comments.push({ id: issue.id, body });
416
+ };
417
+ const deps = makeDeps(provider, codeHost, {
418
+ refineEnabled: true,
419
+ runRefine: async (opts) => ({
420
+ accepted: true,
421
+ adwId: opts.adwId,
422
+ detail: "",
423
+ created: [
424
+ { id: "200", title: "Owner invites by email", kind: "story", isLeaf: true },
425
+ { id: "199", title: "Invitations feature", kind: "feature", isLeaf: false },
426
+ ],
427
+ }),
428
+ });
429
+ await claimSpecs(deps, state);
430
+ await waitUntil(() => state.refining.size === 0);
431
+ assert.deepEqual(provider.claimCalls, ["101"]);
432
+ assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
433
+ assert.equal(comments.length, 1);
434
+ assert.match(comments[0].body, /#200 \(story\): Owner invites by email/);
435
+ assert.deepEqual(provider.entries.get("101").marker?.refined, ["200", "199"]);
436
+ });
437
+ test("claimSpecs: never claims more than refineConcurrency in one tick, independent of the build lane's own budget", async () => {
438
+ const provider = new FakeProvider();
439
+ provider.addIssue("110", "one spec", "spec-ready");
440
+ provider.addIssue("111", "two spec", "spec-ready");
441
+ const codeHost = new FakeCodeHost();
442
+ const state = createWatchState();
443
+ const deps = makeDeps(provider, codeHost, {
444
+ refineEnabled: true,
445
+ refineConcurrency: 1,
446
+ runRefine: () => new Promise(() => { }), // never resolves — keeps the claim "in flight" for this assertion
447
+ });
448
+ await claimSpecs(deps, state);
449
+ assert.equal(state.refining.size, 1);
450
+ assert.equal(provider.claimCalls.length, 1);
451
+ });
452
+ test("claimSpecs: dry-run claims no spec and never calls runRefine", async () => {
453
+ const provider = new FakeProvider();
454
+ provider.addIssue("120", "dry run spec", "spec-ready");
455
+ const codeHost = new FakeCodeHost();
456
+ const state = createWatchState();
457
+ let called = false;
458
+ const deps = makeDeps(provider, codeHost, {
459
+ refineEnabled: true,
460
+ dryRun: true,
461
+ runRefine: async (opts) => {
462
+ called = true;
463
+ return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
464
+ },
465
+ });
466
+ await claimSpecs(deps, state);
467
+ assert.equal(provider.claimCalls.length, 0);
468
+ assert.equal(called, false);
469
+ assert.equal(provider.entries.get("120").state, "spec-ready");
470
+ });
471
+ test("claimSpecs: a rejected refine chain blocks the spec with the failure detail, without publishing anything", async () => {
472
+ const provider = new FakeProvider();
473
+ provider.addIssue("130", "unrefinable spec", "spec-ready");
474
+ const codeHost = new FakeCodeHost();
475
+ const state = createWatchState();
476
+ const deps = makeDeps(provider, codeHost, {
477
+ refineEnabled: true,
478
+ runRefine: async (opts) => ({ accepted: false, adwId: opts.adwId, detail: "refiner failed gates", created: [] }),
479
+ });
480
+ await claimSpecs(deps, state);
481
+ await waitUntil(() => state.refining.size === 0);
482
+ assert.deepEqual(provider.transitions, [{ id: "130", to: "blocked", detail: "refiner failed gates" }]);
483
+ });
484
+ test("claimSpecs: a re-claimed spec whose marker already lists published issues skips runRefine entirely", async () => {
485
+ const provider = new FakeProvider();
486
+ provider.addIssue("140", "already published", "spec-ready", { refined: ["300", "301"] });
487
+ const codeHost = new FakeCodeHost();
488
+ const state = createWatchState();
489
+ let called = false;
490
+ const deps = makeDeps(provider, codeHost, {
491
+ refineEnabled: true,
492
+ runRefine: async (opts) => {
493
+ called = true;
494
+ return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
495
+ },
496
+ });
497
+ await claimSpecs(deps, state);
498
+ await waitUntil(() => state.refining.size === 0);
499
+ assert.equal(called, false, "to-tickets itself has no idempotency guard — this is the one this lane adds");
500
+ assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
501
+ });
502
+ test("reconcileRefining: a refining spec whose marker already lists published issues finishes it without re-running the refiner", async () => {
503
+ const provider = new FakeProvider();
504
+ provider.addIssue("150", "orphaned after publish", "refining", { refined: ["400"] });
505
+ const codeHost = new FakeCodeHost();
506
+ const state = createWatchState();
507
+ await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: true }), state);
508
+ assert.deepEqual(provider.transitions, [{ id: "150", to: "done", detail: undefined }]);
509
+ });
510
+ test("reconcileRefining: a refining spec with no marker retries up to the cap, then blocks", async () => {
511
+ const provider = new FakeProvider();
512
+ provider.addIssue("151", "orphaned, no marker", "refining", null);
513
+ const codeHost = new FakeCodeHost();
514
+ const state = createWatchState();
515
+ const deps = makeDeps(provider, codeHost, { refineEnabled: true });
516
+ await reconcileRefining(deps, state); // attempt 1 -> spec-ready
517
+ assert.equal(provider.entries.get("151").state, "spec-ready");
518
+ provider.entries.get("151").state = "refining";
519
+ await reconcileRefining(deps, state); // attempt 2 -> spec-ready
520
+ assert.equal(provider.entries.get("151").state, "spec-ready");
521
+ provider.entries.get("151").state = "refining";
522
+ await reconcileRefining(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
523
+ assert.equal(provider.entries.get("151").state, "blocked");
524
+ assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned refine attempts/);
525
+ });
526
+ test("reconcileRefining: a disabled refine lane is a complete no-op", async () => {
527
+ const provider = new FakeProvider();
528
+ provider.addIssue("152", "should be ignored", "refining", null);
529
+ const codeHost = new FakeCodeHost();
530
+ const state = createWatchState();
531
+ provider.listInState = async () => {
532
+ throw new Error("listInState should never be called when refineEnabled is false");
533
+ };
534
+ await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: false }), state);
535
+ assert.equal(provider.transitions.length, 0);
536
+ });
537
+ test("claim -> refine -> published fires issue_claimed then spec_refined, both info-level", async () => {
538
+ const provider = new FakeProvider();
539
+ provider.addIssue("160", "A spec", "spec-ready");
540
+ const codeHost = new FakeCodeHost();
541
+ const state = createWatchState();
542
+ const { events, notify } = collectNotifications();
543
+ const deps = makeDeps(provider, codeHost, {
544
+ refineEnabled: true,
545
+ notify,
546
+ runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [{ id: "500", title: "A story", kind: "story", isLeaf: true }] }),
547
+ });
548
+ await claimSpecs(deps, state);
549
+ await waitUntil(() => state.refining.size === 0);
550
+ assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "spec_refined"]);
551
+ assert.ok(events.every((e) => e.level === "info"));
552
+ });
553
+ test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
554
+ const provider = new FakeProvider();
555
+ const codeHost = new FakeCodeHost();
556
+ const state = createWatchState();
557
+ const { events, notify } = collectNotifications();
558
+ provider.listEligible = async () => {
559
+ throw new Error("listEligible exploded");
560
+ };
561
+ await tick(makeDeps(provider, codeHost, { notify }), state);
562
+ assert.deepEqual(events.map((e) => e.kind), ["watch_error"]);
563
+ assert.match(events[0].detail ?? "", /listEligible exploded/);
564
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",