@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
@@ -1,10 +1,18 @@
1
1
  /**
2
- * The `spf watch` state machine: poll -> claim -> run a chain -> PR ->
3
- * done/blocked. Provider-agnostic (drives whatever `IssueProvider` it's
4
- * given) and chain-agnostic (drives whatever `runChain` callback it's
2
+ * The `spf watch` state machine two lanes over the same poll loop.
3
+ *
4
+ * The build lane: poll -> claim -> run a chain -> PR -> done/blocked.
5
+ * The refine lane (`watch.refine.enabled`, off by default): poll a
6
+ * `spec-ready` product spec -> claim -> decompose it into a feature/story
7
+ * tree -> publish those as real issues -> done/blocked. A spec is not
8
+ * individually workable, so this lane never opens a PR — it hands the build
9
+ * lane its next batch of `ready`-able work instead (see `core/refine.ts`).
10
+ *
11
+ * Provider-agnostic (drives whatever `IssueProvider` it's given) and
12
+ * chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
5
13
  * given) — deliberately kept out of `src/chains/`'s dependency direction,
6
- * so this stays testable against a fake provider and a fake `runChain`
7
- * with no chain registry involved.
14
+ * so this stays testable against a fake provider and fake callbacks with no
15
+ * chain registry involved.
8
16
  *
9
17
  * Design lifted from the user's own GitHub-poller reference implementation
10
18
  * (a label-as-state-machine daemon), leaned down for a v1: no per-issue
@@ -30,24 +38,33 @@
30
38
  import path from "node:path";
31
39
  const MAX_ORPHAN_ATTEMPTS = 2;
32
40
  export function createWatchState() {
33
- return { inflight: new Set() };
41
+ return { inflight: new Set(), refining: new Set() };
34
42
  }
35
- export function branchNameFor(issue) {
36
- const slug = issue.title
43
+ function slugifyTitle(title) {
44
+ return (title
37
45
  .toLowerCase()
38
46
  .split(/\s+/)
39
47
  .filter(Boolean)
40
48
  .slice(0, 5)
41
49
  .join("-")
42
- .replace(/[^a-z0-9-]/g, "");
50
+ .replace(/[^a-z0-9-]/g, "") || "issue");
51
+ }
52
+ export function branchNameFor(issue) {
43
53
  // The issue's own id in the branch name isn't just labeling: Jira's
44
54
  // Bitbucket integration auto-links a PR to the issue when its key
45
55
  // appears anywhere in the branch name, no explicit API call needed.
46
- return `spf-watch/${issue.id}-${slug || "issue"}`.slice(0, 200);
56
+ return `spf-watch/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
57
+ }
58
+ /** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
59
+ export function refineBranchNameFor(issue) {
60
+ return `spf-refine/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
47
61
  }
48
62
  function worktreePathFor(deps, issue) {
49
63
  return path.join(deps.worktreesDir, `issue-${issue.id}`);
50
64
  }
65
+ function specWorktreePathFor(deps, issue) {
66
+ return path.join(deps.worktreesDir, `spec-${issue.id}`);
67
+ }
51
68
  function cleanupWorktree(deps, marker) {
52
69
  if (!marker)
53
70
  return;
@@ -106,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
106
123
  }
107
124
  }
108
125
  }
126
+ /**
127
+ * Post the summary comment on a decomposed spec and transition it to `done`
128
+ * — the refine lane's one shared finishing move, reached from both the
129
+ * normal path (`runSpec`, right after a successful publish) and the
130
+ * orphan-resume path (`reconcileRefining`, when a completed publish's
131
+ * marker survived a crash the `transition` itself didn't). `created` only
132
+ * has titles/kinds in the normal path — an orphan resume has nothing but
133
+ * the ids `WatchMarker.refined` recorded, and the comment degrades to a
134
+ * bare list of `#id`s rather than blocking on a re-fetch.
135
+ */
136
+ async function finishSpec(deps, issue, created) {
137
+ const body = created.length > 0
138
+ ? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
139
+ created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
140
+ `\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
141
+ : `spf watch refined this spec but the refiner produced no issues.`;
142
+ deps.notify({
143
+ kind: "spec_refined",
144
+ level: "info",
145
+ title: `spec ${issue.id} refined`,
146
+ detail: `${created.length} issue(s) created.`,
147
+ fields: [["issue", issue.id], ["title", issue.title], ["created", String(created.length)]],
148
+ });
149
+ if (!deps.dryRun) {
150
+ await deps.provider.comment(issue, body);
151
+ await deps.provider.transition(issue, "done");
152
+ }
153
+ }
154
+ /**
155
+ * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
156
+ * process isn't tracking is either a completed publish that crashed before
157
+ * its own `transition(issue, "done")` ran (resume: finish it, no re-run),
158
+ * or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
159
+ * A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
160
+ */
161
+ export async function reconcileRefining(deps, state) {
162
+ if (!deps.refineEnabled)
163
+ return;
164
+ const refining = await deps.provider.listInState("refining");
165
+ for (const issue of refining) {
166
+ if (state.refining.has(issue.id))
167
+ continue;
168
+ const marker = await deps.provider.readMarker(issue);
169
+ if (marker?.refined && marker.refined.length > 0) {
170
+ deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
171
+ await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
172
+ continue;
173
+ }
174
+ const attempt = (marker?.attempt ?? 0) + 1;
175
+ if (attempt <= MAX_ORPHAN_ATTEMPTS) {
176
+ deps.log(`watch: spec ${issue.id} orphaned mid-refine, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to spec-ready`);
177
+ if (!deps.dryRun) {
178
+ await deps.provider.writeMarker(issue, { ...marker, attempt });
179
+ await deps.provider.transition(issue, "spec-ready");
180
+ }
181
+ }
182
+ else {
183
+ deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
184
+ deps.notify({
185
+ kind: "issue_blocked",
186
+ level: "error",
187
+ title: `spec ${issue.id} blocked`,
188
+ detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
189
+ fields: [["issue", issue.id], ["title", issue.title]],
190
+ });
191
+ if (!deps.dryRun) {
192
+ await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`);
193
+ cleanupWorktree(deps, marker);
194
+ }
195
+ }
196
+ }
197
+ }
109
198
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
110
199
  export async function finishReviews(deps) {
111
200
  const reviewing = await deps.provider.listInState("review", { includeAll: true });
@@ -195,6 +284,15 @@ async function runIssue(deps, issue) {
195
284
  return;
196
285
  }
197
286
  wtGit.push("origin", branch);
287
+ // The human merging this PR sees whatever the reviewer found — or, if
288
+ // nothing reviewed this change at all, is told that plainly rather than
289
+ // left to assume a silent approval. `reviewSummary` is already
290
+ // sanitized/truncated by the caller (see runChain's own doc comment).
291
+ const reviewLine = result.reviewSummary
292
+ ? result.reviewSummary
293
+ : result.reviewRequired
294
+ ? "Reviewer ran, but no verdict could be read back from the session data."
295
+ : `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
198
296
  // No cross-linking magic keyword here on purpose (a code host paired
199
297
  // with a different tracker has no "Closes #n" convention to hook into
200
298
  // — see provider.ts) — the issue id in the title/body is plain text
@@ -203,7 +301,7 @@ async function runIssue(deps, issue) {
203
301
  const pr = await deps.codeHost.openPr({
204
302
  branch,
205
303
  title: `${issue.title} (${issue.id})`,
206
- body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.`,
304
+ body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
207
305
  base: deps.baseBranch,
208
306
  });
