@gr8ful/spf 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,9 +17,10 @@
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { existsSync, statSync, readFileSync } from "node:fs";
19
19
  import path from "node:path";
20
- import { GateReport, PRIORITY_RANK } from "./data_types.js";
20
+ import { DEFAULT_REFINE_MAX_DEPTH, DEFAULT_REFINE_MAX_LEAVES, DEFAULT_REFINE_MAX_NODES, GateReport, PRIORITY_RANK, } from "./data_types.js";
21
21
  import { operatorEnv } from "./utils.js";
22
22
  const TAIL_CHARS = 1000; // command output kept as evidence on a failure
23
+ const MIN_OUTCOME_CHARS = 20; // shortest string that can still be the sentence RefinedIssue.user_outcome asks for
23
24
  /**
24
25
  * Anchor an envelope-declared path to `run.repo_root`. Absolute paths pass
25
26
  * through unchanged (an agent that reports an absolute path meant exactly
@@ -130,6 +131,34 @@ export function verdictConsistent(envelope, _run) {
130
131
  }
131
132
  const CONTAINER_KINDS = new Set(["epic", "feature"]);
132
133
  const LEAF_KINDS = new Set(["story", "bug", "task"]);
134
+ export function refineBudget(run) {
135
+ const refine = run.cfg?.watch.refine;
136
+ return {
137
+ maxLeaves: refine?.max_leaves ?? DEFAULT_REFINE_MAX_LEAVES,
138
+ maxNodes: refine?.max_nodes ?? DEFAULT_REFINE_MAX_NODES,
139
+ maxDepth: refine?.max_depth ?? DEFAULT_REFINE_MAX_DEPTH,
140
+ };
141
+ }
142
+ /**
143
+ * How many containment levels deep `key` sits: a top-level node is 1, a node
144
+ * under a top-level container is 2. Bounded by a `seen` set — the cycle
145
+ * check earlier in this gate already reports a `parent` cycle as its own
146
+ * violation, so this just has to not hang on one, not detect it again.
147
+ */
148
+ function containmentDepth(key, byKey) {
149
+ let depth = 1;
150
+ const seen = new Set([key]);
151
+ let current = byKey.get(key);
152
+ while (current?.parent) {
153
+ const parent = byKey.get(current.parent);
154
+ if (!parent || seen.has(parent.key))
155
+ break;
156
+ seen.add(parent.key);
157
+ depth += 1;
158
+ current = parent;
159
+ }
160
+ return depth;
161
+ }
133
162
  /**
134
163
  * The gate that turns `to-tickets`' flat, untyped ticket list into an
135
164
  * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
@@ -142,34 +171,52 @@ const LEAF_KINDS = new Set(["story", "bug", "task"]);
142
171
  * "Container" and "leaf" are derived from the graph, not asserted by the
143
172
  * agent: a node is a container iff some other node names it as `parent`.
144
173
  *
145
- * `questions` (see `RefineQuestionSchema`'s doc comment) is the human-in-the-
146
- * loop escalation path, and it is mutually exclusive with `issues`: a
147
- * refinement that raises material ambiguity must publish NOTHING this round
148
- * a partial tree pinned to an unanswered question is worse than none.
149
- * When `questions` is non-empty this gate checks only its own shape (unique,
150
- * non-blank ids; non-blank question text) and skips every `issues` rule
151
- * below, since there is no tree to validate.
174
+ * `issues` / `questions` / `split` (see `RefineQuestionSchema`'s and
175
+ * `SpecSplitSchema`'s doc comments) are three mutually exclusive ways this
176
+ * round can end: an unambiguous tree, material ambiguity to escalate, or a
177
+ * spec too large for one decomposition. A refinement that raises questions
178
+ * or proposes a split must publish NOTHING this round a partial tree
179
+ * pinned to either is worse than none. When `questions` or `split` is
180
+ * non-empty this gate checks only that shape (never both at once, and never
181
+ * alongside `issues`) and skips every `issues` rule below, since there is no
182
+ * tree to validate.
152
183
  */
