@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
package/dist/core/gates.js
CHANGED
|
@@ -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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
160
|
-
: `${issues.length} issue(s) published alongside ${questions.length} question(s) — escalating means publishing nothing this round`);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
-
/**
|
|
34
|
-
|
|
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 {
|
|
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:
|
|
111
|
+
kind: IssueAuthoringKind;
|
|
112
112
|
}): Promise<Issue>;
|
|
113
113
|
/**
|
|
114
114
|
* The modern mechanism only — Jira's `parent` field, not the legacy
|
|
@@ -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
|
-
*
|
|
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:
|
|
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
|
/**
|
|
@@ -18,13 +18,14 @@ export declare class Notifier {
|
|
|
18
18
|
private readonly timeoutMs;
|
|
19
19
|
private readonly dryRun;
|
|
20
20
|
private readonly log;
|
|
21
|
+
private readonly project;
|
|
21
22
|
private pending;
|
|
22
23
|
constructor(channels: Array<{
|
|
23
24
|
channel: NotificationChannel;
|
|
24
25
|
scope: NotifyScope;
|
|
25
|
-
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
|
|
26
|
+
}>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void, project?: string);
|
|
26
27
|
/** Sync, fire-and-forget — every call site is sync and must stay that way. */
|
|
27
|
-
send(
|
|
28
|
+
send(rawEvent: NotifyEvent): void;
|
|
28
29
|
/** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
|
|
29
30
|
flush(): Promise<void>;
|
|
30
31
|
/**
|
|
@@ -25,15 +25,21 @@ export class Notifier {
|
|
|
25
25
|
timeoutMs;
|
|
26
26
|
dryRun;
|
|
27
27
|
log;
|
|
28
|
+
project;
|
|
28
29
|
pending = new Set();
|
|
29
|
-
constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m)
|
|
30
|
+
constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m),
|
|
31
|
+
// See `NotificationsConfigSchema.project`'s doc comment — empty disables
|
|
32
|
+
// tagging entirely, so a single-repo setup's outbound JSON is unchanged.
|
|
33
|
+
project = "") {
|
|
30
34
|
this.channels = channels;
|
|
31
35
|
this.timeoutMs = timeoutMs;
|
|
32
36
|
this.dryRun = dryRun;
|
|
33
37
|
this.log = log;
|
|
38
|
+
this.project = project;
|
|
34
39
|
}
|
|
35
40
|
/** Sync, fire-and-forget — every call site is sync and must stay that way. */
|
|
36
|
-
send(
|
|
41
|
+
send(rawEvent) {
|
|
42
|
+
const event = this.project ? tagEvent(rawEvent, this.project) : rawEvent;
|
|
37
43
|
for (const { channel, scope } of this.channels) {
|
|
38
44
|
if (!scopeAllows(scope, event.level))
|
|
39
45
|
continue;
|
|
@@ -79,6 +85,23 @@ export class Notifier {
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Prefixes `event.title` with `[project]` and adds a `repo` field, so a
|
|
90
|
+
* webhook shared by several `spf watch` instances reads clearly even
|
|
91
|
+
* collapsed to one line (Slack's notification/thread-list view only shows
|
|
92
|
+
* `title`, never `fields`). Skips the field when one's already there
|
|
93
|
+
* (`watch_started`/`watch_stopped` already carry their own `repo` field —
|
|
94
|
+
* see `cli/commands/watch.ts`) rather than emit a duplicate key with the
|
|
95
|
+
* same value.
|
|
96
|
+
*/
|
|
97
|
+
function tagEvent(event, project) {
|
|
98
|
+
const hasRepo = event.fields.some(([key]) => key === "repo");
|
|
99
|
+
return {
|
|
100
|
+
...event,
|
|
101
|
+
title: `[${project}] ${event.title}`,
|
|
102
|
+
fields: hasRepo ? event.fields : [["repo", project], ...event.fields],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
82
105
|
function makeChannel(kind, url, name) {
|
|
83
106
|
switch (kind) {
|
|
84
107
|
case "slack":
|
|
@@ -121,7 +144,13 @@ export function resolveNotifier(cfg, opts = {}) {
|
|
|
121
144
|
}
|
|
122
145
|
if (resolved.length === 0)
|
|
123
146
|
return null;
|
|
124
|
-
|
|
147
|
+
// See `NotificationsConfigSchema.project`'s doc comment: an explicit
|
|
148
|
+
// `notifications.project` wins; otherwise fall back to `watch.repo`
|
|
149
|
+
// (present whenever `spf watch` is what's sending — the common case for a
|
|
150
|
+
// shared webhook), and empty (a one-off `spf run` with no watch.repo set)
|
|
151
|
+
// disables tagging, same as today.
|
|
152
|
+
const project = nc.project.trim() || cfg.watch.repo.trim();
|
|
153
|
+
const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log, project);
|
|
125
154
|
LIVE.push(notifier);
|
|
126
155
|
return notifier;
|
|
127
156
|
}
|
package/dist/core/refine.d.ts
CHANGED
|
@@ -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[]>;
|