@gr8ful/spf 0.4.0 → 0.5.1

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 (80) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +158 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/paths.d.ts +3 -0
  48. package/dist/core/paths.js +48 -1
  49. package/dist/core/providers.js +4 -0
  50. package/dist/core/refine.js +11 -3
  51. package/dist/core/session.js +39 -2
  52. package/dist/core/tracer.d.ts +31 -2
  53. package/dist/core/tracer.js +69 -11
  54. package/dist/core/watch.d.ts +11 -0
  55. package/dist/core/watch.js +17 -2
  56. package/dist/test/chains.test.js +8 -3
  57. package/dist/test/data_types.test.js +140 -2
  58. package/dist/test/git_helper.test.d.ts +1 -0
  59. package/dist/test/git_helper.test.js +59 -0
  60. package/dist/test/hermetic_git.d.ts +1 -0
  61. package/dist/test/hermetic_git.js +22 -0
  62. package/dist/test/init_command.test.d.ts +14 -1
  63. package/dist/test/init_command.test.js +54 -1
  64. package/dist/test/interview.test.d.ts +15 -1
  65. package/dist/test/interview.test.js +127 -0
  66. package/dist/test/ollama_provider.test.d.ts +1 -0
  67. package/dist/test/ollama_provider.test.js +103 -0
  68. package/dist/test/otel.test.d.ts +26 -0
  69. package/dist/test/otel.test.js +512 -0
  70. package/dist/test/paths.test.d.ts +1 -0
  71. package/dist/test/paths.test.js +68 -0
  72. package/dist/test/refine.test.js +64 -1
  73. package/dist/test/repo_chains.test.d.ts +21 -0
  74. package/dist/test/repo_chains.test.js +416 -0
  75. package/dist/test/signoff.test.d.ts +1 -0
  76. package/dist/test/signoff.test.js +329 -0
  77. package/dist/test/ui_server.test.d.ts +7 -1
  78. package/dist/test/ui_server.test.js +1 -0
  79. package/dist/test/watch.test.js +124 -1
  80. package/package.json +5 -5
