@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.
- package/README.md +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +237 -18
- package/dist/chains/steps.js +477 -58
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +297 -6
- 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
|
|
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`)
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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}${
|
|
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
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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 = {
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
200
|
-
|
|
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 —
|
|
397
|
+
// best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
|
|
206
398
|
}
|
|
207
|
-
return { accepted:
|
|
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}
|
|
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",
|