@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
@@ -10,6 +10,14 @@
10
10
  * `src/cli/index.ts`). Piped input, `--yes`, or `--template <name>` all
11
11
  * fall through to the original non-interactive behavior unchanged — a
12
12
  * scripted `spf init` must never hang waiting on stdin.
13
+ *
14
+ * Every path here also installs the repo-local Claude Code skill (the same
15
+ * work `spf install-skill` does by hand) unless `--no-skills` is passed —
16
+ * on every run, not just the first: `install-skill` is idempotent (a no-op
17
+ * once the skill is already up to date), so this never re-does work or
18
+ * clobbers a locally-edited skill file. `--user` (installing to
19
+ * `~/.claude/skills/spf` instead) stays a `spf install-skill` invocation of
20
+ * its own; `init` only ever writes the repo-local default.
13
21
  */
14
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
23
  import path from "node:path";
@@ -22,6 +30,7 @@ import { paint } from "../../core/console.js";
22
30
  import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
23
31
  import { gatherContext, runInterview } from "../interview.js";
24
32
  import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
33
+ import { installSkillCommand } from "./install-skill.js";
25
34
  const TEMPLATE_SUFFIX = ".spf.config.yaml";
26
35
  /** Every template's short name (e.g. "ts-cc"), derived from disk rather than hand-maintained — never drifts from what's actually packaged. */