209
307
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
@@ -213,7 +311,13 @@ async function runIssue(deps, issue) {
213
311
  kind: "pr_opened",
214
312
  level: "info",
215
313
  title: `PR #${pr.number} opened`,
216
- fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
314
+ detail: reviewLine,
315
+ fields: [
316
+ ["issue", issue.id],
317
+ ["title", issue.title],
318
+ ["chain", deps.chain],
319
+ ["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
320
+ ],
217
321
  url: pr.url || undefined,
218
322
  });
219
323
  }
@@ -231,6 +335,72 @@ async function runIssue(deps, issue) {
231
335
  cleanupWorktree(deps, { worktree: worktreePath, branch });
232
336
  }
233
337
  }
338
+ /**
339
+ * One spec's full claim -> decompose -> publish path, run in the background
340
+ * — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
341
+ * PR half: no `diffFiles` check (the refiner has `writes: []`, so an empty
342
+ * diff is the CORRECT outcome, not a failure), no push, no `openPr`. Its
343
+ * mirror image is "publish, then finish" instead of "commit, then review".
344
+ */
345
+ async function runSpec(deps, issue) {
346
+ const branch = refineBranchNameFor(issue);
347
+ const worktreePath = specWorktreePathFor(deps, issue);
348
+ const adwId = `spec-${issue.id}`;
349
+ try {
350
+ // A spec re-claimed after a completed publish (the transition/comment
351
+ // that should have followed never ran — a crash, a kill) already has
352
+ // its answer on disk: skip straight to finishing rather than asking the
353
+ // refiner to redo work that already exists on the tracker. `to-tickets`
354
+ // (the skill this lane's prompt is ported from) has no such guard and
355
+ // would duplicate every issue on a re-run.
356
+ const existingMarker = await deps.provider.readMarker(issue);
357
+ if (existingMarker?.refined && existingMarker.refined.length > 0) {
358
+ deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
359
+ await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
360
+ return;
361
+ }
362
+ // See runIssue's identical comment: worktreePath/branch are deterministic
363
+ // from issue.id, so a leftover from a killed prior attempt is the only
364
+ // way either could already exist — clear it unconditionally.
365
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
366
+ deps.git.fetch("origin", deps.baseBranch);
367
+ deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
368
+ deps.linkDataDir(worktreePath);
369
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
370
+ const prompt = `${issue.title}\n\n${issue.body}`.trim();
371
+ const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
372
+ if (!result.accepted) {
373
+ deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
374
+ const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
375
+ deps.notify({
376
+ kind: "issue_blocked",
377
+ level: "error",
378
+ title: `spec ${issue.id} blocked`,
379
+ detail,
380
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
381
+ });
382
+ await deps.provider.transition(issue, "blocked", detail);
383
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
384
+ return;
385
+ }
386
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
387
+ await finishSpec(deps, issue, result.created);
388
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
389
+ }
390
+ catch (error) {
391
+ const message = error.message;
392
+ deps.log(`watch: spec ${issue.id}: refine error: ${message}`);
393
+ deps.notify({
394
+ kind: "watch_error",
395
+ level: "error",
396
+ title: `spec ${issue.id} errored`,
397
+ detail: message,
398
+ fields: [["issue", issue.id], ["title", issue.title]],
399
+ });
400
+ await deps.provider.transition(issue, "blocked", `spf watch refine error: ${message}`).catch(() => undefined);
401
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
402
+ }
403
+ }
234
404
  /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
