@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/refine.js
CHANGED
|
@@ -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 = [
|
|
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
|
+
}
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -24,11 +24,11 @@ export declare function newId(length?: number): string;
|
|
|
24
24
|
* retry before it's allowed to throw. Node's global `fetch` (undici) pools
|
|
25
25
|
* keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
|
|
26
26
|
* Bitbucket) close idle ones from their end, which surfaces here as
|
|
27
|
-
* `ECONNRESET` the next time a long-lived poller (`spf
|
|
28
|
-
* a stale-socket race, not a real problem with the
|
|
29
|
-
* error codes that mean "the transport failed," never
|
|
30
|
-
* (a 4xx/5xx response is not a thrown error here, and
|
|
31
|
-
* on the first attempt so callers see it immediately).
|
|
27
|
+
* `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
|
|
28
|
+
* watch`) reuses one — a stale-socket race, not a real problem with the
|
|
29
|
+
* request. Only retries error codes that mean "the transport failed," never
|
|
30
|
+
* an HTTP error status (a 4xx/5xx response is not a thrown error here, and
|
|
31
|
+
* must keep surfacing on the first attempt so callers see it immediately).
|
|
32
32
|
*/
|
|
33
33
|
export declare function fetchRetryTransient(input: string, init?: RequestInit): Promise<Response>;
|
|
34
34
|
/** `process.kill(pid, 0)` sends no signal — it throws iff `pid` isn't running (or isn't ours to signal), the standard Node liveness probe. */
|
package/dist/core/utils.js
CHANGED
|
@@ -31,18 +31,25 @@ export function operatorEnv() {
|
|
|
31
31
|
export function newId(length = 8) {
|
|
32
32
|
return randomBytes(Math.floor(length / 2)).toString("hex");
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Transport-level blips worth one silent retry — see `fetchRetryTransient`
|
|
36
|
+
* below. `UND_ERR_SOCKET` is undici's own code for the same stale-keep-alive
|
|
37
|
+
* race as `ECONNRESET` (the far end closes a pooled socket between calls);
|
|
38
|
+
* without it here, every `spf watch` poll tick that lands on one of those
|
|
39
|
+
* sockets surfaces as a bare, undiagnosable "fetch failed (UND_ERR_SOCKET)"
|
|
40
|
+
* notification instead of being retried away like its `ECONNRESET` sibling.
|
|
41
|
+
*/
|
|
42
|
+
const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_SOCKET"]);
|
|
36
43
|
/**
|
|
37
44
|
* `fetch`, but a transport-level blip on the FIRST attempt gets one silent
|
|
38
45
|
* retry before it's allowed to throw. Node's global `fetch` (undici) pools
|
|
39
46
|
* keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
|
|
40
47
|
* Bitbucket) close idle ones from their end, which surfaces here as
|
|
41
|
-
* `ECONNRESET` the next time a long-lived poller (`spf
|
|
42
|
-
* a stale-socket race, not a real problem with the
|
|
43
|
-
* error codes that mean "the transport failed," never
|
|
44
|
-
* (a 4xx/5xx response is not a thrown error here, and
|
|
45
|
-
* on the first attempt so callers see it immediately).
|
|
48
|
+
* `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
|
|
49
|
+
* watch`) reuses one — a stale-socket race, not a real problem with the
|
|
50
|
+
* request. Only retries error codes that mean "the transport failed," never
|
|
51
|
+
* an HTTP error status (a 4xx/5xx response is not a thrown error here, and
|
|
52
|
+
* must keep surfacing on the first attempt so callers see it immediately).
|
|
46
53
|
*/
|
|
47
54
|
export async function fetchRetryTransient(input, init) {
|
|
48
55
|
try {
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -43,15 +43,32 @@ export interface RefinedQuestionRef {
|
|
|
43
43
|
recommendation: string;
|
|
44
44
|
evidence: string[];
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* One proposed standalone spec, part of splitting an over-large spec into
|
|
48
|
+
* several — mirrors `SpecSplitSchema` (`core/data_types.ts`) field-for-field,
|
|
49
|
+
* same decoupling reasoning as `RefinedIssueRef`/`RefinedQuestionRef` above.
|
|
50
|
+
* Read back from `steps.publishIssues()`'s `refine_split.json` side channel
|
|
51
|
+
* (see `cli/commands/watch.ts`'s `runRefine`) and, once a human approves,
|
|
52
|
+
* handed to `core/refine.ts`'s `publishSpecs()` verbatim via
|
|
53
|
+
* `WatchDeps.publishSpecs` — structurally identical to `SpecSplit`, so no
|
|
54
|
+
* conversion is needed at that boundary.
|
|
55
|
+
*/
|
|
56
|
+
export interface RefinedSpecSplitRef {
|
|
57
|
+
title: string;
|
|
58
|
+
body: string;
|
|
59
|
+
rationale: string;
|
|
60
|
+
}
|
|
46
61
|
export interface RefineRunResult {
|
|
47
62
|
accepted: boolean;
|
|
48
63
|
adwId: string;
|
|
49
64
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
50
65
|
detail: string;
|
|
51
|
-
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated instead of publishing. */
|
|
66
|
+
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated (questions) or proposed a split instead of publishing. */
|
|
52
67
|
created: RefinedIssueRef[];
|
|
53
|
-
/** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree instead of escalating; `gates.refinementWellFormed` guarantees `created
|
|
68
|
+
/** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree or proposed a split instead of escalating; `gates.refinementWellFormed` guarantees `created`/`questions`/`split` are never more than one non-empty at once. */
|
|
54
69
|
questions: RefinedQuestionRef[];
|
|
70
|
+
/** What the refiner proposed splitting this spec into, read back from its own side-channel file (`refine_split.json`). Empty unless this round proposed a split — see `questions`' own doc comment for the same three-way exclusion. */
|
|
71
|
+
split: RefinedSpecSplitRef[];
|
|
55
72
|
}
|
|
56
73
|
/**
|
|
57
74
|
* The fan-out lane's injected trio + the two values `WatchDeps` cannot derive.
|
|
@@ -168,6 +185,24 @@ export interface WatchDeps {
|
|
|
168
185
|
* without authoring at all (see `cli/commands/watch.ts`'s startup check).
|
|
169
186
|
*/
|
|
170
187
|
listChildren?: (parent: Issue) => Promise<Issue[]>;
|
|
188
|
+
/**
|
|
189
|
+
* `core/refine.ts`'s `publishSpecs()`, pre-bound to this run's authoring
|
|
190
|
+
* provider and label prefix — same injection reasoning as `listChildren`
|
|
191
|
+
* just above: this module drives whatever it's given without importing a
|
|
192
|
+
* concrete provider type. `undefined` only on a tracker that isn't
|
|
193
|
+
* authoring-capable, which `cli/commands/watch.ts`'s startup check already
|
|
194
|
+
* refuses to let `refineEnabled` be true for (see its own comment on
|
|
195
|
+
* `watch.refine.enabled` requiring "github" or "jira") — so
|
|
196
|
+
* `executeApprovedSplits` treats an unset value here as an invariant
|
|
197
|
+
* violation, not a graceful degrade, unlike `listChildren`'s logged no-op.
|
|
198
|
+
*/
|
|
199
|
+
publishSpecs?: (specs: RefinedSpecSplitRef[], opts: {
|
|
200
|
+
originalSpecId: string;
|
|
201
|
+
priority?: RefinedPriority | null;
|
|
202
|
+
}) => Promise<Array<{
|
|
203
|
+
id: string;
|
|
204
|
+
title: string;
|
|
205
|
+
}>>;
|
|
171
206
|
log: (message: string) => void;
|
|
172
207
|
/**
|
|
173
208
|
* Structured push, alongside `log`'s plain string — a required field, like
|
|
@@ -295,17 +330,38 @@ export declare function finishTrackedSpecs(deps: WatchDeps): Promise<void>;
|
|
|
295
330
|
* Exported and pure (no provider, no I/O) so it's directly unit-testable.
|
|
296
331
|
*/
|
|
297
332
|
export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"], priority?: RefinedPriority | null): string;
|
|
333
|
+
/**
|
|
334
|
+
* The other half of the split flow: a human already approved
|
|
335
|
+
* (`<prefix>:split-approved`) exactly what `proposeSpecSplit` recorded in
|
|
336
|
+
* `WatchMarker.split`, so this is a deterministic `code`-shaped action, not
|
|
337
|
+
* an agent one — no scout, no refiner session, no worktree, mirroring why
|
|
338
|
+
* `steps.publishIssues()` is a `code` phase rather than an agent phase.
|
|
339
|
+
* Idempotent the same way `runSpec`'s own publish path is: a non-empty
|
|
340
|
+
* `marker.refined` (this function writes the created specs' ids there, same
|
|
341
|
+
* field `finishTrackedSpecs` polls) short-circuits, so a crash between
|
|
342
|
+
* creating the specs and posting the summary comment never double-creates on
|
|
343
|
+
* the next tick.
|
|
344
|
+
*
|
|
345
|
+
* Recording the child SPECS in `marker.refined` — not their eventual
|
|
346
|
+
* decomposed issues — is deliberate: it's what lets `finishTrackedSpecs`
|
|
347
|
+
* close the ORIGINAL spec once both child specs finish their own trees
|
|
348
|
+
* (transitively — each child spec is a `spec-in-progress` tracked the same
|
|
349
|
+
* way once IT publishes), with no new tracking code needed anywhere.
|
|
350
|
+
*/
|
|
351
|
+
export declare function executeApprovedSplits(deps: WatchDeps): Promise<void>;
|
|
298
352
|
/**
|
|
299
353
|
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
300
|
-
* process isn't tracking is one of
|
|
354
|
+
* process isn't tracking is one of four things: a completed publish that
|
|
301
355
|
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
302
|
-
* no re-run), a completed
|
|
356
|
+
* no re-run), a completed split proposal that crashed before its own
|
|
357
|
+
* `transition(issue, "split-proposed")` ran (resume: finish THAT transition
|
|
358
|
+
* — checked before the escalation case below, since `proposeSpecSplit`
|
|
359
|
+
* writes BOTH `split` and `feedback` while `escalateSpec` writes only the
|
|
360
|
+
* latter), a completed escalation that crashed before its own
|
|
303
361
|
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
304
|
-
* no re-asking
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
308
|
-
* is off — see `WatchDeps.refineEnabled`.
|
|
362
|
+
* no re-asking), or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then
|
|
363
|
+
* give up). A no-op entirely when `watch.refine` is off — see
|
|
364
|
+
* `WatchDeps.refineEnabled`.
|
|
309
365
|
*/
|
|
310
366
|
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
311
367
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
package/dist/core/watch.js
CHANGED
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
* posts the questions and moves the spec to `needs-feedback`; a human
|
|
13
13
|
* answers in the issue's comments and adds `continue-refinement`;
|
|
14
14
|
* `claimSpecs` resumes it — the SAME `adw_id`, comment thread folded into
|
|
15
|
-
* the prompt (`buildSpecPrompt`) — for as many rounds as it takes.
|
|
16
|
-
*
|
|
15
|
+
* the prompt (`buildSpecPrompt`) — for as many rounds as it takes. A second,
|
|
16
|
+
* different escape hatch handles a spec too large for one decomposition (not
|
|
17
|
+
* ambiguity — size): `proposeSpecSplit` posts a concrete split into several
|
|
18
|
+
* standalone specs and moves the spec to `split-proposed`; a human either
|
|
19
|
+
* approves as-is (`split-approved`, executed deterministically by
|
|
20
|
+
* `executeApprovedSplits` — no agent re-run) or revises it the same way a
|
|
21
|
+
* question gets answered (`continue-refinement`). See `provider.ts`'s
|
|
22
|
+
* `WatchState` doc comment for the full state diagram.
|
|
17
23
|
*
|
|
18
24
|
* Provider-agnostic (drives whatever `IssueProvider` it's given) and
|
|
19
25
|
* chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
|
|
@@ -395,17 +401,125 @@ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
|
|
|
395
401
|
await deps.provider.transition(issue, "needs-feedback");
|
|
396
402
|
}
|
|
397
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* The refine lane's third finishing move, alongside `announceRefined`
|
|
406
|
+
* (published a tree) and `escalateSpec` (material ambiguity): a spec too
|
|
407
|
+
* large for one decomposition (see `gates.refinementWellFormed`'s budget
|
|
408
|
+
* checks, and `assets/prompts/refiner/system.md`'s "Sizing and the budget").
|
|
409
|
+
* Posts each proposed spec as its own comment section — title, a short
|
|
410
|
+
* preview of its body, and the rationale for splitting it out — records the
|
|
411
|
+
* EXACT proposal in `WatchMarker.split` so `executeApprovedSplits` creates
|
|
412
|
+
* precisely what a human reviewed rather than whatever the marker happens to
|
|
413
|
+
* hold by the time approval lands, and moves the spec to `split-proposed`.
|
|
414
|
+
*
|
|
415
|
+
* Shares `feedback`'s bookkeeping with `escalateSpec`, deliberately: a human
|
|
416
|
+
* can revise a proposed split the same way they answer a question — comment
|
|
417
|
+
* inline, add `continue-refinement` — and `buildSpecPrompt` folds the
|
|
418
|
+
* comment thread back into the resumed prompt identically either way,
|
|
419
|
+
* regardless of which of the two escalation shapes the PREVIOUS round used.
|
|
420
|
+
* `reconcileRefining` below tells the two apart by checking `marker.split`
|
|
421
|
+
* before `marker.feedback`, since this function always writes both while
|
|
422
|
+
* `escalateSpec` writes only the latter.
|
|
423
|
+
*/
|
|
424
|
+
async function proposeSpecSplit(deps, issue, marker, specs, adwId, round) {
|
|
425
|
+
const body = `## spf proposes splitting this spec (round ${round})\n\n` +
|
|
426
|
+
`This spec doesn't fit in one decomposition. Proposed split into ${specs.length} standalone specs:\n\n` +
|
|
427
|
+
specs
|
|
428
|
+
.map((s, i) => {
|
|
429
|
+
const lines = s.body.trim().split("\n");
|
|
430
|
+
const preview = lines.slice(0, 6).join("\n");
|
|
431
|
+
const truncated = lines.length > 6 ? "\n\n_(preview truncated — the full text lands in the created spec)_" : "";
|
|
432
|
+
return `### ${i + 1}. ${s.title}\n\n${preview}${truncated}\n\n**Why this is its own spec:** ${s.rationale}`;
|
|
433
|
+
})
|
|
434
|
+
.join("\n\n---\n\n") +
|
|
435
|
+
`\n\n---\n\nApprove as proposed by adding the \`${deps.labelPrefix}:split-approved\` label — spf will create these ${specs.length} specs with no further agent run. ` +
|
|
436
|
+
`Or comment with changes and add \`${deps.labelPrefix}:continue-refinement\` — refinement resumes from where it left off (adw_id \`${adwId}\`) and can revise the proposal.`;
|
|
437
|
+
deps.notify({
|
|
438
|
+
// "notice" level, not "info" — the same class of event as
|
|
439
|
+
// spec_needs_feedback: spf needs a human, so it belongs on an
|
|
440
|
+
// `attention`-scope channel just as much as an `all`-scope one.
|
|
441
|
+
kind: "spec_split_proposed",
|
|
442
|
+
level: "notice",
|
|
443
|
+
title: `spec ${issue.id} split proposed`,
|
|
444
|
+
detail: `${specs.length} proposed spec(s), round ${round}.`,
|
|
445
|
+
fields: [
|
|
446
|
+
["issue", issue.id],
|
|
447
|
+
["title", issue.title],
|
|
448
|
+
["chain", deps.refineChain],
|
|
449
|
+
["adw_id", adwId],
|
|
450
|
+
["round", String(round)],
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
if (!deps.dryRun) {
|
|
454
|
+
await deps.provider.comment(issue, body);
|
|
455
|
+
await deps.provider.writeMarker(issue, {
|
|
456
|
+
...marker,
|
|
457
|
+
split: { specs, proposed_at: new Date().toISOString(), rounds: round },
|
|
458
|
+
feedback: { rounds: round, asked_at: new Date().toISOString() },
|
|
459
|
+
});
|
|
460
|
+
await deps.provider.transition(issue, "split-proposed");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The other half of the split flow: a human already approved
|
|
465
|
+
* (`<prefix>:split-approved`) exactly what `proposeSpecSplit` recorded in
|
|
466
|
+
* `WatchMarker.split`, so this is a deterministic `code`-shaped action, not
|
|
467
|
+
* an agent one — no scout, no refiner session, no worktree, mirroring why
|
|
468
|
+
* `steps.publishIssues()` is a `code` phase rather than an agent phase.
|
|
469
|
+
* Idempotent the same way `runSpec`'s own publish path is: a non-empty
|
|
470
|
+
* `marker.refined` (this function writes the created specs' ids there, same
|
|
471
|
+
* field `finishTrackedSpecs` polls) short-circuits, so a crash between
|
|
472
|
+
* creating the specs and posting the summary comment never double-creates on
|
|
473
|
+
* the next tick.
|
|
474
|
+
*
|
|
475
|
+
* Recording the child SPECS in `marker.refined` — not their eventual
|
|
476
|
+
* decomposed issues — is deliberate: it's what lets `finishTrackedSpecs`
|
|
477
|
+
* close the ORIGINAL spec once both child specs finish their own trees
|
|
478
|
+
* (transitively — each child spec is a `spec-in-progress` tracked the same
|
|
479
|
+
* way once IT publishes), with no new tracking code needed anywhere.
|
|
480
|
+
*/
|
|
481
|
+
export async function executeApprovedSplits(deps) {
|
|
482
|
+
if (!deps.refineEnabled)
|
|
483
|
+
return;
|
|
484
|
+
const approved = await deps.provider.listInState("split-approved");
|
|
485
|
+
for (const issue of approved) {
|
|
486
|
+
const marker = await deps.provider.readMarker(issue);
|
|
487
|
+
if (marker?.refined && marker.refined.length > 0) {
|
|
488
|
+
deps.log(`watch: spec ${issue.id}: split already executed — finishing`);
|
|
489
|
+
await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.split?.rounds ?? marker.feedback?.rounds ?? 0);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const specs = marker?.split?.specs ?? [];
|
|
493
|
+
if (specs.length === 0) {
|
|
494
|
+
deps.log(`watch: spec ${issue.id} is \`${deps.labelPrefix}:split-approved\` but its marker records no proposed split — leaving it alone`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!deps.publishSpecs) {
|
|
498
|
+
// See WatchDeps.publishSpecs's own doc comment: refineEnabled true
|
|
499
|
+
// without an authoring-capable tracker is a startup failure
|
|
500
|
+
// (cli/commands/watch.ts), never a state this function should reach.
|
|
501
|
+
throw new Error(`watch: spec ${issue.id}: refine is enabled but no publishSpecs was provided — this is an spf bug, not a config problem`);
|
|
502
|
+
}
|
|
503
|
+
deps.log(`watch: spec ${issue.id}: split approved — creating ${specs.length} spec(s)`);
|
|
504
|
+
if (deps.dryRun)
|
|
505
|
+
continue;
|
|
506
|
+
const created = await deps.publishSpecs(specs, { originalSpecId: issue.id, priority: specPriorityLabel(issue, deps.labelPrefix) });
|
|
507
|
+
await announceRefined(deps, issue, created.map((c) => ({ id: c.id, title: c.title, kind: "spec" })), marker?.split?.rounds ?? 0);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
398
510
|
/**
|
|
399
511
|
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
400
|
-
* process isn't tracking is one of
|
|
512
|
+
* process isn't tracking is one of four things: a completed publish that
|
|
401
513
|
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
402
|
-
* no re-run), a completed
|
|
514
|
+
* no re-run), a completed split proposal that crashed before its own
|
|
515
|
+
* `transition(issue, "split-proposed")` ran (resume: finish THAT transition
|
|
516
|
+
* — checked before the escalation case below, since `proposeSpecSplit`
|
|
517
|
+
* writes BOTH `split` and `feedback` while `escalateSpec` writes only the
|
|
518
|
+
* latter), a completed escalation that crashed before its own
|
|
403
519
|
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
404
|
-
* no re-asking
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
408
|
-
* is off — see `WatchDeps.refineEnabled`.
|
|
520
|
+
* no re-asking), or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then
|
|
521
|
+
* give up). A no-op entirely when `watch.refine` is off — see
|
|
522
|
+
* `WatchDeps.refineEnabled`.
|
|
409
523
|
*/
|
|
410
524
|
export async function reconcileRefining(deps, state) {
|
|
411
525
|
if (!deps.refineEnabled)
|
|
@@ -420,6 +534,22 @@ export async function reconcileRefining(deps, state) {
|
|
|
420
534
|
await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
|
|
421
535
|
continue;
|
|
422
536
|
}
|
|
537
|
+
// Checked BEFORE marker.feedback: proposeSpecSplit always writes both
|
|
538
|
+
// `split` and `feedback` together, while escalateSpec writes only the
|
|
539
|
+
// latter — so `split` present is the more specific signal and must win.
|
|
540
|
+
if (marker?.split) {
|
|
541
|
+
deps.log(`watch: spec ${issue.id} orphaned after proposing round ${marker.split.rounds}'s split — finishing the transition to split-proposed`);
|
|
542
|
+
deps.notify({
|
|
543
|
+
kind: "spec_split_proposed",
|
|
544
|
+
level: "notice",
|
|
545
|
+
title: `spec ${issue.id} split proposed`,
|
|
546
|
+
detail: `Round ${marker.split.rounds}.`,
|
|
547
|
+
fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.split.rounds)]],
|
|
548
|
+
});
|
|
549
|
+
if (!deps.dryRun)
|
|
550
|
+
await deps.provider.transition(issue, "split-proposed");
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
423
553
|
if (marker?.feedback) {
|
|
424
554
|
deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
|
|
425
555
|
deps.notify({
|
|
@@ -1058,14 +1188,25 @@ async function runSpec(deps, issue) {
|
|
|
1058
1188
|
return;
|
|
1059
1189
|
}
|
|
1060
1190
|
// Mutually exclusive by construction — gates.refinementWellFormed
|
|
1061
|
-
// guarantees a `questions`-bearing envelope publishes no `issues`
|
|
1062
|
-
// this branches before, never alongside, the publish
|
|
1191
|
+
// guarantees a `questions`-bearing envelope publishes no `issues`/`split`
|
|
1192
|
+
// — so this branches before, never alongside, the split or publish paths
|
|
1193
|
+
// below.
|
|
1063
1194
|
if (result.questions.length > 0) {
|
|
1064
1195
|
const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
|
|
1065
1196
|
await escalateSpec(deps, issue, marker, result.questions, adwId, round);
|
|
1066
1197
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
1067
1198
|
return;
|
|
1068
1199
|
}
|
|
1200
|
+
// Same mutual exclusion, the other escalation shape: a spec too large
|
|
1201
|
+
// for one decomposition, proposed as several standalone specs instead of
|
|
1202
|
+
// a scope question. See proposeSpecSplit's own doc comment for why this
|
|
1203
|
+
// shares `feedback`'s round-tracking with the questions branch above.
|
|
1204
|
+
if (result.split.length > 0) {
|
|
1205
|
+
const round = (existingMarker?.feedback?.rounds ?? existingMarker?.split?.rounds ?? 0) + 1;
|
|
1206
|
+
await proposeSpecSplit(deps, issue, marker, result.split, adwId, round);
|
|
1207
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1069
1210
|
await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
|
|
1070
1211
|
await announceRefined(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
|
|
1071
1212
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
@@ -1336,6 +1477,11 @@ export async function tick(deps, state) {
|
|
|
1336
1477
|
// on — checking in the same tick is strictly cheaper than making a product
|
|
1337
1478
|
// manager wait one extra poll interval to see it.
|
|
1338
1479
|
await finishTrackedSpecs(deps).catch(tickErrorHandler(deps, "finishTrackedSpecs"));
|
|
1480
|
+
// Right after finishTrackedSpecs, same reasoning: a spec a human approved
|
|
1481
|
+
// a split on this tick should get its child specs created this same tick,
|
|
1482
|
+
// not one poll interval later. No agent, no worktree — see
|
|
1483
|
+
// executeApprovedSplits's own doc comment.
|
|
1484
|
+
await executeApprovedSplits(deps).catch(tickErrorHandler(deps, "executeApprovedSplits"));
|
|
1339
1485
|
await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
|
|
1340
1486
|
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
1341
1487
|
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|