153
- export function refinementWellFormed(envelope, _run) {
184
+ export function refinementWellFormed(envelope, run) {
154
185
  const report = new GateReport();
155
186
  const issues = envelope.issues ?? [];
156
187
  const questions = envelope.questions ?? [];
157
- if (questions.length > 0) {
188
+ const split = envelope.split ?? [];
189
+ if (questions.length > 0 || split.length > 0) {
158
190
  report.check("issues empty while escalating", issues.length === 0, issues.length === 0
159
- ? "no issues published alongside the questions"
160
- : `${issues.length} issue(s) published alongside ${questions.length} question(s) — escalating means publishing nothing this round`);
161
- const seenIds = new Set();
162
- for (const q of questions) {
163
- const blank = !q.id.trim();
164
- const dup = !blank && seenIds.has(q.id);
165
- seenIds.add(q.id);
166
- report.check(`question ${JSON.stringify(q.id)}`, !blank && !dup, blank ? "question id is blank" : dup ? "duplicate question id — every question needs a unique id within this round" : "id ok");
167
- report.check(`question ${JSON.stringify(q.id)}.question`, q.question.trim().length > 0, q.question.trim().length > 0 ? "has text" : "question text is blank");
191
+ ? "no issues published alongside the escalation"
192
+ : `${issues.length} issue(s) published alongside ${questions.length} question(s)/${split.length} split proposal(s) — escalating means publishing nothing this round`);
193
+ report.check("questions xor split", !(questions.length > 0 && split.length > 0), questions.length > 0 && split.length > 0
194
+ ? "both questions and split are non-empty — ambiguity (questions) and size (split) are different problems, escalate one at a time"
195
+ : "exactly one escalation shape used");
196
+ if (split.length > 0) {
197
+ report.check("split has at least 2 entries", split.length >= 2, split.length >= 2
198
+ ? `${split.length} proposed specs`
199
+ : "a split into 1 entry is not a split — it's the same spec renamed. Either propose 2+ standalone specs, or this spec fits after all: emit issues instead");
200
+ split.forEach((s, i) => {
201
+ report.check(`split[${i}].title`, s.title.trim().length > 0, s.title.trim().length > 0 ? "has text" : "blank title");
202
+ report.check(`split[${i}].body`, s.body.trim().length > 0, s.body.trim().length > 0 ? "has text" : "blank body — each proposed spec needs its own complete problem/outcome/scope");
203
+ report.check(`split[${i}].rationale`, s.rationale.trim().length > 0, s.rationale.trim().length > 0 ? "has text" : "blank rationale — say why this is a coherent standalone spec and roughly how many leaves you expect it to decompose into");
204
+ });
205
+ }
206
+ else {
207
+ const seenIds = new Set();
208
+ for (const q of questions) {
209
+ const blank = !q.id.trim();
210
+ const dup = !blank && seenIds.has(q.id);
211
+ seenIds.add(q.id);
212
+ report.check(`question ${JSON.stringify(q.id)}`, !blank && !dup, blank ? "question id is blank" : dup ? "duplicate question id — every question needs a unique id within this round" : "id ok");
213
+ report.check(`question ${JSON.stringify(q.id)}.question`, q.question.trim().length > 0, q.question.trim().length > 0 ? "has text" : "question text is blank");
214
+ }
168
215
  }
169
216
  return report;
170
217
  }
171
218
  if (issues.length === 0) {
172
- report.check("issues", false, "a refinement produced no issues at all and raised no questions — decompose the spec into at least one leaf, or escalate what's ambiguous");
219
+ report.check("issues", false, "a refinement produced no issues at all and raised no questions or split — decompose the spec into at least one leaf, escalate what's ambiguous, or propose a split if it's too large");
173
220
  return report;
174
221
  }
175
222
  const byKey = new Map();
@@ -182,6 +229,7 @@ export function refinementWellFormed(envelope, _run) {
182
229
  }
183
230
  }
184
231
  const childKeys = new Set(); // keys named as some OTHER node's parent -> that node is a container
232
+ const childCount = new Map(); // and HOW MANY name it — a container of exactly 1 is a violation below
185
233
  for (const issue of issues) {
186
234
  if (!issue.parent)
187
235
  continue;
@@ -190,6 +238,7 @@ export function refinementWellFormed(envelope, _run) {
190
238
  continue;
191
239
  }
192
240
  childKeys.add(issue.parent);
241
+ childCount.set(issue.parent, (childCount.get(issue.parent) ?? 0) + 1);
193
242
  }
194
243
  for (const issue of issues) {
195
244
  for (const blocker of issue.blocked_by) {
@@ -252,6 +301,112 @@ export function refinementWellFormed(envelope, _run) {
252
301
  }
253
302
  const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
254
303
  report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
304
+ // ── the decomposition budget ────────────────────────────────────────────
305
+ //
306
+ // The first CARDINALITY rules this gate has ever had, and the reason they
307
+ // are gate rules rather than one more paragraph of prompt: a prompt-only
308
+ // version was tried (c9a4471, "tests are not their own leaf") and the model
309
+ // kept the ticket and renamed it around the banned noun. A count is not a
310
+ // noun — there is nothing to rename.
311
+ //
312
+ // BOTH MESSAGES NAME THE ESCALATION BRANCH EXPLICITLY, and that is
313
+ // load-bearing rather than decorative. `core/agents.ts`'s correction prompt
314
+ // is generic ("Your previous response failed validation: ... Fix these
315
+ // problems, then re-emit ONLY your Report JSON"), so a violation's own
316
+ // `note` is the ONLY channel through which the refiner learns that
317
+ // proposing a `split` is a legal way out. Without that sentence, the
318
+ // correction round `steps.refine()`'s `retries` buys gets spent producing a
319
+ // smaller WRONG tree instead of a split proposal.
320
+ //
321
+ // The leaf message also forecloses the other cheap escape: satisfying a
322
+ // leaf cap by MERGING unrelated slices into a few oversized ones. That
323
+ // passes every check here and violates the one rule no gate can measure
324
+ // ("sized to fit what a reviewer can hold in their head" —
325
+ // assets/prompts/refiner/system.md). Saying so is the only lever available.
326
+ const budget = refineBudget(run);
327
+ report.check("leaf budget", leafCount <= budget.maxLeaves, leafCount <= budget.maxLeaves
328
+ ? `${leafCount} leaf/leaves, within the budget of ${budget.maxLeaves}`
329
+ : `${leafCount} leaves exceeds the budget of ${budget.maxLeaves}. Every leaf becomes its own branch, its own build run and its own pull request a person reads, so this spec as decomposed costs ${leafCount} human reviews. Take exactly one of two paths: (1) re-emit a tree of at most ${budget.maxLeaves} leaves, dropping slices that are process rather than product and combining slices that are genuinely one vertical slice — do NOT merge unrelated slices to hit the number, an oversized leaf is a worse outcome than a large tree; or (2) if this spec honestly cannot be built in ${budget.maxLeaves} slices, publish NOTHING: emit an empty "issues" and a "split" entry proposing 2 or more standalone specs to cut it into, each with your expected leaf count.`);
330
+ report.check("node budget", issues.length <= budget.maxNodes, issues.length <= budget.maxNodes
331
+ ? `${issues.length} node(s), within the budget of ${budget.maxNodes}`
332
+ : `${issues.length} nodes exceeds the budget of ${budget.maxNodes} — drop containers that only group, or publish nothing and propose a "split" into separate specs instead`);
333
+ // ── no singleton container ──────────────────────────────────────────────
334
+ //
335
+ // A container with exactly one child buys nothing and costs twice: a
336
+ // second tracker issue to create, link, label and roll up, and a second
337
+ // place the same acceptance criteria are written down. A container's only
338
+ // job is to GROUP (see `assets/prompts/refiner/system.md`'s "The tree"),
339
+ // and one child is nothing to group. It is also the cheapest way an
340
+ // over-decomposed tree pads its node count, which is why this sits beside
341
+ // the budget rather than with the kind checks above.
342
+ for (const issue of issues) {
343
+ const children = childCount.get(issue.key) ?? 0;
344
+ if (children === 0)
345
+ continue; // a leaf — not this check's business
346
+ report.check(`${issue.key}.children`, children >= 2, children >= 2
347
+ ? `${children} children`
348
+ : `container has exactly 1 child — a container exists only to group, and one child is nothing to group. Delete ${JSON.stringify(issue.key)} and re-parent its child to ${issue.parent ? JSON.stringify(issue.parent) : `top level (parent "")`}, folding anything ${JSON.stringify(issue.key)} said into the child's own body`);
349
+ }
350
+ // ── depth cap ───────────────────────────────────────────────────────────
351
+ //
352
+ // Two levels by default: one top-level container, and the leaves directly
353
+ // under it. Deeper nesting is exactly how "6 features and 14 stories" gets
354
+ // built — an epic over features over stories READS as organization and
355
+ // COSTS three tiers of tracker issue for one spec.
356
+ //
357
+ // It is also a live bug fix for the Jira provider, not only a scoping rule.
358
+ // `jira.issue_types` maps both `epic` and `feature` to Jira's Epic type by
359
+ // default, and Jira has no Epic-under-Epic nesting (see
360
+ // `core/issues/jira_provider.ts`'s accepted-limitation note) — so a
361
+ // `feature` parented under an `epic`/`feature` reaches `linkChild` and
362
+ // returns a raw Atlassian API error PARTWAY THROUGH a publish that is
363
+ // deliberately not transactional, leaving whatever was already created
364
+ // stranded on the board. At the default `max_depth: 2`, that tree is now
365
+ // refused before a single issue is created. See `WatchRefineConfigSchema`
366
+ // for why raising this above 2 is unsafe on Jira specifically.
367
+ for (const issue of issues) {
368
+ const depth = containmentDepth(issue.key, byKey);
369
+ report.check(`${issue.key}.depth`, depth <= budget.maxDepth, depth <= budget.maxDepth
370
+ ? `level ${depth} of ${budget.maxDepth}`
371
+ : `nested ${depth} levels deep, past the limit of ${budget.maxDepth} — the shape is one top-level container with its leaves directly under it, not an epic over features over stories. Re-parent ${JSON.stringify(issue.key)} onto a top-level container, or delete the intermediate container entirely`);
372
+ }
373
+ // ── every leaf states a user-observable outcome ─────────────────────────
374
+ //
375
+ // A required SLOT, not a banned-word list. A leaf must say in one sentence
376
+ // what someone can do once it lands that they could not before — see
377
+ // `RefinedIssueSchema.user_outcome`. "Cross-browser testing" has no such
378
+ // sentence that isn't obviously a restatement of a slice that already
379
+ // exists, and a model made to write one tends to notice that itself. A word
380
+ // list only renames the ticket (c9a4471); an empty slot has nowhere to
381
+ // hide.
382
+ //
383
+ // This is a BACKSTOP TO THE BUDGET, not a substitute for it: a determined
384
+ // model CAN write a plausible outcome for a process leaf ("an on-call
385
+ // engineer can revert in under 5 minutes"). What actually kills a process
386
+ // leaf is that it must beat a real product slice for one of `maxLeaves`
387
+ // slots. If process leaves reappear despite this check, lower the budget —
388
+ // do not add words to a list.
389
+ //
390
+ // Checked on LEAVES only (a container's outcome is the union of its
391
+ // children's) and for SHAPE only — non-blank, long enough to be a sentence,
392
+ // not the title again. Whether the sentence is TRUE is a reviewer's
393
+ // judgment, not a mechanical fact, and a gate that tried to grade it would
394
+ // be the word list under another name.
395
+ for (const issue of issues) {
396
+ if (childKeys.has(issue.key))
397
+ continue;
398
+ const outcome = issue.user_outcome.trim();
399
+ const blank = outcome.length === 0;
400
+ const tooShort = !blank && outcome.length < MIN_OUTCOME_CHARS;
401
+ const echoesTitle = !blank && outcome.toLowerCase() === issue.title.trim().toLowerCase();
402
+ report.check(`${issue.key}.user_outcome`, !blank && !tooShort && !echoesTitle, blank
403
+ ? `blank — every leaf must state, in ONE sentence, what someone can do once it lands that they could not before (e.g. "A workspace owner can invite a teammate by email and see the invite listed as pending."). A slice with no such sentence is process, not product: fold it into the slice whose behavior it actually serves, or drop it`
404
+ : tooShort
405
+ ? `${JSON.stringify(outcome)} is too short to be that sentence — name who, and what they can now do`
406
+ : echoesTitle
407
+ ? `repeats the title verbatim — the title NAMES the slice; this sentence says what changes for a person once it lands`
408
+ : "states a user-observable outcome");
409
+ }
255
410
  // Monotonicity: no node may be MORE urgent than its own parent — p0 < p1 <
256
411
  // p2 < p3, so "more urgent" is a lower rank. Only checked against `parent`
257
412
  // (the containment edge), never `blocked_by` (a real dependency can easily
@@ -18,10 +18,15 @@
18
18
  * issue. A repo past even that cap gets a loud warning, never a silent
19
19
  * truncation — see `listByLabel`'s own doc comment.
20
20
  */
21
- import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
22
- import type { RefinedIssue } from "../data_types.ts";
23
- /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
24
- export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
21
+ import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
22
+ /**
23
+ * The refine lane's leaf/container taxonomy — see `data_types.ts`'s
24
+ * `RefinedIssueSchema.kind` plus `"spec"`, a proposed spec's own kind (see
25
+ * `IssueAuthoringKind` in `provider.ts`). Not a `WatchState`: these never
26
+ * appear on the left of a `transition()` call, so `transition()` never
27
+ * strips them.
28
+ */
29
+ export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task", "spec"];
25
30
  export type IssueKind = (typeof ISSUE_KINDS)[number];
26
31
  export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
27
32
  private readonly repo;
@@ -96,7 +101,7 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
96
101
  title: string;
97
102
  body: string;
98
103
  labels: string[];
99
- kind: RefinedIssue["kind"];
104
+ kind: IssueAuthoringKind;
100
105
  }): Promise<Issue>;
101
106
  /**
102
107
  * `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
@@ -11,6 +11,8 @@ const STATES = [
11
11
  "needs-feedback",
12
12
  "continue-refinement",
13
13
  "spec-in-progress",
14
+ "split-proposed",
15
+ "split-approved",
14
16
  ];
15
17
  /**
16
18
  * GREEDY capture, not lazy: `WatchMarker.feedback` nests its own object
@@ -30,8 +32,14 @@ const STATES = [
30
32
  const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*\})\s*-->/s;
31
33
  /** `listByLabel`'s pagination bound — see its own doc comment. */
32
34
  const MAX_LIST_PAGES = 5;
33
- /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
34
- export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
35
+ /**
36
+ * The refine lane's leaf/container taxonomy see `data_types.ts`'s
37
+ * `RefinedIssueSchema.kind` — plus `"spec"`, a proposed spec's own kind (see
38
+ * `IssueAuthoringKind` in `provider.ts`). Not a `WatchState`: these never
39
+ * appear on the left of a `transition()` call, so `transition()` never
40
+ * strips them.
41
+ */
42
+ export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task", "spec"];
35
43
  // GitHub label colors are 6 hex digits, no leading '#'.
36
44
  const LABEL_META = {
37
45
  ready: { color: "0e8a16", description: "spf watch will claim this issue on its next poll" },
@@ -45,6 +53,8 @@ const LABEL_META = {
45
53
  "needs-feedback": { color: "d93f0b", description: "spf's refiner needs a human answer before it can finish decomposing this spec" },
46
54
  "continue-refinement": { color: "0e8a16", description: "add this once you've answered — spf will resume refining from where it left off" },
47
55
  "spec-in-progress": { color: "1d76db", description: "decomposed and published — waiting on every generated issue to reach spf:done" },
56
+ "split-proposed": { color: "d93f0b", description: "spf's refiner proposed splitting this spec into several — review the proposal comment" },
57
+ "split-approved": { color: "0e8a16", description: "add this once you approve the proposed split — spf will create the new specs" },
48
58
  };
49
59
  const TYPE_LABEL_META = {
50
60
  epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
@@ -52,6 +62,7 @@ const TYPE_LABEL_META = {
52
62
  story: { color: "bfd4f2", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
53
63
  bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
54
64
  task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
65
+ spec: { color: "fef2c0", description: "a standalone spec proposed by splitting a larger one — not yet decomposed" },
55
66
  };
56
67
  /**
57
68
  * What `claimNewWork` (`watch.ts`) schedules by — see `RefinedPrioritySchema`
@@ -58,8 +58,8 @@
58
58
  * Jira API error at publish time — a genuine platform difference, not
59
59
  * something this file tries to paper over.
60
60
  */
61
- import type { RefinedIssue, JiraIssueTypeMap } from "../data_types.ts";
62
- import type { EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
61
+ import type { JiraIssueTypeMap } from "../data_types.ts";
62
+ import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
63
63
  export declare class JiraProvider implements IssueProvider, IssueAuthoringProvider {
64
64
  private readonly baseUrl;
65
65
  private readonly projectKey;
@@ -108,7 +108,7 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
108
108
  title: string;
109
109
  body: string;
110
110
  labels: string[];
111
- kind: RefinedIssue["kind"];
111
+ kind: IssueAuthoringKind;
112
112
  }): Promise<Issue>;
113
113
  /**
114
114
  * The modern mechanism only — Jira's `parent` field, not the legacy
@@ -11,6 +11,8 @@ const STATES = [
11
11
  "needs-feedback",
12
12
  "continue-refinement",
13
13
  "spec-in-progress",
14
+ "split-proposed",
15
+ "split-approved",
14
16
  ];
15
17
  /**
16
18
  * GREEDY capture, not lazy — same fix and same reasoning as
@@ -27,7 +27,21 @@
27
27
  * traceable to one call site, and a provider can layer notifications
28
28
  * (Slack, a webhook, whatever) on top of it without the poll loop caring.
29
29
  */
30
- import type { RefinedIssue } from "../data_types.ts";
30
+ import type { RefinedIssue, SpecSplit } from "../data_types.ts";
31
+ /**
32
+ * Every `kind` `IssueAuthoringProvider.createIssue` can be asked to create:
33
+ * `RefinedIssue["kind"]` (a node in a decomposed spec's tree — never
34
+ * includes `"spec"`, since a spec is never a node `gates.refinementWellFormed`
35
+ * validates) plus `"spec"` itself — a spec proposed by `core/refine.ts`'s
36
+ * `publishSpecs()` when a spec splits into several (see `WatchMarker.split`
37
+ * below). Kept as its own union here rather than folded into
38
+ * `RefinedIssueSchema.kind` in `data_types.ts`, because widening THAT
39
+ * picklist would also widen what `gates.refinementWellFormed`'s
40
+ * container/leaf derivation has to reason about for a shape ("spec") that
41
+ * can never legally appear in a `RefineOutput.issues` tree in the first
42
+ * place.
43
+ */
44
+ export type IssueAuthoringKind = RefinedIssue["kind"] | "spec";
31
45
  /**
32
46
  * `spec-ready`/`refining` drive the SECOND lane's state machine (a product
33
47
  * spec being decomposed — see `reconcileRefining`/`claimSpecs` in
@@ -58,13 +72,29 @@ import type { RefinedIssue } from "../data_types.ts";
58
72
  * `spec-in-progress` spec each tick and moves it the rest of the way,
59
73
  * `-> done`, once `WatchMarker.refined` is entirely `<prefix>:done`.
60
74
  *
61
- * All eleven still live in one `WatchState` union (not several separate
75
+ * `split-proposed`/`split-approved` are a second escape hatch alongside
76
+ * `needs-feedback`, for a different problem: not ambiguity, but a spec that
77
+ * is honestly too big to decompose within the leaf budget (see
78
+ * `gates.refinementWellFormed`'s budget checks). Instead of asking a
79
+ * `questions`-shaped question, the refiner proposes splitting the spec into
80
+ * several standalone specs (`RefineOutput.split` — see `SpecSplitSchema` in
81
+ * `data_types.ts`); `watch.ts`'s `proposeSpecSplit` records the proposal in
82
+ * `WatchMarker.split` and moves the spec to `split-proposed`. A human reviews
83
+ * the proposal comment and either adds `split-approved` (executed
84
+ * deterministically, no agent re-run — `watch.ts`'s `executeApprovedSplits`)
85
+ * or answers inline and adds `continue-refinement` to have the refiner
86
+ * revise its proposal. Deliberately a DIFFERENT label from
87
+ * `continue-refinement`'s own approval path: approving a split is an
88
+ * instruction to CODE ("go create these"), not new information a refiner
89
+ * session needs to reason about, so it skips the agent entirely.
90
+ *
91
+ * All thirteen still live in one `WatchState` union (not several separate
62
92
  * unions) because `transition()`'s "strip every `<prefix>:<state>` label,
63
93
  * then add one" logic (see `github_provider.ts`/`jira_provider.ts`) has to
64
94
  * know about every one of them to strip correctly, and `ensureLabels()`
65
95
  * seeds all of them from one `STATES` array.
66
96
  */
67
- export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement" | "spec-in-progress";
97
+ export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement" | "spec-in-progress" | "split-proposed" | "split-approved";
68
98
  export interface Issue {
69
99
  /** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
70
100
  id: string;
@@ -122,6 +152,15 @@ export interface PrStatus {
122
152
  * `buildSpecPrompt` uses it to split the issue's comment thread into
123
153
  * "answers to the open questions" versus "earlier discussion" when building
124
154
  * the resumed run's prompt.
155
+ *
156
+ * `split` is the recorded proposal behind `split-proposed` /
157
+ * `split-approved` (see `WatchState`'s doc comment above): the exact specs
158
+ * `watch.ts`'s `proposeSpecSplit` posted as a comment, so a human's approval
159
+ * executes precisely what they read rather than whatever the marker happens
160
+ * to hold by the time `executeApprovedSplits` runs. `rounds` mirrors
161
+ * `feedback.rounds` — how many times this spec has been through the
162
+ * propose/revise loop, for the same "answered after N rounds" summary-comment
163
+ * purpose.
125
164
  */
126
165
  export interface WatchMarker {
127
166
  worktree?: string;
@@ -133,6 +172,11 @@ export interface WatchMarker {
133
172
  rounds: number;
134
173
  asked_at: string;
135
174
  };
175
+ split?: {
176
+ specs: SpecSplit[];
177
+ proposed_at: string;
178
+ rounds: number;
179
+ };
136
180
  }
137
181
  /** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
138
182
  export interface EnsureLabelsResult {
@@ -248,7 +292,7 @@ export interface IssueAuthoringProvider {
248
292
  title: string;
249
293
  body: string;
250
294
  labels: string[];
251
- kind: RefinedIssue["kind"];
295
+ kind: IssueAuthoringKind;
252
296
  }): Promise<Issue>;
253
297
  /** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API, Jira's `parent` field. */
254
298
  linkChild(parent: Issue, child: Issue): Promise<void>;
@@ -11,7 +11,7 @@
11
11
  * predicate a `Notifier` applies — no separate per-kind severity table to
12
12
  * keep in sync with this list.
13
13
  */
14
- export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined" | "spec_needs_feedback" | "feature_done" | "spec_done";
14
+ export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined" | "spec_needs_feedback" | "spec_split_proposed" | "feature_done" | "spec_done";
15
15
  export interface NotifyEvent {
16
16
  kind: NotifyKind;
17
17
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Issue, IssueAuthoringProvider } from "./issues/provider.ts";
2
- import { type RefinedIssue, type RefinedPriority, type SFConfig } from "./data_types.ts";
2
+ import { type RefinedIssue, type RefinedPriority, type SFConfig, type SpecSplit } from "./data_types.ts";
3
3
  export interface PublishedIssue {
4
4
  /** The `RefinedIssue.key` this came from — a run-local id, never a tracker id. */
5
5
  key: string;
@@ -74,3 +74,37 @@ export interface PublishOptions {
74
74
  * can sort out same as any other `spf watch` failure.
75
75
  */
76
76
  export declare function publish(tracker: IssueAuthoringProvider, issues: RefinedIssue[], opts: PublishOptions): Promise<PublishedIssue[]>;
77
+ export interface PublishSpecsOptions {
78
+ labelPrefix: string;
79
+ /** The original, over-large spec's id — every created spec's `## Parent` back-reference names it. */
80
+ originalSpecId: string;
81
+ /** The original spec's own priority label, if any — inherited by every proposed spec, same ceiling semantics as `PublishOptions.priorityCeiling`. */
82
+ priority?: RefinedPriority | null;
83
+ }
84
+ /**
85
+ * `publish()`'s twin for the OTHER escalation path: a spec too large for one
86
+ * decomposition, split into several standalone specs a human has already
87
+ * approved (see `WatchState`'s doc comment on `split-proposed`/
88
+ * `split-approved` in `core/issues/provider.ts`). Called by
89
+ * `core/watch.ts`'s `executeApprovedSplits` — never by an agent phase, since
90
+ * approving a recorded proposal is a deterministic instruction, not new
91
+ * information a refiner session needs to reason about.
92
+ *
93
+ * Two deliberate differences from `publish()`:
94
+ *
95
+ * - **No `<prefix>:refined` label.** A spec is not a workable leaf — it is
96
+ * itself a thing that gets refined later, by `claimSpecs` picking it up
97
+ * once it carries `<prefix>:spec-ready`.
98
+ * - **No `linkChild`.** On Jira a spec is a Story by default
99
+ * (`JiraIssueTypeMapSchema.spec`), and a Story cannot parent a Story — the
100
+ * tracker-native hierarchy `publish()` uses for a `RefinedIssue` tree is
101
+ * unavailable for spec-under-spec. The `## Parent` body section is the
102
+ * only link, same as a bare `spf refine --issue N` run with no authored
103
+ * hierarchy to speak of.
104
+ *
105
+ * Same not-transactional contract as `publish()`: a create call failing
106
+ * partway through leaves whatever was already created stranded, for a human
107
+ * to sort out the way any other `spf watch` failure is sorted out (`spf
108
+ * phases`, then a re-run).
109
+ */
110
+ export declare function publishSpecs(tracker: IssueAuthoringProvider, specs: SpecSplit[], opts: PublishSpecsOptions): Promise<Issue[]>;
@@ -120,7 +120,18 @@ export function parseRefineMarker(body) {
120
120
  * this runs: `topoOrder` visits a node's dependencies before the node itself.
121
121
  */
122
122
  function renderBody(node, byKey, specIssueId) {
123
- const parts = [node.body.trim()];
123
+ const parts = [];
124
+ // First on the issue, ahead of the refiner's own prose: the one sentence
125
+ // saying why this ticket exists (see `RefinedIssueSchema.user_outcome`). A
126
+ // human triaging `spf:refined` leaves reads a title and a first line, and
127
+ // this is the line worth reading. OMITTED when blank rather than rendered
128
+ // as an empty heading — a container legitimately has none, and neither a
129
+ // hand-built `publish()` call nor a tree from before this field existed
130
+ // carries one. Same tolerate-and-degrade policy as `parseRefineMarker`.
131
+ const outcome = node.user_outcome.trim();
132
+ if (outcome)
133
+ parts.push(`## Outcome\n\n${outcome}`);
134
+ parts.push(node.body.trim());
124
135
  if (specIssueId)
125
136
  parts.push(`## Parent\n\nDecomposed from #${specIssueId}.`);
126
137
  const blockedByIds = node.blocked_by.map((key) => {
@@ -224,3 +235,47 @@ export async function publish(tracker, issues, opts) {
224
235
  }
225
236
  return created;
226
237
  }
238
+ /**
239
+ * `publish()`'s twin for the OTHER escalation path: a spec too large for one
240
+ * decomposition, split into several standalone specs a human has already
241
+ * approved (see `WatchState`'s doc comment on `split-proposed`/
242
+ * `split-approved` in `core/issues/provider.ts`). Called by
243
+ * `core/watch.ts`'s `executeApprovedSplits` — never by an agent phase, since
244
+ * approving a recorded proposal is a deterministic instruction, not new
245
+ * information a refiner session needs to reason about.
246
+ *
247
+ * Two deliberate differences from `publish()`:
248
+ *
249
+ * - **No `<prefix>:refined` label.** A spec is not a workable leaf — it is
250
+ * itself a thing that gets refined later, by `claimSpecs` picking it up
251
+ * once it carries `<prefix>:spec-ready`.
252
+ * - **No `linkChild`.** On Jira a spec is a Story by default
253
+ * (`JiraIssueTypeMapSchema.spec`), and a Story cannot parent a Story — the
254
+ * tracker-native hierarchy `publish()` uses for a `RefinedIssue` tree is
255
+ * unavailable for spec-under-spec. The `## Parent` body section is the
256
+ * only link, same as a bare `spf refine --issue N` run with no authored
257
+ * hierarchy to speak of.
258
+ *
259
+ * Same not-transactional contract as `publish()`: a create call failing
260
+ * partway through leaves whatever was already created stranded, for a human
261
+ * to sort out the way any other `spf watch` failure is sorted out (`spf
262
+ * phases`, then a re-run).
263
+ */
264
+ export async function publishSpecs(tracker, specs, opts) {
265
+ const created = [];
266
+ for (const spec of specs) {
267
+ const labels = [
268
+ typeLabel(opts.labelPrefix, "spec"),
269
+ `${opts.labelPrefix}:spec-ready`,
270
+ ...(opts.priority ? [priorityLabel(opts.labelPrefix, opts.priority)] : []),
271
+ ];
272
+ const body = [
273
+ spec.body.trim(),
274
+ `## Parent\n\nSplit from #${opts.originalSpecId} — too large to decompose as one spec.`,
275
+ `## Why this is its own spec\n\n${spec.rationale.trim()}`,
276
+ ].join("\n\n");
277
+ const issue = await tracker.createIssue({ title: spec.title, body, labels, kind: "spec" });
278
+ created.push(issue);
279
+ }
280
+ return created;
281
+ }