27
36
  function listTemplates() {
@@ -67,8 +76,8 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
67
76
  # explicit Flue-style provider/model-id strings, and an agent's own model
68
77
  # always wins over defaults.model — so those three keep running on whatever
69
78
  # backend you just switched to, with a model id it can't resolve, unless
70
- # you override their model here too (builder/scout have no model of their
71
- # own in the packaged roster, so they need no override). See
79
+ # you override their model here too (builder/scout/refiner have no model of
80
+ # their own in the packaged roster, so they need no override). See
72
81
  # assets/templates/ts.spf.config.yaml (or --template ts) for the full
73
82
  # working pattern.
74
83
 
@@ -82,10 +91,19 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
82
91
  # watch:
83
92
  # issue_provider: github # github | jira
84
93
  # code_host: github # github | bitbucket
85
- # repo: owner/name # "owner/name" (github) or "workspace/repo_slug" (bitbucket)
94
+ # repo: owner/name # the CODE HOST's repo — "owner/name" (github) or "workspace/repo_slug" (bitbucket)
95
+ # # issue_repo: owner/name # ONLY for issue_provider: github + code_host: bitbucket, when
96
+ # # # the tracker and code host are genuinely different repos
86
97
  # label_prefix: spf
87
98
  # chain: plan-build-test
88
99
  # base_branch: main
100
+ # # Optional second lane: decompose a spf:spec-ready product spec into a
101
+ # # feature/story-or-bug tree of real issues. Off by default; needs
102
+ # # issue_provider: github — issue authoring isn't implemented for Jira yet.
103
+ # refine:
104
+ # enabled: true
105
+ # chain: refine
106
+ # concurrency: 1
89
107
 
90
108
  # Uncomment to push notifications for unattended work — spf watch's daemon
91
109
  # lifecycle, and every chain run (spf <chain> / spf run, including watch's
@@ -100,12 +118,76 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
100
118
  # channels:
101
119
  # - kind: slack # slack | teams | webhook
102
120
  # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
121
+
122
+ # Uncomment to change the human sign-off gate in front of simple-sdlc's
123
+ # commit_build phase — the one place an AI reviewer's approved flag alone
124
+ # can gate a commit. See assets/skill/references/config.md's "review"
125
+ # section for the full behavior.
126
+ # review:
127
+ # require_human_signoff: false # true = an unattended simple-sdlc run fails closed instead of committing on the reviewer's verdict alone
128
+ # signoff_timeout_seconds: 300
103
129
  `;
104
130
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
105
131
  // shared project config, same as package.json. Only runtime/generated
106
132
  // content is ignored: session traces (data/), a hand-editable engine copy
107
133
  // (engine/, from `spf eject`), and secrets (.env).
134
+ //
135
+ // `.spf/chains/` is deliberately NOT in this list. A chain file is DATA
136
+ // that decides what the disposer runs (see repo_chains.ts's module comment)
137
+ // — the same trust boundary `defaults.protected_files` exists to guard.
138
+ // Gitignoring it would let anyone (or any agent) rewrite what "spf plan-
139
+ // build-test" or a watch.chain even means, invisibly to `git diff`/PR
140
+ // review, in a repo that otherwise protects every other file that judges
141
+ // agent output. Chain files stay tracked so `protected_files` covers the
142
+ // chain that judges the agents, not just the code it judges.
108
143
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
144
+ /**
145
+ * Scaffolded once by `spf init` — fully commented out, so every line is a
146
+ * `#` comment and `parseYaml` reads the whole file as an empty document;
147
+ * `loadOne` (repo_chains.ts) treats that as "declares no chain" and skips it
148
+ * silently — no chain registered, but also no problem reported against
149
+ * spf's own scaffold. It exists to show the shape (naming existing
150
+ * `chains/steps.ts` factories as data, never importing repo code — params
151
+ * are flat siblings of `step:`, never nested under a `params:` key) and to
152
+ * carry the one divergence an author needs to know before writing a real
153
+ * one.
154
+ */
155
+ export const EXAMPLE_CHAIN_YAML = `# .spf/chains/example.yaml — a repo-local chain, loaded as DATA.
156
+ #
157
+ # This file NAMES existing step factories from spf's own chains/steps.ts
158
+ # (request, plan, build, fixLoop, commit, ...) — it never imports or runs
159
+ # code from this repo. "Agent proposes, code disposes," and the code that
160
+ # disposes is always SPF's own, packaged code; a chain file only ever picks
161
+ # which of ITS steps run, in which order, with which params. See
162
+ # .claude/skills/spf/cookbooks/authoring_chains.md (installed by \`spf init\`
163
+ # into THIS repo) for the full step vocabulary — its "Repo-local chains"
164
+ # section is written for exactly this file.
165
+ #
166
+ # WATCH DIVERGENCE: \`spf watch\` registers chains from the MAIN repo anchor
167
+ # once, at daemon start — not per-issue, not per-worktree. A chain file
168
+ # edited on an issue branch (inside the worktree \`spf watch\` checks that
169
+ # branch out into) is NOT what runs for that issue; the daemon keeps using
170
+ # whatever \`.spf/chains/\` looked like in the main repo when it started. The
171
+ # disposer stays the OPERATOR's, never the branch's — exactly the property
172
+ # that keeps an agent from being able to rewrite its own quality gate mid-run
173
+ # by editing a chain file as part of the change it's making.
174
+ #
175
+ # Uncomment and edit to register this chain (spf list / spf doctor will
176
+ # then show it). Every field below is required unless noted.
177
+ #
178
+ # name: example # spf example "<prompt>" / spf run example "<prompt>"
179
+ # describe: small build+fix example chain — build, then a bounded test-fix loop
180
+ # steps:
181
+ # - step: request # every chain opens with this
182
+ # - step: build
183
+ # fromPlan: false # no preceding plan() step in this chain
184
+ # - step: fixLoop
185
+ # suite: test # must name a key under quality.suites in spf.config.yaml
186
+ # owner: builder # must name an agent in the roster (cfg.agents)
187
+ # max: 3 # optional — defaults to 3 if omitted
188
+ # - step: commit
189
+ # onlyIfAccepted: true
190
+ `;
109
191
  const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s interview, merged ON TOP of
110
192
  # spf's packaged built-in defaults. Only what you changed is here; run
111
193
  # \`spf doctor\` any time to see what's actually in effect for this repo, and
@@ -113,10 +195,29 @@ const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s i
113
195
  # (gitignored) — .env.example lists the key names only.
