@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.
@@ -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
- /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
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>;
@@ -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
- /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
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
@@ -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 doc comment) is the human-in-the-
45
- * loop escalation path, and it is mutually exclusive with `issues`: a
46
- * refinement that raises material ambiguity must publish NOTHING this round
47
- * a partial tree pinned to an unanswered question is worse than none.
48
- * When `questions` is non-empty this gate checks only its own shape (unique,
49
- * non-blank ids; non-blank question text) and skips every `issues` rule
50
- * below, since there is no tree to validate.
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, _run: RunContext): GateReport;
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;