@gr8ful/spf 0.5.0 → 0.6.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 +61 -17
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +32 -6
- package/assets/prompts/refiner/user.md +37 -5
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +153 -4
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +6 -0
- 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 +27 -9
- package/dist/chains/steps.js +82 -13
- package/dist/cli/commands/doctor.js +108 -1
- 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 +19 -0
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.js +51 -26
- package/dist/cli/index.js +12 -0
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +232 -0
- package/dist/core/data_types.js +135 -0
- 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 +24 -1
- 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 +7 -1
- package/dist/core/issues/github_provider.js +33 -2
- package/dist/core/issues/jira_provider.d.ts +5 -1
- package/dist/core/issues/jira_provider.js +26 -3
- package/dist/core/issues/provider.d.ts +53 -2
- 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/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- 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 +89 -9
- package/dist/core/watch.js +193 -19
- 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/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
package/dist/core/watch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
4
|
export interface ChainRunResult {
|
|
5
5
|
accepted: boolean;
|
|
@@ -25,13 +25,31 @@ export interface RefinedIssueRef {
|
|
|
25
25
|
kind: string;
|
|
26
26
|
isLeaf: boolean;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* One open question the refiner raised instead of a tree — mirrors
|
|
30
|
+
* `RefineQuestionSchema` (`core/data_types.ts`) field-for-field, kept as its
|
|
31
|
+
* own local shape rather than importing that type, the same way
|
|
32
|
+
* `RefinedIssueRef` above mirrors `RefinedIssue` rather than importing it:
|
|
33
|
+
* this module stays deliberately decoupled from the chain layer (see the
|
|
34
|
+
* module doc comment), reading only what `escalateSpec`'s comment needs.
|
|
35
|
+
*/
|
|
36
|
+
export interface RefinedQuestionRef {
|
|
37
|
+
id: string;
|
|
38
|
+
question: string;
|
|
39
|
+
why_it_matters: string;
|
|
40
|
+
options: string[];
|
|
41
|
+
recommendation: string;
|
|
42
|
+
evidence: string[];
|
|
43
|
+
}
|
|
28
44
|
export interface RefineRunResult {
|
|
29
45
|
accepted: boolean;
|
|
30
46
|
adwId: string;
|
|
31
47
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
32
48
|
detail: string;
|
|
33
|
-
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted
|
|
49
|
+
/** 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
50
|
created: RefinedIssueRef[];
|
|
51
|
+
/** 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. */
|
|
52
|
+
questions: RefinedQuestionRef[];
|
|
35
53
|
}
|
|
36
54
|
export interface WatchDeps {
|
|
37
55
|
provider: IssueProvider;
|
|
@@ -43,6 +61,17 @@ export interface WatchDeps {
|
|
|
43
61
|
chain: string;
|
|
44
62
|
baseBranch: string;
|
|
45
63
|
concurrency: number;
|
|
64
|
+
/**
|
|
65
|
+
* `watch.chain_options` (see `WatchConfigSchema`'s doc comment) — passed
|
|
66
|
+
* unchanged into both `runChain`'s and `runRefine`'s opts below, exactly
|
|
67
|
+
* like `spf <chain> --suite <name>` builds an options map for an
|
|
68
|
+
* interactive dispatch (`cli/commands/run.ts`). Fixes the KNOWN
|
|
69
|
+
* LIMITATION from PR #20: an unattended `spf watch` dispatch used to call
|
|
70
|
+
* `runChainDef` with no options at all, so nothing --suite-shaped could
|
|
71
|
+
* ever reach it. One shared map for both lanes — `spf watch` has no
|
|
72
|
+
* separate `refine.chain_options` today.
|
|
73
|
+
*/
|
|
74
|
+
chainOptions: Record<string, string>;
|
|
46
75
|
/**
|
|
47
76
|
* The second lane — decomposing a `<prefix>:spec-ready` product spec
|
|
48
77
|
* instead of building a `<prefix>:ready` issue. `false` (the default) is
|
|
@@ -59,6 +88,7 @@ export interface WatchDeps {
|
|
|
59
88
|
cwd: string;
|
|
60
89
|
adwId: string;
|
|
61
90
|
issueId: string;
|
|
91
|
+
chainOptions: Record<string, string>;
|
|
62
92
|
}) => Promise<RefineRunResult>;
|
|
63
93
|
worktreesDir: string;
|
|
64
94
|
/**
|
|
@@ -78,6 +108,7 @@ export interface WatchDeps {
|
|
|
78
108
|
prompt: string;
|
|
79
109
|
cwd: string;
|
|
80
110
|
adwId: string;
|
|
111
|
+
chainOptions: Record<string, string>;
|
|
81
112
|
}) => Promise<ChainRunResult>;
|
|
82
113
|
log: (message: string) => void;
|
|
83
114
|
/**
|
|
@@ -115,19 +146,68 @@ export declare function refineBranchNameFor(issue: Issue): string;
|
|
|
115
146
|
* merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
|
|
116
147
|
*/
|
|
117
148
|
export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Render the spec issue's comment thread for the refiner's prompt, replacing
|
|
151
|
+
* the previous bare `title\n\nbody` — the whole reason a human's answer
|
|
152
|
+
* could never reach a resumed run before this feature. When `feedback` names
|
|
153
|
+
* the timestamp of the most recent question comment, everything at or after
|
|
154
|
+
* it is surfaced as "answers to your open questions" (what a resumed run
|
|
155
|
+
* most needs to read); everything earlier is "earlier discussion" — context,
|
|
156
|
+
* not necessarily an answer. A spec that has never been escalated (no
|
|
157
|
+
* `feedback`) has no such split: any pre-existing comments are all "earlier
|
|
158
|
+
* discussion".
|
|
159
|
+
*
|
|
160
|
+
* Truncates the RENDERED thread, not the comment list, to roughly
|
|
161
|
+
* `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
|
|
162
|
+
* "N earlier comment(s) omitted" line, never a silent truncation.
|
|
163
|
+
*
|
|
164
|
+
* Exported and pure (no provider, no I/O) so it's directly unit-testable.
|
|
165
|
+
*/
|
|
166
|
+
export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"]): string;
|
|
118
167
|
/**
|
|
119
168
|
* 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
|
-
*
|
|
169
|
+
* process isn't tracking is one of three things: a completed publish that
|
|
170
|
+
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
171
|
+
* no re-run), a completed escalation that crashed before its own
|
|
172
|
+
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
173
|
+
* no re-asking — `escalateSpec` writes the marker's `feedback` before it
|
|
174
|
+
* transitions, so seeing `feedback` on a still-`refining` spec can only mean
|
|
175
|
+
* that last step didn't complete), or a genuine orphan (retry up to
|
|
176
|
+
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
177
|
+
* is off — see `WatchDeps.refineEnabled`.
|
|
124
178
|
*/
|
|
125
179
|
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
126
180
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
127
181
|
export declare function finishReviews(deps: WatchDeps): Promise<void>;
|
|
128
182
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
129
183
|
export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Claim as many specs in `from` as `refineConcurrency` allows, and kick off
|
|
186
|
+
* `runSpec` for each in the background. A no-op when `watch.refine` is off.
|
|
187
|
+
*
|
|
188
|
+
* `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
|
|
189
|
+
* with `"continue-refinement"` — a human's signal that they've answered a
|
|
190
|
+
* prior round's questions and refinement should resume. Both share this same
|
|
191
|
+
* function (and `state.refining`'s budget) rather than a second copy of the
|
|
192
|
+
* claim loop, the same way the build lane's `claim()` itself takes a
|
|
193
|
+
* parameterized `{from, to}` rather than a hardcoded `ready -> working`.
|
|
194
|
+
*
|
|
195
|
+
* Known, deliberate wart: `claim()` only removes `from`'s label, so while a
|
|
196
|
+
* resumed run is in flight the issue briefly still carries
|
|
197
|
+
* `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
|
|
198
|
+
* replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
|
|
199
|
+
* and the run's own terminating `transition()` — to `needs-feedback` again,
|
|
200
|
+
* `done`, or `blocked` — strips every state label from the fresh snapshot it
|
|
201
|
+
* reads at that point, so this self-heals on the very next transition rather
|
|
202
|
+
* than needing a second mutator alongside `transition()`.
|
|
203
|
+
*/
|
|
204
|
+
export declare function claimSpecs(deps: WatchDeps, state: WatchRunState, from?: WatchState): Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* One poll tick: reconcile both lanes, finish reviews, then claim both
|
|
207
|
+
* lanes — each stage independently caught, so one stage's error never blocks
|
|
208
|
+
* the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
|
|
209
|
+
* a human who already answered and is waiting) before fresh ones
|
|
210
|
+
* (`spec-ready`) — both share `state.refining`'s budget, so
|
|
211
|
+
* `refine.concurrency` still caps the lane as a whole either way.
|
|
212
|
+
*/
|
|
133
213
|
export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
package/dist/core/watch.js
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* tree -> publish those as real issues -> done/blocked. A spec is not
|
|
8
8
|
* individually workable, so this lane never opens a PR — it hands the build
|
|
9
9
|
* lane its next batch of `ready`-able work instead (see `core/refine.ts`).
|
|
10
|
+
* When the refiner raises material ambiguity instead of a tree, this lane
|
|
11
|
+
* loops through a human instead of guessing or blocking: `escalateSpec`
|
|
12
|
+
* posts the questions and moves the spec to `needs-feedback`; a human
|
|
13
|
+
* answers in the issue's comments and adds `continue-refinement`;
|
|
14
|
+
* `claimSpecs` resumes it — the SAME `adw_id`, comment thread folded into
|
|
15
|
+
* the prompt (`buildSpecPrompt`) — for as many rounds as it takes. See
|
|
16
|
+
* `provider.ts`'s `WatchState` doc comment for the full state diagram.
|
|
10
17
|
*
|
|
11
18
|
* Provider-agnostic (drives whatever `IssueProvider` it's given) and
|
|
12
19
|
* chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
|
|
@@ -133,9 +140,10 @@ export async function reconcileOrphans(deps, state) {
|
|
|
133
140
|
* the ids `WatchMarker.refined` recorded, and the comment degrades to a
|
|
134
141
|
* bare list of `#id`s rather than blocking on a re-fetch.
|
|
135
142
|
*/
|
|
136
|
-
async function finishSpec(deps, issue, created) {
|
|
143
|
+
async function finishSpec(deps, issue, created, rounds = 0) {
|
|
144
|
+
const roundsNote = rounds > 0 ? ` (after ${rounds} round${rounds === 1 ? "" : "s"} of feedback)` : "";
|
|
137
145
|
const body = created.length > 0
|
|
138
|
-
? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
|
|
146
|
+
? `spf watch refined this spec into ${created.length} issue(s)${roundsNote}:\n\n` +
|
|
139
147
|
created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
|
|
140
148
|
`\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
|
|
141
149
|
: `spf watch refined this spec but the refiner produced no issues.`;
|
|
@@ -149,14 +157,120 @@ async function finishSpec(deps, issue, created) {
|
|
|
149
157
|
if (!deps.dryRun) {
|
|
150
158
|
await deps.provider.comment(issue, body);
|
|
151
159
|
await deps.provider.transition(issue, "done");
|
|
160
|
+
// Best-effort and never fatal: publishing already succeeded and the spec
|
|
161
|
+
// is already `done` by the time this runs, so a tracker that can't close
|
|
162
|
+
// (or a `closeIssue` that isn't implemented at all — see `IssueProvider`'s
|
|
163
|
+
// doc comment) must not turn a successfully refined spec into `blocked`.
|
|
164
|
+
await deps.provider.closeIssue?.(issue).catch((error) => {
|
|
165
|
+
deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const MAX_THREAD_CHARS = 20_000;
|
|
170
|
+
/**
|
|
171
|
+
* Render the spec issue's comment thread for the refiner's prompt, replacing
|
|
172
|
+
* the previous bare `title\n\nbody` — the whole reason a human's answer
|
|
173
|
+
* could never reach a resumed run before this feature. When `feedback` names
|
|
174
|
+
* the timestamp of the most recent question comment, everything at or after
|
|
175
|
+
* it is surfaced as "answers to your open questions" (what a resumed run
|
|
176
|
+
* most needs to read); everything earlier is "earlier discussion" — context,
|
|
177
|
+
* not necessarily an answer. A spec that has never been escalated (no
|
|
178
|
+
* `feedback`) has no such split: any pre-existing comments are all "earlier
|
|
179
|
+
* discussion".
|
|
180
|
+
*
|
|
181
|
+
* Truncates the RENDERED thread, not the comment list, to roughly
|
|
182
|
+
* `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
|
|
183
|
+
* "N earlier comment(s) omitted" line, never a silent truncation.
|
|
184
|
+
*
|
|
185
|
+
* Exported and pure (no provider, no I/O) so it's directly unit-testable.
|
|
186
|
+
*/
|
|
187
|
+
export function buildSpecPrompt(issue, comments, feedback) {
|
|
188
|
+
const header = `${issue.title}\n\n${issue.body}`.trim();
|
|
189
|
+
if (comments.length === 0)
|
|
190
|
+
return header;
|
|
191
|
+
const render = (list) => list.map((c) => `**@${c.author}** (${c.created_at}):\n${c.body.trim()}`).join("\n\n");
|
|
192
|
+
let kept = comments;
|
|
193
|
+
let dropped = 0;
|
|
194
|
+
while (render(kept).length > MAX_THREAD_CHARS && kept.length > 1) {
|
|
195
|
+
kept = kept.slice(1);
|
|
196
|
+
dropped++;
|
|
197
|
+
}
|
|
198
|
+
const askedAt = feedback?.asked_at ? Date.parse(feedback.asked_at) : null;
|
|
199
|
+
const answers = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) >= askedAt) : [];
|
|
200
|
+
const earlier = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) < askedAt) : kept;
|
|
201
|
+
const parts = [];
|
|
202
|
+
if (dropped > 0)
|
|
203
|
+
parts.push(`_... ${dropped} earlier comment(s) omitted for length._`);
|
|
204
|
+
if (answers.length > 0)
|
|
205
|
+
parts.push(`### Answers to your open questions (round ${feedback.rounds})\n\n${render(answers)}`);
|
|
206
|
+
if (earlier.length > 0)
|
|
207
|
+
parts.push(`### Earlier discussion\n\n${render(earlier)}`);
|
|
208
|
+
if (parts.length === 0)
|
|
209
|
+
return header;
|
|
210
|
+
return `${header}\n\n## Discussion on the spec issue\n\n${parts.join("\n\n")}`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The refine lane's own finishing move for "the refiner can't proceed
|
|
214
|
+
* without a human" — the escalation twin of `finishSpec` above. Posts every
|
|
215
|
+
* question as its own rich comment section (the question, why it matters,
|
|
216
|
+
* the options considered, its recommendation, and the evidence it read — so
|
|
217
|
+
* a human can often answer in a word or two), records the round in the
|
|
218
|
+
* marker so a resumed run's prompt (`buildSpecPrompt`) can tell "answers"
|
|
219
|
+
* from "earlier discussion", and moves the spec to `needs-feedback` rather
|
|
220
|
+
* than `blocked` — the refiner correctly refusing to guess is a legitimate
|
|
221
|
+
* outcome, not a failure. `marker` is spread first so `worktree`/`branch`
|
|
222
|
+
* (just written by the caller) survive into the marker this writes.
|
|
223
|
+
*/
|
|
224
|
+
async function escalateSpec(deps, issue, marker, questions, adwId, round) {
|
|
225
|
+
const body = `## spf needs your input (round ${round})\n\n` +
|
|
226
|
+
questions
|
|
227
|
+
.map((q) => {
|
|
228
|
+
const lines = [`### ${q.question}`];
|
|
229
|
+
if (q.why_it_matters)
|
|
230
|
+
lines.push(`**Why it matters:** ${q.why_it_matters}`);
|
|
231
|
+
if (q.options.length > 0)
|
|
232
|
+
lines.push(`**Options considered:**\n${q.options.map((o) => `- ${o}`).join("\n")}`);
|
|
233
|
+
if (q.recommendation)
|
|
234
|
+
lines.push(`**Recommendation:** ${q.recommendation}`);
|
|
235
|
+
if (q.evidence.length > 0)
|
|
236
|
+
lines.push(`**Evidence:**\n${q.evidence.map((e) => `- ${e}`).join("\n")}`);
|
|
237
|
+
return lines.join("\n\n");
|
|
238
|
+
})
|
|
239
|
+
.join("\n\n---\n\n") +
|
|
240
|
+
`\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
|
|
241
|
+
deps.notify({
|
|
242
|
+
// "error" level, not "info" — this is the same class of event as
|
|
243
|
+
// issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
|
|
244
|
+
// channel just as much as an `all`-scope one.
|
|
245
|
+
kind: "spec_needs_feedback",
|
|
246
|
+
level: "error",
|
|
247
|
+
title: `spec ${issue.id} needs feedback`,
|
|
248
|
+
detail: `${questions.length} question(s), round ${round}.`,
|
|
249
|
+
fields: [
|
|
250
|
+
["issue", issue.id],
|
|
251
|
+
["title", issue.title],
|
|
252
|
+
["chain", deps.refineChain],
|
|
253
|
+
["adw_id", adwId],
|
|
254
|
+
["round", String(round)],
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
if (!deps.dryRun) {
|
|
258
|
+
await deps.provider.comment(issue, body);
|
|
259
|
+
await deps.provider.writeMarker(issue, { ...marker, feedback: { rounds: round, asked_at: new Date().toISOString() } });
|
|
260
|
+
await deps.provider.transition(issue, "needs-feedback");
|
|
152
261
|
}
|
|
153
262
|
}
|
|
154
263
|
/**
|
|
155
264
|
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
156
|
-
* process isn't tracking is
|
|
157
|
-
* its own `transition(issue, "done")` ran (resume: finish it,
|
|
158
|
-
*
|
|
159
|
-
*
|
|
265
|
+
* process isn't tracking is one of three things: a completed publish that
|
|
266
|
+
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
267
|
+
* no re-run), a completed escalation that crashed before its own
|
|
268
|
+
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
269
|
+
* no re-asking — `escalateSpec` writes the marker's `feedback` before it
|
|
270
|
+
* transitions, so seeing `feedback` on a still-`refining` spec can only mean
|
|
271
|
+
* that last step didn't complete), or a genuine orphan (retry up to
|
|
272
|
+
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
273
|
+
* is off — see `WatchDeps.refineEnabled`.
|
|
160
274
|
*/
|
|
161
275
|
export async function reconcileRefining(deps, state) {
|
|
162
276
|
if (!deps.refineEnabled)
|
|
@@ -168,7 +282,20 @@ export async function reconcileRefining(deps, state) {
|
|
|
168
282
|
const marker = await deps.provider.readMarker(issue);
|
|
169
283
|
if (marker?.refined && marker.refined.length > 0) {
|
|
170
284
|
deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
|
|
171
|
-
await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
|
|
285
|
+
await finishSpec(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (marker?.feedback) {
|
|
289
|
+
deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
|
|
290
|
+
deps.notify({
|
|
291
|
+
kind: "spec_needs_feedback",
|
|
292
|
+
level: "error",
|
|
293
|
+
title: `spec ${issue.id} needs feedback`,
|
|
294
|
+
detail: `Round ${marker.feedback.rounds}.`,
|
|
295
|
+
fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
|
|
296
|
+
});
|
|
297
|
+
if (!deps.dryRun)
|
|
298
|
+
await deps.provider.transition(issue, "needs-feedback");
|
|
172
299
|
continue;
|
|
173
300
|
}
|
|
174
301
|
const attempt = (marker?.attempt ?? 0) + 1;
|
|
@@ -254,7 +381,7 @@ async function runIssue(deps, issue) {
|
|
|
254
381
|
deps.linkDataDir(worktreePath);
|
|
255
382
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
256
383
|
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
257
|
-
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
384
|
+
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId, chainOptions: deps.chainOptions });
|
|
258
385
|
if (!result.accepted) {
|
|
259
386
|
deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
|
|
260
387
|
const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
@@ -356,7 +483,7 @@ async function runSpec(deps, issue) {
|
|
|
356
483
|
const existingMarker = await deps.provider.readMarker(issue);
|
|
357
484
|
if (existingMarker?.refined && existingMarker.refined.length > 0) {
|
|
358
485
|
deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
|
|
359
|
-
await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
|
|
486
|
+
await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
|
|
360
487
|
return;
|
|
361
488
|
}
|
|
362
489
|
// See runIssue's identical comment: worktreePath/branch are deterministic
|
|
@@ -366,9 +493,20 @@ async function runSpec(deps, issue) {
|
|
|
366
493
|
deps.git.fetch("origin", deps.baseBranch);
|
|
367
494
|
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
368
495
|
deps.linkDataDir(worktreePath);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
496
|
+
// Spread existingMarker (not a bare object) so a spec resuming after a
|
|
497
|
+
// prior escalation carries its `feedback` (round count, last-asked-at)
|
|
498
|
+
// through to whatever this run writes next — escalateSpec below and the
|
|
499
|
+
// final writeMarker on a successful publish both build on this object.
|
|
500
|
+
const marker = { ...existingMarker, worktree: worktreePath, branch, attempt: 0 };
|
|
501
|
+
await deps.provider.writeMarker(issue, marker);
|
|
502
|
+
// The comment thread is what makes this a HUMAN-in-the-loop, not just a
|
|
503
|
+
// one-shot prompt: a resumed run needs the human's answers, and
|
|
504
|
+
// buildSpecPrompt tells them apart from any earlier discussion using
|
|
505
|
+
// existingMarker.feedback.asked_at (unset on a spec that's never been
|
|
506
|
+
// escalated, in which case every comment is "earlier discussion").
|
|
507
|
+
const comments = await deps.provider.listComments(issue);
|
|
508
|
+
const prompt = buildSpecPrompt(issue, comments, existingMarker?.feedback);
|
|
509
|
+
const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id, chainOptions: deps.chainOptions });
|
|
372
510
|
if (!result.accepted) {
|
|
373
511
|
deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
|
|
374
512
|
const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
@@ -383,8 +521,17 @@ async function runSpec(deps, issue) {
|
|
|
383
521
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
384
522
|
return;
|
|
385
523
|
}
|
|
386
|
-
|
|
387
|
-
|
|
524
|
+
// Mutually exclusive by construction — gates.refinementWellFormed
|
|
525
|
+
// guarantees a `questions`-bearing envelope publishes no `issues` — so
|
|
526
|
+
// this branches before, never alongside, the publish path below.
|
|
527
|
+
if (result.questions.length > 0) {
|
|
528
|
+
const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
|
|
529
|
+
await escalateSpec(deps, issue, marker, result.questions, adwId, round);
|
|
530
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
|
|
534
|
+
await finishSpec(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
|
|
388
535
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
389
536
|
}
|
|
390
537
|
catch (error) {
|
|
@@ -431,13 +578,32 @@ export async function claimNewWork(deps, state) {
|
|
|
431
578
|
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
432
579
|
}
|
|
433
580
|
}
|
|
434
|
-
/**
|
|
435
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Claim as many specs in `from` as `refineConcurrency` allows, and kick off
|
|
583
|
+
* `runSpec` for each in the background. A no-op when `watch.refine` is off.
|
|
584
|
+
*
|
|
585
|
+
* `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
|
|
586
|
+
* with `"continue-refinement"` — a human's signal that they've answered a
|
|
587
|
+
* prior round's questions and refinement should resume. Both share this same
|
|
588
|
+
* function (and `state.refining`'s budget) rather than a second copy of the
|
|
589
|
+
* claim loop, the same way the build lane's `claim()` itself takes a
|
|
590
|
+
* parameterized `{from, to}` rather than a hardcoded `ready -> working`.
|
|
591
|
+
*
|
|
592
|
+
* Known, deliberate wart: `claim()` only removes `from`'s label, so while a
|
|
593
|
+
* resumed run is in flight the issue briefly still carries
|
|
594
|
+
* `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
|
|
595
|
+
* replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
|
|
596
|
+
* and the run's own terminating `transition()` — to `needs-feedback` again,
|
|
597
|
+
* `done`, or `blocked` — strips every state label from the fresh snapshot it
|
|
598
|
+
* reads at that point, so this self-heals on the very next transition rather
|
|
599
|
+
* than needing a second mutator alongside `transition()`.
|
|
600
|
+
*/
|
|
601
|
+
export async function claimSpecs(deps, state, from = "spec-ready") {
|
|
436
602
|
if (!deps.refineEnabled)
|
|
437
603
|
return;
|
|
438
604
|
if (state.refining.size >= deps.refineConcurrency)
|
|
439
605
|
return;
|
|
440
|
-
const eligible = await deps.provider.listInState(
|
|
606
|
+
const eligible = await deps.provider.listInState(from);
|
|
441
607
|
for (const issue of eligible) {
|
|
442
608
|
if (state.refining.size >= deps.refineConcurrency)
|
|
443
609
|
break;
|
|
@@ -447,7 +613,7 @@ export async function claimSpecs(deps, state) {
|
|
|
447
613
|
deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
|
|
448
614
|
continue;
|
|
449
615
|
}
|
|
450
|
-
const claimed = await deps.provider.claim(issue, { from
|
|
616
|
+
const claimed = await deps.provider.claim(issue, { from, to: "refining" });
|
|
451
617
|
if (!claimed) {
|
|
452
618
|
deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
|
|
453
619
|
continue;
|
|
@@ -470,11 +636,19 @@ function tickErrorHandler(deps, stage) {
|
|
|
470
636
|
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
471
637
|
};
|
|
472
638
|
}
|
|
473
|
-
/**
|
|
639
|
+
/**
|
|
640
|
+
* One poll tick: reconcile both lanes, finish reviews, then claim both
|
|
641
|
+
* lanes — each stage independently caught, so one stage's error never blocks
|
|
642
|
+
* the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
|
|
643
|
+
* a human who already answered and is waiting) before fresh ones
|
|
644
|
+
* (`spec-ready`) — both share `state.refining`'s budget, so
|
|
645
|
+
* `refine.concurrency` still caps the lane as a whole either way.
|
|
646
|
+
*/
|
|
474
647
|
export async function tick(deps, state) {
|
|
475
648
|
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
476
649
|
await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
|
|
477
650
|
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
651
|
+
await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
|
|
478
652
|
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
479
653
|
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
480
654
|
}
|
package/dist/ui/server/db.d.ts
CHANGED
|
@@ -79,4 +79,43 @@ export declare class SfDb {
|
|
|
79
79
|
envelopes(adwId: string): Envelope[];
|
|
80
80
|
gates(adwId: string): GateResult[];
|
|
81
81
|
sessionCount(): number;
|
|
82
|
+
/**
|
|
83
|
+
* `spf estimate`'s one read: every session that ran EXACTLY `chainName`
|
|
84
|
+
* (never a joined one — see below), each with its per-phase token totals,
|
|
85
|
+
* most recent first. Also reports how many joined sessions (`adw_name`
|
|
86
|
+
* like `"a + b"`, `core/tracer.ts:215`) named `chainName` as one of their
|
|
87
|
+
* components — real history that cannot be attributed to this one chain
|
|
88
|
+
* alone (both chains' phases share one continuing `seq`), so it is
|
|
89
|
+
* counted for the "excluded" message rather than silently absent.
|
|
90
|
+
*
|
|
91
|
+
* Guarded by `hasColumn("sessions", "adw_name")` — a MIGRATED column
|
|
92
|
+
* (`core/tracer.ts:109-116`) — rather than `optionalColumn`: this method
|
|
93
|
+
* FILTERS on `adw_name`, and a `WHERE` clause naming a column an older db's
|
|
94
|
+
* `sessions` table does not have is a SQLite error, not a no-match.
|
|
95
|
+
* Short-circuiting to the empty shape here is what lets an old db read as
|
|
96
|
+
* cold start (estimate's case 3) instead of crashing.
|
|
97
|
+
*
|
|
98
|
+
* No `LIMIT` at the SQL level: capping at 20 and collapsing fanout
|
|
99
|
+
* siblings both depend on which of `success`-only vs any-status qualifies
|
|
100
|
+
* first (`spf estimate`'s own sample-selection order), which is a decision
|
|
101
|
+
* `estimate.ts` makes, not this method.
|
|
102
|
+
*/
|
|
103
|
+
chainPhaseHistory(chainName: string): {
|
|
104
|
+
sessions: ChainHistorySession[];
|
|
105
|
+
joinedExcluded: number;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** One EXACT-match session's history, as `chainPhaseHistory` returns it — `spf estimate`'s raw material. */
|
|
109
|
+
export interface ChainHistorySession {
|
|
110
|
+
adw_id: string;
|
|
111
|
+
status: string;
|
|
112
|
+
started_at: string | null;
|
|
113
|
+
total_tokens: number;
|
|
114
|
+
total_cost: number;
|
|
115
|
+
/** In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`, `fix_2`), not yet loop-normalized. */
|
|
116
|
+
phases: {
|
|
117
|
+
name: string;
|
|
118
|
+
seq: number;
|
|
119
|
+
tokens: number;
|
|
120
|
+
}[];
|
|
82
121
|
}
|
package/dist/ui/server/db.js
CHANGED
|
@@ -325,6 +325,67 @@ export class SfDb {
|
|
|
325
325
|
.get();
|
|
326
326
|
return row?.n ?? 0;
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* `spf estimate`'s one read: every session that ran EXACTLY `chainName`
|
|
330
|
+
* (never a joined one — see below), each with its per-phase token totals,
|
|
331
|
+
* most recent first. Also reports how many joined sessions (`adw_name`
|
|
332
|
+
* like `"a + b"`, `core/tracer.ts:215`) named `chainName` as one of their
|
|
333
|
+
* components — real history that cannot be attributed to this one chain
|
|
334
|
+
* alone (both chains' phases share one continuing `seq`), so it is
|
|
335
|
+
* counted for the "excluded" message rather than silently absent.
|
|
336
|
+
*
|
|
337
|
+
* Guarded by `hasColumn("sessions", "adw_name")` — a MIGRATED column
|
|
338
|
+
* (`core/tracer.ts:109-116`) — rather than `optionalColumn`: this method
|
|
339
|
+
* FILTERS on `adw_name`, and a `WHERE` clause naming a column an older db's
|
|
340
|
+
* `sessions` table does not have is a SQLite error, not a no-match.
|
|
341
|
+
* Short-circuiting to the empty shape here is what lets an old db read as
|
|
342
|
+
* cold start (estimate's case 3) instead of crashing.
|
|
343
|
+
*
|
|
344
|
+
* No `LIMIT` at the SQL level: capping at 20 and collapsing fanout
|
|
345
|
+
* siblings both depend on which of `success`-only vs any-status qualifies
|
|
346
|
+
* first (`spf estimate`'s own sample-selection order), which is a decision
|
|
347
|
+
* `estimate.ts` makes, not this method.
|
|
348
|
+
*/
|
|
349
|
+
chainPhaseHistory(chainName) {
|
|
350
|
+
if (!this.hasColumn("sessions", "adw_name"))
|
|
351
|
+
return { sessions: [], joinedExcluded: 0 };
|
|
352
|
+
const joinedRow = this.db
|
|
353
|
+
.query(`SELECT COUNT(*) AS n FROM sessions
|
|
354
|
+
WHERE adw_name != ? AND (adw_name LIKE ? OR adw_name LIKE ? OR adw_name LIKE ?)`)
|
|
355
|
+
.get(chainName, `${chainName} + %`, `% + ${chainName}`, `% + ${chainName} + %`);
|
|
356
|
+
const sessionRows = this.db
|
|
357
|
+
.query(`SELECT adw_id, status, started_at, total_tokens, total_cost
|
|
358
|
+
FROM sessions WHERE adw_name = ? ORDER BY started_at DESC, rowid DESC`)
|
|
359
|
+
.all(chainName);
|
|
360
|
+
if (sessionRows.length === 0)
|
|
361
|
+
return { sessions: [], joinedExcluded: joinedRow?.n ?? 0 };
|
|
362
|
+
const ids = sessionRows.map((r) => r.adw_id);
|
|
363
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
364
|
+
const phaseRows = this.db
|
|
365
|
+
.query(`SELECT p.adw_id, p.name, p.seq, e.tokens
|
|
366
|
+
FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
|
|
367
|
+
WHERE p.adw_id IN (${placeholders})
|
|
368
|
+
ORDER BY p.seq ASC`)
|
|
369
|
+
.all(...ids);
|
|
370
|
+
const phasesByAdw = new Map();
|
|
371
|
+
for (const row of phaseRows) {
|
|
372
|
+
const list = phasesByAdw.get(row.adw_id);
|
|
373
|
+
const entry = { name: row.name, seq: row.seq, tokens: row.tokens ?? 0 };
|
|
374
|
+
if (list)
|
|
375
|
+
list.push(entry);
|
|
376
|
+
else
|
|
377
|
+
phasesByAdw.set(row.adw_id, [entry]);
|
|
378
|
+
}
|
|
379
|
+
const sessions = sessionRows.map((row) => ({
|
|
380
|
+
adw_id: row.adw_id,
|
|
381
|
+
status: row.status ?? "fail",
|
|
382
|
+
started_at: row.started_at,
|
|
383
|
+
total_tokens: row.total_tokens ?? 0,
|
|
384
|
+
total_cost: row.total_cost ?? 0,
|
|
385
|
+
phases: phasesByAdw.get(row.adw_id) ?? [],
|
|
386
|
+
}));
|
|
387
|
+
return { sessions, joinedExcluded: joinedRow?.n ?? 0 };
|
|
388
|
+
}
|
|
328
389
|
}
|
|
329
390
|
function clamp(value, min, max) {
|
|
330
391
|
if (!Number.isFinite(value))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gr8ful/spf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"!dist/test",
|
|
16
17
|
"web",
|
|
17
18
|
"assets",
|
|
18
19
|
"README.md",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|