@gr8ful/spf 0.11.2 → 0.13.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 +104 -28
- package/assets/defaults/spf.config.yaml +2 -0
- package/assets/prompts/refiner/system.md +106 -25
- package/assets/prompts/refiner/user.md +50 -15
- package/assets/prompts/scout/system.md +3 -2
- package/assets/prompts/scout/user.md +1 -1
- package/assets/skill/references/config.md +34 -9
- package/assets/templates/ts.spf.config.yaml +11 -4
- package/dist/chains/steps.d.ts +14 -14
- package/dist/chains/steps.js +44 -25
- package/dist/cli/commands/doctor.js +6 -0
- package/dist/cli/commands/watch.js +33 -11
- package/dist/core/agent_cc.js +9 -0
- package/dist/core/agent_flue.d.ts +41 -1
- package/dist/core/agent_flue.js +99 -1
- package/dist/core/data_types.d.ts +199 -1
- package/dist/core/data_types.js +127 -1
- package/dist/core/gates.d.ts +24 -8
- package/dist/core/gates.js +175 -20
- package/dist/core/issues/github_provider.d.ts +10 -5
- package/dist/core/issues/github_provider.js +13 -2
- package/dist/core/issues/jira_provider.d.ts +3 -3
- package/dist/core/issues/jira_provider.js +2 -0
- package/dist/core/issues/provider.d.ts +48 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +35 -1
- package/dist/core/refine.js +56 -1
- package/dist/core/watch.d.ts +65 -9
- package/dist/core/watch.js +157 -11
- package/package.json +1 -1
|
@@ -159,10 +159,13 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
|
|
|
159
159
|
| `concurrency` | int ≥1 | Max ISSUES claimed and run at once — not attempts; see `fanout.concurrency`. The build lane's own budget (independent of `refine.concurrency`). Default `2`. |
|
|
160
160
|
| `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
|
|
161
161
|
| `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
|
|
162
|
-
| `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` creates as on Jira — defaults `epic`/`feature` → `Epic`, `story` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
|
|
162
|
+
| `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task`/`spec` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` (plus `spec`, a standalone spec proposed by a split — see below) creates as on Jira — defaults `epic`/`feature` → `Epic`, `story`/`spec` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
|
|
163
163
|
| `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
|
|
164
164
|
| `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
|
|
165
165
|
| `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
|
|
166
|
+
| `refine.max_leaves` | int ≥1 | The decomposition budget: at most this many LEAVES per refinement — each one becomes its own worktree, chain run, and pull request once promoted, so this is a human-review budget, not a model-effort one. Default `4`. Enforced by `gates.refinementWellFormed`; exceeding it (with no human-approved split already in the thread) sends the refiner back for a correction, and a genuine overrun proposes a `split` into several specs instead of publishing (see below) rather than force-fitting an oversized tree. |
|
|
167
|
+
| `refine.max_nodes` | int ≥1 | Total node ceiling (leaves + containers). Default `6`. Doesn't bind at the other two defaults — it exists so raising `max_leaves` or `max_depth` alone can't silently uncap the whole tree. |
|
|
168
|
+
| `refine.max_depth` | int ≥1 | Containment-depth ceiling — `1` is a top-level leaf/container, `2` is a leaf directly under a top-level container. Default `2`. **Raising this above 2 is unsafe on Jira**: `epic`/`feature` both map to Jira's Epic type by default, and Jira has no Epic-under-Epic nesting, so a 3-level tree fails partway through a non-transactional publish. At the default, that shape is rejected before any issue is created. |
|
|
166
169
|
| `fanout.n` | int, 1-8 | Best-of-N per claimed issue: run `n` sibling attempts of the same issue and let code pick a winner (`core/fanout.ts`'s `pickBest`, the same mechanism `spf fanout` uses standalone). Default `1` — single dispatch, byte-identical to `spf watch` before this key existed. `n > 1` requires `chain` to have a commit step (`spf watch` refuses to start otherwise — a chain with no commit step would have its N-1 losing attempts' uncommitted work destroyed by best-of-N's own cleanup). |
|
|
167
170
|
| `fanout.concurrency` | int ≥1 | Attempts of ONE issue's fan-out IN FLIGHT at once — **not** `concurrency`, which counts issues. The two multiply: `concurrency: 2` × `fanout.concurrency: 2` is up to 4 chain runs in flight; `spf doctor`'s `watch.fanout` line prints the exact product. Does **not** bound worktrees on disk — a successful attempt's tree is kept until every sibling in its fan-out has settled, so disk peak is `concurrency × fanout.n`, not `concurrency × fanout.concurrency`. Default `2`. |
|
|
168
171
|
|
|
@@ -177,6 +180,9 @@ watch:
|
|
|
177
180
|
enabled: true # decompose spf:spec-ready specs into a feature/story tree
|
|
178
181
|
chain: refine
|
|
179
182
|
concurrency: 1
|
|
183
|
+
max_leaves: 4 # optional — shown are the defaults; see refine.max_leaves above
|
|
184
|
+
max_nodes: 6
|
|
185
|
+
max_depth: 2
|
|
180
186
|
fanout:
|
|
181
187
|
n: 1 # attempts per claimed issue. 1 = single dispatch (default) — no-op for the daemon
|
|
182
188
|
concurrency: 2 # attempts IN FLIGHT per issue — not watch.concurrency (issues in flight)
|
|
@@ -206,18 +212,31 @@ The `refine` chain grounds its decomposition with a `scout` phase before the
|
|
|
206
212
|
refiner runs, so `scout` is a required agent for it — a roster that pruned
|
|
207
213
|
it fails `spf watch` startup by name.
|
|
208
214
|
|
|
209
|
-
The refine lane's own state machine has
|
|
210
|
-
`spec-ready → refining → spec-in-progress → done`/`blocked
|
|
211
|
-
refiner raises material ambiguity instead of a
|
|
212
|
-
`assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec
|
|
213
|
-
`<prefix>:needs-feedback` with a comment naming its questions,
|
|
214
|
-
publishing anything. A human answers in the issue's comments and
|
|
215
|
-
`<prefix>:continue-refinement`; `spf watch` claims that label back into
|
|
215
|
+
The refine lane's own state machine has two extra loops beyond
|
|
216
|
+
`spec-ready → refining → spec-in-progress → done`/`blocked`, for two
|
|
217
|
+
different problems. When the refiner raises material ambiguity instead of a
|
|
218
|
+
tree (see `assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec
|
|
219
|
+
moves to `<prefix>:needs-feedback` with a comment naming its questions,
|
|
220
|
+
instead of publishing anything. A human answers in the issue's comments and
|
|
221
|
+
adds `<prefix>:continue-refinement`; `spf watch` claims that label back into
|
|
216
222
|
`refining` and resumes the **same** `adw_id` — the comment thread (split
|
|
217
223
|
into "answers to your open questions" and "earlier discussion") is folded
|
|
218
224
|
into the resumed prompt, and the refiner's own coding-agent session
|
|
219
225
|
continues rather than starting cold. This can loop any number of rounds.
|
|
220
226
|
|
|
227
|
+
The other loop is for size, not ambiguity: when the spec honestly exceeds
|
|
228
|
+
`refine.max_leaves` (see above), the refiner proposes a `split` into two or
|
|
229
|
+
more standalone specs instead of a scope question, and the spec moves to
|
|
230
|
+
`<prefix>:split-proposed` with a comment naming each proposed spec and why
|
|
231
|
+
it's coherent on its own. A human either adds `<prefix>:split-approved`
|
|
232
|
+
(executed deterministically — `core/refine.ts`'s `publishSpecs()`, no agent
|
|
233
|
+
re-run: it creates exactly what the proposal comment showed, each new spec
|
|
234
|
+
carrying `<prefix>:type:spec` + `<prefix>:spec-ready`, never
|
|
235
|
+
`<prefix>:refined`) or revises the proposal the same way a question gets
|
|
236
|
+
answered (`<prefix>:continue-refinement`). The original spec then tracks
|
|
237
|
+
both child specs' own trees to completion transitively, the same way it
|
|
238
|
+
tracks a published tree's leaves.
|
|
239
|
+
|
|
221
240
|
Publishing a tree does NOT mean the spec is done: `<prefix>:spec-in-progress`
|
|
222
241
|
is where a spec lands right after publish, and it stays there — with a
|
|
223
242
|
summary comment listing every issue created — until **every one of those
|
|
@@ -564,11 +583,17 @@ session** — a joined run starts that agent fresh instead of resuming.
|
|
|
564
583
|
| `grep` | search file contents |
|
|
565
584
|
| `glob` (alias: `find`) | find files by pattern |
|
|
566
585
|
| `ls` | recognized name, **no built-in on either backend** — harmless to list, never mounts |
|
|
586
|
+
| `webfetch` | fetch a URL over HTTP(S), return its content as plain text — e.g. a library/API's current docs |
|
|
567
587
|
|
|
568
588
|
These names are canonical across backends — a roster entry never says
|
|
569
589
|
which; each backend module (`agent_flue.ts`, `agent_cc.ts`) maps them to
|
|
570
590
|
its own tool vocabulary (Flue's lowercase functions, Claude Code's
|
|
571
|
-
capitalized `Read`/`Bash`/...).
|
|
591
|
+
capitalized `Read`/`Bash`/...). `webfetch` maps to Claude Code's own native
|
|
592
|
+
`WebFetch` on that backend; Flue has no such built-in, so on `flue` it's a
|
|
593
|
+
custom tool that runs `curl`/`wget` through the agent's own `Sandbox.exec()`
|
|
594
|
+
— which means a remote sandbox's `egress` policy (see `sandbox` below)
|
|
595
|
+
governs it exactly like any other `bash`-issued network call, with no
|
|
596
|
+
separate rule to configure.
|
|
572
597
|
|
|
573
598
|
**Resolution order:** an agent's own `tools` wins → else `defaults.tools` →
|
|
574
599
|
else unset (all tools usable). An empty list is a tool-less agent, and it
|
|
@@ -79,14 +79,21 @@ agents:
|
|
|
79
79
|
# # feature/story-or-bug tree of real issues instead of building it
|
|
80
80
|
# # directly. Off by default; needs issue_provider: github OR jira — both
|
|
81
81
|
# # support issue authoring (create + link a hierarchy). On Jira, each
|
|
82
|
-
# # RefinedIssue.kind
|
|
83
|
-
# # watch.jira.issue_types (defaults: epic/feature ->
|
|
84
|
-
# # bug -> Bug, task -> Task) — spf watch init
|
|
85
|
-
# # real project when refine is enabled.
|
|
82
|
+
# # RefinedIssue.kind (plus spec, from a split proposal) maps to a real
|
|
83
|
+
# # Jira issue type via watch.jira.issue_types (defaults: epic/feature ->
|
|
84
|
+
# # Epic, story/spec -> Story, bug -> Bug, task -> Task) — spf watch init
|
|
85
|
+
# # validates this against the real project when refine is enabled.
|
|
86
|
+
# # max_leaves/max_nodes/max_depth are the decomposition budget: every leaf
|
|
87
|
+
# # becomes its own pull request once promoted, so this bounds how many a
|
|
88
|
+
# # human reviews per spec. An honest overrun proposes splitting the spec
|
|
89
|
+
# # into several standalone ones instead of publishing an oversized tree.
|
|
86
90
|
# refine:
|
|
87
91
|
# enabled: true
|
|
88
92
|
# chain: refine
|
|
89
93
|
# concurrency: 1
|
|
94
|
+
# max_leaves: 4
|
|
95
|
+
# max_nodes: 6
|
|
96
|
+
# max_depth: 2
|
|
90
97
|
|
|
91
98
|
# Optional: push notifications for unattended work — spf watch's daemon
|
|
92
99
|
# lifecycle, and every chain run (including watch's own per-issue runs).
|
package/dist/chains/steps.d.ts
CHANGED
|
@@ -317,20 +317,20 @@ export declare function refine(opts?: {
|
|
|
317
317
|
extraGates?: string[];
|
|
318
318
|
}): Step;
|
|
319
319
|
/**
|
|
320
|
-
* `cli/commands/watch.ts`'s `runRefine()` reads
|
|
321
|
-
*
|
|
322
|
-
* exits — it has no other way to learn what THIS run did,
|
|
323
|
-
* return value is just an exit code. A resumed spec
|
|
324
|
-
* reruns this entire chain from `request` on, into
|
|
325
|
-
* `context_handoff_dir` a PRIOR round already wrote
|
|
326
|
-
* the
|
|
327
|
-
* from an earlier
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* issues were already created on the tracker seconds earlier. Called
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
320
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads `refine_publish.json`,
|
|
321
|
+
* `refine_questions.json`, AND `refine_split.json` back, unconditionally,
|
|
322
|
+
* after this chain exits — it has no other way to learn what THIS run did,
|
|
323
|
+
* since a chain's return value is just an exit code. A resumed spec
|
|
324
|
+
* (`continue-refinement`) reruns this entire chain from `request` on, into
|
|
325
|
+
* the SAME deterministic `context_handoff_dir` a PRIOR round already wrote
|
|
326
|
+
* into. Without clearing the files this run is NOT about to write, a stale
|
|
327
|
+
* one from an earlier round survives a LATER round's successful publish —
|
|
328
|
+
* `runRefine` then reports that stale outcome as if it happened again THIS
|
|
329
|
+
* round, so `runSpec` escalates (or splits) a second time even though real
|
|
330
|
+
* issues were already created on the tracker seconds earlier. Called before
|
|
331
|
+
* ANY branch writes, so exactly one of the three files reflects this run
|
|
332
|
+
* when the phase returns, never a leftover from a previous one. `force:
|
|
333
|
+
* true` — a first-ever run has none of them yet, which is fine.
|
|
334
334
|
*/
|
|
335
335
|
export declare function clearStaleRefineOutputFiles(contextHandoffDir: string): void;
|
|
336
336
|
export declare function publishIssues(opts?: {
|
package/dist/chains/steps.js
CHANGED
|
@@ -664,19 +664,26 @@ export function refine(opts = {}) {
|
|
|
664
664
|
output_type: RefineOutput,
|
|
665
665
|
description: opts.description ?? "Decompose the spec into a feature/story tree of vertical slices",
|
|
666
666
|
// refinementWellFormed is NOT in GATE_ALLOWLIST — it is meaningless on
|
|
667
|
-
// any other envelope type (it reads `issues`/`questions`), so
|
|
668
|
-
// nothing to gain by letting a definition name it, and it stays
|
|
667
|
+
// any other envelope type (it reads `issues`/`questions`/`split`), so
|
|
668
|
+
// there is nothing to gain by letting a definition name it, and it stays
|
|
669
669
|
// non-removable here.
|
|
670
670
|
//
|
|
671
|
-
// retries:
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
671
|
+
// retries: 2, not 1. `retries: 1` bought exactly one correction round
|
|
672
|
+
// (`core/agents.ts`'s gate loop: attempts 1..retries+1), which is enough
|
|
673
|
+
// for a MECHANICAL violation — an unresolved parent, a mislabeled kind —
|
|
674
|
+
// because the fix is local and obvious. The budget checks in
|
|
675
|
+
// `gates.refinementWellFormed` are not mechanical: the first correction
|
|
676
|
+
// is "cut this down," and a refiner that genuinely cannot needs a SECOND
|
|
677
|
+
// round to reach for the other branch (publish nothing, propose a
|
|
678
|
+
// `split`). Without it, the honest-overrun case exits as a
|
|
679
|
+
// `GateFailure` — which does NOT return non-zero through
|
|
680
|
+
// `cli/commands/watch.ts`'s `runRefine`, but THROWS past it into
|
|
681
|
+
// `core/watch.ts`'s `runSpec` catch, moving the spec to
|
|
682
|
+
// `<prefix>:blocked` and paging a `watch_error` at level "error". A spec
|
|
683
|
+
// that is merely too big is not an spf failure, and it should not look
|
|
684
|
+
// like one.
|
|
678
685
|
gates: withExtraGates([gates.refinementWellFormed], opts.extraGates),
|
|
679
|
-
retries: opts.retries ??
|
|
686
|
+
retries: opts.retries ?? 2,
|
|
680
687
|
});
|
|
681
688
|
}
|
|
682
689
|
/**
|
|
@@ -724,23 +731,23 @@ function parsePriorityOption(raw) {
|
|
|
724
731
|
return raw;
|
|
725
732
|
}
|
|
726
733
|
/**
|
|
727
|
-
* `cli/commands/watch.ts`'s `runRefine()` reads
|
|
728
|
-
*
|
|
729
|
-
* exits — it has no other way to learn what THIS run did,
|
|
730
|
-
* return value is just an exit code. A resumed spec
|
|
731
|
-
* reruns this entire chain from `request` on, into
|
|
732
|
-
* `context_handoff_dir` a PRIOR round already wrote
|
|
733
|
-
* the
|
|
734
|
-
* from an earlier
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
* issues were already created on the tracker seconds earlier. Called
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
734
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads `refine_publish.json`,
|
|
735
|
+
* `refine_questions.json`, AND `refine_split.json` back, unconditionally,
|
|
736
|
+
* after this chain exits — it has no other way to learn what THIS run did,
|
|
737
|
+
* since a chain's return value is just an exit code. A resumed spec
|
|
738
|
+
* (`continue-refinement`) reruns this entire chain from `request` on, into
|
|
739
|
+
* the SAME deterministic `context_handoff_dir` a PRIOR round already wrote
|
|
740
|
+
* into. Without clearing the files this run is NOT about to write, a stale
|
|
741
|
+
* one from an earlier round survives a LATER round's successful publish —
|
|
742
|
+
* `runRefine` then reports that stale outcome as if it happened again THIS
|
|
743
|
+
* round, so `runSpec` escalates (or splits) a second time even though real
|
|
744
|
+
* issues were already created on the tracker seconds earlier. Called before
|
|
745
|
+
* ANY branch writes, so exactly one of the three files reflects this run
|
|
746
|
+
* when the phase returns, never a leftover from a previous one. `force:
|
|
747
|
+
* true` — a first-ever run has none of them yet, which is fine.
|
|
741
748
|
*/
|
|
742
749
|
export function clearStaleRefineOutputFiles(contextHandoffDir) {
|
|
743
|
-
for (const name of ["refine_questions.json", "refine_publish.json"]) {
|
|
750
|
+
for (const name of ["refine_questions.json", "refine_publish.json", "refine_split.json"]) {
|
|
744
751
|
rmSync(path.join(contextHandoffDir, name), { force: true });
|
|
745
752
|
}
|
|
746
753
|
}
|
|
@@ -752,6 +759,7 @@ export function publishIssues(opts = {}) {
|
|
|
752
759
|
throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
|
|
753
760
|
}
|
|
754
761
|
const questions = envelope.questions ?? [];
|
|
762
|
+
const split = envelope.split ?? [];
|
|
755
763
|
const priorityCeiling = parsePriorityOption(state.options["priority"]);
|
|
756
764
|
await run.phase(makePhaseParams({
|
|
757
765
|
name: "publish",
|
|
@@ -765,6 +773,17 @@ export function publishIssues(opts = {}) {
|
|
|
765
773
|
ph.log({ escalated: questions.length });
|
|
766
774
|
return;
|
|
767
775
|
}
|
|
776
|
+
if (split.length > 0) {
|
|
777
|
+
// Recorded, never executed, here: creating the proposed specs is
|
|
778
|
+
// `core/watch.ts`'s `executeApprovedSplits`' job, gated on a human
|
|
779
|
+
// adding `<prefix>:split-approved` — this phase only writes the
|
|
780
|
+
// proposal down for `runRefine` to hand to `runSpec`'s own
|
|
781
|
+
// `proposeSpecSplit`, same division of labor as the `questions`
|
|
782
|
+
// branch above (this writes, `runSpec` posts/transitions).
|
|
783
|
+
writeFileSync(path.join(run.context_handoff_dir, "refine_split.json"), JSON.stringify(split, null, 2));
|
|
784
|
+
ph.log({ split_proposed: split.length });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
768
787
|
const tracker = refineLib.resolveAuthoringProvider(run.cfg);
|
|
769
788
|
const created = await refineLib.publish(tracker, envelope.issues, {
|
|
770
789
|
labelPrefix: run.cfg.watch.label_prefix,
|
|
@@ -23,6 +23,7 @@ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
|
|
|
23
23
|
import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
|
|
24
24
|
import { isRepoAt } from "../../core/git_helper.js";
|
|
25
25
|
import { allChains, findChain, hasCommitStep, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
26
|
+
import { refineBudget } from "../../core/gates.js";
|
|
26
27
|
import * as sandbox from "../../core/sandbox.js";
|
|
27
28
|
import { loadOpenSandboxSdk } from "../../core/sandbox_opensandbox.js";
|
|
28
29
|
import { isInteractive } from "../ask.js";
|
|
@@ -978,6 +979,11 @@ export async function doctorCommand(argv) {
|
|
|
978
979
|
: cfg.watch.issue_provider === "jira"
|
|
979
980
|
? "jira supports issue authoring (createIssue/parent field) — run `spf watch init` to validate watch.jira.issue_types against the real project"
|
|
980
981
|
: `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" or "jira"`);
|
|
982
|
+
// Same resolution `gates.refinementWellFormed` itself uses
|
|
983
|
+
// (refineBudget), never a second reading of these three keys, so this
|
|
984
|
+
// line can never drift from what a refinement is actually held to.
|
|
985
|
+
const budget = refineBudget({ repo_root: anchor.repo_root, cfg });
|
|
986
|
+
check(report, "watch.refine decomposition budget", true, `at most ${budget.maxLeaves} leaf/leaves, ${budget.maxNodes} node(s) total, ${budget.maxDepth} level(s) deep — every leaf becomes one pull request a human reviews once promoted`, "info");
|
|
981
987
|
}
|
|
982
988
|
}
|
|
983
989
|
// OTel span export: informational in every direction. It is off unless
|
|
@@ -17,6 +17,7 @@ import { GitHubProvider } from "../../core/issues/github_provider.js";
|
|
|
17
17
|
import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
18
18
|
import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
|
|
19
19
|
import { isAuthoringProvider } from "../../core/issues/provider.js";
|
|
20
|
+
import * as refineLib from "../../core/refine.js";
|
|
20
21
|
import { createWatchState, tick } from "../../core/watch.js";
|
|
21
22
|
import { findChain, hasCommitStep, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
22
23
|
import { withRunScope } from "../../core/sandbox.js";
|
|
@@ -559,7 +560,7 @@ export async function watchCommand(argv) {
|
|
|
559
560
|
const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
|
|
560
561
|
if (code !== 0) {
|
|
561
562
|
const detail = detailFromFailedPhase(cfg, 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.`);
|
|
562
|
-
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
|
|
563
|
+
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [], split: [] };
|
|
563
564
|
}
|
|
564
565
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
565
566
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
@@ -578,19 +579,32 @@ export async function watchCommand(argv) {
|
|
|
578
579
|
catch {
|
|
579
580
|
// best-effort, same as above — no questions file means this run wasn't an escalation
|
|
580
581
|
}
|
|
582
|
+
let split = [];
|
|
583
|
+
try {
|
|
584
|
+
split = JSON.parse(readFileSync(path.join(handoffDir, "refine_split.json"), "utf-8"));
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// best-effort, same as above — no split file means this run didn't propose one
|
|
588
|
+
}
|
|
581
589
|
// Defense in depth: `steps.publishIssues()` now clears whichever of
|
|
582
|
-
// these
|
|
583
|
-
// non-empty here — but if some future change (or an older worktree's
|
|
584
|
-
// leftover files, before that fix existed) ever produces
|
|
585
|
-
// completed publish must never be silently overridden by a stale
|
|
586
|
-
// question. `runSpec` checks `questions
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
590
|
+
// these three files it's NOT about to write, so at most one should ever
|
|
591
|
+
// be non-empty here — but if some future change (or an older worktree's
|
|
592
|
+
// leftover files, before that fix existed) ever produces more than one, a
|
|
593
|
+
// real completed publish must never be silently overridden by a stale
|
|
594
|
+
// question or split proposal. `runSpec` checks `questions` before
|
|
595
|
+
// `split` before the publish path, so without this it would re-escalate
|
|
596
|
+
// (or re-propose a split) over issues that already landed on the tracker
|
|
597
|
+
// seconds earlier.
|
|
598
|
+
if (created.length > 0 && (questions.length > 0 || split.length > 0)) {
|
|
599
|
+
console.error(`watch: ${opts.adwId}: refine_publish.json had content alongside a stale refine_questions.json/refine_split.json — treating the ${created.length} published issue(s) as authoritative and discarding the stale escalation`);
|
|
591
600
|
questions = [];
|
|
601
|
+
split = [];
|
|
602
|
+
}
|
|
603
|
+
else if (questions.length > 0 && split.length > 0) {
|
|
604
|
+
console.error(`watch: ${opts.adwId}: refine_questions.json AND refine_split.json both had content — treating the questions as authoritative and discarding the stale split proposal`);
|
|
605
|
+
split = [];
|
|
592
606
|
}
|
|
593
|
-
return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
|
|
607
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created, questions, split };
|
|
594
608
|
};
|
|
595
609
|
// `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
|
|
596
610
|
// structural check (see `provider.ts`), so both GitHub and Jira are
|
|
@@ -639,6 +653,14 @@ export async function watchCommand(argv) {
|
|
|
639
653
|
dryRun: Boolean(flags["dry-run"]),
|
|
640
654
|
runChain,
|
|
641
655
|
listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
|
|
656
|
+
// See `WatchDeps.publishSpecs`'s own doc comment: `authoringProvider` is
|
|
657
|
+
// guaranteed non-null whenever `refine.enabled` is true (the startup
|
|
658
|
+
// check above already refuses any `issue_provider` besides github/jira
|
|
659
|
+
// in that case), so this is never `undefined` in the one state
|
|
660
|
+
// `executeApprovedSplits` actually reads it in.
|
|
661
|
+
publishSpecs: authoringProvider
|
|
662
|
+
? (specs, opts) => refineLib.publishSpecs(authoringProvider, specs, { labelPrefix: cfg.watch.label_prefix, originalSpecId: opts.originalSpecId, priority: opts.priority })
|
|
663
|
+
: undefined,
|
|
642
664
|
log: (message) => (dashboard ? dashboard.log(message) : console.log(message)),
|
|
643
665
|
notify: (event) => {
|
|
644
666
|
notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
|
package/dist/core/agent_cc.js
CHANGED
|
@@ -213,6 +213,15 @@ const TOOL_NAME_MAP = {
|
|
|
213
213
|
bash: "Bash",
|
|
214
214
|
grep: "Grep",
|
|
215
215
|
glob: "Glob",
|
|
216
|
+
// CC's own built-in — fetches a URL and returns its content as markdown,
|
|
217
|
+
// already runs headlessly under --dangerously-skip-permissions like every
|
|
218
|
+
// other tool here. No sandbox/egress layer of SPF's own to route through:
|
|
219
|
+
// CC's own process makes the request directly, same as a bash `curl` an
|
|
220
|
+
// agent with `bash` could already issue — see agent_flue.ts's
|
|
221
|
+
// `createWebFetchTool` for the equivalent on the flue backend, where no
|
|
222
|
+
// native fetch tool exists and one has to route through the Sandbox
|
|
223
|
+
// instead.
|
|
224
|
+
webfetch: "WebFetch",
|
|
216
225
|
};
|
|
217
226
|
const TOOL_ALIASES = { find: "glob" };
|
|
218
227
|
const DROPPED_TOOLS = new Set(["ls"]);
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* `run()`'s signature deliberately mirrors the old agent_pi.ts `run()` so
|
|
23
23
|
* agents.ts's `send()` closure changes only its imports and field names.
|
|
24
24
|
*/
|
|
25
|
-
import
|
|
25
|
+
import * as v from "valibot";
|
|
26
|
+
import { type ConversationStreamChunk, type Sandbox } from "@flue/runtime";
|
|
26
27
|
import type { AgentRequest, AgentResult } from "./data_types.ts";
|
|
27
28
|
/**
|
|
28
29
|
* Folds Flue's `tool-input` + `tool-output`/`tool-output-error` chunk pair
|
|
@@ -37,6 +38,45 @@ export declare class ToolCallTracker {
|
|
|
37
38
|
observe(chunk: ConversationStreamChunk): Record<string, any> | null;
|
|
38
39
|
private finish;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Strip `<script>`/`<style>` blocks and tags, decode the handful of entities
|
|
43
|
+
* a real docs page actually uses, and collapse whitespace. NOT a real
|
|
44
|
+
* HTML-to-markdown renderer — Claude Code's native `WebFetch` (the
|
|
45
|
+
* claude_code backend) does that; Flue has nothing equivalent built in, and
|
|
46
|
+
* a full renderer is more than this needs. A JSON/plain-text response
|
|
47
|
+
* passes through essentially unchanged, since none of these patterns match
|
|
48
|
+
* it — same "minimal, not a full parser" trade `jira_provider.ts`'s
|
|
49
|
+
* `adfToText` makes for ADF.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stripHtml(text: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* The flue backend's stand-in for Claude Code's native `WebFetch` tool —
|
|
54
|
+
* `@flue/runtime` ships no such built-in (its only tool factories are
|
|
55
|
+
* read/write/edit/bash/grep/glob), so this is a custom `defineTool()`.
|
|
56
|
+
*
|
|
57
|
+
* Runs the actual request THROUGH `env.exec()` rather than calling Node's
|
|
58
|
+
* own `fetch()` directly — deliberately: `exec()` is the one universal
|
|
59
|
+
* primitive every `Sandbox` implements (`local`, and the remote
|
|
60
|
+
* `opensandbox`/`cloudflare` backends via `core/sandbox.ts`), so a command
|
|
61
|
+
* run through it executes INSIDE whichever sandbox the agent is actually
|
|
62
|
+
* using. A remote sandbox's own `egress` policy (`sandbox_opensandbox.ts`'s
|
|
63
|
+
* `networkPolicy`) then governs this exactly as it already governs every
|
|
64
|
+
* `bash` call — no separate egress rule needed for this tool. Calling
|
|
65
|
+
* `fetch()` here instead would silently bypass that policy by making the
|
|
66
|
+
* request from SPF's own orchestrator process rather than the sandbox.
|
|
67
|
+
*
|
|
68
|
+
* `curl`, with a `wget` fallback in the SAME command for portability across
|
|
69
|
+
* whatever base image a remote sandbox happens to ship — both are close to
|
|
70
|
+
* universal, but neither is guaranteed; a sandbox image with neither
|
|
71
|
+
* surfaces that plainly as a failed tool call (exit code + stderr handed
|
|
72
|
+
* back to the model), not a hang. Only `http`/`https` are accepted — this is
|
|
73
|
+
* also what stops a `file://` URL from turning "fetch a page" into "read an
|
|
74
|
+
* arbitrary local file" on the `local` sandbox, where `exec()` runs with the
|
|
75
|
+
* operator's own full filesystem access.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createWebFetchTool(env: Sandbox): import("@flue/runtime").ToolDefinition<v.ObjectSchema<{
|
|
78
|
+
readonly url: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.NonEmptyAction<string, "url is required">]>;
|
|
79
|
+
}, undefined>, undefined, false, false>;
|
|
40
80
|
/** Used by agents.validate() so a typo'd tool name fails before anything spawns. */
|
|
41
81
|
export declare function isKnownToolName(name: string): boolean;
|
|
42
82
|
/**
|
package/dist/core/agent_flue.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* `run()`'s signature deliberately mirrors the old agent_pi.ts `run()` so
|
|
23
23
|
* agents.ts's `send()` closure changes only its imports and field names.
|
|
24
24
|
*/
|
|
25
|
-
import
|
|
25
|
+
import * as v from "valibot";
|
|
26
|
+
import { AgentRunError, createBashTool, createEditTool, createGlobTool, createGrepTool, createReadTool, createWriteTool, defineTool, init, observe, useDataWriter, useModel, useSandbox, useTool, } from "@flue/runtime";
|
|
26
27
|
import { local, sqlite, start } from "@flue/runtime/node";
|
|
27
28
|
import { UsageBreakdown, makeAgentResult } from "./data_types.js";
|
|
28
29
|
import { registerOllamaModel } from "./ollama_provider.js";
|
|
@@ -127,6 +128,102 @@ export class ToolCallTracker {
|
|
|
127
128
|
return record;
|
|
128
129
|
}
|
|
129
130
|
}
|
|
131
|
+
// ── webfetch: Flue's stand-in for Claude Code's native WebFetch ─────────────
|
|
132
|
+
const WEBFETCH_MAX_BYTES = 200_000; // a bounded chunk of a page, not the whole thing — matches RESULT_SNIPPET_CHARS's spirit
|
|
133
|
+
const WEBFETCH_TIMEOUT_MS = 20_000;
|
|
134
|
+
const WebFetchParams = v.object({ url: v.pipe(v.string(), v.nonEmpty("url is required")) });
|
|
135
|
+
/** POSIX-safe single-quoting for a shell argument: closes and reopens the quote around any embedded `'`. */
|
|
136
|
+
function shQuote(value) {
|
|
137
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Strip `<script>`/`<style>` blocks and tags, decode the handful of entities
|
|
141
|
+
* a real docs page actually uses, and collapse whitespace. NOT a real
|
|
142
|
+
* HTML-to-markdown renderer — Claude Code's native `WebFetch` (the
|
|
143
|
+
* claude_code backend) does that; Flue has nothing equivalent built in, and
|
|
144
|
+
* a full renderer is more than this needs. A JSON/plain-text response
|
|
145
|
+
* passes through essentially unchanged, since none of these patterns match
|
|
146
|
+
* it — same "minimal, not a full parser" trade `jira_provider.ts`'s
|
|
147
|
+
* `adfToText` makes for ADF.
|
|
148
|
+
*/
|
|
149
|
+
export function stripHtml(text) {
|
|
150
|
+
const cleaned = text
|
|
151
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
152
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
153
|
+
.replace(/<[^>]+>/g, " ")
|
|
154
|
+
.replace(/ /g, " ")
|
|
155
|
+
.replace(/&/g, "&")
|
|
156
|
+
.replace(/</g, "<")
|
|
157
|
+
.replace(/>/g, ">")
|
|
158
|
+
.replace(/"/g, '"')
|
|
159
|
+
.replace(/'/g, "'")
|
|
160
|
+
.replace(/[ \t]+/g, " ");
|
|
161
|
+
// Line-by-line trim BEFORE collapsing blank runs: a tag->" " substitution
|
|
162
|
+
// (above) routinely leaves a stray leading/trailing space on the line that
|
|
163
|
+
// used to hold a block-level tag's boundary — collapsing blank lines first
|
|
164
|
+
// would miss those, since they're not yet blank.
|
|
165
|
+
return cleaned
|
|
166
|
+
.split("\n")
|
|
167
|
+
.map((line) => line.trim())
|
|
168
|
+
.join("\n")
|
|
169
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
170
|
+
.trim();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The flue backend's stand-in for Claude Code's native `WebFetch` tool —
|
|
174
|
+
* `@flue/runtime` ships no such built-in (its only tool factories are
|
|
175
|
+
* read/write/edit/bash/grep/glob), so this is a custom `defineTool()`.
|
|
176
|
+
*
|
|
177
|
+
* Runs the actual request THROUGH `env.exec()` rather than calling Node's
|
|
178
|
+
* own `fetch()` directly — deliberately: `exec()` is the one universal
|
|
179
|
+
* primitive every `Sandbox` implements (`local`, and the remote
|
|
180
|
+
* `opensandbox`/`cloudflare` backends via `core/sandbox.ts`), so a command
|
|
181
|
+
* run through it executes INSIDE whichever sandbox the agent is actually
|
|
182
|
+
* using. A remote sandbox's own `egress` policy (`sandbox_opensandbox.ts`'s
|
|
183
|
+
* `networkPolicy`) then governs this exactly as it already governs every
|
|
184
|
+
* `bash` call — no separate egress rule needed for this tool. Calling
|
|
185
|
+
* `fetch()` here instead would silently bypass that policy by making the
|
|
186
|
+
* request from SPF's own orchestrator process rather than the sandbox.
|
|
187
|
+
*
|
|
188
|
+
* `curl`, with a `wget` fallback in the SAME command for portability across
|
|
189
|
+
* whatever base image a remote sandbox happens to ship — both are close to
|
|
190
|
+
* universal, but neither is guaranteed; a sandbox image with neither
|
|
191
|
+
* surfaces that plainly as a failed tool call (exit code + stderr handed
|
|
192
|
+
* back to the model), not a hang. Only `http`/`https` are accepted — this is
|
|
193
|
+
* also what stops a `file://` URL from turning "fetch a page" into "read an
|
|
194
|
+
* arbitrary local file" on the `local` sandbox, where `exec()` runs with the
|
|
195
|
+
* operator's own full filesystem access.
|
|
196
|
+
*/
|
|
197
|
+
export function createWebFetchTool(env) {
|
|
198
|
+
return defineTool({
|
|
199
|
+
name: "webfetch",
|
|
200
|
+
description: "Fetch a URL over HTTP(S) and return its content as plain text (HTML tags stripped). " +
|
|
201
|
+
"Use it to check current documentation for a library, API, or tool before relying on prior " +
|
|
202
|
+
"knowledge that may be stale or version-specific. GET only, no custom headers/auth, response " +
|
|
203
|
+
"truncated to a safe size.",
|
|
204
|
+
input: WebFetchParams,
|
|
205
|
+
run: async ({ data }) => {
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = new URL(data.url);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return `webfetch: ${JSON.stringify(data.url)} is not a valid URL`;
|
|
212
|
+
}
|
|
213
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
214
|
+
return `webfetch: unsupported scheme ${JSON.stringify(parsed.protocol)} — only http/https are allowed`;
|
|
215
|
+
}
|
|
216
|
+
const url = shQuote(parsed.toString());
|
|
217
|
+
const command = `(curl -sS -L --max-time 20 --max-redirs 5 -A "spf-webfetch/1.0" ${url} ` +
|
|
218
|
+
`|| wget -qO- --timeout=20 ${url}) | head -c ${WEBFETCH_MAX_BYTES}`;
|
|
219
|
+
const result = await env.exec(command, { timeoutMs: WEBFETCH_TIMEOUT_MS });
|
|
220
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
221
|
+
return `webfetch: fetching ${parsed.toString()} failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim().slice(0, 2000)}`;
|
|
222
|
+
}
|
|
223
|
+
return stripHtml(result.stdout);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
130
227
|
// ── tool-name resolution ─────────────────────────────────────────────────────
|
|
131
228
|
const BUILTIN_TOOLS = {
|
|
132
229
|
read: createReadTool,
|
|
@@ -135,6 +232,7 @@ const BUILTIN_TOOLS = {
|
|
|
135
232
|
bash: createBashTool,
|
|
136
233
|
grep: createGrepTool,
|
|
137
234
|
glob: createGlobTool,
|
|
235
|
+
webfetch: createWebFetchTool,
|
|
138
236
|
};
|
|
139
237
|
// pi's vocabulary -> Flue's. "ls" has no Flue built-in (bash/glob cover it);
|
|
140
238
|
// it is a KNOWN name that resolves to nothing, not an unknown one.
|