@@ -0,0 +1,329 @@
1
+ import "./hermetic_git.js";
2
+ /**
3
+ * Review-accountability thread: an AI reviewer's `approved` flag is a
4
+ * PROPOSAL; `decideSignoff` (chains/simple_sdlc.ts) is where a human DISPOSES
5
+ * of it. These tests drive that function directly — running the whole
6
+ * imperative `simple-sdlc` chain (real agents, a real config, a real
7
+ * session) is far too heavy just to exercise one decision — plus the
8
+ * lighter-weight pieces it depends on: `commitEnvelope`'s trailer handling
9
+ * (chains/steps.ts) and `committerIdentity` (core/git_helper.ts).
10
+ */
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { AI_ONLY_SIGNOFF_WARNING, decideSignoff, trailerFor } from "../chains/simple_sdlc.js";
18
+ import { commitEnvelope } from "../chains/steps.js";
19
+ import { committerIdentity } from "../core/git_helper.js";
20
+ /** A minimal, schema-shaped ReviewOutputT — only the fields decideSignoff reads. */
21
+ function reviewOutput(overrides = {}) {
22
+ return {
23
+ status: "success",
24
+ summary: "",
25
+ artifacts: [],
26
+ notes_for_next_agent: "",
27
+ approved: false,
28
+ findings: [],
29
+ blocking: [],
30
+ ...overrides,
31
+ };
32
+ }
33
+ /** Records every `confirm()` call — label, the default it was offered, and any timeout — then answers `answer` every time. */
34
+ function scriptedAsker(answer) {
35
+ const calls = [];
36
+ const asker = {
37
+ async text(_label, opts) {
38
+ return opts?.default ?? "";
39
+ },
40
+ async select(_label, _choices, dflt) {
41
+ return dflt;
42
+ },
43
+ async confirm(label, dflt, opts) {
44
+ calls.push({ label, dflt, timeoutMs: opts?.timeoutMs });
45
+ return answer;
46
+ },
47
+ async secret() {
48
+ return "";
49
+ },
50
+ note() { },
51
+ heading() { },
52
+ close() { },
53
+ };
54
+ return { asker, calls };
55
+ }
56
+ // ── decideSignoff ────────────────────────────────────────────────────────
57
+ test("decideSignoff: human yes -> accepted, recordedYes true, decision logged against the findings", async () => {
58
+ const { asker, calls } = scriptedAsker(true);
59
+ const logs = [];
60
+ const outcome = await decideSignoff({
61
+ review: reviewOutput({ approved: true, blocking: [], findings: [{ requirement: "does X", met: true, evidence: "src/x.ts" }] }),
62
+ canPrompt: true,
63
+ requireHumanSignoff: false,
64
+ signoffTimeoutSeconds: 300,
65
+ asker,
66
+ identity: { name: "Ada Lovelace", email: "ada@example.com" },
67
+ log: (p) => logs.push(p),
68
+ warn: () => { },
69
+ });
70
+ assert.equal(outcome.accepted, true);
71
+ assert.equal(outcome.recordedYes, true);
72
+ assert.equal(calls.length, 1);
73
+ assert.equal(calls[0].dflt, false, "the prompt's own default must be false, never review.approved (no auto-approval bias)");
74
+ assert.equal(calls[0].timeoutMs, 300_000, "signoffTimeoutSeconds must reach confirm() as milliseconds");
75
+ assert.ok(logs.some((l) => l.decision === "approved" && l.human === true && Array.isArray(l.findings)), "the decision, engineer identity, and findings basis must land in the trace via ph.log");
76
+ });
77
+ test("decideSignoff: human no -> not accepted, never a recorded yes", async () => {
78
+ const { asker } = scriptedAsker(false);
79
+ const logs = [];
80
+ const outcome = await decideSignoff({
81
+ review: reviewOutput({ approved: true, blocking: ["still missing a test"] }),
82
+ canPrompt: true,
83
+ requireHumanSignoff: false,
84
+ signoffTimeoutSeconds: 300,
85
+ asker,
86
+ identity: { name: "Ada Lovelace", email: "ada@example.com" },
87
+ log: (p) => logs.push(p),
88
+ warn: () => { },
89
+ });
90
+ assert.equal(outcome.accepted, false);
91
+ assert.equal(outcome.recordedYes, false);
92
+ assert.ok(logs.some((l) => l.decision === "declined"));
93
+ });
94
+ test("decideSignoff: an expired prompt resolves to confirm()'s own default (false) -> not accepted, not a recorded yes", async () => {
95
+ // cli/ask.ts's real confirm() collapses an expired timer into `dflt` — a
96
+ // fake whose confirm mimics that exact collapse is the faithful stand-in
97
+ // for "the timeout fired" without actually waiting out a real clock here.
98
+ const asker = {
99
+ async text(_l, opts) {
100
+ return opts?.default ?? "";
101
+ },
102
+ async select(_l, _c, dflt) {
103
+ return dflt;
104
+ },
105
+ async confirm(_label, dflt) {
106
+ return dflt;
107
+ },
108
+ async secret() {
109
+ return "";
110
+ },
111
+ note() { },
112
+ heading() { },
113
+ close() { },
114
+ };
115
+ const outcome = await decideSignoff({
116
+ review: reviewOutput({ approved: true }),
117
+ canPrompt: true,
118
+ requireHumanSignoff: false,
119
+ signoffTimeoutSeconds: 1,
120
+ asker,
121
+ identity: undefined,
122
+ log: () => { },
123
+ warn: () => { },
124
+ });
125
+ assert.equal(outcome.accepted, false);
126
+ assert.equal(outcome.recordedYes, false);
127
+ });
128
+ test("decideSignoff: unattended, require_human_signoff false -> proceeds on the AI verdict alone, with a loud warning printed and logged", async () => {
129
+ const warns = [];
130
+ const logs = [];
131
+ const outcome = await decideSignoff({
132
+ review: reviewOutput({ approved: true }),
133
+ canPrompt: false,
134
+ requireHumanSignoff: false,
135
+ signoffTimeoutSeconds: 300,
136
+ asker: null,
137
+ identity: undefined,
138
+ log: (p) => logs.push(p),
139
+ warn: (l) => warns.push(l),
140
+ });
141
+ assert.equal(outcome.accepted, true, "review.approved alone gates the commit on this default-false, unattended path");
142
+ assert.equal(outcome.recordedYes, false, "never a recorded human yes when nobody was asked");
143
+ assert.ok(warns.some((w) => w.includes(AI_ONLY_SIGNOFF_WARNING)), "the warning must be loud (printed), not just traced");
144
+ assert.ok(logs.some((l) => l.decision === "ai_only" && l.warning === AI_ONLY_SIGNOFF_WARNING), "the warning text itself must also land in the traced payload, not just stdout");
145
+ });
146
+ test("decideSignoff: unattended, require_human_signoff true -> fails the phase closed instead of auto-approving", async () => {
147
+ await assert.rejects(() => decideSignoff({
148
+ review: reviewOutput({ approved: true }),
149
+ canPrompt: false,
150
+ requireHumanSignoff: true,
151
+ signoffTimeoutSeconds: 300,
152
+ asker: null,
153
+ identity: undefined,
154
+ log: () => { },
155
+ warn: () => { },
156
+ }), /unattended/);
157
+ });
158
+ test("decideSignoff: no TTY behaves the same as unattended, even if ctx.unattended alone said otherwise — canPrompt is the conjunction", async () => {
159
+ // This is `isInteractive() && !ctx.unattended` already folded down to a
160
+ // single boolean by the caller (simple_sdlc.ts) — decideSignoff itself
161
+ // just trusts `canPrompt`, so this pins that "false" here means the
162
+ // AI-only/fail-closed branch regardless of which half made it false.
163
+ await assert.rejects(() => decideSignoff({
164
+ review: reviewOutput({ approved: true }),
165
+ canPrompt: false,
166
+ requireHumanSignoff: true,
167
+ signoffTimeoutSeconds: 300,
168
+ asker: null,
169
+ identity: undefined,
170
+ log: () => { },
171
+ warn: () => { },
172
+ }), /unattended/);
173
+ });
174
+ test("decideSignoff: no git committer identity -> the decision is still logged; the trailer is the caller's problem to skip", async () => {
175
+ const { asker } = scriptedAsker(true);
176
+ const logs = [];
177
+ const warns = [];
178
+ const outcome = await decideSignoff({
179
+ review: reviewOutput({ approved: true }),
180
+ canPrompt: true,
181
+ requireHumanSignoff: false,
182
+ signoffTimeoutSeconds: 300,
183
+ asker,
184
+ identity: undefined,
185
+ log: (p) => logs.push(p),
186
+ warn: (l) => warns.push(l),
187
+ });
188
+ assert.equal(outcome.recordedYes, true, "the human said yes — a missing identity affects the trailer, never the decision itself");
189
+ assert.ok(logs.some((l) => typeof l.note === "string" && l.note.includes("without a git committer identity")));
190
+ });
191
+ // ── trailerFor: the join between decideSignoff and commitEnvelope ────────
192
+ test("trailerFor: the AI-only path's recordedYes:false reaches commitEnvelope as null, even when accepted is true and an identity exists", () => {
193
+ // The exact join this test guards: a refactor that swapped `recordedYes`
194
+ // for `accepted` (or `verified`) at the commit_build call site would keep
195
+ // every other test in this suite green while minting trailers for
196
+ // AI-only commits.
197
+ const identity = { name: "Ada Lovelace", email: "ada@example.com" };
198
+ assert.equal(trailerFor({ accepted: true, recordedYes: false }, identity), null);
199
+ });
200
+ test("trailerFor: a recorded human yes with an identity reaches commitEnvelope as that identity", () => {
201
+ const identity = { name: "Ada Lovelace", email: "ada@example.com" };
202
+ assert.deepEqual(trailerFor({ accepted: true, recordedYes: true }, identity), identity);
203
+ });
204
+ test("trailerFor: a recorded human yes with no git identity configured still yields null, not a fabricated one", () => {
205
+ assert.equal(trailerFor({ accepted: true, recordedYes: true }, undefined), null);
206
+ });
207
+ // ── commitEnvelope's trailer ─────────────────────────────────────────────
208
+ function fakeCommitRun() {
209
+ const committed = [];
210
+ const run = {
211
+ adw_id: "abcd1234",
212
+ git: {
213
+ commitAll: (message) => {
214
+ committed.push(message);
215
+ return "abc1234";
216
+ },
217
+ },
218
+ };
219
+ return { run, committed };
220
+ }
221
+ function fakePh() {
222
+ return {
223
+ log: () => { },
224
+ call: async () => {
225
+ throw new Error("not used by these tests");
226
+ },
227
+ };
228
+ }
229
+ test("commitEnvelope: a recorded human sign-off appends a real Signed-off-by trailer", () => {
230
+ const { run, committed } = fakeCommitRun();
231
+ commitEnvelope(run, fakePh(), { status: "success", summary: "did it", artifacts: [], notes_for_next_agent: "", commit_message: "Implement the thing" }, { name: "Ada Lovelace", email: "ada@example.com" });
232
+ assert.equal(committed.length, 1);
233
+ assert.equal(committed[0], "Implement the thing\n\nSigned-off-by: Ada Lovelace <ada@example.com>");
234
+ });
235
+ test("commitEnvelope: no signoff argument -> no trailer, ever", () => {
236
+ const { run, committed } = fakeCommitRun();
237
+ commitEnvelope(run, fakePh(), {
238
+ status: "success",
239
+ summary: "did it",
240
+ artifacts: [],
241
+ notes_for_next_agent: "",
242
+ commit_message: "Implement the thing",
243
+ });
244
+ assert.equal(committed[0], "Implement the thing");
245
+ assert.doesNotMatch(committed[0], /Signed-off-by/);
246
+ });
247
+ test("commitEnvelope: an explicit null signoff (identity was unset) -> no trailer, same as omitted", () => {
248
+ const { run, committed } = fakeCommitRun();
249
+ commitEnvelope(run, fakePh(), { status: "success", summary: "did it", artifacts: [], notes_for_next_agent: "", commit_message: "Fixed it" }, null);
250
+ assert.doesNotMatch(committed[0], /Signed-off-by/);
251
+ });
252
+ test("commitEnvelope: a lone conventional-commit subject is never mistaken for a trailer block", () => {
253
+ // Regression: `feat: add X` matches TRAILER_LINE's `Key: value` shape, but
254
+ // a single paragraph is the SUBJECT, never an existing trailer block — the
255
+ // trailer must land after a blank line, not glued onto the subject with no
256
+ // separator (which git would not recognize as a trailer at all).
257
+ const { run, committed } = fakeCommitRun();
258
+ commitEnvelope(run, fakePh(), { status: "success", summary: "did it", artifacts: [], notes_for_next_agent: "", commit_message: "feat: add the signoff gate" }, { name: "Ada Lovelace", email: "ada@example.com" });
259
+ assert.equal(committed[0], "feat: add the signoff gate\n\nSigned-off-by: Ada Lovelace <ada@example.com>");
260
+ });
261
+ test("commitEnvelope: a real body ending in a Key: value block still joins rather than growing a blank-line-separated second block", () => {
262
+ const { run, committed } = fakeCommitRun();
263
+ commitEnvelope(run, fakePh(), { status: "success", summary: "", artifacts: [], notes_for_next_agent: "", commit_message: "wip\n\nNote: I did stuff" }, { name: "Ada Lovelace", email: "ada@example.com" });
264
+ assert.equal(committed[0], "wip\n\nNote: I did stuff\nSigned-off-by: Ada Lovelace <ada@example.com>");
265
+ });
266
+ test("commitEnvelope: an agent-authored message that already carries this exact Signed-off-by line is not duplicated", () => {
267
+ const { run, committed } = fakeCommitRun();
268
+ commitEnvelope(run, fakePh(), {
269
+ status: "success",
270
+ summary: "",
271
+ artifacts: [],
272
+ notes_for_next_agent: "",
273
+ commit_message: "feat: add the signoff gate\n\nSigned-off-by: Ada Lovelace <ada@example.com>",
274
+ }, { name: "Ada Lovelace", email: "ada@example.com" });
275
+ assert.equal(committed[0], "feat: add the signoff gate\n\nSigned-off-by: Ada Lovelace <ada@example.com>");
276
+ assert.equal(committed[0].match(/Signed-off-by/g)?.length, 1);
277
+ });
278
+ test("commitEnvelope: joins an already-trailered message's block instead of starting a second one", () => {
279
+ const { run, committed } = fakeCommitRun();
280
+ commitEnvelope(run, fakePh(), {
281
+ status: "success",
282
+ summary: "",
283
+ artifacts: [],
284
+ notes_for_next_agent: "",
285
+ commit_message: "Implement the thing\n\nCo-authored-by: Bot <bot@example.com>",
286
+ }, { name: "Ada Lovelace", email: "ada@example.com" });
287
+ assert.equal(committed[0], "Implement the thing\n\nCo-authored-by: Bot <bot@example.com>\nSigned-off-by: Ada Lovelace <ada@example.com>");
288
+ });
289
+ // ── committerIdentity ────────────────────────────────────────────────────
290
+ test("committerIdentity: reads the repo-local user.name/user.email", () => {
291
+ const dir = mkdtempSync(path.join(tmpdir(), "spf-signoff-identity-"));
292
+ try {
293
+ execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
294
+ execFileSync("git", ["config", "user.name", "Ada Lovelace"], { cwd: dir, stdio: "ignore" });
295
+ execFileSync("git", ["config", "user.email", "ada@example.com"], { cwd: dir, stdio: "ignore" });
296
+ assert.deepEqual(committerIdentity(dir), { name: "Ada Lovelace", email: "ada@example.com" });
297
+ }
298
+ finally {
299
+ rmSync(dir, { recursive: true, force: true });
300
+ }
301
+ });
302
+ test("committerIdentity: undefined (never a fallback literal) when no config carries a name/email", () => {
303
+ const dir = mkdtempSync(path.join(tmpdir(), "spf-signoff-identity-unset-"));
304
+ const fakeHome = mkdtempSync(path.join(tmpdir(), "spf-signoff-fakehome-"));
305
+ const saved = {
306
+ HOME: process.env.HOME,
307
+ GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
308
+ GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
309
+ };
310
+ try {
311
+ execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
312
+ // Isolate from whatever global/system gitconfig the host running this
313
+ // suite happens to have — otherwise "unset" is only true by accident.
314
+ process.env.HOME = fakeHome;
315
+ process.env.GIT_CONFIG_GLOBAL = path.join(fakeHome, "does-not-exist");
316
+ process.env.GIT_CONFIG_NOSYSTEM = "1";
317
+ assert.equal(committerIdentity(dir), undefined);
318
+ }
319
+ finally {
320
+ for (const [key, value] of Object.entries(saved)) {
321
+ if (value === undefined)
322
+ delete process.env[key];
323
+ else
324
+ process.env[key] = value;
325
+ }
326
+ rmSync(dir, { recursive: true, force: true });
327
+ rmSync(fakeHome, { recursive: true, force: true });
328
+ }
329
+ });
@@ -1 +1,7 @@
1
- export {};
1
+ /**
2
+ * Route-parity regression test for the Bun.serve -> Hono port. Builds a real
3
+ * spf.db (via the tracer, not a fixture file) and drives runUi() with real
4
+ * HTTP requests — the same shape as the manual verification this port was
5
+ * checked against, kept as a regression guard rather than a one-off.
6
+ */
7
+ import "./hermetic_git.js";
@@ -4,6 +4,7 @@
4
4
  * HTTP requests — the same shape as the manual verification this port was