114
196
  `;
115
197
  export async function initCommand(argv) {
116
- const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes"]);
198
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes", "no-skills"]);
117
199
  const anchor = paths.resolveAnchor(options["cwd"]);
118
200
  const sfDir = path.join(anchor.repo_root, ".spf");
119
201
  mkdirSync(sfDir, { recursive: true });
202
+ // Scaffold `.spf/chains/` unconditionally, on every path below (interview
203
+ // or not) — same idempotent shape as installSkill(): never overwrites a
204
+ // file that's already there (an author may have started editing the
205
+ // example, or written their own chains alongside it), so this is always
206
+ // safe to run again on a repo that already has one.
207
+ const chainsDir = path.join(sfDir, "chains");
208
+ mkdirSync(chainsDir, { recursive: true });
209
+ const exampleChainPath = path.join(chainsDir, "example.yaml");
210
+ if (!existsSync(exampleChainPath)) {
211
+ writeFileSync(exampleChainPath, EXAMPLE_CHAIN_YAML);
212
+ }
213
+ // Idempotent (a no-op once the skill is already up to date, a `.new`
214
+ // sibling rather than an overwrite for a locally-edited file) — safe to
215
+ // call on every `spf init`, not just the first.
216
+ const installSkill = () => {
217
+ if (flags["no-skills"])
218
+ return;
219
+ installSkillCommand(options["cwd"] ? ["--cwd", options["cwd"]] : []);
220
+ };
120
221
  const configPath = path.join(sfDir, "spf.config.yaml");
121
222
  const templateName = options["template"];
122
223
  const interactive = !templateName && !flags["yes"] && isInteractive();
@@ -129,6 +230,7 @@ export async function initCommand(argv) {
129
230
  writeFileSync(configPath, content);
130
231
  console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
131
232
  }
233
+ installSkill();
132
234
  }
133
235
  else {
134
236
  const asker = createAsker();
@@ -138,6 +240,7 @@ export async function initCommand(argv) {
138
240
  if (!overwrite) {
139
241
  console.log("leaving the existing config alone (--force to skip this prompt)");
140
242
  asker.close();
243
+ installSkill();
141
244
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
142
245
  return 0;
143
246
  }
@@ -155,6 +258,7 @@ export async function initCommand(argv) {
155
258
  if (Object.keys(result.env).length > 0)
156
259
  upsertEnvFile(anchor.repo_root, result.env);
157
260
  writeEnvExample(anchor.repo_root, result.envExampleKeys);
261
+ installSkill();
158
262
  // The same merge-then-validate pipeline `spf doctor` runs — catches a
159
263
  // bad answer (e.g. a suite naming an unconfigured check) right after
160
264
  // writing, not at the user's first real chain run. Non-fatal: the
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * `spf install-skill` — copy the packaged Claude Code skill (`assets/skill/`)
3
- * into a target repo (or `~/.claude/skills/spf` with `--user`), on explicit
4
- * request only. Nothing here ever runs unless a user asks for it.
3
+ * into a target repo (or `~/.claude/skills/spf` with `--user`). `spf init`
4
+ * calls this itself, every run, unless `--no-skills` is passed it's the
5
+ * command name a user (or a script) still reaches for by hand: to reinstall
6
+ * after an edit, to target `--user` instead of repo-local, or on a repo
7
+ * that never ran `spf init` at all (a pure-built-ins setup).
5
8
  *
6
9
  * Idempotent via a manifest (`.spf-skill-version`: {package, version,
7
10
  * files: {relpath: sha256}}) written at the skill root:
@@ -1,13 +1,51 @@
1
- import { CHAINS } from "../../chains/index.js";
1
+ import path from "node:path";
2
+ import { allChains, repoChainProblems } from "../../chains/index.js";
3
+ /**
4
+ * A repo-defined chain's `source` is an absolute path into `.spf/chains/` by
5
+ * construction (that's the one place `loadRepoChains` ever looks) — trim it
6
+ * back to the repo-relative fragment a human actually wants to see, rather
7
+ * than a long absolute path that's identical on every machine's checkout
8
+ * except for the leading segment.
9
+ */
10
+ function repoChainLabel(source) {
11
+ const marker = path.join(".spf", "chains");
12
+ const idx = source.lastIndexOf(marker);
13
+ return idx === -1 ? source : source.slice(idx);
14
+ }
2
15
  export function listCommand() {
3
- const width = Math.max(...CHAINS.map((c) => c.name.length));
4
- for (const chain of CHAINS) {
16
+ // Built-ins first, then repo chains — same order `allChains()` guarantees,
17
+ // so this listing and `findChain`'s resolution order never disagree about
18
+ // which chain wins on a name collision.
19
+ const chains = allChains();
20
+ const width = Math.max(...chains.map((c) => c.name.length));
21
+ for (const chain of chains) {
5
22
  const agents = typeof chain.requiredAgents === "function" ? "(--agent picks who)" : chain.requiredAgents.join(", ") || "(none)";
23
+ const suites = typeof chain.requiredSuites === "function" ? chain.requiredSuites({}) : chain.requiredSuites;
24
+ const suiteNote = typeof chain.requiredSuites === "function" ? " (--suite overrides)" : "";
6
25
  console.log(`${chain.name.padEnd(width)} ${chain.phases}`);
7
26
  console.log(`${"".padEnd(width)} ${chain.describe}`);
8
- console.log(`${"".padEnd(width)} agents: ${agents}${chain.requiredSuites.length ? ` · quality suites: ${chain.requiredSuites.join(", ")}` : ""}`);
27
+ console.log(`${"".padEnd(width)} agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`);
28
+ // `source` is undefined for every built-in — only a chain loaded from a
29
+ // `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
30
+ if (chain.source) {
31
+ console.log(`${"".padEnd(width)} (repo: ${repoChainLabel(chain.source)})`);
32
+ }
33
+ console.log();
34
+ }
35
+ console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`);
36
+ console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
37
+ // Every malformed `.spf/chains/*.yaml` file (bad YAML, a schema/params
38
+ // mismatch, a name naming a step factory that doesn't exist) becomes a
39
+ // problem here instead of a chain in the list above — loadRepoChains()
40
+ // never throws, so this is the only place an operator finds out their
41
+ // chain file didn't register at all.
42
+ const problems = repoChainProblems();
43
+ if (problems.length > 0) {
9
44
  console.log();
45
+ console.log(`${problems.length} repo chain file(s) failed to load — run \`spf doctor\` for detail:`);
46
+ for (const problem of problems) {
47
+ console.log(` ! ${problem.file}: ${problem.message}`);
48
+ }
10
49
  }
11
- console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] (spf run <name> ... works identically)`);
12
50
  return 0;
13
51
  }
@@ -2,9 +2,10 @@
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
4
  import { runChain } from "../../chains/index.js";
5
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
5
+ import { isInteractive } from "../ask.js";
6
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite"];
6
7
  export function usageFor(chain) {
7
- return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
8
+ return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>]`;
8
9
  }
9
10
  export async function dispatchChain(chain, argv) {
10
11
  const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
@@ -12,6 +13,15 @@ export async function dispatchChain(chain, argv) {
12
13
  console.error(usageFor(chain));
13
14
  return 1;
14
15
  }
16
+ // Only a step-derived chain's requiredSuites can actually read --suite
17
+ // (see deriveRequiredSuites/qualityCheck/fixLoop) — an imperative chain
18
+ // like simple-sdlc has a compiled-in static array and never consults
19
+ // options["suite"] at all, so accepting the flag there would silently do
20
+ // nothing. Reject loudly instead of letting it lie.
21
+ if (options["suite"] !== undefined && typeof chain.requiredSuites !== "function") {
22
+ console.error(`--suite has no effect on chain "${chain.name}" — its quality suite is fixed at ${JSON.stringify(chain.requiredSuites)}`);
23
+ return 2;
24
+ }
15
25
  const anchor = paths.resolveAnchor(options["cwd"]);
16
26
  const ctx = {
17
27
  prompt: resolvePrompt(positionals[0]),
@@ -19,11 +29,28 @@ export async function dispatchChain(chain, argv) {
19
29
  adw_id: options["adw-id"] ?? null,
20
30
  cwd: anchor.cwd,
21
31
  chain_name: chain.name,
32
+ // Only `refine` reads this (a "## Parent: #<id>" back-reference on
33
+ // every issue it creates — see ChainContext's doc comment); every
34
+ // other chain ignores it, so it's harmless to always pass through.
35
+ issue_id: options["issue"] ?? null,
36
+ // A real TTY (isInteractive(), from cli/ask.ts) is the one case an
37
+ // agent step could plausibly block on a human — everything else (a CI
38
+ // run, a piped `spf <chain>`, spf watch's own dispatch) is unattended.
39
+ // Read by `simple_sdlc.ts`'s sign-off phase (`isInteractive() &&
40
+ // !ctx.unattended`, folded into `canPrompt`) — see `decideSignoff`.
41
+ unattended: !isInteractive(),
42
+ // `undefined` for every built-in chain (ChainDefinition.source is only
43
+ // ever set for a chain loaded from `.spf/chains/*.yaml` — see
44
+ // chains/repo_chains.ts) — passed through unconditionally since a
45
+ // `string | undefined` field is exactly what ChainContext declares.
46
+ chain_source: chain.source,
22
47
  };
23
48
  const chainOptions = {};
24
49
  if (options["agent"] !== undefined)
25
50
  chainOptions["agent"] = options["agent"];
26
51
  if (options["base"] !== undefined)
27
52
  chainOptions["base"] = options["base"];
53
+ if (options["suite"] !== undefined)
54
+ chainOptions["suite"] = options["suite"];
28
55
  return runChain(chain, ctx, chainOptions);
29
56
  }
@@ -1,3 +1,21 @@
1
+ import { type ReviewOutputT } from "../../core/data_types.ts";
2
+ /**
3
+ * `&`/`<`/`>` are active markup in every destination a digest lands in —
4
+ * `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
5
+ * pipeline, and (cosmetically only — Adaptive Cards don't decode entities)
6
+ * Teams' TextBlock and the generic webhook payload — and a reviewer's
7
+ * findings/blocking text is LLM-authored, so it is untrusted content, not a
8
+ * template. Escaped before it ever reaches any renderer, never trusted as
9
+ * pre-formatted. Note this runs BEFORE truncateDigest, so a cut can in
10
+ * principle land mid-entity (e.g. `&am…`) — cosmetic only, since the `<`
11
+ * escape (the one that actually prevents Slack link forgery) is always
12
+ * complete by the time truncation could touch it.
13
+ */
14
+ export declare function escapeForMarkup(text: string): string;
15
+ /** Codepoint-safe truncation — a plain `.slice(0, n)` can land mid surrogate pair on a multi-byte finding. */
16
+ export declare function truncateDigest(text: string): string;
17
+ /** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
18
+ export declare function formatReviewDigest(review: ReviewOutputT): string;
1
19
  /**
2
20
  * `spf watch init` — idempotently seed the `<prefix>:*` labels the state
3
21
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
@@ -8,6 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import path from "node:path";
11
+ import * as v from "valibot";
11
12
  import * as agents from "../../core/agents.js";
12
13
  import * as paths from "../../core/paths.js";
13
14
  import { resolveNotifier } from "../../core/notify/notifier.js";
@@ -16,9 +17,58 @@ import { GitHubProvider } from "../../core/issues/github_provider.js";
16
17
  import { JiraProvider } from "../../core/issues/jira_provider.js";
17
18
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
18
19
  import { createWatchState, tick } from "../../core/watch.js";
19
- import { findChain, runChain as runChainDef } from "../../chains/index.js";
20
+ import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
21
+ import { ReviewOutput } from "../../core/data_types.js";
20
22
  import { SfDb } from "../../ui/server/db.js";
21
23
  import { parseCli } from "../../core/utils.js";
24
+ /** Kept well under Slack's own 2900-char slice on `detail` (see `slack_channel.ts`) — a reviewer can emit a lot of findings, but the PR body/notification only needs enough to tell a human whether to look closer. */
25
+ const MAX_REVIEW_DIGEST_CHARS = 1200;
26
+ /**
27
+ * `&`/`<`/`>` are active markup in every destination a digest lands in —
28
+ * `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
29
+ * pipeline, and (cosmetically only — Adaptive Cards don't decode entities)
30
+ * Teams' TextBlock and the generic webhook payload — and a reviewer's
31
+ * findings/blocking text is LLM-authored, so it is untrusted content, not a
32
+ * template. Escaped before it ever reaches any renderer, never trusted as
33
+ * pre-formatted. Note this runs BEFORE truncateDigest, so a cut can in
34
+ * principle land mid-entity (e.g. `&am…`) — cosmetic only, since the `<`
35
+ * escape (the one that actually prevents Slack link forgery) is always
36
+ * complete by the time truncation could touch it.
37
+ */
38
+ export function escapeForMarkup(text) {
39
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
40
+ }
41
+ /** Codepoint-safe truncation — a plain `.slice(0, n)` can land mid surrogate pair on a multi-byte finding. */
42
+ export function truncateDigest(text) {
43
+ const codepoints = Array.from(text);
44
+ if (codepoints.length <= MAX_REVIEW_DIGEST_CHARS)
45
+ return text;
46
+ return `${codepoints.slice(0, MAX_REVIEW_DIGEST_CHARS).join("")}…`;
47
+ }
48
+ /** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
49
+ export function formatReviewDigest(review) {
50
+ const lines = [`Reviewer verdict: ${review.approved ? "approved" : "changes requested"}.`];
51
+ if (review.blocking.length > 0) {
52
+ lines.push("Blocking:", ...review.blocking.map((b) => `- ${escapeForMarkup(b)}`));
53
+ }
54
+ const unmet = review.findings.filter((f) => !f.met);
55
+ if (unmet.length > 0) {
56
+ lines.push("Unmet requirements:", ...unmet.map((f) => `- ${escapeForMarkup(f.requirement)}${f.evidence ? ` — ${escapeForMarkup(f.evidence)}` : ""}`));
57
+ }
58
+ // The common case on the success path (see the module comment on
59
+ // `reviewSummaryFor`): every reachable ReviewOutput has approved:true,
60
+ // blocking:[], unmet:[] — gates.verdictConsistent throws otherwise, and a
61
+ // gate failure exits the run non-zero before a digest is ever built. So
62
+ // without this, an approved run's digest would be the vacuous constant
63
+ // "Reviewer verdict: approved." with zero information in it. The met
64
+ // findings ARE the reviewer's signal on an approved run — what it actually
65
+ // verified — so surface them.
66
+ const met = review.findings.filter((f) => f.met);
67
+ if (met.length > 0) {
68
+ lines.push(`Verified ${met.length} requirement(s):`, ...met.map((f) => `- ${escapeForMarkup(f.requirement)}${f.evidence ? ` — ${escapeForMarkup(f.evidence)}` : ""}`));
69
+ }
70
+ return truncateDigest(lines.join("\n"));
71
+ }
22
72
  /**
23
73
  * Shared by `watch` and `watch init`: resolve config into an `IssueProvider`
24
74
  * — checking only what BOTH need. `watch`'s own extra checks (a real git
@@ -27,8 +77,16 @@ import { parseCli } from "../../core/utils.js";
27
77
  */
