@gr8ful/spf 0.12.0 → 0.14.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 +120 -28
- package/assets/prompts/refiner/system.md +105 -25
- package/assets/prompts/refiner/user.md +50 -15
- package/assets/prompts/scout/system.md +2 -2
- package/assets/prompts/scout/user.md +1 -1
- package/assets/skill/references/config.md +28 -8
- 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 +15 -0
- package/dist/cli/commands/watch.js +33 -11
- package/dist/cli/interview.js +9 -0
- package/dist/core/data_types.d.ts +202 -1
- package/dist/core/data_types.js +134 -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/notify/notifier.d.ts +3 -2
- package/dist/core/notify/notifier.js +32 -3
- package/dist/core/refine.d.ts +35 -1
- package/dist/core/refine.js +56 -1
- package/dist/core/utils.d.ts +5 -5
- package/dist/core/utils.js +14 -7
- package/dist/core/watch.d.ts +65 -9
- package/dist/core/watch.js +157 -11
- package/package.json +1 -1
|
@@ -182,6 +182,37 @@ export declare const RefinedIssueSchema: v.ObjectSchema<{
|
|
|
182
182
|
readonly key: v.StringSchema<undefined>;
|
|
183
183
|
readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
|
|
184
184
|
readonly title: v.StringSchema<undefined>;
|
|
185
|
+
/**
|
|
186
|
+
* One sentence: what someone can do once this node lands that they could
|
|
187
|
+
* not before — the "slice test" sentence from
|
|
188
|
+
* `assets/prompts/refiner/system.md`. A FIELD rather than a `##` section
|
|
189
|
+
* inside `body`, for three reasons. It rides the constrained decode —
|
|
190
|
+
* `core/agents.ts` hands `call.output_type.schema` to the coding agent as
|
|
191
|
+
* `output_schema` on every send, corrections included — so this is a slot
|
|
192
|
+
* the model fills while it is still deciding what the slice IS, not a
|
|
193
|
+
* heading it appends afterwards to satisfy a check it read about (the
|
|
194
|
+
* asymmetry `c9a4471`'s banned-noun rule lacked, and why the model routed
|
|
195
|
+
* around it). It is checkable without a regex over prose the model
|
|
196
|
+
* controls the format of. And it leaves `body`'s documented shape — "##
|
|
197
|
+
* What to build" then "## Acceptance criteria" only, a rule stated in
|
|
198
|
+
* `body`'s own comment below, in `assets/prompts/refiner/user.md` and in
|
|
199
|
+
* `system.md` — alone, instead of contradicting it in three places.
|
|
200
|
+
*
|
|
201
|
+
* `v.optional(..., "")` rather than a bare `v.string()`, deliberately, and
|
|
202
|
+
* against the pattern `title`/`body` set: a MISSING field fails the
|
|
203
|
+
* valibot parse and burns a JSON-repair retry on a generic "field
|
|
204
|
+
* required" message, whereas an EMPTY one reaches
|
|
205
|
+
* `gates.refinementWellFormed`, which sends back a correction that says
|
|
206
|
+
* what sentence to write and why. The better message is worth more here
|
|
207
|
+
* than the earlier failure, and every existing `core/refine.ts`
|
|
208
|
+
* `publish()` caller keeps compiling unchanged.
|
|
209
|
+
*
|
|
210
|
+
* Present on every node in the schema (a container should have to answer
|
|
211
|
+
* for itself too), enforced by the gate on LEAVES only: a container's
|
|
212
|
+
* outcome is the union of its children's, and a blank one there is a
|
|
213
|
+
* style note, not a decomposition mistake.
|
|
214
|
+
*/
|
|
215
|
+
readonly user_outcome: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
185
216
|
readonly body: v.StringSchema<undefined>;
|
|
186
217
|
readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
187
218
|
readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
|
|
@@ -210,7 +241,43 @@ export declare const RefineQuestionSchema: v.ObjectSchema<{
|
|
|
210
241
|
readonly evidence: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
|
|
211
242
|
}, undefined>;
|
|
212
243
|
export type RefineQuestion = v.InferOutput<typeof RefineQuestionSchema>;
|
|
213
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* One proposed standalone spec, part of splitting an over-large spec into
|
|
246
|
+
* several — the refiner's answer to "this spec doesn't fit the leaf budget"
|
|
247
|
+
* that is NOT ambiguity (contrast `RefineQuestionSchema`, which is). `body`
|
|
248
|
+
* is a complete spec in its own right — problem, proposed outcome, scope —
|
|
249
|
+
* not a fragment or a "phase" of a larger plan: `assets/prompts/refiner/
|
|
250
|
+
* system.md`'s sizing section requires each entry to be something a human
|
|
251
|
+
* would ship on its own. `rationale` is why it's coherent alone, and should
|
|
252
|
+
* state the leaf count the refiner expects it to decompose into, so a human
|
|
253
|
+
* approving the split can see at a glance that the split actually fixes the
|
|
254
|
+
* over-budget problem rather than just moving it downstream.
|
|
255
|
+
*
|
|
256
|
+
* `core/refine.ts`'s `publishSpecs()` turns an approved list of these into
|
|
257
|
+
* real tracker issues (`kind: "spec"`, `<prefix>:type:spec` +
|
|
258
|
+
* `<prefix>:spec-ready` labels) — see `WatchMarker.split` in
|
|
259
|
+
* `core/issues/provider.ts` for how a proposal is recorded between "the
|
|
260
|
+
* refiner proposed it" and "a human approved it."
|
|
261
|
+
*/
|
|
262
|
+
export declare const SpecSplitSchema: v.ObjectSchema<{
|
|
263
|
+
readonly title: v.StringSchema<undefined>;
|
|
264
|
+
readonly body: v.StringSchema<undefined>;
|
|
265
|
+
readonly rationale: v.StringSchema<undefined>;
|
|
266
|
+
}, undefined>;
|
|
267
|
+
export type SpecSplit = v.InferOutput<typeof SpecSplitSchema>;
|
|
268
|
+
/**
|
|
269
|
+
* A product spec decomposed into a feature/story tree — see `steps.refine()`
|
|
270
|
+
* and `core/refine.ts`. Exactly one of `issues` / `questions` / `split` is
|
|
271
|
+
* non-empty for a given round (`gates.refinementWellFormed` enforces all
|
|
272
|
+
* three pairwise): `issues` is an unambiguous decomposition that fits the
|
|
273
|
+
* budget; `questions` is material ambiguity (scope, data model, an external
|
|
274
|
+
* dependency, a UX contract — see `system.md`'s "Ask, don't decide");
|
|
275
|
+
* `split` is neither of those — the spec is simply too large for one
|
|
276
|
+
* decomposition, and the refiner proposes cutting it into several standalone
|
|
277
|
+
* specs instead of guessing at a scope cut or force-fitting an oversized
|
|
278
|
+
* tree. See `SpecSplitSchema`'s own doc comment for what `split`'s job is
|
|
279
|
+
* *not*: an ambiguity question, or a fragment of a bigger plan.
|
|
280
|
+
*/
|
|
214
281
|
export declare const RefineOutput: EnvelopeType<{
|
|
215
282
|
status: "fail" | "success";
|
|
216
283
|
summary: string;
|
|
@@ -220,6 +287,7 @@ export declare const RefineOutput: EnvelopeType<{
|
|
|
220
287
|
key: string;
|
|
221
288
|
kind: "bug" | "epic" | "feature" | "story" | "task";
|
|
222
289
|
title: string;
|
|
290
|
+
user_outcome: string;
|
|
223
291
|
body: string;
|
|
224
292
|
parent: string;
|
|
225
293
|
blocked_by: string[];
|
|
@@ -233,6 +301,11 @@ export declare const RefineOutput: EnvelopeType<{
|
|
|
233
301
|
recommendation: string;
|
|
234
302
|
evidence: string[];
|
|
235
303
|
}[];
|
|
304
|
+
split: {
|
|
305
|
+
title: string;
|
|
306
|
+
body: string;
|
|
307
|
+
rationale: string;
|
|
308
|
+
}[];
|
|
236
309
|
}>;
|
|
237
310
|
export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
|
|
238
311
|
export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
|
|
@@ -384,8 +457,29 @@ export declare class GateReport {
|
|
|
384
457
|
get passed(): boolean;
|
|
385
458
|
}
|
|
386
459
|
/** The minimal shape a gate needs from a run — avoids a circular import with runner.ts. */
|
|
460
|
+
/** The minimal shape a gate needs from a run — avoids a circular import with runner.ts. */
|
|
387
461
|
export interface RunContext {
|
|
388
462
|
repo_root: string;
|
|
463
|
+
/**
|
|
464
|
+
* The run's resolved config. The one field here that isn't about the
|
|
465
|
+
* filesystem: `gates.refinementWellFormed` reads `watch.refine`'s
|
|
466
|
+
* decomposition budget from it. `core/runner.ts`'s `Run` already carries
|
|
467
|
+
* `cfg` — it satisfies this interface structurally, which is the entire
|
|
468
|
+
* point of an interface this narrow — so the single production call site
|
|
469
|
+
* (`core/agents.ts`'s gate loop, which passes the `Run` itself) always
|
|
470
|
+
* supplies it.
|
|
471
|
+
*
|
|
472
|
+
* OPTIONAL rather than required so a gate stays callable from a test or a
|
|
473
|
+
* tool holding nothing but a repo root; absent, a gate falls back to the
|
|
474
|
+
* packaged `DEFAULT_REFINE_*` constants — the SAME numbers a config file
|
|
475
|
+
* that omits those keys resolves to, never a skipped check. Same shape as
|
|
476
|
+
* `core/permissions.ts`'s own `RunLike` (`{repo_root, cfg}`), deliberately
|
|
477
|
+
* — two structural narrowings of `Run` that agree.
|
|
478
|
+
*
|
|
479
|
+
* `SFConfig` is declared later in this file; a forward reference in a type
|
|
480
|
+
* position is erased at compile time and needs no reordering.
|
|
481
|
+
*/
|
|
482
|
+
cfg?: SFConfig;
|
|
389
483
|
}
|
|
390
484
|
export type GateFn = (envelope: EnvelopeBase, run: RunContext) => GateReport | string[];
|
|
391
485
|
/** One agent invocation: prompt in, typed envelope out, gates verified. */
|
|
@@ -735,6 +829,14 @@ export declare const JiraIssueTypeMapSchema: v.ObjectSchema<{
|
|
|
735
829
|
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
736
830
|
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
737
831
|
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
832
|
+
/**
|
|
833
|
+
* A spec proposed by `core/refine.ts`'s `publishSpecs()` — a Story by
|
|
834
|
+
* default, the same hierarchy level BT-1789 itself was created at. Never
|
|
835
|
+
* `"Epic"`: a spec is not a container in the `RefineOutput.issues` tree
|
|
836
|
+
* `gates.refinementWellFormed` validates (it can't be a `parent` there),
|
|
837
|
+
* so it doesn't need Epic-under-Epic nesting the way `feature` does.
|
|
838
|
+
*/
|
|
839
|
+
readonly spec: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
738
840
|
}, undefined>;
|
|
739
841
|
export type JiraIssueTypeMap = v.InferOutput<typeof JiraIssueTypeMapSchema>;
|
|
740
842
|
/**
|
|
@@ -755,12 +857,21 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
|
|
|
755
857
|
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
756
858
|
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
757
859
|
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
860
|
+
/**
|
|
861
|
+
* A spec proposed by `core/refine.ts`'s `publishSpecs()` — a Story by
|
|
862
|
+
* default, the same hierarchy level BT-1789 itself was created at. Never
|
|
863
|
+
* `"Epic"`: a spec is not a container in the `RefineOutput.issues` tree
|
|
864
|
+
* `gates.refinementWellFormed` validates (it can't be a `parent` there),
|
|
865
|
+
* so it doesn't need Epic-under-Epic nesting the way `feature` does.
|
|
866
|
+
*/
|
|
867
|
+
readonly spec: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
758
868
|
}, undefined>, () => {
|
|
759
869
|
epic: string;
|
|
760
870
|
feature: string;
|
|
761
871
|
story: string;
|
|
762
872
|
bug: string;
|
|
763
873
|
task: string;
|
|
874
|
+
spec: string;
|
|
764
875
|
}>;
|
|
765
876
|
}, undefined>;
|
|
766
877
|
export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
@@ -774,10 +885,61 @@ export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
|
774
885
|
* any other value fails loudly at `spf watch` startup rather than running a
|
|
775
886
|
* refine lane that can never publish anything.
|
|
776
887
|
*/
|
|
888
|
+
/**
|
|
889
|
+
* The decomposition BUDGET's packaged defaults, and the single source of
|
|
890
|
+
* truth for them: `WatchRefineConfigSchema` below uses them as its valibot
|
|
891
|
+
* defaults, and `gates.refineBudget` falls back to them for a `RunContext`
|
|
892
|
+
* carrying no `cfg` (see `RunContext.cfg`). Two copies of a default is how a
|
|
893
|
+
* config file and the gate that enforces it come to disagree about what the
|
|
894
|
+
* budget IS, so there is one copy.
|
|
895
|
+
*
|
|
896
|
+
* These are CEILINGS, not targets. `assets/prompts/refiner/system.md` states
|
|
897
|
+
* the target — one feature, three or four leaves. A gate that failed AT the
|
|
898
|
+
* target would MAKE the target a floor: every spec would land exactly on the
|
|
899
|
+
* number and none would land under it. So the gate fires one slice past
|
|
900
|
+
* where a good decomposition sits, not at it.
|
|
901
|
+
*
|
|
902
|
+
* The unit of `DEFAULT_REFINE_MAX_LEAVES` is HUMAN REVIEWS, not model effort:
|
|
903
|
+
* every leaf gets `<prefix>:refined`, and `core/watch.ts`'s build lane turns
|
|
904
|
+
* each one into its own worktree, its own chain run and its own pull request
|
|
905
|
+
* a person reads.
|
|
906
|
+
*/
|
|
907
|
+
export declare const DEFAULT_REFINE_MAX_LEAVES = 4;
|
|
908
|
+
export declare const DEFAULT_REFINE_MAX_NODES = 6;
|
|
909
|
+
export declare const DEFAULT_REFINE_MAX_DEPTH = 2;
|
|
910
|
+
/**
|
|
911
|
+
* `max_leaves`/`max_nodes`/`max_depth` are the decomposition budget
|
|
912
|
+
* `gates.refinementWellFormed` enforces — see `DEFAULT_REFINE_*` above for
|
|
913
|
+
* what each is denominated in and why the gate's number is looser than the
|
|
914
|
+
* prompt's target. Three notes an operator tuning these needs:
|
|
915
|
+
*
|
|
916
|
+
* - `max_nodes` DOES NOT BIND at the defaults. With `max_depth: 2` and the
|
|
917
|
+
* gate's no-singleton-container rule, every container has at least two
|
|
918
|
+
* children, so a tree is at most `max_leaves + floor(max_leaves / 2)` = 6
|
|
919
|
+
* nodes at the default `max_leaves: 4`. It exists so that raising ONE of
|
|
920
|
+
* the other two knobs cannot silently uncap the whole tree.
|
|
921
|
+
* - RAISING `max_depth` ABOVE 2 IS UNSAFE ON JIRA. `jira.issue_types` maps
|
|
922
|
+
* both `epic` and `feature` to Jira's Epic type by default, and Jira has
|
|
923
|
+
* no Epic-under-Epic nesting (see `core/issues/jira_provider.ts`'s
|
|
924
|
+
* accepted-limitation note) — a three-level tree reaches `linkChild` and
|
|
925
|
+
* comes back as a raw Atlassian API error partway through a publish that
|
|
926
|
+
* is deliberately not transactional. At 2, that tree is rejected before a
|
|
927
|
+
* single issue is created.
|
|
928
|
+
* - There is no "unlimited". `minValue(1)` on all three is deliberate: a
|
|
929
|
+
* spec that genuinely needs more slices than the budget allows is a spec
|
|
930
|
+
* that should be SPLIT, and the refiner has a first-class way to say so
|
|
931
|
+
* (`RefineOutput.split` -> `core/watch.ts`'s `proposeSpecSplit` ->
|
|
932
|
+
* `split-proposed`, approved by a human, executed by
|
|
933
|
+
* `executeApprovedSplits`). An operator who disagrees raises the number
|
|
934
|
+
* rather than removing it.
|
|
935
|
+
*/
|
|
777
936
|
export declare const WatchRefineConfigSchema: v.ObjectSchema<{
|
|
778
937
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
779
938
|
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
780
939
|
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
940
|
+
readonly max_leaves: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 50, undefined>]>, 4>;
|
|
941
|
+
readonly max_nodes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 100, undefined>]>, 6>;
|
|
942
|
+
readonly max_depth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 4, undefined>]>, 2>;
|
|
781
943
|
}, undefined>;
|
|
782
944
|
export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
|
|
783
945
|
/**
|
|
@@ -846,12 +1008,21 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
|
846
1008
|
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
847
1009
|
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
848
1010
|
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
1011
|
+
/**
|
|
1012
|
+
* A spec proposed by `core/refine.ts`'s `publishSpecs()` — a Story by
|
|
1013
|
+
* default, the same hierarchy level BT-1789 itself was created at. Never
|
|
1014
|
+
* `"Epic"`: a spec is not a container in the `RefineOutput.issues` tree
|
|
1015
|
+
* `gates.refinementWellFormed` validates (it can't be a `parent` there),
|
|
1016
|
+
* so it doesn't need Epic-under-Epic nesting the way `feature` does.
|
|
1017
|
+
*/
|
|
1018
|
+
readonly spec: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
849
1019
|
}, undefined>, () => {
|
|
850
1020
|
epic: string;
|
|
851
1021
|
feature: string;
|
|
852
1022
|
story: string;
|
|
853
1023
|
bug: string;
|
|
854
1024
|
task: string;
|
|
1025
|
+
spec: string;
|
|
855
1026
|
}>;
|
|
856
1027
|
}, undefined>, () => {
|
|
857
1028
|
base_url: string;
|
|
@@ -862,16 +1033,23 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
|
862
1033
|
story: string;
|
|
863
1034
|
bug: string;
|
|
864
1035
|
task: string;
|
|
1036
|
+
spec: string;
|
|
865
1037
|
};
|
|
866
1038
|
}>;
|
|
867
1039
|
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
868
1040
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
869
1041
|
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
870
1042
|
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
1043
|
+
readonly max_leaves: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 50, undefined>]>, 4>;
|
|
1044
|
+
readonly max_nodes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 100, undefined>]>, 6>;
|
|
1045
|
+
readonly max_depth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 4, undefined>]>, 2>;
|
|
871
1046
|
}, undefined>, () => {
|
|
872
1047
|
enabled: boolean;
|
|
873
1048
|
chain: string;
|
|
874
1049
|
concurrency: number;
|
|
1050
|
+
max_leaves: number;
|
|
1051
|
+
max_nodes: number;
|
|
1052
|
+
max_depth: number;
|
|
875
1053
|
}>;
|
|
876
1054
|
readonly fanout: v.OptionalSchema<v.ObjectSchema<{
|
|
877
1055
|
readonly n: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 8, undefined>]>, 1>;
|
|
@@ -921,6 +1099,7 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
|
|
|
921
1099
|
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
922
1100
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
923
1101
|
}, undefined>, undefined>, () => never[]>;
|
|
1102
|
+
readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
924
1103
|
}, undefined>;
|
|
925
1104
|
export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
|
|
926
1105
|
/**
|
|
@@ -1168,12 +1347,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1168
1347
|
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
1169
1348
|
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
1170
1349
|
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
1350
|
+
/**
|
|
1351
|
+
* A spec proposed by `core/refine.ts`'s `publishSpecs()` — a Story by
|
|
1352
|
+
* default, the same hierarchy level BT-1789 itself was created at. Never
|
|
1353
|
+
* `"Epic"`: a spec is not a container in the `RefineOutput.issues` tree
|
|
1354
|
+
* `gates.refinementWellFormed` validates (it can't be a `parent` there),
|
|
1355
|
+
* so it doesn't need Epic-under-Epic nesting the way `feature` does.
|
|
1356
|
+
*/
|
|
1357
|
+
readonly spec: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
1171
1358
|
}, undefined>, () => {
|
|
1172
1359
|
epic: string;
|
|
1173
1360
|
feature: string;
|
|
1174
1361
|
story: string;
|
|
1175
1362
|
bug: string;
|
|
1176
1363
|
task: string;
|
|
1364
|
+
spec: string;
|
|
1177
1365
|
}>;
|
|
1178
1366
|
}, undefined>, () => {
|
|
1179
1367
|
base_url: string;
|
|
@@ -1184,16 +1372,23 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1184
1372
|
story: string;
|
|
1185
1373
|
bug: string;
|
|
1186
1374
|
task: string;
|
|
1375
|
+
spec: string;
|
|
1187
1376
|
};
|
|
1188
1377
|
}>;
|
|
1189
1378
|
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
1190
1379
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
1191
1380
|
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
1192
1381
|
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
1382
|
+
readonly max_leaves: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 50, undefined>]>, 4>;
|
|
1383
|
+
readonly max_nodes: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 100, undefined>]>, 6>;
|
|
1384
|
+
readonly max_depth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 4, undefined>]>, 2>;
|
|
1193
1385
|
}, undefined>, () => {
|
|
1194
1386
|
enabled: boolean;
|
|
1195
1387
|
chain: string;
|
|
1196
1388
|
concurrency: number;
|
|
1389
|
+
max_leaves: number;
|
|
1390
|
+
max_nodes: number;
|
|
1391
|
+
max_depth: number;
|
|
1197
1392
|
}>;
|
|
1198
1393
|
readonly fanout: v.OptionalSchema<v.ObjectSchema<{
|
|
1199
1394
|
readonly n: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 8, undefined>]>, 1>;
|
|
@@ -1224,12 +1419,16 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1224
1419
|
story: string;
|
|
1225
1420
|
bug: string;
|
|
1226
1421
|
task: string;
|
|
1422
|
+
spec: string;
|
|
1227
1423
|
};
|
|
1228
1424
|
};
|
|
1229
1425
|
refine: {
|
|
1230
1426
|
enabled: boolean;
|
|
1231
1427
|
chain: string;
|
|
1232
1428
|
concurrency: number;
|
|
1429
|
+
max_leaves: number;
|
|
1430
|
+
max_nodes: number;
|
|
1431
|
+
max_depth: number;
|
|
1233
1432
|
};
|
|
1234
1433
|
fanout: {
|
|
1235
1434
|
n: number;
|
|
@@ -1245,6 +1444,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1245
1444
|
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
|
|
1246
1445
|
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1247
1446
|
}, undefined>, undefined>, () => never[]>;
|
|
1447
|
+
readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
1248
1448
|
}, undefined>, () => {
|
|
1249
1449
|
events: "all" | "attention" | "errors" | "off";
|
|
1250
1450
|
timeout_ms: number;
|
|
@@ -1254,6 +1454,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1254
1454
|
events?: "all" | "attention" | "errors" | "off" | null | undefined;
|
|
1255
1455
|
name: string;
|
|
1256
1456
|
}[];
|
|
1457
|
+
project: string;
|
|
1257
1458
|
}>;
|
|
1258
1459
|
readonly review: v.OptionalSchema<v.ObjectSchema<{
|
|
1259
1460
|
readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
package/dist/core/data_types.js
CHANGED
|
@@ -138,6 +138,37 @@ export const RefinedIssueSchema = v.object({
|
|
|
138
138
|
key: v.string(),
|
|
139
139
|
kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
|
|
140
140
|
title: v.string(),
|
|
141
|
+
/**
|
|
142
|
+
* One sentence: what someone can do once this node lands that they could
|
|
143
|
+
* not before — the "slice test" sentence from
|
|
144
|
+
* `assets/prompts/refiner/system.md`. A FIELD rather than a `##` section
|
|
145
|
+
* inside `body`, for three reasons. It rides the constrained decode —
|
|
146
|
+
* `core/agents.ts` hands `call.output_type.schema` to the coding agent as
|
|
147
|
+
* `output_schema` on every send, corrections included — so this is a slot
|
|
148
|
+
* the model fills while it is still deciding what the slice IS, not a
|
|
149
|
+
* heading it appends afterwards to satisfy a check it read about (the
|
|
150
|
+
* asymmetry `c9a4471`'s banned-noun rule lacked, and why the model routed
|
|
151
|
+
* around it). It is checkable without a regex over prose the model
|
|
152
|
+
* controls the format of. And it leaves `body`'s documented shape — "##
|
|
153
|
+
* What to build" then "## Acceptance criteria" only, a rule stated in
|
|
154
|
+
* `body`'s own comment below, in `assets/prompts/refiner/user.md` and in
|
|
155
|
+
* `system.md` — alone, instead of contradicting it in three places.
|
|
156
|
+
*
|
|
157
|
+
* `v.optional(..., "")` rather than a bare `v.string()`, deliberately, and
|
|
158
|
+
* against the pattern `title`/`body` set: a MISSING field fails the
|
|
159
|
+
* valibot parse and burns a JSON-repair retry on a generic "field
|
|
160
|
+
* required" message, whereas an EMPTY one reaches
|
|
161
|
+
* `gates.refinementWellFormed`, which sends back a correction that says
|
|
162
|
+
* what sentence to write and why. The better message is worth more here
|
|
163
|
+
* than the earlier failure, and every existing `core/refine.ts`
|
|
164
|
+
* `publish()` caller keeps compiling unchanged.
|
|
165
|
+
*
|
|
166
|
+
* Present on every node in the schema (a container should have to answer
|
|
167
|
+
* for itself too), enforced by the gate on LEAVES only: a container's
|
|
168
|
+
* outcome is the union of its children's, and a blank one there is a
|
|
169
|
+
* style note, not a decomposition mistake.
|
|
170
|
+
*/
|
|
171
|
+
user_outcome: v.optional(v.string(), ""),
|
|
141
172
|
body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
|
|
142
173
|
parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
|
|
143
174
|
blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
|
|
@@ -164,10 +195,46 @@ export const RefineQuestionSchema = v.object({
|
|
|
164
195
|
recommendation: v.optional(v.string(), ""),
|
|
165
196
|
evidence: v.optional(v.array(v.string()), () => []),
|
|
166
197
|
});
|
|
167
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* One proposed standalone spec, part of splitting an over-large spec into
|
|
200
|
+
* several — the refiner's answer to "this spec doesn't fit the leaf budget"
|
|
201
|
+
* that is NOT ambiguity (contrast `RefineQuestionSchema`, which is). `body`
|
|
202
|
+
* is a complete spec in its own right — problem, proposed outcome, scope —
|
|
203
|
+
* not a fragment or a "phase" of a larger plan: `assets/prompts/refiner/
|
|
204
|
+
* system.md`'s sizing section requires each entry to be something a human
|
|
205
|
+
* would ship on its own. `rationale` is why it's coherent alone, and should
|
|
206
|
+
* state the leaf count the refiner expects it to decompose into, so a human
|
|
207
|
+
* approving the split can see at a glance that the split actually fixes the
|
|
208
|
+
* over-budget problem rather than just moving it downstream.
|
|
209
|
+
*
|
|
210
|
+
* `core/refine.ts`'s `publishSpecs()` turns an approved list of these into
|
|
211
|
+
* real tracker issues (`kind: "spec"`, `<prefix>:type:spec` +
|
|
212
|
+
* `<prefix>:spec-ready` labels) — see `WatchMarker.split` in
|
|
213
|
+
* `core/issues/provider.ts` for how a proposal is recorded between "the
|
|
214
|
+
* refiner proposed it" and "a human approved it."
|
|
215
|
+
*/
|
|
216
|
+
export const SpecSplitSchema = v.object({
|
|
217
|
+
title: v.string(),
|
|
218
|
+
body: v.string(),
|
|
219
|
+
rationale: v.string(),
|
|
220
|
+
});
|
|
221
|
+
/**
|
|
222
|
+
* A product spec decomposed into a feature/story tree — see `steps.refine()`
|
|
223
|
+
* and `core/refine.ts`. Exactly one of `issues` / `questions` / `split` is
|
|
224
|
+
* non-empty for a given round (`gates.refinementWellFormed` enforces all
|
|
225
|
+
* three pairwise): `issues` is an unambiguous decomposition that fits the
|
|
226
|
+
* budget; `questions` is material ambiguity (scope, data model, an external
|
|
227
|
+
* dependency, a UX contract — see `system.md`'s "Ask, don't decide");
|
|
228
|
+
* `split` is neither of those — the spec is simply too large for one
|
|
229
|
+
* decomposition, and the refiner proposes cutting it into several standalone
|
|
230
|
+
* specs instead of guessing at a scope cut or force-fitting an oversized
|
|
231
|
+
* tree. See `SpecSplitSchema`'s own doc comment for what `split`'s job is
|
|
232
|
+
* *not*: an ambiguity question, or a fragment of a bigger plan.
|
|
233
|
+
*/
|
|
168
234
|
export const RefineOutput = envelopeType("RefineOutput", {
|
|
169
235
|
issues: v.optional(v.array(RefinedIssueSchema), () => []),
|
|
170
236
|
questions: v.optional(v.array(RefineQuestionSchema), () => []),
|
|
237
|
+
split: v.optional(v.array(SpecSplitSchema), () => []),
|
|
171
238
|
});
|
|
172
239
|
// ── Deterministic quality blocks ─────────────────────────────────────────────
|
|
173
240
|
export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
|
|
@@ -585,6 +652,14 @@ export const JiraIssueTypeMapSchema = v.object({
|
|
|
585
652
|
story: v.optional(v.string(), "Story"),
|
|
586
653
|
bug: v.optional(v.string(), "Bug"),
|
|
587
654
|
task: v.optional(v.string(), "Task"),
|
|
655
|
+
/**
|
|
656
|
+
* A spec proposed by `core/refine.ts`'s `publishSpecs()` — a Story by
|
|
657
|
+
* default, the same hierarchy level BT-1789 itself was created at. Never
|
|
658
|
+
* `"Epic"`: a spec is not a container in the `RefineOutput.issues` tree
|
|
659
|
+
* `gates.refinementWellFormed` validates (it can't be a `parent` there),
|
|
660
|
+
* so it doesn't need Epic-under-Epic nesting the way `feature` does.
|
|
661
|
+
*/
|
|
662
|
+
spec: v.optional(v.string(), "Story"),
|
|
588
663
|
});
|
|
589
664
|
/**
|
|
590
665
|
* Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
|
|
@@ -610,10 +685,61 @@ export const WatchJiraConfigSchema = v.object({
|
|
|
610
685
|
* any other value fails loudly at `spf watch` startup rather than running a
|
|
611
686
|
* refine lane that can never publish anything.
|
|
612
687
|
*/
|
|
688
|
+
/**
|
|
689
|
+
* The decomposition BUDGET's packaged defaults, and the single source of
|
|
690
|
+
* truth for them: `WatchRefineConfigSchema` below uses them as its valibot
|
|
691
|
+
* defaults, and `gates.refineBudget` falls back to them for a `RunContext`
|
|
692
|
+
* carrying no `cfg` (see `RunContext.cfg`). Two copies of a default is how a
|
|
693
|
+
* config file and the gate that enforces it come to disagree about what the
|
|
694
|
+
* budget IS, so there is one copy.
|
|
695
|
+
*
|
|
696
|
+
* These are CEILINGS, not targets. `assets/prompts/refiner/system.md` states
|
|
697
|
+
* the target — one feature, three or four leaves. A gate that failed AT the
|
|
698
|
+
* target would MAKE the target a floor: every spec would land exactly on the
|
|
699
|
+
* number and none would land under it. So the gate fires one slice past
|
|
700
|
+
* where a good decomposition sits, not at it.
|
|
701
|
+
*
|
|
702
|
+
* The unit of `DEFAULT_REFINE_MAX_LEAVES` is HUMAN REVIEWS, not model effort:
|
|
703
|
+
* every leaf gets `<prefix>:refined`, and `core/watch.ts`'s build lane turns
|
|
704
|
+
* each one into its own worktree, its own chain run and its own pull request
|
|
705
|
+
* a person reads.
|
|
706
|
+
*/
|
|
707
|
+
export const DEFAULT_REFINE_MAX_LEAVES = 4;
|
|
708
|
+
export const DEFAULT_REFINE_MAX_NODES = 6;
|
|
709
|
+
export const DEFAULT_REFINE_MAX_DEPTH = 2;
|
|
710
|
+
/**
|
|
711
|
+
* `max_leaves`/`max_nodes`/`max_depth` are the decomposition budget
|
|
712
|
+
* `gates.refinementWellFormed` enforces — see `DEFAULT_REFINE_*` above for
|
|
713
|
+
* what each is denominated in and why the gate's number is looser than the
|
|
714
|
+
* prompt's target. Three notes an operator tuning these needs:
|
|
715
|
+
*
|
|
716
|
+
* - `max_nodes` DOES NOT BIND at the defaults. With `max_depth: 2` and the
|
|
717
|
+
* gate's no-singleton-container rule, every container has at least two
|
|
718
|
+
* children, so a tree is at most `max_leaves + floor(max_leaves / 2)` = 6
|
|
719
|
+
* nodes at the default `max_leaves: 4`. It exists so that raising ONE of
|
|
720
|
+
* the other two knobs cannot silently uncap the whole tree.
|
|
721
|
+
* - RAISING `max_depth` ABOVE 2 IS UNSAFE ON JIRA. `jira.issue_types` maps
|
|
722
|
+
* both `epic` and `feature` to Jira's Epic type by default, and Jira has
|
|
723
|
+
* no Epic-under-Epic nesting (see `core/issues/jira_provider.ts`'s
|
|
724
|
+
* accepted-limitation note) — a three-level tree reaches `linkChild` and
|
|
725
|
+
* comes back as a raw Atlassian API error partway through a publish that
|
|
726
|
+
* is deliberately not transactional. At 2, that tree is rejected before a
|
|
727
|
+
* single issue is created.
|
|
728
|
+
* - There is no "unlimited". `minValue(1)` on all three is deliberate: a
|
|
729
|
+
* spec that genuinely needs more slices than the budget allows is a spec
|
|
730
|
+
* that should be SPLIT, and the refiner has a first-class way to say so
|
|
731
|
+
* (`RefineOutput.split` -> `core/watch.ts`'s `proposeSpecSplit` ->
|
|
732
|
+
* `split-proposed`, approved by a human, executed by
|
|
733
|
+
* `executeApprovedSplits`). An operator who disagrees raises the number
|
|
734
|
+
* rather than removing it.
|
|
735
|
+
*/
|
|
613
736
|
export const WatchRefineConfigSchema = v.object({
|
|
614
737
|
enabled: v.optional(v.boolean(), false),
|
|
615
738
|
chain: v.optional(v.string(), "refine"),
|
|
616
739
|
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
|
|
740
|
+
max_leaves: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(50)), DEFAULT_REFINE_MAX_LEAVES),
|
|
741
|
+
max_nodes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), DEFAULT_REFINE_MAX_NODES),
|
|
742
|
+
max_depth: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(4)), DEFAULT_REFINE_MAX_DEPTH),
|
|
617
743
|
});
|
|
618
744
|
/**
|
|
619
745
|
* Best-of-N per claimed issue — `core/fanout.ts`'s proven machinery pointed at
|
|
@@ -708,6 +834,13 @@ export const NotificationsConfigSchema = v.object({
|
|
|
708
834
|
events: v.optional(NotifyScopeSchema, "off"),
|
|
709
835
|
timeout_ms: v.optional(v.number(), 5_000),
|
|
710
836
|
channels: v.optional(v.array(NotifyChannelSchema), () => []),
|
|
837
|
+
// Prefixed onto every outbound title (e.g. "[api] watch: ..." ) and added
|
|
838
|
+
// as a `repo` field, so one Slack/Teams/webhook endpoint shared across
|
|
839
|
+
// several `spf watch` instances (one per repo) can tell them apart.
|
|
840
|
+
// Empty means "derive from watch.repo" (see `resolveNotifier`) — set this
|
|
841
|
+
// explicitly only when that repo string isn't a good enough label, e.g.
|
|
842
|
+
// it's blank, or two watched repos share a basename.
|
|
843
|
+
project: v.optional(v.string(), ""),
|
|
711
844
|
});
|
|
712
845
|
/**
|
|
713
846
|
* `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
|
package/dist/core/gates.d.ts
CHANGED
|
@@ -29,6 +29,20 @@ export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContex
|
|
|
29
29
|
* reading a line of the diff.
|
|
30
30
|
*/
|
|
31
31
|
export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
|
|
32
|
+
/**
|
|
33
|
+
* The decomposition budget in force for this run: `watch.refine`'s
|
|
34
|
+
* configured ceilings, or the packaged defaults when the caller handed us no
|
|
35
|
+
* `cfg` (see `RunContext.cfg`). Its own function, and exported, so `spf
|
|
36
|
+
* doctor` can print the three numbers this gate enforces by calling the SAME
|
|
37
|
+
* resolution the gate uses, rather than a second reading of the config that
|
|
38
|
+
* can drift from it — the `clampPriority` pattern.
|
|
39
|
+
*/
|
|
40
|
+
export interface RefineBudget {
|
|
41
|
+
maxLeaves: number;
|
|
42
|
+
maxNodes: number;
|
|
43
|
+
maxDepth: number;
|
|
44
|
+
}
|
|
45
|
+
export declare function refineBudget(run: RunContext): RefineBudget;
|
|
32
46
|
/**
|
|
33
47
|
* The gate that turns `to-tickets`' flat, untyped ticket list into an
|
|
34
48
|
* actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
|
|
@@ -41,14 +55,16 @@ export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunConte
|
|
|
41
55
|
* "Container" and "leaf" are derived from the graph, not asserted by the
|
|
42
56
|
* agent: a node is a container iff some other node names it as `parent`.
|
|
43
57
|
*
|
|
44
|
-
* `questions` (see `RefineQuestionSchema`'s
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
58
|
+
* `issues` / `questions` / `split` (see `RefineQuestionSchema`'s and
|
|
59
|
+
* `SpecSplitSchema`'s doc comments) are three mutually exclusive ways this
|
|
60
|
+
* round can end: an unambiguous tree, material ambiguity to escalate, or a
|
|
61
|
+
* spec too large for one decomposition. A refinement that raises questions
|
|
62
|
+
* or proposes a split must publish NOTHING this round — a partial tree
|
|
63
|
+
* pinned to either is worse than none. When `questions` or `split` is
|
|
64
|
+
* non-empty this gate checks only that shape (never both at once, and never
|
|
65
|
+
* alongside `issues`) and skips every `issues` rule below, since there is no
|
|
66
|
+
* tree to validate.
|
|
51
67
|
*/
|
|
52
|
-
export declare function refinementWellFormed(envelope: EnvelopeBase,
|
|
68
|
+
export declare function refinementWellFormed(envelope: EnvelopeBase, run: RunContext): GateReport;
|
|
53
69
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
54
70
|
export declare function testsPass(command: string): GateFn;
|