@gr8ful/spf 0.2.1 → 0.4.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 +106 -6
- package/assets/defaults/spf.config.yaml +16 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/skill/references/config.md +83 -3
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts.spf.config.yaml +22 -2
- package/dist/chains/context.d.ts +9 -0
- package/dist/chains/index.js +5 -0
- package/dist/chains/steps.d.ts +24 -0
- package/dist/chains/steps.js +55 -4
- package/dist/cli/commands/doctor.js +18 -0
- package/dist/cli/commands/init.js +44 -3
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +1 -0
- package/dist/cli/commands/run.js +5 -1
- package/dist/cli/commands/watch.js +86 -8
- package/dist/cli/index.js +7 -3
- package/dist/cli/interview.d.ts +2 -0
- package/dist/cli/interview.js +107 -3
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +133 -0
- package/dist/core/data_types.js +72 -0
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- 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 +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +144 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.js +3 -0
- package/dist/core/watch.d.ts +66 -1
- package/dist/core/watch.js +267 -15
- package/dist/test/chains.test.js +1 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/init_command.test.js +17 -0
- package/dist/test/interview.test.js +119 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +126 -0
- package/dist/test/watch.test.js +286 -5
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import * as agents from "../../core/agents.js";
|
|
|
13
13
|
import * as paths from "../../core/paths.js";
|
|
14
14
|
import * as permissions from "../../core/permissions.js";
|
|
15
15
|
import * as agentCc from "../../core/agent_cc.js";
|
|
16
|
+
import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
|
|
16
17
|
import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
|
|
17
18
|
import { binaryOnPath, parseCli } from "../../core/utils.js";
|
|
18
19
|
import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
|
|
@@ -142,6 +143,23 @@ export function doctorCommand(argv) {
|
|
|
142
143
|
: 'not set — Bitbucket app passwords are being removed; spf watch needs an Atlassian account email plus an API token instead; see README.md\'s "spf watch" section');
|
|
143
144
|
}
|
|
144
145
|
check(report, "watch.chain", Boolean(findChain(cfg.watch.chain)), findChain(cfg.watch.chain) ? cfg.watch.chain : `"${cfg.watch.chain}" is not a registered chain`);
|
|
146
|
+
if (cfg.watch.refine.enabled) {
|
|
147
|
+
check(report, "watch.refine.chain", Boolean(findChain(cfg.watch.refine.chain)), findChain(cfg.watch.refine.chain) ? cfg.watch.refine.chain : `"${cfg.watch.refine.chain}" is not a registered chain`);
|
|
148
|
+
check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github", cfg.watch.issue_provider === "github"
|
|
149
|
+
? "github supports issue authoring (createIssue/sub-issues)"
|
|
150
|
+
: `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" (Jira issue authoring isn't implemented yet)`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (cfg.notifications.events !== "off") {
|
|
154
|
+
check(report, "notifications.events", true, cfg.notifications.events);
|
|
155
|
+
if (cfg.notifications.channels.length === 0) {
|
|
156
|
+
check(report, "notifications.channels", false, `notifications.events is ${JSON.stringify(cfg.notifications.events)} but no channels are configured`);
|
|
157
|
+
}
|
|
158
|
+
for (const ch of cfg.notifications.channels) {
|
|
159
|
+
const envKey = ch.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[ch.kind];
|
|
160
|
+
const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
|
|
161
|
+
check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
|
|
162
|
+
}
|
|
145
163
|
}
|
|
146
164
|
return finish(report, flags["json"]);
|
|
147
165
|
}
|
|
@@ -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
|
|
|
@@ -86,6 +95,27 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
|
|
|
86
95
|
# label_prefix: spf
|
|
87
96
|
# chain: plan-build-test
|
|
88
97
|
# base_branch: main
|
|
98
|
+
# # Optional second lane: decompose a spf:spec-ready product spec into a
|
|
99
|
+
# # feature/story-or-bug tree of real issues. Off by default; needs
|
|
100
|
+
# # issue_provider: github — issue authoring isn't implemented for Jira yet.
|
|
101
|
+
# refine:
|
|
102
|
+
# enabled: true
|
|
103
|
+
# chain: refine
|
|
104
|
+
# concurrency: 1
|
|
105
|
+
|
|
106
|
+
# Uncomment to push notifications for unattended work — spf watch's daemon
|
|
107
|
+
# lifecycle, and every chain run (spf <chain> / spf run, including watch's
|
|
108
|
+
# own per-issue runs). Interactive commands (doctor, list, sessions, ...)
|
|
109
|
+
# never notify — you're already looking at the terminal for those. events:
|
|
110
|
+
# "errors" sends only failures/blocked issues; "all" adds every milestone
|
|
111
|
+
# (run started, issue claimed, PR opened, ...). The URL is a secret and
|
|
112
|
+
# lives only in .env — never in this file. See README.md's "Notifications"
|
|
113
|
+
# section for how to get each webhook URL.
|
|
114
|
+
# notifications:
|
|
115
|
+
# events: errors # off (default) | errors | all
|
|
116
|
+
# channels:
|
|
117
|
+
# - kind: slack # slack | teams | webhook
|
|
118
|
+
# webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
|
|
89
119
|
`;
|
|
90
120
|
// .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
|
|
91
121
|
// shared project config, same as package.json. Only runtime/generated
|
|
@@ -99,10 +129,18 @@ const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s i
|
|
|
99
129
|
# (gitignored) — .env.example lists the key names only.
|
|
100
130
|
`;
|
|
101
131
|
export async function initCommand(argv) {
|
|
102
|
-
const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes"]);
|
|
132
|
+
const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes", "no-skills"]);
|
|
103
133
|
const anchor = paths.resolveAnchor(options["cwd"]);
|
|
104
134
|
const sfDir = path.join(anchor.repo_root, ".spf");
|
|
105
135
|
mkdirSync(sfDir, { recursive: true });
|
|
136
|
+
// Idempotent (a no-op once the skill is already up to date, a `.new`
|
|
137
|
+
// sibling rather than an overwrite for a locally-edited file) — safe to
|
|
138
|
+
// call on every `spf init`, not just the first.
|
|
139
|
+
const installSkill = () => {
|
|
140
|
+
if (flags["no-skills"])
|
|
141
|
+
return;
|
|
142
|
+
installSkillCommand(options["cwd"] ? ["--cwd", options["cwd"]] : []);
|
|
143
|
+
};
|
|
106
144
|
const configPath = path.join(sfDir, "spf.config.yaml");
|
|
107
145
|
const templateName = options["template"];
|
|
108
146
|
const interactive = !templateName && !flags["yes"] && isInteractive();
|
|
@@ -115,6 +153,7 @@ export async function initCommand(argv) {
|
|
|
115
153
|
writeFileSync(configPath, content);
|
|
116
154
|
console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
|
|
117
155
|
}
|
|
156
|
+
installSkill();
|
|
118
157
|
}
|
|
119
158
|
else {
|
|
120
159
|
const asker = createAsker();
|
|
@@ -124,6 +163,7 @@ export async function initCommand(argv) {
|
|
|
124
163
|
if (!overwrite) {
|
|
125
164
|
console.log("leaving the existing config alone (--force to skip this prompt)");
|
|
126
165
|
asker.close();
|
|
166
|
+
installSkill();
|
|
127
167
|
ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
|
|
128
168
|
return 0;
|
|
129
169
|
}
|
|
@@ -141,6 +181,7 @@ export async function initCommand(argv) {
|
|
|
141
181
|
if (Object.keys(result.env).length > 0)
|
|
142
182
|
upsertEnvFile(anchor.repo_root, result.env);
|
|
143
183
|
writeEnvExample(anchor.repo_root, result.envExampleKeys);
|
|
184
|
+
installSkill();
|
|
144
185
|
// The same merge-then-validate pipeline `spf doctor` runs — catches a
|
|
145
186
|
// bad answer (e.g. a suite naming an unconfigured check) right after
|
|
146
187
|
// 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:
|
|
@@ -9,5 +9,6 @@ export function listCommand() {
|
|
|
9
9
|
console.log();
|
|
10
10
|
}
|
|
11
11
|
console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] (spf run <name> ... works identically)`);
|
|
12
|
+
console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
|
|
12
13
|
return 0;
|
|
13
14
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
|
+
const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue"];
|
|
6
6
|
export function usageFor(chain) {
|
|
7
7
|
return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
|
|
8
8
|
}
|
|
@@ -19,6 +19,10 @@ export async function dispatchChain(chain, argv) {
|
|
|
19
19
|
adw_id: options["adw-id"] ?? null,
|
|
20
20
|
cwd: anchor.cwd,
|
|
21
21
|
chain_name: chain.name,
|
|
22
|
+
// Only `refine` reads this (a "## Parent: #<id>" back-reference on
|
|
23
|
+
// every issue it creates — see ChainContext's doc comment); every
|
|
24
|
+
// other chain ignores it, so it's harmless to always pass through.
|
|
25
|
+
issue_id: options["issue"] ?? null,
|
|
22
26
|
};
|
|
23
27
|
const chainOptions = {};
|
|
24
28
|
if (options["agent"] !== undefined)
|
|
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import * as agents from "../../core/agents.js";
|
|
12
12
|
import * as paths from "../../core/paths.js";
|
|
13
|
+
import { resolveNotifier } from "../../core/notify/notifier.js";
|
|
13
14
|
import { isRepoAt, makeGit } from "../../core/git_helper.js";
|
|
14
15
|
import { GitHubProvider } from "../../core/issues/github_provider.js";
|
|
15
16
|
import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
@@ -149,6 +150,21 @@ export async function watchCommand(argv) {
|
|
|
149
150
|
console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
150
151
|
return 1;
|
|
151
152
|
}
|
|
153
|
+
if (cfg.watch.refine.enabled) {
|
|
154
|
+
if (cfg.watch.issue_provider !== "github") {
|
|
155
|
+
// Fail loudly at startup, not silently every tick: JiraProvider
|
|
156
|
+
// doesn't implement IssueAuthoringProvider yet (see its module
|
|
157
|
+
// comment) — a refine lane that can never publish would otherwise
|
|
158
|
+
// just claim every spec-ready spec and block it, forever.
|
|
159
|
+
console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
|
|
160
|
+
`the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
if (!findChain(cfg.watch.refine.chain)) {
|
|
164
|
+
console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
152
168
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
153
169
|
const lockPath = path.join(dataPaths.data_dir, "watch.lock");
|
|
154
170
|
try {
|
|
@@ -161,6 +177,9 @@ export async function watchCommand(argv) {
|
|
|
161
177
|
const git = makeGit(anchor.repo_root);
|
|
162
178
|
const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
|
|
163
179
|
mkdirSync(worktreesDir, { recursive: true });
|
|
180
|
+
// `null` when notifications are off/unconfigured — every call below is a
|
|
181
|
+
// no-op fallback to `console.error` in that case (see notify()`s default).
|
|
182
|
+
const notifier = resolveNotifier(cfg, { dryRun: Boolean(flags["dry-run"]) });
|
|
164
183
|
/**
|
|
165
184
|
* Without this, a claimed issue's chain resolves its session/trace data
|
|
166
185
|
* relative to `cwd` (the worktree, not the main repo — see
|
|
@@ -182,25 +201,65 @@ export async function watchCommand(argv) {
|
|
|
182
201
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
183
202
|
symlinkSync(dataPaths.data_dir, target, "dir");
|
|
184
203
|
}
|
|
204
|
+
/** 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. */
|
|
205
|
+
function detailFromFailedPhase(cwd, adwId, prefix) {
|
|
206
|
+
let detail = prefix;
|
|
207
|
+
try {
|
|
208
|
+
const wtAnchor = paths.resolveAnchor(cwd);
|
|
209
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
210
|
+
const db = new SfDb(wtDataPaths.db_path);
|
|
211
|
+
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
212
|
+
if (failed)
|
|
213
|
+
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// best-effort — the generic message above still points at where to look
|
|
217
|
+
}
|
|
218
|
+
return detail;
|
|
219
|
+
}
|
|
185
220
|
const runChain = async (opts) => {
|
|
186
221
|
const chainDef = findChain(cfg.watch.chain); // checked above
|
|
187
222
|
const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
|
|
188
223
|
const code = await runChainDef(chainDef, ctx);
|
|
189
224
|
if (code === 0)
|
|
190
225
|
return { accepted: true, adwId: opts.adwId, detail: "" };
|
|
191
|
-
|
|
226
|
+
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.`);
|
|
227
|
+
return { accepted: false, adwId: opts.adwId, detail };
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Same shape as `runChain`, for the refine lane — with one extra step on
|
|
231
|
+
* success: a chain's return value is just an exit code, so the created-
|
|
232
|
+
* issues list `steps.publishIssues()` actually produced has to come back
|
|
233
|
+
* through the side channel it wrote (`refine_publish.json`, under the
|
|
234
|
+
* SAME symlinked session dir `linkDataDir` already wires up), not through
|
|
235
|
+
* `runChainDef`'s return value.
|
|
236
|
+
*/
|
|
237
|
+
const runRefine = async (opts) => {
|
|
238
|
+
const chainDef = findChain(cfg.watch.refine.chain); // checked above
|
|
239
|
+
const ctx = {
|
|
240
|
+
prompt: opts.prompt,
|
|
241
|
+
config_paths: configPaths,
|
|
242
|
+
adw_id: opts.adwId,
|
|
243
|
+
cwd: opts.cwd,
|
|
244
|
+
chain_name: chainDef.name,
|
|
245
|
+
issue_id: opts.issueId,
|
|
246
|
+
};
|
|
247
|
+
const code = await runChainDef(chainDef, ctx);
|
|
248
|
+
if (code !== 0) {
|
|
249
|
+
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.`);
|
|
250
|
+
return { accepted: false, adwId: opts.adwId, detail, created: [] };
|
|
251
|
+
}
|
|
252
|
+
let created = [];
|
|
192
253
|
try {
|
|
193
254
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
194
255
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
if (failed)
|
|
198
|
-
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
256
|
+
const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
|
|
257
|
+
created = JSON.parse(readFileSync(summaryPath, "utf-8"));
|
|
199
258
|
}
|
|
200
259
|
catch {
|
|
201
|
-
// best-effort —
|
|
260
|
+
// best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
|
|
202
261
|
}
|
|
203
|
-
return { accepted:
|
|
262
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created };
|
|
204
263
|
};
|
|
205
264
|
const deps = {
|
|
206
265
|
provider,
|
|
@@ -211,11 +270,16 @@ export async function watchCommand(argv) {
|
|
|
211
270
|
chain: cfg.watch.chain,
|
|
212
271
|
baseBranch: cfg.watch.base_branch,
|
|
213
272
|
concurrency: cfg.watch.concurrency,
|
|
273
|
+
refineEnabled: cfg.watch.refine.enabled,
|
|
274
|
+
refineConcurrency: cfg.watch.refine.concurrency,
|
|
275
|
+
refineChain: cfg.watch.refine.chain,
|
|
276
|
+
runRefine,
|
|
214
277
|
worktreesDir,
|
|
215
278
|
linkDataDir,
|
|
216
279
|
dryRun: Boolean(flags["dry-run"]),
|
|
217
280
|
runChain,
|
|
218
281
|
log: (message) => console.log(message),
|
|
282
|
+
notify: (event) => notifier?.send(event),
|
|
219
283
|
};
|
|
220
284
|
const state = createWatchState();
|
|
221
285
|
let stopping = false;
|
|
@@ -249,7 +313,19 @@ export async function watchCommand(argv) {
|
|
|
249
313
|
};
|
|
250
314
|
process.on("SIGINT", stop);
|
|
251
315
|
process.on("SIGTERM", stop);
|
|
252
|
-
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}
|
|
316
|
+
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}` +
|
|
317
|
+
(cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
|
|
318
|
+
(flags["dry-run"] ? " (dry run)" : ""));
|
|
319
|
+
deps.notify({
|
|
320
|
+
kind: "watch_started",
|
|
321
|
+
level: "info",
|
|
322
|
+
title: "watch started",
|
|
323
|
+
fields: [
|
|
324
|
+
["repo", cfg.watch.repo],
|
|
325
|
+
["label_prefix", cfg.watch.label_prefix],
|
|
326
|
+
["chain", cfg.watch.chain],
|
|
327
|
+
],
|
|
328
|
+
});
|
|
253
329
|
try {
|
|
254
330
|
for (;;) {
|
|
255
331
|
await tick(deps, state);
|
|
@@ -268,6 +344,8 @@ export async function watchCommand(argv) {
|
|
|
268
344
|
return 0;
|
|
269
345
|
}
|
|
270
346
|
finally {
|
|
347
|
+
deps.notify({ kind: "watch_stopped", level: "info", title: "watch stopped", fields: [["repo", cfg.watch.repo]] });
|
|
348
|
+
await notifier?.flush();
|
|
271
349
|
process.off("SIGINT", stop);
|
|
272
350
|
process.off("SIGTERM", stop);
|
|
273
351
|
releaseLock(lockPath);
|
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import * as agentCc from "../core/agent_cc.js";
|
|
9
9
|
import * as agentFlue from "../core/agent_flue.js";
|
|
10
|
+
import * as notify from "../core/notify/notifier.js";
|
|
10
11
|
import * as paths from "../core/paths.js";
|
|
11
12
|
import { findChain } from "../chains/index.js";
|
|
12
13
|
import { dispatchChain, usageFor } from "./commands/run.js";
|
|
@@ -27,8 +28,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
27
28
|
|
|
28
29
|
spf list the chain registry — names, phases, what each needs
|
|
29
30
|
spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
|
|
30
|
-
spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
|
|
31
|
-
spf install-skill [--user] [--force] install the Claude Code skill
|
|
31
|
+
spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
|
|
32
|
+
spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
|
|
32
33
|
spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
|
|
33
34
|
spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
|
|
34
35
|
spf doctor [--json] check everything that fails silently otherwise
|
|
@@ -41,7 +42,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
41
42
|
spf abort <adw_id> signal a run's process to stop
|
|
42
43
|
spf version print the installed version
|
|
43
44
|
|
|
44
|
-
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
|
|
45
|
+
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
|
|
45
46
|
Run \`spf list\` to see every chain and what it needs.`;
|
|
46
47
|
/** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
|
|
47
48
|
function findCwdFlag(argv) {
|
|
@@ -152,5 +153,8 @@ export async function main() {
|
|
|
152
153
|
// agent_cc.ts's shutdown() just kills any still-running claude children.
|
|
153
154
|
await agentFlue.shutdown();
|
|
154
155
|
await agentCc.shutdown();
|
|
156
|
+
// A no-op if notifications are off/unconfigured — awaits any in-flight
|
|
157
|
+
// webhook POST so a fast-exiting command doesn't drop it mid-flight.
|
|
158
|
+
await notify.flushAll();
|
|
155
159
|
}
|
|
156
160
|
}
|
package/dist/cli/interview.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface DetectedContext {
|
|
|
8
8
|
claudeOnPath: boolean;
|
|
9
9
|
/** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
|
|
10
10
|
existingEnv: Map<string, string>;
|
|
11
|
+
/** The packaged roster's agent names (planner, builder, scout, reviewer, documenter today) — read from the built-in config so a 6th agent added there needs no interview change. */
|
|
12
|
+
rosterNames: string[];
|
|
11
13
|
}
|
|
12
14
|
export declare function gatherContext(repoRoot: string, existingEnv?: Map<string, string>): DetectedContext;
|
|
13
15
|
export interface InterviewResult {
|
package/dist/cli/interview.js
CHANGED
|
@@ -18,6 +18,9 @@ import { binaryOnPath } from "../core/utils.js";
|
|
|
18
18
|
import { PROVIDER_ENV_KEYS } from "../core/providers.js";
|
|
19
19
|
import { resolveModel } from "../core/agent_flue.js";
|
|
20
20
|
import { ThinkingLevelSchema } from "../core/data_types.js";
|
|
21
|
+
import { loadConfig } from "../core/agents.js";
|
|
22
|
+
import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
|
|
23
|
+
import { DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
|
|
21
24
|
import { CHAINS } from "../chains/index.js";
|
|
22
25
|
function gitConfigValue(repoRoot, key) {
|
|
23
26
|
const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
|
|
@@ -40,6 +43,15 @@ function readScripts(repoRoot) {
|
|
|
40
43
|
return {};
|
|
41
44
|
}
|
|
42
45
|
}
|
|
46
|
+
/** Best-effort — a corrupt/missing built-in config falls back to the three names the auto-override already knows about, rather than failing the whole interview. */
|
|
47
|
+
function readRosterNames() {
|
|
48
|
+
try {
|
|
49
|
+
return loadConfig([BUILTIN_CONFIG_PATH]).agents.map((a) => a.name);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return ["planner", "builder", "scout", "reviewer", "documenter"];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
43
55
|
export function gatherContext(repoRoot, existingEnv = new Map()) {
|
|
44
56
|
const remote = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: repoRoot, encoding: "utf-8" });
|
|
45
57
|
const branch = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
|
|
@@ -51,6 +63,7 @@ export function gatherContext(repoRoot, existingEnv = new Map()) {
|
|
|
51
63
|
scripts: readScripts(repoRoot),
|
|
52
64
|
claudeOnPath: binaryOnPath("claude"),
|
|
53
65
|
existingEnv,
|
|
66
|
+
rosterNames: readRosterNames(),
|
|
54
67
|
};
|
|
55
68
|
}
|
|
56
69
|
const QUALITY_TIMEOUTS = { typecheck: 60, lint: 60, build: 120, test: 180 };
|
|
@@ -79,7 +92,7 @@ export async function runInterview(asker, ctx) {
|
|
|
79
92
|
if (!ctx.claudeOnPath) {
|
|
80
93
|
asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
|
|
81
94
|
}
|
|
82
|
-
const launchCommand = await asker.text(
|
|
95
|
+
const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude" to route through a wrapper (SPF_CLAUDE_CMD)', { default: "claude" });
|
|
83
96
|
if (launchCommand !== "claude")
|
|
84
97
|
env["SPF_CLAUDE_CMD"] = launchCommand;
|
|
85
98
|
const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
|
|
@@ -142,6 +155,34 @@ export async function runInterview(asker, ctx) {
|
|
|
142
155
|
env[envKey] = key;
|
|
143
156
|
envExampleKeys.push(envKey);
|
|
144
157
|
}
|
|
158
|
+
// Declined (the default): today's behavior exactly — on claude_code, the
|
|
159
|
+
// three-agent auto-pin above stands unchanged; on flue, no agents: block
|
|
160
|
+
// at all. Accepted: ask every roster agent's model, defaulting to the one
|
|
161
|
+
// chosen above, and patch/append its override — `mergeAgentLists`
|
|
162
|
+
// (agents.ts:63-71) shallow-patches by name, so re-setting `model` on an
|
|
163
|
+
// already-pinned entry (claude_code's planner/reviewer/documenter) is
|
|
164
|
+
// exactly the right shape, no `prompt_engineering` needed (interview.ts's
|
|
165
|
+
// review-section comment explains why that's fine).
|
|
166
|
+
const customizeModels = await asker.confirm("Customize models per agent?", false);
|
|
167
|
+
if (customizeModels) {
|
|
168
|
+
asker.heading("Per-agent models");
|
|
169
|
+
for (const name of ctx.rosterNames) {
|
|
170
|
+
const answer = await asker.text(` ${name}`, { default: String(defaults.model) });
|
|
171
|
+
if (codingAgent === "flue") {
|
|
172
|
+
try {
|
|
173
|
+
resolveModel(answer);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
asker.note(`warning: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const existing = agentOverrides.find((a) => a.name === name);
|
|
180
|
+
if (existing)
|
|
181
|
+
existing.model = answer;
|
|
182
|
+
else
|
|
183
|
+
agentOverrides.push({ name, model: answer });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
145
186
|
// ── 2. quality checks ──────────────────────────────────────────────────────
|
|
146
187
|
asker.heading("Quality checks");
|
|
147
188
|
asker.note("a chain that gates on a suite (e.g. plan-build-test's `test`) fails loudly before anything runs if the suite is unconfigured.");
|
|
@@ -190,6 +231,18 @@ export async function runInterview(asker, ctx) {
|
|
|
190
231
|
const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
|
|
191
232
|
const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
|
|
192
233
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
234
|
+
// Issue authoring (create + link a hierarchy) is only implemented on
|
|
235
|
+
// GitHubProvider today — see jira_provider.ts's module comment — so
|
|
236
|
+
// this lane isn't offered at all on a Jira tracker rather than asking a
|
|
237
|
+
// question that would just fail at `spf watch` startup.
|
|
238
|
+
if (issueProvider === "github") {
|
|
239
|
+
const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
|
|
240
|
+
if (enableRefine) {
|
|
241
|
+
const refineChainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
|
|
242
|
+
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
243
|
+
watch.refine = { enabled: true, chain: refineChain };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
193
246
|
if (issueProvider === "jira") {
|
|
194
247
|
const baseUrl = await asker.text("Jira base URL", {
|
|
195
248
|
default: "https://your-domain.atlassian.net",
|
|
@@ -227,7 +280,46 @@ export async function runInterview(asker, ctx) {
|
|
|
227
280
|
asker.note("Bitbucket app passwords are being removed — this needs an Atlassian API token instead.");
|
|
228
281
|
}
|
|
229
282
|
}
|
|
230
|
-
// ── 4.
|
|
283
|
+
// ── 4. notifications ─────────────────────────────────────────────────────────
|
|
284
|
+
asker.heading("Notifications");
|
|
285
|
+
const enableNotify = await asker.confirm("Send notifications to Slack, Teams, or a webhook?", false);
|
|
286
|
+
let notifications = null;
|
|
287
|
+
if (enableNotify) {
|
|
288
|
+
const events = await asker.select("Notify on", [
|
|
289
|
+
{ value: "errors", label: "errors — failed runs, blocked issues, watch errors" },
|
|
290
|
+
{ value: "all", label: "all — every milestone (claimed, PR opened, done, ...) plus errors" },
|
|
291
|
+
], "errors");
|
|
292
|
+
const channels = [];
|
|
293
|
+
for (;;) {
|
|
294
|
+
const kind = await asker.select("Channel", [
|
|
295
|
+
{ value: "slack", label: "Slack — Incoming Webhook" },
|
|
296
|
+
{ value: "teams", label: "Microsoft Teams — Workflows webhook" },
|
|
297
|
+
{ value: "webhook", label: "generic webhook — POSTs the raw event as JSON" },
|
|
298
|
+
], "slack");
|
|
299
|
+
const envKey = DEFAULT_NOTIFY_ENV_KEY[kind];
|
|
300
|
+
if (kind === "slack") {
|
|
301
|
+
asker.note("Slack app -> Incoming Webhooks -> Add New Webhook to Workspace.");
|
|
302
|
+
asker.note("Docs: https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks");
|
|
303
|
+
}
|
|
304
|
+
else if (kind === "teams") {
|
|
305
|
+
asker.note("In the target channel, add a Workflows webhook template (search for one like \"Post to a channel when a webhook request is received\"). The old Office 365 connector webhooks are retired — this is the only path now.");
|
|
306
|
+
asker.note("Docs: https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498");
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
asker.note("Any endpoint that accepts a JSON POST — Discord, n8n, Zapier, your own.");
|
|
310
|
+
}
|
|
311
|
+
const url = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
|
|
312
|
+
if (url)
|
|
313
|
+
env[envKey] = url;
|
|
314
|
+
envExampleKeys.push(envKey);
|
|
315
|
+
channels.push({ kind, webhook_url_env: envKey });
|
|
316
|
+
const another = await asker.confirm("Add another channel?", false);
|
|
317
|
+
if (!another)
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
notifications = { events, channels };
|
|
321
|
+
}
|
|
322
|
+
// ── 5. advanced (gated) ─────────────────────────────────────────────────────
|
|
231
323
|
const wantAdvanced = await asker.confirm("\nConfigure advanced settings (thinking level, tools, protected files, data dir, poll intervals, ...)?", false);
|
|
232
324
|
if (wantAdvanced) {
|
|
233
325
|
asker.heading("Advanced");
|
|
@@ -254,6 +346,11 @@ export async function runInterview(asker, ctx) {
|
|
|
254
346
|
const concurrency = await asker.text("watch.concurrency", { default: "2" });
|
|
255
347
|
if (concurrency !== "2")
|
|
256
348
|
watch.concurrency = Number(concurrency);
|
|
349
|
+
if (watch.refine?.enabled) {
|
|
350
|
+
const refineConcurrency = await asker.text("watch.refine.concurrency", { default: "1" });
|
|
351
|
+
if (refineConcurrency !== "1")
|
|
352
|
+
watch.refine.concurrency = Number(refineConcurrency);
|
|
353
|
+
}
|
|
257
354
|
}
|
|
258
355
|
const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
|
|
259
356
|
if (engineerName && engineerName !== ctx.gitName)
|
|
@@ -263,8 +360,13 @@ export async function runInterview(asker, ctx) {
|
|
|
263
360
|
if (debug)
|
|
264
361
|
env["SPF_JIRA_DEBUG"] = "1";
|
|
265
362
|
}
|
|
363
|
+
if (notifications) {
|
|
364
|
+
const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
|
|
365
|
+
if (timeoutMs !== "5000")
|
|
366
|
+
notifications.timeout_ms = Number(timeoutMs);
|
|
367
|
+
}
|
|
266
368
|
}
|
|
267
|
-
// ──
|
|
369
|
+
// ── 6. review + confirm ─────────────────────────────────────────────────────
|
|
268
370
|
const observability = defaults["__observability__"];
|
|
269
371
|
delete defaults["__observability__"];
|
|
270
372
|
const quality = defaults["__quality__"];
|
|
@@ -278,6 +380,8 @@ export async function runInterview(asker, ctx) {
|
|
|
278
380
|
config.watch = watch;
|
|
279
381
|
if (observability)
|
|
280
382
|
config.observability = observability;
|
|
383
|
+
if (notifications)
|
|
384
|
+
config.notifications = notifications;
|
|
281
385
|
// No schema validation here on purpose: `config.agents` overrides are
|
|
282
386
|
// intentionally PARTIAL (name + model only, no prompt_engineering) — valid
|
|
283
387
|
// only once merged by name into the packaged roster (agents.ts's
|
package/dist/core/agents.js
CHANGED
|
@@ -57,13 +57,16 @@ function mergeAgentLists(base, override) {
|
|
|
57
57
|
}
|
|
58
58
|
return merged;
|
|
59
59
|
}
|
|
60
|
-
/** `defaults`/`observability`/`quality` merge key-by-key; `agents` merges by name. */
|
|
60
|
+
/** `defaults`/`observability`/`quality`/`watch`/`notifications` merge key-by-key; `agents` merges by name. */
|
|
61
61
|
function mergeRawConfig(base, override) {
|
|
62
62
|
return {
|
|
63
63
|
defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
|
|
64
64
|
observability: { ...(base.observability || {}), ...(override.observability || {}) },
|
|
65
65
|
quality: { ...(base.quality || {}), ...(override.quality || {}) },
|
|
66
66
|
watch: { ...(base.watch || {}), ...(override.watch || {}) },
|
|
67
|
+
// channels is a whole-array replace on override, same as quality.checks —
|
|
68
|
+
// you don't want an override's channels appended to the built-in's.
|
|
69
|
+
notifications: { ...(base.notifications || {}), ...(override.notifications || {}) },
|
|
67
70
|
agents: mergeAgentLists(base.agents || [], override.agents || []),
|
|
68
71
|
};
|
|
69
72
|
}
|
package/dist/core/console.d.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* so a CI log reads exactly like a terminal.
|
|
8
8
|
*/
|
|
9
9
|
import type { EnvelopeBase, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
10
|
+
import type { NotifyEvent } from "./notify/channel.ts";
|
|
11
|
+
interface Notifier {
|
|
12
|
+
send(event: NotifyEvent): void;
|
|
13
|
+
}
|
|
10
14
|
export declare function paint(style: string, text: string): string;
|
|
11
15
|
interface Tracer {
|
|
12
16
|
event(record: EventRecord): string;
|
|
@@ -15,11 +19,19 @@ interface Tracer {
|
|
|
15
19
|
export declare class Console {
|
|
16
20
|
private tracer;
|
|
17
21
|
private adwId;
|
|
22
|
+
/** `null` when notifications are off — every call site below guards with `?.`. */
|
|
23
|
+
private notifier;
|
|
24
|
+
/** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
|
|
25
|
+
private chainName;
|
|
18
26
|
private phaseId;
|
|
19
27
|
private phaseName;
|
|
20
28
|
private results;
|
|
21
29
|
private finished;
|
|
22
|
-
constructor(tracer: Tracer, adwId: string
|
|
30
|
+
constructor(tracer: Tracer, adwId: string,
|
|
31
|
+
/** `null` when notifications are off — every call site below guards with `?.`. */
|
|
32
|
+
notifier?: Notifier | null,
|
|
33
|
+
/** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
|
|
34
|
+
chainName?: string);
|
|
23
35
|
private emit;
|
|
24
36
|
sessionStarted(adwId: string, engineer: string): void;
|
|
25
37
|
sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): void;
|