5
5
  * checked against, kept as a regression guard rather than a one-off.
6
6
  */
7
+ import "./hermetic_git.js";
7
8
  import { test, before, after } from "node:test";
8
9
  import assert from "node:assert/strict";
9
10
  import { mkdtempSync, rmSync } from "node:fs";
@@ -2,6 +2,20 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
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
+ }
5
19
  /** In-memory fake — exactly the seam `provider.ts` exists for. */
6
20
  class FakeProvider {
7
21
  entries = new Map();
@@ -50,7 +64,7 @@ class FakeCodeHost {
50
64
  nextPrNumber = 1000;
51
65
  async openPr(opts) {
52
66
  const number = this.nextPrNumber++;
53
- this.openedPrs.push({ title: opts.title, branch: opts.branch });
67
+ this.openedPrs.push({ title: opts.title, branch: opts.branch, body: opts.body });
54
68
  this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
55
69
  return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
56
70
  }
@@ -390,6 +404,61 @@ test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only
390
404
  await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
391
405
  assert.deepEqual(events, []);
392
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
+ });
393
462
  // ── the refine lane ──────────────────────────────────────────────────────
394
463
  test("refineBranchNameFor: same sanitizer as branchNameFor, different prefix", () => {
395
464
  assert.equal(refineBranchNameFor({ id: "9", title: "Team invitations spec", body: "", labels: [] }), "spf-refine/9-team-invitations-spec");
@@ -550,6 +619,60 @@ test("claim -> refine -> published fires issue_claimed then spec_refined, both i
550
619
  assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "spec_refined"]);
551
620
  assert.ok(events.every((e) => e.level === "info"));
552
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
+ });
553
676
  test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
554
677
  const provider = new FakeProvider();
555
678
  const codeHost = new FakeCodeHost();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,10 +18,6 @@
18
18
  "README.md",
19
19
  "LICENSE"
20
20
  ],
21
- "exports": {
22
- ".": "./dist/index.js",
23
- "./core": "./dist/core/index.js"
24
- },
25
21
  "engines": {
26
22
  "node": ">=22.19.0"
27
23
  },
@@ -44,6 +40,7 @@
44
40
  "tag:patch": "node scripts/tag-release.js patch"
45
41
  },
46
42
  "dependencies": {
43
+ "@earendil-works/pi-ai": "0.83.0",
47
44
  "@flue/runtime": "2.0.3",
48
45
  "@hono/node-server": "^2.1.1",
49
46
  "@valibot/to-json-schema": "^1.7.1",
@@ -51,6 +48,9 @@
51
48
  "valibot": "^1.4.2",
52
49
  "yaml": "^2.5.1"
53
50
  },
51
+ "overrides": {
52
+ "@earendil-works/pi-ai": "0.83.0"
53
+ },
54
54
  "devDependencies": {
55
55
  "@types/node": "^22.10.0",
56
56
  "lefthook": "^2.1.10",