@gr8ful/spf 0.5.1 → 0.7.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 +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk-tiered per-role model routing — SPF #14.
|
|
3
|
+
*
|
|
4
|
+
* PURE except for `probeServedOllamaTags`, the one async/impure export (a
|
|
5
|
+
* network probe — see its own doc comment below). Everything else here is a
|
|
6
|
+
* function of its own arguments: no `Run`, no `ChainContext`, no git handle,
|
|
7
|
+
* no filesystem. That purity is deliberate — it is what lets `startRun`
|
|
8
|
+
* (`src/chains/steps.ts`) and `spf estimate` call the IDENTICAL
|
|
9
|
+
* `resolveTiering` and get identical answers with no drift between "what
|
|
10
|
+
* will dispatch" and "what would dispatch".
|
|
11
|
+
*
|
|
12
|
+
* No imports from `src/chains/` and none from `src/cli/` — `spf doctor` and
|
|
13
|
+
* `startRun` both import FROM this module, never the reverse.
|
|
14
|
+
*
|
|
15
|
+
* ── The mechanism, in one paragraph ─────────────────────────────────────
|
|
16
|
+
* A run's `risk` (`low`/`standard`/`high`) is a single run-global scalar,
|
|
17
|
+
* classified once from the chain's name and the prompt's word count
|
|
18
|
+
* (`classifyRisk`). Separately, `tiering.roles` names a baseline tier per
|
|
19
|
+
* ROLE (an agent name) on `tiering.tiers`, a ladder ordered weakest first.
|
|
20
|
+
* `resolveTiering` shifts every routed role's baseline by the SAME step in
|
|
21
|
+
* the SAME direction (down for `low`, up for `high`), walking down to the
|
|
22
|
+
* nearest rung that is both available (§ probe) and backend-compatible
|
|
23
|
+
* (rule T) — never up, because a degradation must never silently escalate
|
|
24
|
+
* spend. An agent not named in `roles` is never touched: its own `model:`
|
|
25
|
+
* stands. `effectiveAgent` is the one place that turns a resolution into an
|
|
26
|
+
* actual `AgentConfig` — overriding `model` and NOTHING else.
|
|
27
|
+
*/
|
|
28
|
+
import { ollamaBaseUrl } from "./ollama_provider.js";
|
|
29
|
+
// ── the classifier (design doc §2) ──────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* One explicit `name -> weight` table entry per BUILT-IN chain
|
|
32
|
+
* (`src/chains/index.ts`'s `CHAINS`), including every chain that weighs
|
|
33
|
+
* `0` — an entry, not a fallthrough. A name absent from this table (any
|
|
34
|
+
* repo-local `.spf/chains/*.yaml` chain, or a built-in nobody has wired in
|
|
35
|
+
* yet) falls to `0` through `chainWeight`'s own default — a DIFFERENT code
|
|
36
|
+
* path from being explicitly listed as `0`, which is what lets a test
|
|
37
|
+
* assert "every built-in chain has a real entry here" rather than merely
|
|
38
|
+
* "this returned 0", so a new built-in nobody wired in cannot land in the
|
|
39
|
+
* `0` bucket unnoticed.
|
|
40
|
+
*/
|
|
41
|
+
export const CHAIN_WEIGHTS = {
|
|
42
|
+
scout: -1,
|
|
43
|
+
prompt: -1,
|
|
44
|
+
document: -1,
|
|
45
|
+
quality: -1,
|
|
46
|
+
"simple-sdlc": 1,
|
|
47
|
+
"plan-build-test-quality": 1,
|
|
48
|
+
refine: 1,
|
|
49
|
+
plan: 0,
|
|
50
|
+
build: 0,
|
|
51
|
+
"plan-build": 0,
|
|
52
|
+
"build-test": 0,
|
|
53
|
+
"plan-build-test": 0,
|
|
54
|
+
"build-review": 0,
|
|
55
|
+
};
|
|
56
|
+
/** `name in CHAIN_WEIGHTS` distinguishes "explicitly 0" from "fell through" — see `CHAIN_WEIGHTS`'s own comment. */
|
|
57
|
+
export function chainWeight(name) {
|
|
58
|
+
return name in CHAIN_WEIGHTS ? CHAIN_WEIGHTS[name] : 0;
|
|
59
|
+
}
|
|
60
|
+
/** Whitespace-delimited word count, deliberately not tokens — no tokenizer exists for an arbitrary ollama/<tag>, and a word count is free, stable, and reproducible from the trace. */
|
|
61
|
+
export function promptWords(prompt) {
|
|
62
|
+
return prompt.split(/\s+/).filter(Boolean).length;
|
|
63
|
+
}
|
|
64
|
+
/** `-1` for a terse ask, `+1` for a long one, `0` otherwise. */
|
|
65
|
+
export function promptWeight(words) {
|
|
66
|
+
if (words <= 60)
|
|
67
|
+
return -1;
|
|
68
|
+
if (words >= 400)
|
|
69
|
+
return 1;
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The classifier. Asymmetric on purpose: demotion (`low`) requires TWO
|
|
74
|
+
* agreeing signals (`sum <= -2` — both `chain_weight` and `prompt_weight`
|
|
75
|
+
* at `-1`); promotion (`high`) requires only ONE. Concretely that means a
|
|
76
|
+
* long prompt (`prompt_weight === 1`, ≥400 words) forces `high`
|
|
77
|
+
* UNCONDITIONALLY, even riding on top of a `-1` chain (`spf scout "<≥400
|
|
78
|
+
* words>"` → `high`, not the `sum === 0` "standard" a naive `sum >= 1`
|
|
79
|
+
* threshold would give) — the design doc's own §2 3×3 table and its §3.1
|
|
80
|
+
* reachability table both pin this cell as `high`, so the table (not the
|
|
81
|
+
* simpler-looking `sum` arithmetic sketched alongside it) is what
|
|
82
|
+
* `src/test/tiering.test.ts`'s full-grid test treats as the specification.
|
|
83
|
+
* An unnecessarily weak model produces a wrong answer that costs a whole
|
|
84
|
+
* re-run — the expensive failure; an unnecessarily strong one just costs
|
|
85
|
+
* some tokens — the cheap failure. Skewed toward the cheap one.
|
|
86
|
+
*/
|
|
87
|
+
export function classifyRisk(chainName, prompt) {
|
|
88
|
+
const chain_weight = chainWeight(chainName);
|
|
89
|
+
const prompt_words = promptWords(prompt);
|
|
90
|
+
const prompt_weight = promptWeight(prompt_words);
|
|
91
|
+
const sum = chain_weight + prompt_weight;
|
|
92
|
+
const risk = prompt_weight === 1 || sum >= 1 ? "high" : sum <= -2 ? "low" : "standard";
|
|
93
|
+
return { risk, signals: { chain: chainName, chain_weight, prompt_words, prompt_weight, sum } };
|
|
94
|
+
}
|
|
95
|
+
const RISK_STEP = { low: -1, standard: 0, high: 1 };
|
|
96
|
+
function stripOllamaPrefix(model) {
|
|
97
|
+
return model.startsWith("ollama/") ? model.slice("ollama/".length) : model;
|
|
98
|
+
}
|
|
99
|
+
/** Usable == not dropped by the availability probe, AND backend-compatible (rule T). */
|
|
100
|
+
function usable(tier, agent, servedOllamaTags) {
|
|
101
|
+
if (tier.coding_agent !== agent.coding_agent)
|
|
102
|
+
return false; // rule T
|
|
103
|
+
if (servedOllamaTags !== null && tier.model.startsWith("ollama/")) {
|
|
104
|
+
// The comparison strips the "ollama/" prefix before lookup — getting
|
|
105
|
+
// this backwards fails CLOSED in the wrong direction: every ollama/*
|
|
106
|
+
// rung would look unserved and every ladder would walk to the bottom.
|
|
107
|
+
return servedOllamaTags.has(stripOllamaPrefix(tier.model));
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The ladder walk (design doc §4.3). Never throws and never reports a
|
|
113
|
+
* severity — a backend mismatch (rule T) or an unknown tier name is a
|
|
114
|
+
* `notes` line plus a role left unrouted; `agents.validate()` owns
|
|
115
|
+
* severity, this owns detection.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveTiering(input) {
|
|
118
|
+
const { cfg, chainName, prompt, servedOllamaTags, required } = input;
|
|
119
|
+
const { risk, signals } = classifyRisk(chainName, prompt);
|
|
120
|
+
const routing = {};
|
|
121
|
+
const notes = [];
|
|
122
|
+
if (!cfg.tiering.enabled)
|
|
123
|
+
return { risk, signals, routing, notes };
|
|
124
|
+
const step = RISK_STEP[risk];
|
|
125
|
+
for (const agentName of required) {
|
|
126
|
+
const baseline = cfg.tiering.roles[agentName];
|
|
127
|
+
if (baseline === undefined)
|
|
128
|
+
continue; // precedence: not named in roles -> untouched
|
|
129
|
+
const agent = cfg.agents.find((a) => a.name === agentName);
|
|
130
|
+
if (!agent) {
|
|
131
|
+
notes.push(`tiering.roles.${agentName} names an agent not defined in this roster — left unrouted, no change`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const i = cfg.tiering.tiers.findIndex((t) => t.name === baseline);
|
|
135
|
+
if (i === -1) {
|
|
136
|
+
notes.push(`tiering.roles.${agentName} names tier ${JSON.stringify(baseline)}, which is not declared in tiering.tiers — left unrouted`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const target = Math.min(Math.max(i + step, 0), cfg.tiering.tiers.length - 1);
|
|
140
|
+
let j = target;
|
|
141
|
+
while (j >= 0 && !usable(cfg.tiering.tiers[j], agent, servedOllamaTags))
|
|
142
|
+
j--;
|
|
143
|
+
if (j < 0) {
|
|
144
|
+
notes.push(`${agentName}: no rung at or below ${cfg.tiering.tiers[target].name} is usable for this run (unserved tag or backend mismatch) — ` +
|
|
145
|
+
`left unrouted, dispatching on ${agent.model}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const tier = cfg.tiering.tiers[j];
|
|
149
|
+
routing[agentName] = { tier: tier.name, configured: agent.model, effective: tier.model };
|
|
150
|
+
}
|
|
151
|
+
return { risk, signals, routing, notes };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The diff view, derived — `{agent: effective}` for entries where
|
|
155
|
+
* `configured !== effective`. Empty means tiering changed nothing this run.
|
|
156
|
+
* Deriving it (rather than storing it) is what keeps this view and the
|
|
157
|
+
* full `routing` map from disagreeing: one source, one filter.
|
|
158
|
+
*/
|
|
159
|
+
export function changedModels(res) {
|
|
160
|
+
const changed = {};
|
|
161
|
+
for (const [agentName, route] of Object.entries(res.routing)) {
|
|
162
|
+
if (route.configured !== route.effective)
|
|
163
|
+
changed[agentName] = route.effective;
|
|
164
|
+
}
|
|
165
|
+
return changed;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The single dispatch-site change (design doc §4.6): `model` and ONLY
|
|
169
|
+
* `model`. `run` is a minimal structural shape — deliberately not
|
|
170
|
+
* `agents.ts`'s `RunForAgents`, which would import this module and create a
|
|
171
|
+
* cycle — so any run-shaped object with an (optional) `tiering` field
|
|
172
|
+
* satisfies it.
|
|
173
|
+
*/
|
|
174
|
+
export function effectiveAgent(run, base) {
|
|
175
|
+
const effective = run.tiering?.routing[base.name]?.effective;
|
|
176
|
+
return effective === undefined ? base : { ...base, model: effective };
|
|
177
|
+
}
|
|
178
|
+
// ── availability probe (design doc §4.4B) ───────────────────────────────────
|
|
179
|
+
const PROBE_TIMEOUT_MS = 3_000; // same budget spf doctor's own probeGet uses
|
|
180
|
+
let cachedProbe;
|
|
181
|
+
async function fetchServedOllamaTags() {
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
184
|
+
try {
|
|
185
|
+
// ollamaBaseUrl() is the SAME default-substitution a real dispatch
|
|
186
|
+
// uses, including treating a set-but-EMPTY OLLAMA_BASE_URL as unset —
|
|
187
|
+
// never re-derive that default here.
|
|
188
|
+
const url = ollamaBaseUrl().replace(/\/+$/, "") + "/models";
|
|
189
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
190
|
+
if (res.status !== 200)
|
|
191
|
+
return null;
|
|
192
|
+
const body = (await res.json());
|
|
193
|
+
if (!body || !Array.isArray(body.data))
|
|
194
|
+
return null;
|
|
195
|
+
const tags = new Set();
|
|
196
|
+
for (const entry of body.data) {
|
|
197
|
+
if (entry && typeof entry === "object" && "id" in entry) {
|
|
198
|
+
tags.add(String(entry.id));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return tags; // bare tags — no "ollama/" prefix in the response
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null; // timeout | connection refused | unparseable body all fail OPEN
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* `Promise<Set<string> | null>` of bare (unprefixed) served Ollama tags, or
|
|
212
|
+
* `null` on any failure — non-200, timeout, connection refused, an
|
|
213
|
+
* unparseable/`data`-less body. `null` means "fail open, drop nothing",
|
|
214
|
+
* never "everything is unserved": an unreachable probe silently degrading
|
|
215
|
+
* every agent to the bottom rung is a worse outcome than the honest
|
|
216
|
+
* "Unknown model ID" error Ollama itself raises at first dispatch.
|
|
217
|
+
*
|
|
218
|
+
* Only actually probes when tiering is enabled AND at least one declared
|
|
219
|
+
* tier's model starts with `ollama/` — never on a run whose tiers are all
|
|
220
|
+
* hosted, and never when tiering is off. Memoized once per process.
|
|
221
|
+
*
|
|
222
|
+
* A NEW helper, not a reuse of `spf doctor`'s `probeGet`: that helper
|
|
223
|
+
* discards the response body, which is the only part that matters here.
|
|
224
|
+
*/
|
|
225
|
+
export async function probeServedOllamaTags(cfg) {
|
|
226
|
+
if (!cfg.tiering.enabled || !cfg.tiering.tiers.some((t) => t.model.startsWith("ollama/")))
|
|
227
|
+
return null;
|
|
228
|
+
if (cachedProbe === undefined)
|
|
229
|
+
cachedProbe = fetchServedOllamaTags();
|
|
230
|
+
return cachedProbe;
|
|
231
|
+
}
|
|
232
|
+
/** Test-only: `probeServedOllamaTags` memoizes once per process, so a test that stubs `fetch` differently across cases must reset the cache between them. */
|
|
233
|
+
export function resetProbeCacheForTest() {
|
|
234
|
+
cachedProbe = undefined;
|
|
235
|
+
}
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -31,6 +31,16 @@ export declare class Tracer {
|
|
|
31
31
|
/** `null` unless `observability.otel` is configured — see the header. */
|
|
32
32
|
otel: OtelExporter | null;
|
|
33
33
|
constructor(dbPath: string, eventsJsonl: string, otel?: OtelExporter | null);
|
|
34
|
+
/**
|
|
35
|
+
* Close the sqlite handle. A one-shot CLI process never needs this — it
|
|
36
|
+
* exits right after its one Tracer anyway — but `spf watch`'s daemon loop
|
|
37
|
+
* builds a fresh Tracer (and a fresh `new Database(dbPath)`) per claimed
|
|
38
|
+
* issue, in-process, for the life of the daemon; without this, every
|
|
39
|
+
* issue's handle stayed open forever. Called from `session.ts`'s
|
|
40
|
+
* `finalize()`, once a run's own dispatch has fully settled — see its
|
|
41
|
+
* comment for why that timing is safe.
|
|
42
|
+
*/
|
|
43
|
+
close(): void;
|
|
34
44
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
35
45
|
private migrate;
|
|
36
46
|
/**
|
package/dist/core/tracer.js
CHANGED
|
@@ -128,6 +128,18 @@ export class Tracer {
|
|
|
128
128
|
this.db.exec(SCHEMA);
|
|
129
129
|
this.migrate();
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Close the sqlite handle. A one-shot CLI process never needs this — it
|
|
133
|
+
* exits right after its one Tracer anyway — but `spf watch`'s daemon loop
|
|
134
|
+
* builds a fresh Tracer (and a fresh `new Database(dbPath)`) per claimed
|
|
135
|
+
* issue, in-process, for the life of the daemon; without this, every
|
|
136
|
+
* issue's handle stayed open forever. Called from `session.ts`'s
|
|
137
|
+
* `finalize()`, once a run's own dispatch has fully settled — see its
|
|
138
|
+
* comment for why that timing is safe.
|
|
139
|
+
*/
|
|
140
|
+
close() {
|
|
141
|
+
this.db.close();
|
|
142
|
+
}
|
|
131
143
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
132
144
|
migrate() {
|
|
133
145
|
for (const [table, column, decl] of MIGRATIONS) {
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GitHandle } from "./git_helper.ts";
|
|
2
|
-
import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
|
|
2
|
+
import type { CodeHostProvider, Issue, IssueComment, IssueProvider, WatchMarker, WatchState } from "./issues/provider.ts";
|
|
3
3
|
import type { NotifyEvent } from "./notify/channel.ts";
|
|
4
|
+
import { type RefinedPriority } from "./data_types.ts";
|
|
4
5
|
export interface ChainRunResult {
|
|
5
6
|
accepted: boolean;
|
|
6
7
|
adwId: string;
|
|
@@ -18,20 +19,38 @@ export interface ChainRunResult {
|
|
|
18
19
|
/** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
|
|
19
20
|
reviewRequired?: boolean;
|
|
20
21
|
}
|
|
21
|
-
/** One issue the refine lane created — enough for `
|
|
22
|
+
/** One issue the refine lane created — enough for `announceRefined`'s summary comment and the marker's idempotency record. */
|
|
22
23
|
export interface RefinedIssueRef {
|
|
23
24
|
id: string;
|
|
24
25
|
title: string;
|
|
25
26
|
kind: string;
|
|
26
27
|
isLeaf: boolean;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* One open question the refiner raised instead of a tree — mirrors
|
|
31
|
+
* `RefineQuestionSchema` (`core/data_types.ts`) field-for-field, kept as its
|
|
32
|
+
* own local shape rather than importing that type, the same way
|
|
33
|
+
* `RefinedIssueRef` above mirrors `RefinedIssue` rather than importing it:
|
|
34
|
+
* this module stays deliberately decoupled from the chain layer (see the
|
|
35
|
+
* module doc comment), reading only what `escalateSpec`'s comment needs.
|
|
36
|
+
*/
|
|
37
|
+
export interface RefinedQuestionRef {
|
|
38
|
+
id: string;
|
|
39
|
+
question: string;
|
|
40
|
+
why_it_matters: string;
|
|
41
|
+
options: string[];
|
|
42
|
+
recommendation: string;
|
|
43
|
+
evidence: string[];
|
|
44
|
+
}
|
|
28
45
|
export interface RefineRunResult {
|
|
29
46
|
accepted: boolean;
|
|
30
47
|
adwId: string;
|
|
31
48
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
32
49
|
detail: string;
|
|
33
|
-
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted
|
|
50
|
+
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated instead of publishing. */
|
|
34
51
|
created: RefinedIssueRef[];
|
|
52
|
+
/** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree instead of escalating; `gates.refinementWellFormed` guarantees `created` and `questions` are never both non-empty. */
|
|
53
|
+
questions: RefinedQuestionRef[];
|
|
35
54
|
}
|
|
36
55
|
export interface WatchDeps {
|
|
37
56
|
provider: IssueProvider;
|
|
@@ -43,6 +62,17 @@ export interface WatchDeps {
|
|
|
43
62
|
chain: string;
|
|
44
63
|
baseBranch: string;
|
|
45
64
|
concurrency: number;
|
|
65
|
+
/**
|
|
66
|
+
* `watch.chain_options` (see `WatchConfigSchema`'s doc comment) — passed
|
|
67
|
+
* unchanged into both `runChain`'s and `runRefine`'s opts below, exactly
|
|
68
|
+
* like `spf <chain> --suite <name>` builds an options map for an
|
|
69
|
+
* interactive dispatch (`cli/commands/run.ts`). Fixes the KNOWN
|
|
70
|
+
* LIMITATION from PR #20: an unattended `spf watch` dispatch used to call
|
|
71
|
+
* `runChainDef` with no options at all, so nothing --suite-shaped could
|
|
72
|
+
* ever reach it. One shared map for both lanes — `spf watch` has no
|
|
73
|
+
* separate `refine.chain_options` today.
|
|
74
|
+
*/
|
|
75
|
+
chainOptions: Record<string, string>;
|
|
46
76
|
/**
|
|
47
77
|
* The second lane — decomposing a `<prefix>:spec-ready` product spec
|
|
48
78
|
* instead of building a `<prefix>:ready` issue. `false` (the default) is
|
|
@@ -59,6 +89,7 @@ export interface WatchDeps {
|
|
|
59
89
|
cwd: string;
|
|
60
90
|
adwId: string;
|
|
61
91
|
issueId: string;
|
|
92
|
+
chainOptions: Record<string, string>;
|
|
62
93
|
}) => Promise<RefineRunResult>;
|
|
63
94
|
worktreesDir: string;
|
|
64
95
|
/**
|
|
@@ -78,7 +109,20 @@ export interface WatchDeps {
|
|
|
78
109
|
prompt: string;
|
|
79
110
|
cwd: string;
|
|
80
111
|
adwId: string;
|
|
112
|
+
chainOptions: Record<string, string>;
|
|
81
113
|
}) => Promise<ChainRunResult>;
|
|
114
|
+
/**
|
|
115
|
+
* `IssueAuthoringProvider.listChildren`'s read-back, injected as a bound
|
|
116
|
+
* function rather than a whole provider object — same reasoning as
|
|
117
|
+
* `runChain`/`runRefine`/`linkDataDir`: this module drives whatever it's
|
|
118
|
+
* given without importing a concrete provider type. `undefined` on any
|
|
119
|
+
* tracker that doesn't implement `IssueAuthoringProvider` (Jira today —
|
|
120
|
+
* see `jira_provider.ts`'s module comment); `rollUp` treats that as a
|
|
121
|
+
* logged no-op, not a failure — the build lane still functions without
|
|
122
|
+
* container roll-up, unlike the refine lane, which cannot function
|
|
123
|
+
* without authoring at all (see `cli/commands/watch.ts`'s startup check).
|
|
124
|
+
*/
|
|
125
|
+
listChildren?: (parent: Issue) => Promise<Issue[]>;
|
|
82
126
|
log: (message: string) => void;
|
|
83
127
|
/**
|
|
84
128
|
* Structured push, alongside `log`'s plain string — a required field, like
|
|
@@ -103,6 +147,20 @@ export interface WatchRunState {
|
|
|
103
147
|
* that isn't why this is separate — the budgets are what require it.
|
|
104
148
|
*/
|
|
105
149
|
refining: Set<string>;
|
|
150
|
+
/**
|
|
151
|
+
* Issue id -> its container's real issue id, recorded the moment
|
|
152
|
+
* `claimNewWork` claims a leaf whose hidden `spf-refine:` marker names a
|
|
153
|
+
* parent, deleted in the same `.finally()` that clears `inflight`. Zero
|
|
154
|
+
* API cost — no tracker read needed to populate it — and it's the whole
|
|
155
|
+
* basis of sibling affinity in `orderEligible`: a sibling of something
|
|
156
|
+
* already in flight sorts ahead of an equal-priority issue from an
|
|
157
|
+
* unrelated feature, so a feature already underway tends to finish before
|
|
158
|
+
* the daemon starts a new one instead of interleaving both. This holds
|
|
159
|
+
* only while a sibling is ACTUALLY in flight — a daemon restart begins
|
|
160
|
+
* with this empty, so ordering falls back to priority + created-asc until
|
|
161
|
+
* the in-memory picture rebuilds itself over the next few ticks.
|
|
162
|
+
*/
|
|
163
|
+
inflightParents: Map<string, string>;
|
|
106
164
|
}
|
|
107
165
|
export declare function createWatchState(): WatchRunState;
|
|
108
166
|
export declare function branchNameFor(issue: Issue): string;
|
|
@@ -115,19 +173,124 @@ export declare function refineBranchNameFor(issue: Issue): string;
|
|
|
115
173
|
* merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
|
|
116
174
|
*/
|
|
117
175
|
export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
176
|
+
/**
|
|
177
|
+
* Poll every `spec-in-progress` spec: once every id `WatchMarker.refined`
|
|
178
|
+
* recorded — every issue the refiner produced, leaf or container — carries
|
|
179
|
+
* `<prefix>:done`, the spec's own decomposed work is actually finished, and
|
|
180
|
+
* only then does this move it `-> done`. This is the whole point of
|
|
181
|
+
* `announceRefined` landing on `spec-in-progress` rather than `done`
|
|
182
|
+
* straight away: the spec's status is what a product manager reads to know
|
|
183
|
+
* whether the work is finished, and "done" the instant a tree gets PUBLISHED
|
|
184
|
+
* would be a lie — the work hasn't started yet, let alone finished.
|
|
185
|
+
*
|
|
186
|
+
* A container in the refined list is done exactly when `rollUp` (see
|
|
187
|
+
* `finishReviews`) has already rolled it up — by the time every id here is
|
|
188
|
+
* `<prefix>:done`, every leaf beneath every container is too, transitively,
|
|
189
|
+
* with no need to walk the hierarchy again from this side.
|
|
190
|
+
*
|
|
191
|
+
* A referenced id that 404s (deleted from the tracker) is treated as
|
|
192
|
+
* satisfied — same policy as `frontierBlockedOn`'s blockers: a removed issue
|
|
193
|
+
* must not wedge the spec's completion forever. A spec with no marker, or an
|
|
194
|
+
* empty `refined` list, is left alone with a log line rather than assumed
|
|
195
|
+
* done — data that shouldn't exist given the gate's at-least-one-leaf rule,
|
|
196
|
+
* but never silently marked complete on that assumption.
|
|
197
|
+
*/
|
|
198
|
+
export declare function finishTrackedSpecs(deps: WatchDeps): Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Render the spec issue's comment thread for the refiner's prompt, replacing
|
|
201
|
+
* the previous bare `title\n\nbody` — the whole reason a human's answer
|
|
202
|
+
* could never reach a resumed run before this feature. When `feedback` names
|
|
203
|
+
* the timestamp of the most recent question comment, everything at or after
|
|
204
|
+
* it is surfaced as "answers to your open questions" (what a resumed run
|
|
205
|
+
* most needs to read); everything earlier is "earlier discussion" — context,
|
|
206
|
+
* not necessarily an answer. A spec that has never been escalated (no
|
|
207
|
+
* `feedback`) has no such split: any pre-existing comments are all "earlier
|
|
208
|
+
* discussion".
|
|
209
|
+
*
|
|
210
|
+
* Truncates the RENDERED thread, not the comment list, to roughly
|
|
211
|
+
* `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
|
|
212
|
+
* "N earlier comment(s) omitted" line, never a silent truncation.
|
|
213
|
+
*
|
|
214
|
+
* `priority` — the spec's own `<prefix>:priority:pN` label, read by
|
|
215
|
+
* `runSpec` below — renders as its own `## Priority` section right after the
|
|
216
|
+
* header, present or absent independent of whether there's any comment
|
|
217
|
+
* thread at all: a spec with no priority label (the common case today) omits
|
|
218
|
+
* the section entirely, exactly the prompt this function produced before
|
|
219
|
+
* priority existed. `core/refine.ts`'s `publish()` is what actually ENFORCES
|
|
220
|
+
* the ceiling this section only asks for — see its own doc comment.
|
|
221
|
+
*
|
|
222
|
+
* Exported and pure (no provider, no I/O) so it's directly unit-testable.
|
|
223
|
+
*/
|
|
224
|
+
export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"], priority?: RefinedPriority | null): string;
|
|
118
225
|
/**
|
|
119
226
|
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
120
|
-
* process isn't tracking is
|
|
121
|
-
* its own `transition(issue, "done")` ran (resume: finish it,
|
|
122
|
-
*
|
|
123
|
-
*
|
|
227
|
+
* process isn't tracking is one of three things: a completed publish that
|
|
228
|
+
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
229
|
+
* no re-run), a completed escalation that crashed before its own
|
|
230
|
+
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
231
|
+
* no re-asking — `escalateSpec` writes the marker's `feedback` before it
|
|
232
|
+
* transitions, so seeing `feedback` on a still-`refining` spec can only mean
|
|
233
|
+
* that last step didn't complete), or a genuine orphan (retry up to
|
|
234
|
+
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
235
|
+
* is off — see `WatchDeps.refineEnabled`.
|
|
124
236
|
*/
|
|
125
237
|
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
126
238
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
127
239
|
export declare function finishReviews(deps: WatchDeps): Promise<void>;
|
|
128
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* Sort `issues` the way `claimNewWork` walks them: priority first (p0 ahead
|
|
242
|
+
* of p2 regardless of creation order), then sibling affinity (a leaf whose
|
|
243
|
+
* hidden marker names a parent already in `inflightParents.values()` sorts
|
|
244
|
+
* ahead of an equal-priority leaf from an unrelated feature — the mechanism
|
|
245
|
+
* that tends to finish one feature before starting the next, without giving
|
|
246
|
+
* up the one-PR-per-story design), then creation order (oldest first,
|
|
247
|
+
* matching `listByLabel`'s own `sort=created&direction=asc` — the final,
|
|
248
|
+
* stable tiebreaker when priority and affinity both tie).
|
|
249
|
+
*
|
|
250
|
+
* Pure and exported so it's directly unit-testable without a provider, same
|
|
251
|
+
* spirit as `buildSpecPrompt` below. Two honest limits, both already true of
|
|
252
|
+
* what feeds it: affinity only reflects a sibling ACTUALLY in flight in this
|
|
253
|
+
* process right now — a daemon restart begins with `inflightParents` empty,
|
|
254
|
+
* so ordering degrades to priority + created-asc until it rebuilds itself
|
|
255
|
+
* over the next few ticks; and priority is read from the label, so an issue
|
|
256
|
+
* a human just relabeled sorts by its NEW priority starting next tick, never
|
|
257
|
+
* retroactively re-ordering claims a previous tick already made.
|
|
258
|
+
*/
|
|
259
|
+
export declare function orderEligible(issues: Issue[], inflightParents: Map<string, string>, labelPrefix: string): Issue[];
|
|
260
|
+
/**
|
|
261
|
+
* Claim as many `ready` issues as the concurrency budget allows, in priority
|
|
262
|
+
* + sibling-affinity + created-asc order (`orderEligible`), skipping any
|
|
263
|
+
* whose `blocked_by` isn't fully `<prefix>:done` yet (`frontierBlockedOn`),
|
|
264
|
+
* and kick off `runIssue` for each claimed one in the background.
|
|
265
|
+
*/
|
|
129
266
|
export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Claim as many specs in `from` as `refineConcurrency` allows, and kick off
|
|
269
|
+
* `runSpec` for each in the background. A no-op when `watch.refine` is off.
|
|
270
|
+
*
|
|
271
|
+
* `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
|
|
272
|
+
* with `"continue-refinement"` — a human's signal that they've answered a
|
|
273
|
+
* prior round's questions and refinement should resume. Both share this same
|
|
274
|
+
* function (and `state.refining`'s budget) rather than a second copy of the
|
|
275
|
+
* claim loop, the same way the build lane's `claim()` itself takes a
|
|
276
|
+
* parameterized `{from, to}` rather than a hardcoded `ready -> working`.
|
|
277
|
+
*
|
|
278
|
+
* Known, deliberate wart: `claim()` only removes `from`'s label, so while a
|
|
279
|
+
* resumed run is in flight the issue briefly still carries
|
|
280
|
+
* `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
|
|
281
|
+
* replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
|
|
282
|
+
* and the run's own terminating `transition()` — to `needs-feedback` again,
|
|
283
|
+
* `done`, or `blocked` — strips every state label from the fresh snapshot it
|
|
284
|
+
* reads at that point, so this self-heals on the very next transition rather
|
|
285
|
+
* than needing a second mutator alongside `transition()`.
|
|
286
|
+
*/
|
|
287
|
+
export declare function claimSpecs(deps: WatchDeps, state: WatchRunState, from?: WatchState): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* One poll tick: reconcile both lanes, finish reviews, then claim both
|
|
290
|
+
* lanes — each stage independently caught, so one stage's error never blocks
|
|
291
|
+
* the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
|
|
292
|
+
* a human who already answered and is waiting) before fresh ones
|
|
293
|
+
* (`spec-ready`) — both share `state.refining`'s budget, so
|
|
294
|
+
* `refine.concurrency` still caps the lane as a whole either way.
|
|
295
|
+
*/
|
|
133
296
|
export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
|