@gr8ful/spf 0.4.0 → 0.5.1
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 +122 -4
- package/assets/defaults/spf.config.yaml +6 -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 +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -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 +215 -20
- package/dist/chains/steps.js +429 -61
- 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 +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- 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 +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -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/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- 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 +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -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 +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- 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/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- 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 +124 -1
- package/package.json +5 -5
|
@@ -1,14 +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
|
+
}
|
|
9
33
|
console.log();
|
|
10
34
|
}
|
|
11
|
-
console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] (spf run <name> ... works identically)`);
|
|
35
|
+
console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`);
|
|
12
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) {
|
|
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
|
+
}
|
|
49
|
+
}
|
|
13
50
|
return 0;
|
|
14
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]),
|
|
@@ -23,11 +33,24 @@ export async function dispatchChain(chain, argv) {
|
|
|
23
33
|
// every issue it creates — see ChainContext's doc comment); every
|
|
24
34
|
// other chain ignores it, so it's harmless to always pass through.
|
|
25
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,
|
|
26
47
|
};
|
|
27
48
|
const chainOptions = {};
|
|
28
49
|
if (options["agent"] !== undefined)
|
|
29
50
|
chainOptions["agent"] = options["agent"];
|
|
30
51
|
if (options["base"] !== undefined)
|
|
31
52
|
chainOptions["base"] = options["base"];
|
|
53
|
+
if (options["suite"] !== undefined)
|
|
54
|
+
chainOptions["suite"] = options["suite"];
|
|
32
55
|
return runChain(chain, ctx, chainOptions);
|
|
33
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`);
|
|
@@ -196,6 +260,17 @@ export async function watchCommand(argv) {
|
|
|
196
260
|
*/
|
|
197
261
|
function linkDataDir(worktreePath) {
|
|
198
262
|
const target = path.join(worktreePath, ".spf", "data");
|
|
263
|
+
// Hard guard, independent of however `worktreePath` got resolved: a real
|
|
264
|
+
// self-referential .spf/data symlink was observed live once already
|
|
265
|
+
// (points at itself — every later `mkdirSync` through it throws ELOOP;
|
|
266
|
+
// see `paths.healBrokenDataDir` for the recovery side of this same bug).
|
|
267
|
+
// The exact trigger was never pinned down, but `worktreePath` resolving
|
|
268
|
+
// to the main repo's own `data_dir` can never be correct regardless of
|
|
269
|
+
// how it got there, so refuse outright rather than create it again.
|
|
270
|
+
if (path.resolve(target) === path.resolve(dataPaths.data_dir)) {
|
|
271
|
+
console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
199
274
|
if (existsSync(target))
|
|
200
275
|
return;
|
|
201
276
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -204,10 +279,11 @@ export async function watchCommand(argv) {
|
|
|
204
279
|
/** 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
280
|
function detailFromFailedPhase(cwd, adwId, prefix) {
|
|
206
281
|
let detail = prefix;
|
|
282
|
+
let db;
|
|
207
283
|
try {
|
|
208
284
|
const wtAnchor = paths.resolveAnchor(cwd);
|
|
209
285
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
210
|
-
|
|
286
|
+
db = new SfDb(wtDataPaths.db_path);
|
|
211
287
|
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
212
288
|
if (failed)
|
|
213
289
|
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
@@ -215,16 +291,83 @@ export async function watchCommand(argv) {
|
|
|
215
291
|
catch {
|
|
216
292
|
// best-effort — the generic message above still points at where to look
|
|
217
293
|
}
|
|
294
|
+
finally {
|
|
295
|
+
db?.close();
|
|
296
|
+
}
|
|
218
297
|
return detail;
|
|
219
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Best-effort: the reviewer's latest verdict for this run, read back from
|
|
301
|
+
* the worktree's own (symlinked) trace db and reduced to a digest — same
|
|
302
|
+
* DB, same try/catch shape as `detailFromFailedPhase` above, but on the
|
|
303
|
+
* SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
|
|
304
|
+
* never produced a `ReviewOutput` envelope at all — never thrown: a digest
|
|
305
|
+
* is a nice-to-have, not something that gets to block the PR-open flow
|
|
306
|
+
* it's decorating.
|
|
307
|
+
*/
|
|
308
|
+
function reviewSummaryFor(cwd, adwId) {
|
|
309
|
+
let db;
|
|
310
|
+
try {
|
|
311
|
+
const wtAnchor = paths.resolveAnchor(cwd);
|
|
312
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
313
|
+
db = new SfDb(wtDataPaths.db_path);
|
|
314
|
+
const envelope = db
|
|
315
|
+
.envelopes(adwId)
|
|
316
|
+
.filter((e) => e.output_type === ReviewOutput.name)
|
|
317
|
+
.at(-1); // the LATEST verdict — a revise loop can produce several
|
|
318
|
+
if (!envelope?.payload_json)
|
|
319
|
+
return undefined;
|
|
320
|
+
const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
|
|
321
|
+
return formatReviewDigest(review);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return undefined; // best-effort — see the doc comment above
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
db?.close();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
220
330
|
const runChain = async (opts) => {
|
|
331
|
+
// WATCH DIVERGENCE: `findChain` here resolves against the registry
|
|
332
|
+
// `cli/index.ts`'s `main()` built ONCE, at daemon start, from the MAIN
|
|
333
|
+
// repo anchor (the `registerRepoChains(...)` call before the command
|
|
334
|
+
// switch). `opts.cwd` below is a per-issue WORKTREE, which may carry a
|
|
335
|
+
// different (or edited) `.spf/chains/`, but that file is never
|
|
336
|
+
// re-read here — this is deliberate, not a gap: the disposer stays the
|
|
337
|
+
// OPERATOR's, never the branch's, so an agent can't rewrite its own
|
|
338
|
+
// quality gate mid-run by editing a chain file as part of the change
|
|
339
|
+
// it's making. (Also documented in the `spf init` scaffold, since that's
|
|
340
|
+
// the one place an author is invited to edit a chain file at all.)
|
|
221
341
|
const chainDef = findChain(cfg.watch.chain); // checked above
|
|
222
|
-
const ctx = {
|
|
342
|
+
const ctx = {
|
|
343
|
+
prompt: opts.prompt,
|
|
344
|
+
config_paths: configPaths,
|
|
345
|
+
adw_id: opts.adwId,
|
|
346
|
+
cwd: opts.cwd,
|
|
347
|
+
chain_name: chainDef.name,
|
|
348
|
+
// Every `spf watch` dispatch is unattended by definition — there is
|
|
349
|
+
// no human at a TTY to prompt, ever, for an issue claimed off a
|
|
350
|
+
// tracker poll. `chain_source` is undefined for a built-in chain,
|
|
351
|
+
// the resolved chain's `.spf/chains/*.yaml` path for a repo one.
|
|
352
|
+
unattended: true,
|
|
353
|
+
chain_source: chainDef.source,
|
|
354
|
+
};
|
|
355
|
+
// KNOWN LIMITATION (not fixed here): runChainDef is called below with no
|
|
356
|
+
// third `options` argument, so nothing --suite-shaped ever reaches this
|
|
357
|
+
// dispatch — `resolveRequiredSuites`/`resolveRequiredAgents` below both
|
|
358
|
+
// fall back to each chain's compiled-in/YAML-declared default. An
|
|
359
|
+
// unattended watch run therefore can't override a chain's suite the way
|
|
360
|
+
// an interactive `spf <chain> --suite <name>` can.
|
|
223
361
|
const code = await runChainDef(chainDef, ctx);
|
|
224
|
-
|
|
225
|
-
|
|
362
|
+
// Static for every chain but "prompt" (whose --agent flag `spf watch`
|
|
363
|
+
// never passes) — resolved with no options, exactly like `runChainDef`
|
|
364
|
+
// above ran it.
|
|
365
|
+
const reviewRequired = resolveRequiredAgents(chainDef, {}).includes("reviewer");
|
|
366
|
+
if (code === 0) {
|
|
367
|
+
return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
|
|
368
|
+
}
|
|
226
369
|
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 };
|
|
370
|
+
return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
|
|
228
371
|
};
|
|
229
372
|
/**
|
|
230
373
|
* Same shape as `runChain`, for the refine lane — with one extra step on
|
|
@@ -243,6 +386,11 @@ export async function watchCommand(argv) {
|
|
|
243
386
|
cwd: opts.cwd,
|
|
244
387
|
chain_name: chainDef.name,
|
|
245
388
|
issue_id: opts.issueId,
|
|
389
|
+
// Same reasoning as runChain's ctx above — an unattended dispatch,
|
|
390
|
+
// with no --suite-shaped options reaching it either (same KNOWN
|
|
391
|
+
// LIMITATION).
|
|
392
|
+
unattended: true,
|
|
393
|
+
chain_source: chainDef.source,
|
|
246
394
|
};
|
|
247
395
|
const code = await runChainDef(chainDef, ctx);
|
|
248
396
|
if (code !== 0) {
|
package/dist/cli/index.js
CHANGED
|
@@ -8,8 +8,10 @@ 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
10
|
import * as notify from "../core/notify/notifier.js";
|
|
11
|
+
import * as otel from "../core/otel.js";
|
|
11
12
|
import * as paths from "../core/paths.js";
|
|
12
|
-
import { findChain } from "../chains/index.js";
|
|
13
|
+
import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
|
|
14
|
+
import { loadRepoChains } from "../chains/repo_chains.js";
|
|
13
15
|
import { dispatchChain, usageFor } from "./commands/run.js";
|
|
14
16
|
import { listCommand } from "./commands/list.js";
|
|
15
17
|
import { initCommand } from "./commands/init.js";
|
|
@@ -49,6 +51,26 @@ function findCwdFlag(argv) {
|
|
|
49
51
|
const idx = argv.indexOf("--cwd");
|
|
50
52
|
return idx !== -1 ? argv[idx + 1] : undefined;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The moment someone types a chain name that doesn't resolve is the highest-
|
|
56
|
+
* traffic place a broken `.spf/chains/*.yaml` file is ever discovered — and,
|
|
57
|
+
* before this, the one place its load problem was withheld (`spf list` and
|
|
58
|
+
* `spf doctor` report it; this "unknown chain"/"unknown command or chain"
|
|
59
|
+
* path printed only the full HELP text). A file named for exactly the chain
|
|
60
|
+
* someone typed is surfaced first, ahead of any other broken file, since
|
|
61
|
+
* that's the one they're actually looking for right now.
|
|
62
|
+
*/
|
|
63
|
+
function reportUnresolvedChain(typedName) {
|
|
64
|
+
const problems = repoChainProblems();
|
|
65
|
+
if (problems.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const matching = typedName ? problems.filter((p) => path.basename(p.file).startsWith(typedName)) : [];
|
|
68
|
+
const rest = problems.filter((p) => !matching.includes(p));
|
|
69
|
+
const ordered = [...matching, ...rest];
|
|
70
|
+
console.error(`${problems.length} repo chain file(s) failed to load — one of these may be what you meant:`);
|
|
71
|
+
for (const p of ordered)
|
|
72
|
+
console.error(` ! ${p.file}: ${p.message}`);
|
|
73
|
+
}
|
|
52
74
|
export async function main() {
|
|
53
75
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
54
76
|
// `process.loadEnvFile()` with no argument reads from `process.cwd()` —
|
|
@@ -58,13 +80,41 @@ export async function main() {
|
|
|
58
80
|
// invoking shell's directory instead of the target repo's is exactly the
|
|
59
81
|
// anchor-mismatch bug that principle exists to close. Resolve the same
|
|
60
82
|
// way every command does, and load .env from the repo root it finds.
|
|
83
|
+
//
|
|
84
|
+
// Hoisted out of the try/catch below: resolveAnchor() cannot throw —
|
|
85
|
+
// findRepoRoot() (core/git_helper.ts) guards its own `git rev-parse
|
|
86
|
+
// --git-dir` probe with isRepoAt() and falls back to `cwd` itself when
|
|
87
|
+
// it's not a repo, AND guards the follow-up `--show-toplevel` call the
|
|
88
|
+
// same way (that one can fail even when isRepoAt() said yes — a bare repo
|
|
89
|
+
// or a `.git/` directory itself has no work tree), so it never throws
|
|
90
|
+
// either. findSfDir()'s walk is a plain existsSync/statSync loop with no
|
|
91
|
+
// failure path. The only thing in this block that CAN throw is
|
|
92
|
+
// loadEnvFile() (ENOENT when there's no .env), which is exactly what the
|
|
93
|
+
// catch below still guards.
|
|
94
|
+
const anchor = paths.resolveAnchor(findCwdFlag(rest));
|
|
61
95
|
try {
|
|
62
|
-
const anchor = paths.resolveAnchor(findCwdFlag(rest));
|
|
63
96
|
process.loadEnvFile(path.join(anchor.repo_root, ".env"));
|
|
64
97
|
}
|
|
65
98
|
catch {
|
|
66
99
|
// no .env there — fine, nothing to load
|
|
67
100
|
}
|
|
101
|
+
// Repo-local chains as DATA: a `.spf/chains/*.yaml` file names existing
|
|
102
|
+
// step factories, it never imports repo code — SPF's own dispatcher stays
|
|
103
|
+
// the one thing that ever executes. Registered here, before the command
|
|
104
|
+
// switch, so every dispatch path (`spf <chain>`, `spf run <chain>`, `spf
|
|
105
|
+
// list`, `spf doctor`, `spf watch`) sees the same merged registry without
|
|
106
|
+
// each of them re-scanning `.spf/chains/` on its own. This is also the
|
|
107
|
+
// ONCE in "`spf watch` registers chains from the main anchor once, at
|
|
108
|
+
// daemon start" — see the WATCH DIVERGENCE comment on `runChain` in
|
|
109
|
+
// `cli/commands/watch.ts` for what that means for a chain file edited on
|
|
110
|
+
// an issue branch mid-run. Zero filesystem cost when there's no `.spf/` at
|
|
111
|
+
// all (anchor.spf_dir is null for a repo that
|
|
112
|
+
// never ran `spf init`) — loadRepoChains() is only ever called once we
|
|
113
|
+
// know there's something to look at.
|
|
114
|
+
if (anchor.spf_dir) {
|
|
115
|
+
const { chains, problems } = loadRepoChains(anchor);
|
|
116
|
+
registerRepoChains(chains, problems);
|
|
117
|
+
}
|
|
68
118
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
69
119
|
console.log(HELP);
|
|
70
120
|
process.exitCode = 0;
|
|
@@ -81,6 +131,7 @@ export async function main() {
|
|
|
81
131
|
const chain = chainName ? findChain(chainName) : undefined;
|
|
82
132
|
if (!chain) {
|
|
83
133
|
console.error(`unknown chain: ${chainName ?? "(none given)"} — run \`spf list\` to see every chain`);
|
|
134
|
+
reportUnresolvedChain(chainName);
|
|
84
135
|
process.exitCode = 1;
|
|
85
136
|
return;
|
|
86
137
|
}
|
|
@@ -103,7 +154,7 @@ export async function main() {
|
|
|
103
154
|
process.exitCode = ejectCommand(rest);
|
|
104
155
|
return;
|
|
105
156
|
case "doctor":
|
|
106
|
-
process.exitCode = doctorCommand(rest);
|
|
157
|
+
process.exitCode = await doctorCommand(rest);
|
|
107
158
|
return;
|
|
108
159
|
case "ui":
|
|
109
160
|
process.exitCode = await uiCommand(rest);
|
|
@@ -129,6 +180,7 @@ export async function main() {
|
|
|
129
180
|
const chain = findChain(cmd);
|
|
130
181
|
if (!chain) {
|
|
131
182
|
console.error(`unknown command or chain: ${cmd}\n`);
|
|
183
|
+
reportUnresolvedChain(cmd);
|
|
132
184
|
console.error(HELP);
|
|
133
185
|
process.exitCode = 1;
|
|
134
186
|
return;
|
|
@@ -156,5 +208,10 @@ export async function main() {
|
|
|
156
208
|
// A no-op if notifications are off/unconfigured — awaits any in-flight
|
|
157
209
|
// webhook POST so a fast-exiting command doesn't drop it mid-flight.
|
|
158
210
|
await notify.flushAll();
|
|
211
|
+
// A no-op if `observability.otel` is not configured — drains the bounded
|
|
212
|
+
// span queue and awaits any in-flight OTLP POST, under its own hard
|
|
213
|
+
// deadline, so a fast-exiting command neither drops spans mid-flight nor
|
|
214
|
+
// waits on an unreachable collector. Never throws (core/otel.ts).
|
|
215
|
+
await otel.flushAll();
|
|
159
216
|
}
|
|
160
217
|
}
|
package/dist/cli/interview.js
CHANGED
|
@@ -21,7 +21,7 @@ import { ThinkingLevelSchema } from "../core/data_types.js";
|
|
|
21
21
|
import { loadConfig } from "../core/agents.js";
|
|
22
22
|
import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
|
|
23
23
|
import { DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
|
|
24
|
-
import {
|
|
24
|
+
import { allChains } from "../chains/index.js";
|
|
25
25
|
function gitConfigValue(repoRoot, key) {
|
|
26
26
|
const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
|
|
27
27
|
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
@@ -120,7 +120,7 @@ export async function runInterview(asker, ctx) {
|
|
|
120
120
|
const token = await asker.secret("ANTHROPIC_AUTH_TOKEN", { current: ctx.existingEnv.get("ANTHROPIC_AUTH_TOKEN") });
|
|
121
121
|
env["ANTHROPIC_AUTH_TOKEN"] = token || "ollama";
|
|
122
122
|
envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
|
|
123
|
-
asker.note("spf doctor
|
|
123
|
+
asker.note("spf doctor now probes ANTHROPIC_BASE_URL with a minimal POST /v1/messages (informational only, never a hard failure) and flags a base URL that already ends in \"/v1\" as a likely double-path mistake — the default above (without a trailing /v1) is exactly what that check is guarding against. It still cannot validate ANTHROPIC_AUTH_TOKEN itself.");
|
|
124
124
|
}
|
|
125
125
|
// The packaged roster pins planner/reviewer/documenter to their own
|
|
126
126
|
// Flue-style provider/model-id strings, which always win over
|
|
@@ -149,11 +149,44 @@ export async function runInterview(asker, ctx) {
|
|
|
149
149
|
asker.note(`warning: ${error.message}`);
|
|
150
150
|
}
|
|
151
151
|
defaults.model = model;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
const envKeys = PROVIDER_ENV_KEYS[provider];
|
|
153
|
+
if (envKeys.length > 0) {
|
|
154
|
+
const envKey = envKeys[0];
|
|
155
|
+
const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
|
|
156
|
+
if (key)
|
|
157
|
+
env[envKey] = key;
|
|
158
|
+
envExampleKeys.push(envKey);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
asker.note(`${provider} is keyless — no API key to collect.`);
|
|
162
|
+
if (provider === "ollama") {
|
|
163
|
+
// Same shape as the claude_code branch's ANTHROPIC_BASE_URL prompt
|
|
164
|
+
// above: a local/cloud Ollama server has no key, just an address.
|
|
165
|
+
// The `/v1` suffix matches agent_flue.ts's own registration default
|
|
166
|
+
// (spike-verified: Ollama serves its OpenAI-compatible surface —
|
|
167
|
+
// the one Flue's pi-ai backend actually speaks — under `/v1`, not
|
|
168
|
+
// at the bare root `/api/...` Ollama also exposes).
|
|
169
|
+
const baseUrl = await asker.text("OLLAMA_BASE_URL", {
|
|
170
|
+
default: "http://localhost:11434/v1",
|
|
171
|
+
validate: (v) => (v.trim() ? null : "required"),
|
|
172
|
+
});
|
|
173
|
+
env["OLLAMA_BASE_URL"] = baseUrl;
|
|
174
|
+
envExampleKeys.push("OLLAMA_BASE_URL");
|
|
175
|
+
asker.note("spf doctor checks this one — a probe against OLLAMA_BASE_URL/models runs on every `spf doctor`.");
|
|
176
|
+
// Same problem the claude_code branch already solves above: the
|
|
177
|
+
// packaged roster pins planner/reviewer/documenter to their own
|
|
178
|
+
// fireworks/gemini/openai model strings, which always win over
|
|
179
|
+
// defaults.model. Left alone, this flow would produce a config
|
|
180
|
+
// where three of six agents are still routed at hosted providers
|
|
181
|
+
// whose keys this ollama flow never collects — `spf doctor` would
|
|
182
|
+
// immediately report missing FIREWORKS_API_KEY/GEMINI_API_KEY/
|
|
183
|
+
// OPENAI_API_KEY right after an interview that asked for neither.
|
|
184
|
+
for (const name of ["planner", "reviewer", "documenter"]) {
|
|
185
|
+
agentOverrides.push({ name, model: defaults.model });
|
|
186
|
+
}
|
|
187
|
+
notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to the ollama model chosen above, or spf doctor would report missing FIREWORKS_API_KEY/GEMINI_API_KEY/OPENAI_API_KEY for providers this flow never asked about.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
157
190
|
}
|
|
158
191
|
// Declined (the default): today's behavior exactly — on claude_code, the
|
|
159
192
|
// three-agent auto-pin above stands unchanged; on flue, no agents: block
|
|
@@ -222,15 +255,37 @@ export async function runInterview(asker, ctx) {
|
|
|
222
255
|
{ value: "bitbucket", label: "Bitbucket" },
|
|
223
256
|
], "github");
|
|
224
257
|
const repoHint = codeHost === "bitbucket" ? "workspace/repo_slug" : "owner/name";
|
|
225
|
-
|
|
258
|
+
// github issues + bitbucket code is the one combination where a single
|
|
259
|
+
// repo field is ambiguous — they're two different repos in two
|
|
260
|
+
// different systems, not one repo worn two ways (github+github is one
|
|
261
|
+
// repo by construction; jira never reads `repo` at all). Ask a second,
|
|
262
|
+
// explicitly-labeled question only in that one case.
|
|
263
|
+
const splitRepo = issueProvider === "github" && codeHost === "bitbucket";
|
|
264
|
+
const repo = await asker.text(splitRepo ? `Code repo, where PRs open (${repoHint})` : `Repo (${repoHint})`, {
|
|
226
265
|
default: codeHost === "github" ? ctx.repoSlug ?? "" : "",
|
|
227
266
|
validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "${repoHint}"`),
|
|
228
267
|
});
|
|
268
|
+
let issueRepo = "";
|
|
269
|
+
if (splitRepo) {
|
|
270
|
+
asker.note("GitHub issues and the Bitbucket code repo are different repos here — spf watch needs both.");
|
|
271
|
+
issueRepo = await asker.text("Issue repo, where spf:ready issues live (owner/name)", {
|
|
272
|
+
default: ctx.repoSlug ?? "",
|
|
273
|
+
validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "owner/name"`),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
229
276
|
const labelPrefix = await asker.text("Label prefix", { default: "spf" });
|
|
230
277
|
const baseBranch = await asker.text("Base branch", { default: ctx.currentBranch });
|
|
231
|
-
|
|
278
|
+
// allChains(), not the built-in-only CHAINS: `registerRepoChains` already
|
|
279
|
+
// ran in main() before initCommand, so a pre-existing `.spf/chains/`
|
|
280
|
+
// repo chain is a legal watch.chain everywhere else (watch.ts resolves
|
|
281
|
+
// it via findChain, doctor.ts validates and labels it) — this picker is
|
|
282
|
+
// the one place a user chooses watch.chain, so it must offer the same
|
|
283
|
+
// set. The "(repo)" hint marker distinguishes it in the list.
|
|
284
|
+
const chainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
232
285
|
const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
|
|
233
286
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
287
|
+
if (issueRepo)
|
|
288
|
+
watch.issue_repo = issueRepo;
|
|
234
289
|
// Issue authoring (create + link a hierarchy) is only implemented on
|
|
235
290
|
// GitHubProvider today — see jira_provider.ts's module comment — so
|
|
236
291
|
// this lane isn't offered at all on a Jira tracker rather than asking a
|
|
@@ -238,7 +293,7 @@ export async function runInterview(asker, ctx) {
|
|
|
238
293
|
if (issueProvider === "github") {
|
|
239
294
|
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
295
|
if (enableRefine) {
|
|
241
|
-
const refineChainChoices =
|
|
296
|
+
const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
242
297
|
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
243
298
|
watch.refine = { enabled: true, chain: refineChain };
|
|
244
299
|
}
|