28
78
  function resolveIssueProvider(cfg) {
29
79
  if (cfg.watch.issue_provider === "github") {
30
- if (!cfg.watch.repo.trim()) {
31
- console.error(`watch.repo is not configured add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
80
+ // `issue_repo` (falling back to `repo`) — NOT `repo` alone — because
81
+ // `repo` always names `code_host`'s own repo (see WatchConfigSchema's
82
+ // doc comment). Those coincide for issue_provider: github + code_host:
83
+ // github (the common case, and why the fallback exists at all), but
84
+ // for issue_provider: github + code_host: bitbucket they are two
85
+ // different repos in two different systems — reading `repo` here would
86
+ // silently poll the BITBUCKET repo's identifier for GitHub issues.
87
+ const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
88
+ if (!repo) {
89
+ console.error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
32
90
  return null;
33
91
  }
34
92
  const token = process.env["GITHUB_TOKEN"];
@@ -36,7 +94,7 @@ function resolveIssueProvider(cfg) {
36
94
  console.error('GITHUB_TOKEN is not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo). See README.md\'s "GITHUB_TOKEN scope" section.');
37
95
  return null;
38
96
  }
39
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
97
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token);
40
98
  }
41
99
  if (cfg.watch.issue_provider === "jira") {
42
100
  if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
@@ -54,7 +112,13 @@ function resolveIssueProvider(cfg) {
54
112
  console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
55
113
  return null;
56
114
  }
57
- /** Same shape as `resolveIssueProvider`, for `watch.code_host`. */
115
+ /**
116
+ * Same shape as `resolveIssueProvider`, for `watch.code_host` — always
117
+ * reads plain `watch.repo`, never `watch.issue_repo`. `repo` is defined as
118
+ * the CODE HOST's own repo (see `WatchConfigSchema`'s doc comment); `issue_repo`
119
+ * exists only to give the issue-tracker side an override when it names a
120
+ * different repo, which is `resolveIssueProvider`'s concern, not this one's.
121
+ */
58
122
  function resolveCodeHostProvider(cfg) {
59
123
  if (!cfg.watch.repo.trim()) {
60
124
  console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section`);
@@ -150,6 +214,21 @@ export async function watchCommand(argv) {
150
214
  console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
151
215
  return 1;
152
216
  }
217
+ if (cfg.watch.refine.enabled) {
218
+ if (cfg.watch.issue_provider !== "github") {
219
+ // Fail loudly at startup, not silently every tick: JiraProvider
220
+ // doesn't implement IssueAuthoringProvider yet (see its module
221
+ // comment) — a refine lane that can never publish would otherwise
222
+ // just claim every spec-ready spec and block it, forever.
223
+ console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
224
+ `the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
225
+ return 1;
226
+ }
227
+ if (!findChain(cfg.watch.refine.chain)) {
228
+ console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
229
+ return 1;
230
+ }
231
+ }
153
232
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
154
233
  const lockPath = path.join(dataPaths.data_dir, "watch.lock");
155
234
  try {
@@ -186,25 +265,138 @@ export async function watchCommand(argv) {
186
265
  mkdirSync(path.dirname(target), { recursive: true });
187
266
  symlinkSync(dataPaths.data_dir, target, "dir");
188
267
  }
268
+ /** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
269
+ function detailFromFailedPhase(cwd, adwId, prefix) {
270
+ let detail = prefix;
271
+ let db;
272
+ try {
273
+ const wtAnchor = paths.resolveAnchor(cwd);
274
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
275
+ db = new SfDb(wtDataPaths.db_path);
276
+ const failed = db.phases(adwId).find((p) => p.status === "fail");
277
+ if (failed)
278
+ detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
279
+ }
280
+ catch {
281
+ // best-effort — the generic message above still points at where to look
282
+ }
283
+ finally {
284
+ db?.close();
285
+ }
286
+ return detail;
287
+ }
288
+ /**
289
+ * Best-effort: the reviewer's latest verdict for this run, read back from
290
+ * the worktree's own (symlinked) trace db and reduced to a digest — same
291
+ * DB, same try/catch shape as `detailFromFailedPhase` above, but on the
292
+ * SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
293
+ * never produced a `ReviewOutput` envelope at all — never thrown: a digest
294
+ * is a nice-to-have, not something that gets to block the PR-open flow
295
+ * it's decorating.
296
+ */
297
+ function reviewSummaryFor(cwd, adwId) {
298
+ let db;
299
+ try {
300
+ const wtAnchor = paths.resolveAnchor(cwd);
301
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
302
+ db = new SfDb(wtDataPaths.db_path);
303
+ const envelope = db
304
+ .envelopes(adwId)
305
+ .filter((e) => e.output_type === ReviewOutput.name)
306
+ .at(-1); // the LATEST verdict — a revise loop can produce several
307
+ if (!envelope?.payload_json)
308
+ return undefined;
309
+ const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
310
+ return formatReviewDigest(review);
311
+ }
312
+ catch {
313
+ return undefined; // best-effort — see the doc comment above
314
+ }
315
+ finally {
316
+ db?.close();
317
+ }
318
+ }
189
319
  const runChain = async (opts) => {
320
+ // WATCH DIVERGENCE: `findChain` here resolves against the registry
321
+ // `cli/index.ts`'s `main()` built ONCE, at daemon start, from the MAIN
322
+ // repo anchor (the `registerRepoChains(...)` call before the command
323
+ // switch). `opts.cwd` below is a per-issue WORKTREE, which may carry a
324
+ // different (or edited) `.spf/chains/`, but that file is never
325
+ // re-read here — this is deliberate, not a gap: the disposer stays the
326
+ // OPERATOR's, never the branch's, so an agent can't rewrite its own
327
+ // quality gate mid-run by editing a chain file as part of the change
328
+ // it's making. (Also documented in the `spf init` scaffold, since that's
329
+ // the one place an author is invited to edit a chain file at all.)
190
330
  const chainDef = findChain(cfg.watch.chain); // checked above
191
- const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
331
+ const ctx = {
332
+ prompt: opts.prompt,
333
+ config_paths: configPaths,
334
+ adw_id: opts.adwId,
335
+ cwd: opts.cwd,
336
+ chain_name: chainDef.name,
337
+ // Every `spf watch` dispatch is unattended by definition — there is
338
+ // no human at a TTY to prompt, ever, for an issue claimed off a
339
+ // tracker poll. `chain_source` is undefined for a built-in chain,
340
+ // the resolved chain's `.spf/chains/*.yaml` path for a repo one.
341
+ unattended: true,
342
+ chain_source: chainDef.source,
343
+ };
344
+ // KNOWN LIMITATION (not fixed here): runChainDef is called below with no
345
+ // third `options` argument, so nothing --suite-shaped ever reaches this
346
+ // dispatch — `resolveRequiredSuites`/`resolveRequiredAgents` below both
347
+ // fall back to each chain's compiled-in/YAML-declared default. An
348
+ // unattended watch run therefore can't override a chain's suite the way
349
+ // an interactive `spf <chain> --suite <name>` can.
350
+ const code = await runChainDef(chainDef, ctx);
351
+ // Static for every chain but "prompt" (whose --agent flag `spf watch`
352
+ // never passes) — resolved with no options, exactly like `runChainDef`
353
+ // above ran it.
354
+ const reviewRequired = resolveRequiredAgents(chainDef, {}).includes("reviewer");
355
+ if (code === 0) {
356
+ return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
357
+ }
358
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
359
+ return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
360
+ };
361
+ /**
362
+ * Same shape as `runChain`, for the refine lane — with one extra step on
363
+ * success: a chain's return value is just an exit code, so the created-
364
+ * issues list `steps.publishIssues()` actually produced has to come back
365
+ * through the side channel it wrote (`refine_publish.json`, under the
366
+ * SAME symlinked session dir `linkDataDir` already wires up), not through
367
+ * `runChainDef`'s return value.
368
+ */
369
+ const runRefine = async (opts) => {
370
+ const chainDef = findChain(cfg.watch.refine.chain); // checked above
371
+ const ctx = {
372
+ prompt: opts.prompt,
373
+ config_paths: configPaths,
374
+ adw_id: opts.adwId,
375
+ cwd: opts.cwd,
376
+ chain_name: chainDef.name,
377
+ issue_id: opts.issueId,
378
+ // Same reasoning as runChain's ctx above — an unattended dispatch,
379
+ // with no --suite-shaped options reaching it either (same KNOWN
380
+ // LIMITATION).
381
+ unattended: true,
382
+ chain_source: chainDef.source,
383
+ };
192
384
  const code = await runChainDef(chainDef, ctx);
193
- if (code === 0)
194
- return { accepted: true, adwId: opts.adwId, detail: "" };
195
- let detail = `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`;
385
+ if (code !== 0) {
386
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
387
+ return { accepted: false, adwId: opts.adwId, detail, created: [] };
388
+ }
389
+ let created = [];
196
390
  try {
197
391
  const wtAnchor = paths.resolveAnchor(opts.cwd);
198
392
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
199
- const db = new SfDb(wtDataPaths.db_path);
200
- const failed = db.phases(opts.adwId).find((p) => p.status === "fail");
201
- if (failed)
202
- detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
393
+ const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
394
+ created = JSON.parse(readFileSync(summaryPath, "utf-8"));
203
395
  }
204
396
  catch {
205
- // best-effort — the generic message above still points at where to look
397
+ // best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
206
398
  }
207
- return { accepted: false, adwId: opts.adwId, detail };
399
+ return { accepted: true, adwId: opts.adwId, detail: "", created };
208
400
  };
209
401
  const deps = {
210
402
  provider,
@@ -215,6 +407,10 @@ export async function watchCommand(argv) {
215
407
  chain: cfg.watch.chain,
216
408
  baseBranch: cfg.watch.base_branch,
217
409
  concurrency: cfg.watch.concurrency,
410
+ refineEnabled: cfg.watch.refine.enabled,
411
+ refineConcurrency: cfg.watch.refine.concurrency,
412
+ refineChain: cfg.watch.refine.chain,
413
+ runRefine,
218
414
  worktreesDir,
219
415
  linkDataDir,
220
416
  dryRun: Boolean(flags["dry-run"]),
@@ -254,7 +450,9 @@ export async function watchCommand(argv) {
254
450
  };
255
451
  process.on("SIGINT", stop);
256
452
  process.on("SIGTERM", stop);
257
- console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
453
+ console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
454
+ (cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
455
+ (flags["dry-run"] ? " (dry run)" : ""));
258
456
  deps.notify({
259
457
  kind: "watch_started",
260
458
  level: "info",