@gr8ful/spf 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. 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";