235
405
  export async function claimNewWork(deps, state) {
236
406
  if (state.inflight.size >= deps.concurrency)
@@ -261,6 +431,38 @@ export async function claimNewWork(deps, state) {
261
431
  runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
262
432
  }
263
433
  }
434
+ /** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
435
+ export async function claimSpecs(deps, state) {
436
+ if (!deps.refineEnabled)
437
+ return;
438
+ if (state.refining.size >= deps.refineConcurrency)
439
+ return;
440
+ const eligible = await deps.provider.listInState("spec-ready");
441
+ for (const issue of eligible) {
442
+ if (state.refining.size >= deps.refineConcurrency)
443
+ break;
444
+ if (state.refining.has(issue.id))
445
+ continue;
446
+ if (deps.dryRun) {
447
+ deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
448
+ continue;
449
+ }
450
+ const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
451
+ if (!claimed) {
452
+ deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
453
+ continue;
454
+ }
455
+ deps.log(`watch: claimed spec ${issue.id}: ${issue.title}`);
456
+ deps.notify({
457
+ kind: "issue_claimed",
458
+ level: "info",
459
+ title: `spec ${issue.id} claimed`,
460
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain]],
461
+ });
462
+ state.refining.add(issue.id);
463
+ runSpec(deps, issue).finally(() => state.refining.delete(issue.id));
464
+ }
465
+ }
264
466
  function tickErrorHandler(deps, stage) {
265
467
  return (error) => {
266
468
  const message = error.message;
@@ -268,9 +470,11 @@ function tickErrorHandler(deps, stage) {
268
470
  deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
269
471
  };
270
472
  }
271
- /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
473
+ /** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
272
474
  export async function tick(deps, state) {
273
475
  await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
476
+ await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
274
477
  await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
478
+ await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
275
479
  await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
276
480
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { test } from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
14
+ import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
15
15
  // name -> [phases, requiredAgents (with no options), requiredSuites]
16
16
  const EXPECTED = {
17
17
  prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
@@ -41,9 +41,10 @@ const EXPECTED = {
41
41
  },
42
42
  quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
43
43
  document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
44
+ refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
44
45
  "simple-sdlc": {
45
46
  phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
46
- "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
47
+ "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
47
48
  "-> code(changes) -> documenter -> git(commit_docs)",
48
49
  agents: ["planner", "builder", "reviewer", "documenter"],
49
50
  suites: ["test"],
@@ -58,7 +59,7 @@ for (const chain of CHAINS) {
58
59
  test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
59
60
  assert.equal(chain.phases, expected.phases);
60
61
  assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
61
- assert.deepEqual(chain.requiredSuites, expected.suites);
62
+ assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
62
63
  });
63
64
  }
64
65
  test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
@@ -66,6 +67,11 @@ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dy
66
67
  assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
67
68
  assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
68
69
  });
70
+ test("plan-build-test: requiredSuites depends on --suite, the same way prompt's agent does", () => {
71
+ const chain = findChain("plan-build-test");
72
+ assert.deepEqual(resolveRequiredSuites(chain, {}), ["test"], "no --suite -> falls back to the compiled-in default");
73
+ assert.deepEqual(resolveRequiredSuites(chain, { suite: "custom" }), ["custom"], "--suite overrides the default");
74
+ });
69
75
  test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
70
76
  for (const chain of CHAINS) {
71
77
  if (chain.name === "simple-sdlc") {
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import * as v from "valibot";
16
16
  import { toJsonSchema } from "@valibot/to-json-schema";
17
- import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
- import { loadConfig } from "../core/agents.js";
17
+ import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, EventRecordTypeSchema, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewConfigSchema, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
+ import { agentEnv, loadConfig } from "../core/agents.js";
19
19
  test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
20
20
  const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
21
21
  const unrestricted = v.parse(AgentConfigSchema, base);
@@ -80,3 +80,141 @@ test("notifications survives loadConfig's merge — key-by-key like observabilit
80
80
  rmSync(dir, { recursive: true, force: true });
81
81
  }
82
82
  });
83
+ test("ReviewConfigSchema: defaults to require_human_signoff=false, signoff_timeout_seconds=300", () => {
84
+ const parsed = v.parse(ReviewConfigSchema, {});
85
+ assert.equal(parsed.require_human_signoff, false, "this release fails OPEN by default — see the schema's own doc comment");
86
+ assert.equal(parsed.signoff_timeout_seconds, 300);
87
+ });
88
+ // The silent-drop trap: mergeRawConfig (core/agents.ts) is a FIXED-SHAPE
89
+ // object literal, so a `review:` key in a real config file that isn't named
90
+ // on both sides of that literal is dropped before SFConfigSchema ever sees
91
+ // it — parsing would then succeed anyway, quietly, on the schema default.
92
+ // This is adversarial history on this branch, not a hypothetical: it is
93
+ // exactly the bug `observability`/`notifications` already guard against, and
94
+ // `review` gets the same guard the day it is added.
95
+ test("review survives loadConfig's merge — the silent-drop trap mergeRawConfig's fixed-shape literal sets, for a single config file", () => {
96
+ const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-test-"));
97
+ try {
98
+ const configPath = join(dir, "spf.config.yaml");
99
+ writeFileSync(configPath, "review:\n require_human_signoff: true\n signoff_timeout_seconds: 45\n");
100
+ const cfg = loadConfig([configPath]);
101
+ assert.equal(cfg.review.require_human_signoff, true, "a review: value from a real config file must reach SFConfig, not be dropped by mergeRawConfig's object literal");
102
+ assert.equal(cfg.review.signoff_timeout_seconds, 45);
103
+ }
104
+ finally {
105
+ rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ });
108
+ test("review merges key-by-key across two layered config files, like observability/notifications", () => {
109
+ const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-layered-test-"));
110
+ try {
111
+ const base = join(dir, "base.yaml");
112
+ const override = join(dir, "override.yaml");
113
+ writeFileSync(base, "review:\n require_human_signoff: false\n signoff_timeout_seconds: 120\n");
114
+ writeFileSync(override, "review:\n require_human_signoff: true\n");
115
+ const cfg = loadConfig([base, override]);
116
+ assert.equal(cfg.review.require_human_signoff, true, "override wins for the key it names");
117
+ assert.equal(cfg.review.signoff_timeout_seconds, 120, "unset in the override -> the base's value survives, key-by-key, not a whole-block replace");
118
+ }
119
+ finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+ // observability.otel is the one nested OBJECT under a top-level key that
124
+ // mergeRawConfig spreads field-by-field, so its merge semantics differ from its
125
+ // siblings' and are worth pinning: `otel:` is replaced as a WHOLE OBJECT by an
126
+ // override that names it (you never want a half-merged endpoint/headers pair —
127
+ // that is how an auth token gets POSTed to the wrong collector), while
128
+ // `observability`'s other keys still merge key-by-key around it. Also the
129
+ // activation contract: absent by default, so no repo starts exporting because
130
+ // it upgraded.
131
+ test("observability.otel survives loadConfig's merge — absent by default, whole-object replace on override", () => {
132
+ const dir = mkdtempSync(join(tmpdir(), "spf-otel-merge-test-"));
133
+ try {
134
+ const bare = join(dir, "bare.yaml");
135
+ writeFileSync(bare, "observability:\n poll_ms: 250\n");
136
+ assert.equal(loadConfig([bare]).observability.otel, undefined, "no otel: block -> export stays off, the default for every repo");
137
+ const base = join(dir, "base.yaml");
138
+ const override = join(dir, "override.yaml");
139
+ writeFileSync(base, "observability:\n poll_ms: 250\n otel:\n endpoint: http://base-collector:4318/v1/traces\n headers: {authorization: base-token}\n service_name: base\n");
140
+ writeFileSync(override, "observability:\n otel:\n endpoint: http://override-collector:4318/v1/traces\n");
141
+ const cfg = loadConfig([base, override]);
142
+ assert.equal(cfg.observability.poll_ms, 250, "observability's other keys still merge key-by-key around otel");
143
+ assert.equal(cfg.observability.otel?.endpoint, "http://override-collector:4318/v1/traces");
144
+ assert.equal(cfg.observability.otel?.headers, undefined, "whole-object replace: the base's auth header does NOT follow the override's endpoint");
145
+ assert.equal(cfg.observability.otel?.service_name, "spf", "and the base's service_name doesn't either — the schema default applies");
146
+ // A single config file must reach SFConfig at all — the silent-drop trap
147
+ // mergeRawConfig's fixed-shape object literal sets for any new key.
148
+ const single = join(dir, "single.yaml");
149
+ writeFileSync(single, "observability:\n otel:\n endpoint: https://collector.example.com/v1/traces\n");
150
+ assert.equal(loadConfig([single]).observability.otel?.endpoint, "https://collector.example.com/v1/traces");
151
+ // A typo fails at config load, not as a silent per-run export failure.
152
+ const bad = join(dir, "bad.yaml");
153
+ writeFileSync(bad, "observability:\n otel:\n endpoint: not-a-url\n");
154
+ assert.throws(() => loadConfig([bad]), /invalid config/, "endpoint is URL-validated");
155
+ }
156
+ finally {
157
+ rmSync(dir, { recursive: true, force: true });
158
+ }
159
+ });
160
+ test("EventRecordTypeSchema accepts every observed event type and rejects an unknown one", () => {
161
+ for (const type of ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"]) {
162
+ assert.equal(v.parse(EventRecordTypeSchema, type), type);
163
+ }
164
+ assert.throws(() => v.parse(EventRecordTypeSchema, "bogus_type"), "an event type outside the observed set must be rejected, not silently stored");
165
+ });
166
+ test("AgentConfigSchema: env_allowlist is optional and defaults to undefined (byte-identical to before this field existed)", () => {
167
+ const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
168
+ const unset = v.parse(AgentConfigSchema, base);
169
+ assert.equal(unset.env_allowlist, undefined);
170
+ const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
171
+ assert.deepEqual(allowlisted.env_allowlist, ["MY_API_KEY"]);
172
+ const nulled = v.parse(AgentConfigSchema, { ...base, env_allowlist: null });
173
+ assert.equal(nulled.env_allowlist, null, "null must parse (the 'unrestricted' spelling config.md teaches for writes)");
174
+ });
175
+ // This is the load-bearing half of the field: the schema merely declares
176
+ // the shape, agentEnv() is what actually filters the operator's own
177
+ // environment down to the allowlist. A test that only exercised the schema
178
+ // (as the previous version of this test did) would never notice agentEnv
179
+ // silently failing to filter anything.
180
+ test("agentEnv: filters the operator environment down to the allowlist plus the baseline keys", () => {
181
+ const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
182
+ const savedApiKey = process.env["MY_API_KEY"];
183
+ const savedSecret = process.env["SECRET_TOKEN"];
184
+ try {
185
+ process.env["MY_API_KEY"] = "abc123";
186
+ process.env["SECRET_TOKEN"] = "should-never-appear";
187
+ // unset -> undefined: both backends fall back to `request.env ?? operatorEnv()`,
188
+ // so this is byte-identical to the unfiltered behavior that predates the field.
189
+ const unset = v.parse(AgentConfigSchema, base);
190
+ assert.equal(agentEnv(unset), undefined);
191
+ // [] -> baseline-only: every ENV_BASELINE_KEYS entry actually present in
192
+ // process.env, and nothing else — SECRET_TOKEN must not leak through.
193
+ const empty = v.parse(AgentConfigSchema, { ...base, env_allowlist: [] });
194
+ const baselineOnly = agentEnv(empty);
195
+ const baselineKeys = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
196
+ for (const key of Object.keys(baselineOnly)) {
197
+ assert.ok(baselineKeys.includes(key), `${key} is not a baseline key — [] must yield baseline-only`);
198
+ }
199
+ assert.equal(baselineOnly["SECRET_TOKEN"], undefined);
200
+ assert.equal(baselineOnly["MY_API_KEY"], undefined);
201
+ // ["MY_API_KEY"] -> baseline + MY_API_KEY, with SECRET_TOKEN provably absent.
202
+ const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
203
+ const filtered = agentEnv(allowlisted);
204
+ assert.equal(filtered["MY_API_KEY"], "abc123");
205
+ assert.equal(filtered["SECRET_TOKEN"], undefined, "an unrelated secret must not survive the filter");
206
+ for (const key of Object.keys(filtered)) {
207
+ assert.ok(baselineKeys.includes(key) || key === "MY_API_KEY", `${key} leaked through the allowlist unexpectedly`);
208
+ }
209
+ }
210
+ finally {
211
+ if (savedApiKey === undefined)
212
+ delete process.env["MY_API_KEY"];
213
+ else
214
+ process.env["MY_API_KEY"] = savedApiKey;
215
+ if (savedSecret === undefined)
216
+ delete process.env["SECRET_TOKEN"];
217
+ else
218
+ process.env["SECRET_TOKEN"] = savedSecret;
219
+ }
220
+ });
@@ -0,0 +1 @@
1
+ import "./hermetic_git.ts";
@@ -0,0 +1,59 @@
1
+ import "./hermetic_git.js";
2
+ /**
3
+ * `findRepoRoot()`'s contract is "never throws, always returns some root."
4
+ * `isRepoAt()` only proves `git rev-parse --git-dir` succeeds, which is also
5
+ * true inside a bare repo and inside a `.git/` directory itself — neither
6
+ * has a work tree, so the follow-up `--show-toplevel` call fails there even
7
+ * though `isRepoAt()` said yes. Regression coverage for that gap: before the
8
+ * fix, `findRepoRoot()` let that failure propagate as an uncaught throw,
9
+ * which — because `cli/index.ts` calls `paths.resolveAnchor()` outside its
10
+ * top-level try/catch — crashed every command (including `spf --version`)
11
+ * when run from a bare repo or from inside `.git/`.
12
+ */
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { execFileSync } from "node:child_process";
16
+ import { mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import path from "node:path";
19
+ import { findRepoRoot } from "../core/git_helper.js";
20
+ test("findRepoRoot falls back to cwd inside a bare repo (no work tree)", () => {
21
+ const dir = mkdtempSync(path.join(tmpdir(), "spf-bare-"));
22
+ try {
23
+ execFileSync("git", ["init", "--bare", dir], { stdio: "ignore" });
24
+ const root = findRepoRoot(dir);
25
+ assert.equal(root, path.resolve(dir));
26
+ }
27
+ finally {
28
+ rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+ test("findRepoRoot falls back to cwd inside a repo's .git directory", () => {
32
+ const dir = mkdtempSync(path.join(tmpdir(), "spf-dotgit-"));
33
+ try {
34
+ execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
35
+ const gitDir = path.join(dir, ".git");
36
+ const root = findRepoRoot(gitDir);
37
+ assert.equal(root, path.resolve(gitDir));
38
+ }
39
+ finally {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ });
43
+ test("findRepoRoot resolves the toplevel of an ordinary work tree", () => {
44
+ const dir = mkdtempSync(path.join(tmpdir(), "spf-worktree-"));
45
+ try {
46
+ execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
47
+ const sub = path.join(dir, "nested");
48
+ execFileSync("node", ["-e", `require("fs").mkdirSync(${JSON.stringify(sub)})`]);
49
+ const root = findRepoRoot(sub);
50
+ // git may resolve symlinked tmpdirs (e.g. macOS /tmp -> /private/tmp) —
51
+ // compare against what git itself reports as the raw toplevel is what
52
+ // resolveAnchor ultimately does too, so lean on findRepoRoot from the
53
+ // repo root itself for a stable assertion.
54
+ assert.equal(root, findRepoRoot(dir));
55
+ }
56
+ finally {
57
+ rmSync(dir, { recursive: true, force: true });
58
+ }
59
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Import this FIRST in any test file that spawns `git`.
3
+ *
4
+ * When the suite runs inside a git hook (lefthook's `pre-push`), git exports
5
+ * GIT_DIR — and sometimes GIT_WORK_TREE/GIT_INDEX_FILE — into every child
6
+ * process, and those beat `cwd` for every git invocation. A test that does
7
+ * `git init` / `git remote add` / `git commit` in a scratch tmpdir then
8
+ * silently operates on the REAL repository being pushed. Observed damage
9
+ * before this guard existed: a stray empty "init" commit landed on the
10
+ * checked-out branch, and `git init` re-initialized the shared `.git`
11
+ * directory as BARE (git treats a target directory named `.git` as a bare
12
+ * repo), breaking `git status` in the main checkout until `core.bare` was
13
+ * flipped back.
14
+ *
15
+ * Deleting the variables at module load — each test file is its own
16
+ * `node --test` process — makes `cwd` authoritative again for the whole
17
+ * file, including git calls made by the code under test.
18
+ */
19
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"]) {
20
+ delete process.env[key];
21
+ }
22
+ export {};
@@ -1 +1,14 @@
1
- export {};
1
+ /**
2
+ * Regression guard for `spf init`'s non-interactive paths — `--yes`,
3
+ * `--template <name>`, and (implicitly, since the test runner's stdin/stdout
4
+ * are never a TTY) plain `spf init` with no flags. All three must keep
5
+ * writing the exact same content they always have, byte for byte, and must
6
+ * never touch stdin — a scripted `spf init` in CI must not hang.
7
+ *
8
+ * The interactive interview itself is covered directly against `Asker`
9
+ * in `interview.test.ts`; `initCommand`'s interactive branch always calls
10
+ * the real `createAsker()` (backed by `node:readline` on `process.stdin`),
11
+ * which has nothing to read in a test process — exercising it here would
12
+ * just hang, so it isn't.
13
+ */
14
+ import "./hermetic_git.js";