@gr8ful/spf 0.5.1 → 0.7.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 +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -9,19 +9,25 @@
|
|
|
9
9
|
* interfaces, not one bundled seam — a tracker and a code host are
|
|
10
10
|
* independent choices in practice (Jira issues against a Bitbucket repo is
|
|
11
11
|
* a real setup, not a hypothetical one). `github_provider.ts`'s single
|
|
12
|
-
* class implements
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
12
|
+
* class implements all three (GitHub natively is a tracker, a code host,
|
|
13
|
+
* AND an authoring API); `jira_provider.ts` implements `IssueProvider` and
|
|
14
|
+
* `IssueAuthoringProvider` (Jira is a tracker and can author, but never
|
|
15
|
+
* opens PRs); `bitbucket_provider.ts` only `CodeHostProvider` — any tracker
|
|
16
|
+
* x host combination is just config (`watch.issue_provider` x
|
|
17
|
+
* `watch.code_host`), never a poll-loop change.
|
|
16
18
|
* `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
|
|
17
|
-
* third, again separate — the refine lane's own need, optional per tracker
|
|
18
|
-
*
|
|
19
|
+
* third, again separate — the refine lane's own need, optional per tracker
|
|
20
|
+
* (not every tracker's write API can author + link a hierarchy), and
|
|
21
|
+
* orthogonal to which one is the code host. `isAuthoringProvider()` (below)
|
|
22
|
+
* is how the rest of the codebase asks "can this provider author?" without
|
|
23
|
+
* caring which concrete class answers yes.
|
|
19
24
|
*
|
|
20
25
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
21
26
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
22
27
|
* traceable to one call site, and a provider can layer notifications
|
|
23
28
|
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
24
29
|
*/
|
|
30
|
+
import type { RefinedIssue } from "../data_types.ts";
|
|
25
31
|
/**
|
|
26
32
|
* `spec-ready`/`refining` drive the SECOND lane's state machine (a product
|
|
27
33
|
* spec being decomposed — see `reconcileRefining`/`claimSpecs` in
|
|
@@ -29,13 +35,36 @@
|
|
|
29
35
|
* `refined` is not a lane state at all — it never appears on the left of a
|
|
30
36
|
* `transition()` call. It is the terminal label a generated LEAF issue
|
|
31
37
|
* (story/bug/task) gets, marking it awaiting a human's promotion to `ready`.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
38
|
+
*
|
|
39
|
+
* `needs-feedback`/`continue-refinement` are the refine lane's human-in-the-
|
|
40
|
+
* loop loop, layered onto the same three states: a `refining` run whose
|
|
41
|
+
* refiner raised material ambiguity (see `RefineOutput.questions`) posts its
|
|
42
|
+
* questions and moves the spec to `needs-feedback` (`watch.ts`'s
|
|
43
|
+
* `escalateSpec`) instead of either finishing or blocking. A human answers in
|
|
44
|
+
* the issue's comments, then adds `continue-refinement` — `claimSpecs`
|
|
45
|
+
* accepts an optional `from` state precisely so it can claim
|
|
46
|
+
* `continue-refinement -> refining` the same way it claims
|
|
47
|
+
* `spec-ready -> refining`, resuming the SAME `adw_id` (deterministic from
|
|
48
|
+
* the issue id) with the comment thread folded into the prompt. This can
|
|
49
|
+
* loop any number of rounds; there is no cap.
|
|
50
|
+
*
|
|
51
|
+
* `spec-in-progress` is where a spec lands once it's been decomposed and
|
|
52
|
+
* published — deliberately NOT `done` yet: a product manager watching this
|
|
53
|
+
* spec's status must not see "done" until every issue the refiner produced
|
|
54
|
+
* (every story/bug/task, and every feature/epic container once its own
|
|
55
|
+
* children finish — see `rollUp` in `watch.ts`) is itself `<prefix>:done`.
|
|
56
|
+
* `announceRefined` (`watch.ts`) makes the move `refining -> spec-in-progress`
|
|
57
|
+
* once publish succeeds; `finishTrackedSpecs` (`watch.ts`) polls every
|
|
58
|
+
* `spec-in-progress` spec each tick and moves it the rest of the way,
|
|
59
|
+
* `-> done`, once `WatchMarker.refined` is entirely `<prefix>:done`.
|
|
60
|
+
*
|
|
61
|
+
* All eleven still live in one `WatchState` union (not several separate
|
|
62
|
+
* unions) because `transition()`'s "strip every `<prefix>:<state>` label,
|
|
63
|
+
* then add one" logic (see `github_provider.ts`/`jira_provider.ts`) has to
|
|
64
|
+
* know about every one of them to strip correctly, and `ensureLabels()`
|
|
65
|
+
* seeds all of them from one `STATES` array.
|
|
37
66
|
*/
|
|
38
|
-
export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined";
|
|
67
|
+
export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined" | "needs-feedback" | "continue-refinement" | "spec-in-progress";
|
|
39
68
|
export interface Issue {
|
|
40
69
|
/** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
|
|
41
70
|
id: string;
|
|
@@ -53,6 +82,15 @@ export interface Issue {
|
|
|
53
82
|
*/
|
|
54
83
|
internal_id?: string;
|
|
55
84
|
}
|
|
85
|
+
/** One comment on an issue, as read back for the refine lane's escalation loop — see `IssueProvider.listComments`. */
|
|
86
|
+
export interface IssueComment {
|
|
87
|
+
id: string;
|
|
88
|
+
/** Display handle — GitHub's `user.login`, Jira's `author.displayName`. */
|
|
89
|
+
author: string;
|
|
90
|
+
/** ISO 8601, verbatim from the tracker. */
|
|
91
|
+
created_at: string;
|
|
92
|
+
body: string;
|
|
93
|
+
}
|
|
56
94
|
export interface PrRef {
|
|
57
95
|
number: number;
|
|
58
96
|
branch: string;
|
|
@@ -72,7 +110,18 @@ export interface PrStatus {
|
|
|
72
110
|
* issue a completed publish pass created for this spec. A re-claimed spec
|
|
73
111
|
* whose marker already lists them skips creation entirely — `to-tickets`
|
|
74
112
|
* (the skill this lane's prompt is ported from) has no such guard and
|
|
75
|
-
* duplicates every ticket on a re-run; this is what closes that gap.
|
|
113
|
+
* duplicates every ticket on a re-run; this is what closes that gap. It does
|
|
114
|
+
* double duty once the spec reaches `spec-in-progress`: `finishTrackedSpecs`
|
|
115
|
+
* (`watch.ts`) reads this same list back to check whether every one of them
|
|
116
|
+
* is `<prefix>:done` yet — the gate on the spec's OWN move to `done`.
|
|
117
|
+
*
|
|
118
|
+
* `feedback` is the refine lane's human-in-the-loop cursor: `rounds` counts
|
|
119
|
+
* how many times this spec has been escalated (so a resumed run's summary
|
|
120
|
+
* comment can say "answered after 2 rounds"), and `asked_at` is the ISO
|
|
121
|
+
* timestamp of the most recent question comment — `watch.ts`'s
|
|
122
|
+
* `buildSpecPrompt` uses it to split the issue's comment thread into
|
|
123
|
+
* "answers to the open questions" versus "earlier discussion" when building
|
|
124
|
+
* the resumed run's prompt.
|
|
76
125
|
*/
|
|
77
126
|
export interface WatchMarker {
|
|
78
127
|
worktree?: string;
|
|
@@ -80,6 +129,10 @@ export interface WatchMarker {
|
|
|
80
129
|
pr?: number;
|
|
81
130
|
attempt?: number;
|
|
82
131
|
refined?: string[];
|
|
132
|
+
feedback?: {
|
|
133
|
+
rounds: number;
|
|
134
|
+
asked_at: string;
|
|
135
|
+
};
|
|
83
136
|
}
|
|
84
137
|
/** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
|
|
85
138
|
export interface EnsureLabelsResult {
|
|
@@ -100,6 +153,15 @@ export interface IssueProvider {
|
|
|
100
153
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
101
154
|
/** Issues currently labeled `<prefix>:ready`. */
|
|
102
155
|
listEligible(): Promise<Issue[]>;
|
|
156
|
+
/**
|
|
157
|
+
* One issue by its tracker-facing id, or `null` if it no longer exists
|
|
158
|
+
* (deleted, or — on a tracker where a closed item 404s a plain fetch —
|
|
159
|
+
* closed). The frontier check needs this on every tracker (`claimNewWork`
|
|
160
|
+
* in `watch.ts` calls it once per distinct `blocked_by` id per tick, to
|
|
161
|
+
* decide whether a leaf's blockers all carry `<prefix>:done`), so unlike
|
|
162
|
+
* `IssueAuthoringProvider`'s methods below, this is required, not optional.
|
|
163
|
+
*/
|
|
164
|
+
getIssue(id: string): Promise<Issue | null>;
|
|
103
165
|
/**
|
|
104
166
|
* Issues currently in `state`. `includeAll` queries closed issues too —
|
|
105
167
|
* required for `review` on a tracker where closing an issue is a side
|
|
@@ -133,6 +195,23 @@ export interface IssueProvider {
|
|
|
133
195
|
comment(issue: Issue, body: string): Promise<void>;
|
|
134
196
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
135
197
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* Oldest-first, the hidden marker comment excluded — the refine lane's
|
|
200
|
+
* escalation loop reads a human's answers back out of the thread (see
|
|
201
|
+
* `watch.ts`'s `buildSpecPrompt`). Every other seam on this interface is
|
|
202
|
+
* write-only towards comments (`comment()`, and `transition()`'s own
|
|
203
|
+
* `detail`); this is the one read.
|
|
204
|
+
*/
|
|
205
|
+
listComments(issue: Issue): Promise<IssueComment[]>;
|
|
206
|
+
/**
|
|
207
|
+
* Close the issue as completed, where the tracker has such a concept.
|
|
208
|
+
* Optional, like `IssueAuthoringProvider`'s methods below: the label IS the
|
|
209
|
+
* state machine (see `transition()` above), and closing is a courtesy on
|
|
210
|
+
* top of `<prefix>:done`, never something `spf watch` itself reads back —
|
|
211
|
+
* a tracker (or a caller) that skips this leaves the spec `done` and open,
|
|
212
|
+
* exactly as every state before this feature existed already behaved.
|
|
213
|
+
*/
|
|
214
|
+
closeIssue?(issue: Issue): Promise<void>;
|
|
136
215
|
}
|
|
137
216
|
/**
|
|
138
217
|
* The PR-lifecycle seam, independent of `IssueProvider` — see the module
|
|
@@ -158,18 +237,44 @@ export interface CodeHostProvider {
|
|
|
158
237
|
* provides (a tracker's read/claim/transition surface has no reason to
|
|
159
238
|
* create new work items). Kept separate rather than folded into
|
|
160
239
|
* `IssueProvider` for the same reason `CodeHostProvider` is separate: not
|
|
161
|
-
* every tracker can do this
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
240
|
+
* every tracker can do this — GitHub and Jira both implement it today
|
|
241
|
+
* (GitHub via sub-issues, Jira via native issue types + the `parent`
|
|
242
|
+
* field), Bitbucket does not — and a tracker that can't should be
|
|
243
|
+
* recognized as such via `isAuthoringProvider()` (below), not a method
|
|
244
|
+
* that throws at call time.
|
|
166
245
|
*/
|
|
167
246
|
export interface IssueAuthoringProvider {
|
|
168
247
|
createIssue(input: {
|
|
169
248
|
title: string;
|
|
170
249
|
body: string;
|
|
171
250
|
labels: string[];
|
|
251
|
+
kind: RefinedIssue["kind"];
|
|
172
252
|
}): Promise<Issue>;
|
|
173
|
-
/** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API
|
|
253
|
+
/** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API, Jira's `parent` field. */
|
|
174
254
|
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
255
|
+
/**
|
|
256
|
+
* Read back what `linkChild` wrote — every issue currently linked under
|
|
257
|
+
* `parent`. What makes container roll-up possible at all (`rollUp` in
|
|
258
|
+
* `watch.ts`: a container is `done` once every one of these carries
|
|
259
|
+
* `<prefix>:done`); lives here rather than on `IssueProvider` for the same
|
|
260
|
+
* reason `linkChild` does — a tracker's plain list/claim/transition surface
|
|
261
|
+
* has no reason to know about a hierarchy it may not even have. A tracker
|
|
262
|
+
* without this (Bitbucket-as-issue-tracker isn't a real combination this
|
|
263
|
+
* codebase supports, so in practice: any provider that isn't `IssueAuthoringProvider`
|
|
264
|
+
* at all) makes roll-up a logged no-op, not a startup failure the way
|
|
265
|
+
* `watch.refine.enabled` without ANY authoring support is
|
|
266
|
+
* (`cli/commands/watch.ts`) — the build lane still functions without
|
|
267
|
+
* roll-up, refine cannot function without authoring at all.
|
|
268
|
+
*/
|
|
269
|
+
listChildren(parent: Issue): Promise<Issue[]>;
|
|
175
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Structural, not nominal: checks for the three methods rather than
|
|
273
|
+
* `instanceof SomeConcreteClass` — so a new authoring-capable provider is
|
|
274
|
+
* recognized automatically everywhere this is used (today: `cli/commands/
|
|
275
|
+
* watch.ts`'s container-roll-up wiring) without an edit to an `instanceof`
|
|
276
|
+
* chain. Every current implementer (`GitHubProvider`, `JiraProvider`)
|
|
277
|
+
* satisfies `IssueProvider` too, so the intersection type is sound in
|
|
278
|
+
* practice, not just at the type level.
|
|
279
|
+
*/
|
|
280
|
+
export declare function isAuthoringProvider(provider: IssueProvider): provider is IssueProvider & IssueAuthoringProvider;
|
|
@@ -9,17 +9,34 @@
|
|
|
9
9
|
* interfaces, not one bundled seam — a tracker and a code host are
|
|
10
10
|
* independent choices in practice (Jira issues against a Bitbucket repo is
|
|
11
11
|
* a real setup, not a hypothetical one). `github_provider.ts`'s single
|
|
12
|
-
* class implements
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
12
|
+
* class implements all three (GitHub natively is a tracker, a code host,
|
|
13
|
+
* AND an authoring API); `jira_provider.ts` implements `IssueProvider` and
|
|
14
|
+
* `IssueAuthoringProvider` (Jira is a tracker and can author, but never
|
|
15
|
+
* opens PRs); `bitbucket_provider.ts` only `CodeHostProvider` — any tracker
|
|
16
|
+
* x host combination is just config (`watch.issue_provider` x
|
|
17
|
+
* `watch.code_host`), never a poll-loop change.
|
|
16
18
|
* `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
|
|
17
|
-
* third, again separate — the refine lane's own need, optional per tracker
|
|
18
|
-
*
|
|
19
|
+
* third, again separate — the refine lane's own need, optional per tracker
|
|
20
|
+
* (not every tracker's write API can author + link a hierarchy), and
|
|
21
|
+
* orthogonal to which one is the code host. `isAuthoringProvider()` (below)
|
|
22
|
+
* is how the rest of the codebase asks "can this provider author?" without
|
|
23
|
+
* caring which concrete class answers yes.
|
|
19
24
|
*
|
|
20
25
|
* The label-as-state-machine design is deliberate, copied from that same
|
|
21
26
|
* reference: `transition()` is the ONE mutator, so every state change is
|
|
22
27
|
* traceable to one call site, and a provider can layer notifications
|
|
23
28
|
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
24
29
|
*/
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Structural, not nominal: checks for the three methods rather than
|
|
32
|
+
* `instanceof SomeConcreteClass` — so a new authoring-capable provider is
|
|
33
|
+
* recognized automatically everywhere this is used (today: `cli/commands/
|
|
34
|
+
* watch.ts`'s container-roll-up wiring) without an edit to an `instanceof`
|
|
35
|
+
* chain. Every current implementer (`GitHubProvider`, `JiraProvider`)
|
|
36
|
+
* satisfies `IssueProvider` too, so the intersection type is sound in
|
|
37
|
+
* practice, not just at the type level.
|
|
38
|
+
*/
|
|
39
|
+
export function isAuthoringProvider(provider) {
|
|
40
|
+
const candidate = provider;
|
|
41
|
+
return typeof candidate.createIssue === "function" && typeof candidate.linkChild === "function" && typeof candidate.listChildren === "function";
|
|
42
|
+
}
|
|
@@ -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";
|
|
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";
|
|
15
15
|
export interface NotifyEvent {
|
|
16
16
|
kind: NotifyKind;
|
|
17
17
|
/** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
|
|
@@ -27,6 +27,14 @@ export declare class Notifier {
|
|
|
27
27
|
send(event: NotifyEvent): void;
|
|
28
28
|
/** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
|
|
29
29
|
flush(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Bounded drain: race `flush()` against `budgetMs`, never throwing. Mirrors
|
|
32
|
+
* `otel.ts`'s `OtelExporter.drain()` discipline — for the signal handler in
|
|
33
|
+
* `session.ts`, which is racing a hard ^C deadline shorter than any single
|
|
34
|
+
* channel's own `AbortSignal.timeout(timeoutMs)`, so a webhook to an
|
|
35
|
+
* unreachable host cannot hold the process open past `budgetMs`.
|
|
36
|
+
*/
|
|
37
|
+
drain(budgetMs: number): Promise<void>;
|
|
30
38
|
}
|
|
31
39
|
/**
|
|
32
40
|
* Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
|
|
@@ -40,3 +48,11 @@ export declare function resolveNotifier(cfg: SFConfig, opts?: {
|
|
|
40
48
|
}): Notifier | null;
|
|
41
49
|
/** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
|
|
42
50
|
export declare function flushAll(): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Bounded drain of every Notifier this process created, under one shared
|
|
53
|
+
* budget — the signal-handler twin of `flushAll()`. Called from
|
|
54
|
+
* `session.ts`'s SIGTERM/SIGINT handler, alongside `otel.flushAll()`, so a
|
|
55
|
+
* killed run's in-flight Slack/Teams/webhook sends get the same
|
|
56
|
+
* timeout-and-swallow chance the otel drain already had. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export declare function drainAll(budgetMs: number): Promise<void>;
|
|
@@ -47,6 +47,32 @@ export class Notifier {
|
|
|
47
47
|
async flush() {
|
|
48
48
|
await Promise.all([...this.pending]);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Bounded drain: race `flush()` against `budgetMs`, never throwing. Mirrors
|
|
52
|
+
* `otel.ts`'s `OtelExporter.drain()` discipline — for the signal handler in
|
|
53
|
+
* `session.ts`, which is racing a hard ^C deadline shorter than any single
|
|
54
|
+
* channel's own `AbortSignal.timeout(timeoutMs)`, so a webhook to an
|
|
55
|
+
* unreachable host cannot hold the process open past `budgetMs`.
|
|
56
|
+
*/
|
|
57
|
+
async drain(budgetMs) {
|
|
58
|
+
let deadline = null;
|
|
59
|
+
const budget = new Promise((resolve) => {
|
|
60
|
+
deadline = setTimeout(resolve, budgetMs);
|
|
61
|
+
deadline.unref?.();
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
await Promise.race([this.flush(), budget]);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// unreachable in practice — every send() task is pre-caught into the log
|
|
68
|
+
// line above, so flush() never rejects — but a drain that can throw
|
|
69
|
+
// would break the shutdown path it exists to protect.
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (deadline)
|
|
73
|
+
clearTimeout(deadline);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
50
76
|
}
|
|
51
77
|
function makeChannel(kind, url, name) {
|
|
52
78
|
switch (kind) {
|
|
@@ -98,3 +124,13 @@ export function resolveNotifier(cfg, opts = {}) {
|
|
|
98
124
|
export async function flushAll() {
|
|
99
125
|
await Promise.all(LIVE.map((n) => n.flush()));
|
|
100
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Bounded drain of every Notifier this process created, under one shared
|
|
129
|
+
* budget — the signal-handler twin of `flushAll()`. Called from
|
|
130
|
+
* `session.ts`'s SIGTERM/SIGINT handler, alongside `otel.flushAll()`, so a
|
|
131
|
+
* killed run's in-flight Slack/Teams/webhook sends get the same
|
|
132
|
+
* timeout-and-swallow chance the otel drain already had. Never throws.
|
|
133
|
+
*/
|
|
134
|
+
export async function drainAll(budgetMs) {
|
|
135
|
+
await Promise.all(LIVE.map((n) => n.drain(budgetMs)));
|
|
136
|
+
}
|
package/dist/core/otel.d.ts
CHANGED
|
@@ -92,15 +92,41 @@
|
|
|
92
92
|
* silently (see `parseTraceparent`) — a malformed variable must degrade to
|
|
93
93
|
* "own root", never to an error.
|
|
94
94
|
*
|
|
95
|
-
* LIFECYCLE (copied from `notify/notifier.ts`'s discipline
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Send failures log ONE line for the life of the exporter,
|
|
103
|
-
* and every header VALUE redacted, and are then swallowed.
|
|
95
|
+
* LIFECYCLE (copied from `notify/notifier.ts`'s discipline, with one
|
|
96
|
+
* addition `notify` doesn't need — see RUN-SCOPED CLEANUP below). A
|
|
97
|
+
* module-level LIVE registry holds every exporter this process created;
|
|
98
|
+
* `flushAll()` is awaited in `src/cli/index.ts`'s existing `finally` block
|
|
99
|
+
* next to `notify.flushAll()`, AND `session.ts`'s signal handler runs a
|
|
100
|
+
* bounded, timeout-capped drain before its `process.exit(128+n)` (notify
|
|
101
|
+
* drains there too, via `drainAll()`, both racing the same budget
|
|
102
|
+
* concurrently — see #25). Send failures log ONE line for the life of the exporter,
|
|
103
|
+
* with the endpoint and every header VALUE redacted, and are then swallowed.
|
|
104
|
+
*
|
|
105
|
+
* RUN-SCOPED CLEANUP (#26). `notify`'s `LIVE` array has the same
|
|
106
|
+
* unbounded-growth problem under a long-lived daemon (tracked as #31, not
|
|
107
|
+
* fixed here). `spf watch` breaks the one-session-per-process assumption
|
|
108
|
+
* both registries were written under: its daemon loop runs many sessions
|
|
109
|
+
* in-process, one per claimed issue, and every one of them calls
|
|
110
|
+
* `resolveOtelExporter` — with no removal path, that was one exporter (plus
|
|
111
|
+
* its bounded span queue, its buffered-event maps, its open-agent-call
|
|
112
|
+
* tracking) held forever per issue processed, for the life of the daemon.
|
|
113
|
+
* `LIVE` is therefore keyed by adw_id (not a plain array) so a finished run
|
|
114
|
+
* can be found and dropped by id, and `releaseOtelExporter(adwId)` — called
|
|
115
|
+
* from `chains/index.ts`'s `runChain()`, the one call site every dispatch
|
|
116
|
+
* (one-shot CLI and `spf watch` alike) passes through on its way out,
|
|
117
|
+
* success or thrown error alike — drains that one exporter and removes it.
|
|
118
|
+
* Draining BEFORE removing matters: the removal itself must never be the
|
|
119
|
+
* reason a run's final root span goes unsent (that guarantee is what
|
|
120
|
+
* `flushAll()` already gave the one-shot CLI path, and this must not weaken
|
|
121
|
+
* it). And it must not run any EARLIER than "this run's own dispatch has
|
|
122
|
+
* fully settled" — a signal can still land while the run is in flight, and
|
|
123
|
+
* `session.ts`'s handler drains the GLOBAL registry, so an exporter removed
|
|
124
|
+
* before its run is actually done would silently stop being reachable from
|
|
125
|
+
* that drain. A one-shot invocation with no explicit `--adw-id` is a
|
|
126
|
+
* harmless no-op here (the registry key is the RESOLVED id `session.ensure`
|
|
127
|
+
* mints, which `runChain()`'s caller never sees) — that process exits right
|
|
128
|
+
* after anyway, so the existing end-of-process `flushAll()` still covers it
|
|
129
|
+
* exactly as it always did.
|
|
104
130
|
*
|
|
105
131
|
* BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
|
|
106
132
|
* promise-per-span fire-and-forget is a memory bug, not a style choice.
|
|
@@ -334,6 +360,14 @@ export declare class OtelExporter {
|
|
|
334
360
|
* call site is `otel?.record...()` and never a conditional branch. `null` is
|
|
335
361
|
* the default for every repo that has not configured an endpoint, and no
|
|
336
362
|
* environment variable can change that (see EXPLICIT CONFIG ONLY).
|
|
363
|
+
*
|
|
364
|
+
* Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
|
|
365
|
+
* `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
|
|
366
|
+
* key `releaseOtelExporter` below looks it up by. A second registration
|
|
367
|
+
* under an id that's still live (in practice: a bug elsewhere, since adw_id
|
|
368
|
+
* is meant to be unique per in-flight run) replaces the map entry; the
|
|
369
|
+
* orphaned exporter's own queue still drains itself on its own unref'd
|
|
370
|
+
* timer, just unreachable from `flushAll()` from that point on.
|
|
337
371
|
*/
|
|
338
372
|
export declare function resolveOtelExporter(cfg: SFConfig, opts: {
|
|
339
373
|
adwId: string;
|
|
@@ -348,5 +382,26 @@ export declare function resolveOtelExporter(cfg: SFConfig, opts: {
|
|
|
348
382
|
* handler. Never throws.
|
|
349
383
|
*/
|
|
350
384
|
export declare function flushAll(budgetMs?: number): Promise<void>;
|
|
385
|
+
/**
|
|
386
|
+
* The counterpart to `resolveOtelExporter`: drain and forget the one
|
|
387
|
+
* exporter registered for `adwId`, so a long-lived process (`spf watch`'s
|
|
388
|
+
* daemon loop) doesn't hold one exporter per run forever (see #26 / the
|
|
389
|
+
* RUN-SCOPED CLEANUP note above). Called from `chains/index.ts`'s
|
|
390
|
+
* `runChain()` once a run's own dispatch has fully settled — success or
|
|
391
|
+
* thrown error alike.
|
|
392
|
+
*
|
|
393
|
+
* A no-op, not an error, when `adwId` is falsy (a one-shot invocation with
|
|
394
|
+
* no explicit `--adw-id` — its caller never learns the id `session.ensure`
|
|
395
|
+
* actually minted, so it cannot ask for this by id; that process exits
|
|
396
|
+
* right after anyway and `flushAll()` still covers it) or when nothing is
|
|
397
|
+
* registered under it (otel unconfigured, or already released). Draining
|
|
398
|
+
* BEFORE deleting the map entry, never after: this run's exporter must stay
|
|
399
|
+
* reachable from a concurrent `flushAll()` (a signal landing on some OTHER
|
|
400
|
+
* still-in-flight run, under `spf watch`'s concurrency) for the full
|
|
401
|
+
* duration of ITS OWN drain, and removing the entry first would let that
|
|
402
|
+
* concurrent drain skip an exporter that has not actually finished sending
|
|
403
|
+
* yet.
|
|
404
|
+
*/
|
|
405
|
+
export declare function releaseOtelExporter(adwId: string | null | undefined, budgetMs?: number): Promise<void>;
|
|
351
406
|
/** Tests only: forget every registered exporter so cases cannot leak into each other. */
|
|
352
407
|
export declare function resetLiveForTest(): void;
|
package/dist/core/otel.js
CHANGED
|
@@ -92,15 +92,41 @@
|
|
|
92
92
|
* silently (see `parseTraceparent`) — a malformed variable must degrade to
|
|
93
93
|
* "own root", never to an error.
|
|
94
94
|
*
|
|
95
|
-
* LIFECYCLE (copied from `notify/notifier.ts`'s discipline
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Send failures log ONE line for the life of the exporter,
|
|
103
|
-
* and every header VALUE redacted, and are then swallowed.
|
|
95
|
+
* LIFECYCLE (copied from `notify/notifier.ts`'s discipline, with one
|
|
96
|
+
* addition `notify` doesn't need — see RUN-SCOPED CLEANUP below). A
|
|
97
|
+
* module-level LIVE registry holds every exporter this process created;
|
|
98
|
+
* `flushAll()` is awaited in `src/cli/index.ts`'s existing `finally` block
|
|
99
|
+
* next to `notify.flushAll()`, AND `session.ts`'s signal handler runs a
|
|
100
|
+
* bounded, timeout-capped drain before its `process.exit(128+n)` (notify
|
|
101
|
+
* drains there too, via `drainAll()`, both racing the same budget
|
|
102
|
+
* concurrently — see #25). Send failures log ONE line for the life of the exporter,
|
|
103
|
+
* with the endpoint and every header VALUE redacted, and are then swallowed.
|
|
104
|
+
*
|
|
105
|
+
* RUN-SCOPED CLEANUP (#26). `notify`'s `LIVE` array has the same
|
|
106
|
+
* unbounded-growth problem under a long-lived daemon (tracked as #31, not
|
|
107
|
+
* fixed here). `spf watch` breaks the one-session-per-process assumption
|
|
108
|
+
* both registries were written under: its daemon loop runs many sessions
|
|
109
|
+
* in-process, one per claimed issue, and every one of them calls
|
|
110
|
+
* `resolveOtelExporter` — with no removal path, that was one exporter (plus
|
|
111
|
+
* its bounded span queue, its buffered-event maps, its open-agent-call
|
|
112
|
+
* tracking) held forever per issue processed, for the life of the daemon.
|
|
113
|
+
* `LIVE` is therefore keyed by adw_id (not a plain array) so a finished run
|
|
114
|
+
* can be found and dropped by id, and `releaseOtelExporter(adwId)` — called
|
|
115
|
+
* from `chains/index.ts`'s `runChain()`, the one call site every dispatch
|
|
116
|
+
* (one-shot CLI and `spf watch` alike) passes through on its way out,
|
|
117
|
+
* success or thrown error alike — drains that one exporter and removes it.
|
|
118
|
+
* Draining BEFORE removing matters: the removal itself must never be the
|
|
119
|
+
* reason a run's final root span goes unsent (that guarantee is what
|
|
120
|
+
* `flushAll()` already gave the one-shot CLI path, and this must not weaken
|
|
121
|
+
* it). And it must not run any EARLIER than "this run's own dispatch has
|
|
122
|
+
* fully settled" — a signal can still land while the run is in flight, and
|
|
123
|
+
* `session.ts`'s handler drains the GLOBAL registry, so an exporter removed
|
|
124
|
+
* before its run is actually done would silently stop being reachable from
|
|
125
|
+
* that drain. A one-shot invocation with no explicit `--adw-id` is a
|
|
126
|
+
* harmless no-op here (the registry key is the RESOLVED id `session.ensure`
|
|
127
|
+
* mints, which `runChain()`'s caller never sees) — that process exits right
|
|
128
|
+
* after anyway, so the existing end-of-process `flushAll()` still covers it
|
|
129
|
+
* exactly as it always did.
|
|
104
130
|
*
|
|
105
131
|
* BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
|
|
106
132
|
* promise-per-span fire-and-forget is a memory bug, not a style choice.
|
|
@@ -755,14 +781,23 @@ function clip(value, limit = 200) {
|
|
|
755
781
|
const text = String(value ?? "");
|
|
756
782
|
return text.length <= limit ? text : text.slice(0, limit);
|
|
757
783
|
}
|
|
758
|
-
// ── module-level lifecycle (mirrors notify/notifier.ts's LIVE + flushAll
|
|
759
|
-
|
|
784
|
+
// ── module-level lifecycle (mirrors notify/notifier.ts's LIVE + flushAll,
|
|
785
|
+
// keyed by adw_id — see RUN-SCOPED CLEANUP above) ─────────────────────────
|
|
786
|
+
const LIVE = new Map();
|
|
760
787
|
/**
|
|
761
788
|
* Build an exporter from `cfg.observability.otel`, or `null` when it is
|
|
762
789
|
* absent — the same optional-dependency shape as `resolveNotifier`, so every
|
|
763
790
|
* call site is `otel?.record...()` and never a conditional branch. `null` is
|
|
764
791
|
* the default for every repo that has not configured an endpoint, and no
|
|
765
792
|
* environment variable can change that (see EXPLICIT CONFIG ONLY).
|
|
793
|
+
*
|
|
794
|
+
* Registered under `opts.adwId` — the RESOLVED id (`session.ensure`'s own
|
|
795
|
+
* `id`, never a caller's possibly-null `ctx.adw_id`) — which is exactly the
|
|
796
|
+
* key `releaseOtelExporter` below looks it up by. A second registration
|
|
797
|
+
* under an id that's still live (in practice: a bug elsewhere, since adw_id
|
|
798
|
+
* is meant to be unique per in-flight run) replaces the map entry; the
|
|
799
|
+
* orphaned exporter's own queue still drains itself on its own unref'd
|
|
800
|
+
* timer, just unreachable from `flushAll()` from that point on.
|
|
766
801
|
*/
|
|
767
802
|
export function resolveOtelExporter(cfg, opts) {
|
|
768
803
|
const otel = cfg.observability.otel;
|
|
@@ -775,7 +810,7 @@ export function resolveOtelExporter(cfg, opts) {
|
|
|
775
810
|
log: opts.log,
|
|
776
811
|
env: opts.env,
|
|
777
812
|
});
|
|
778
|
-
LIVE.
|
|
813
|
+
LIVE.set(opts.adwId, exporter);
|
|
779
814
|
return exporter;
|
|
780
815
|
}
|
|
781
816
|
/**
|
|
@@ -785,9 +820,38 @@ export function resolveOtelExporter(cfg, opts) {
|
|
|
785
820
|
* handler. Never throws.
|
|
786
821
|
*/
|
|
787
822
|
export async function flushAll(budgetMs) {
|
|
788
|
-
await Promise.all(LIVE.map((exporter) => exporter.drain(budgetMs)));
|
|
823
|
+
await Promise.all([...LIVE.values()].map((exporter) => exporter.drain(budgetMs)));
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* The counterpart to `resolveOtelExporter`: drain and forget the one
|
|
827
|
+
* exporter registered for `adwId`, so a long-lived process (`spf watch`'s
|
|
828
|
+
* daemon loop) doesn't hold one exporter per run forever (see #26 / the
|
|
829
|
+
* RUN-SCOPED CLEANUP note above). Called from `chains/index.ts`'s
|
|
830
|
+
* `runChain()` once a run's own dispatch has fully settled — success or
|
|
831
|
+
* thrown error alike.
|
|
832
|
+
*
|
|
833
|
+
* A no-op, not an error, when `adwId` is falsy (a one-shot invocation with
|
|
834
|
+
* no explicit `--adw-id` — its caller never learns the id `session.ensure`
|
|
835
|
+
* actually minted, so it cannot ask for this by id; that process exits
|
|
836
|
+
* right after anyway and `flushAll()` still covers it) or when nothing is
|
|
837
|
+
* registered under it (otel unconfigured, or already released). Draining
|
|
838
|
+
* BEFORE deleting the map entry, never after: this run's exporter must stay
|
|
839
|
+
* reachable from a concurrent `flushAll()` (a signal landing on some OTHER
|
|
840
|
+
* still-in-flight run, under `spf watch`'s concurrency) for the full
|
|
841
|
+
* duration of ITS OWN drain, and removing the entry first would let that
|
|
842
|
+
* concurrent drain skip an exporter that has not actually finished sending
|
|
843
|
+
* yet.
|
|
844
|
+
*/
|
|
845
|
+
export async function releaseOtelExporter(adwId, budgetMs) {
|
|
846
|
+
if (!adwId)
|
|
847
|
+
return;
|
|
848
|
+
const exporter = LIVE.get(adwId);
|
|
849
|
+
if (!exporter)
|
|
850
|
+
return;
|
|
851
|
+
await exporter.drain(budgetMs);
|
|
852
|
+
LIVE.delete(adwId);
|
|
789
853
|
}
|
|
790
854
|
/** Tests only: forget every registered exporter so cases cannot leak into each other. */
|
|
791
855
|
export function resetLiveForTest() {
|
|
792
|
-
LIVE.
|
|
856
|
+
LIVE.clear();
|
|
793
857
|
}
|
package/dist/core/refine.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Issue, IssueAuthoringProvider } from "./issues/provider.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RefinedIssue, type RefinedPriority, type SFConfig } 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;
|
|
@@ -9,25 +9,62 @@ export interface PublishedIssue {
|
|
|
9
9
|
isLeaf: boolean;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* `IssueAuthoringProvider` has a real implementation
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* `IssueAuthoringProvider` has a real implementation on `GitHubProvider` and
|
|
13
|
+
* `JiraProvider` — any other `issue_provider` value fails here, defensively
|
|
14
|
+
* (the config schema's picklist already rejects it earlier). Throws rather
|
|
15
|
+
* than returning `null` so a `code` phase calling this
|
|
15
16
|
* (`steps.publishIssues()`) fails the phase with a clear, specific reason —
|
|
16
17
|
* the same "fail loudly, never silently do nothing" contract
|
|
17
18
|
* `agents.validate()` uses for an unconfigured quality suite.
|
|
19
|
+
*
|
|
20
|
+
* Duplicates `cli/commands/watch.ts`'s own `resolveIssueProvider`
|
|
21
|
+
* construction logic for each provider — a pre-existing pattern for GitHub
|
|
22
|
+
* (this function has always rebuilt its own `GitHubProvider` rather than
|
|
23
|
+
* sharing one with the CLI layer's build-lane provider), mirrored for Jira
|
|
24
|
+
* rather than refactored away, to stay within this change's scope.
|
|
18
25
|
*/
|
|
19
26
|
export declare function resolveAuthoringProvider(cfg: SFConfig): IssueAuthoringProvider;
|
|
27
|
+
export interface RefineMarker {
|
|
28
|
+
/** The parent's real issue id, or `null` for a top-level node. */
|
|
29
|
+
parent: string | null;
|
|
30
|
+
/** Real issue ids — resolved from `blocked_by` `key`s at publish time, see `renderBody`. */
|
|
31
|
+
blocked_by: string[];
|
|
32
|
+
priority: RefinedPriority;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pure and exported so it's directly unit-testable without a provider —
|
|
36
|
+
* `core/watch.ts`'s `claimNewWork` and `rollUp` are the real callers, reading
|
|
37
|
+
* it straight out of the `Issue.body` a `listEligible`/`getIssue` call
|
|
38
|
+
* already returned. Never throws: a body with no marker (any issue not
|
|
39
|
+
* created by this lane, or one whose marker a human stripped while editing)
|
|
40
|
+
* degrades to `NO_REFINE_MARKER`, same as malformed JSON inside one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseRefineMarker(body: string): RefineMarker;
|
|
20
43
|
export interface PublishOptions {
|
|
21
44
|
labelPrefix: string;
|
|
22
45
|
/** The originating spec issue's id, for every created issue's `## Parent` back-reference. `null`/omitted for a manual run with no source issue. */
|
|
23
46
|
specIssueId?: string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The spec's own priority (its `spf:priority:pN` label, read by
|
|
49
|
+
* `core/watch.ts`'s `runSpec`, or `spf refine`'s `--priority` flag for a
|
|
50
|
+
* bare manual run) — a CEILING, never a floor. Every node is clamped down
|
|
51
|
+
* to this if it outranks it (`clampPriority`), so a p3 "someday" spec
|
|
52
|
+
* cannot spawn p0 work that jumps the build lane's queue, regardless of
|
|
53
|
+
* what the refiner's own per-node judgment (or the gate's monotonicity
|
|
54
|
+
* check, which only sees the tree, never the spec) would otherwise allow.
|
|
55
|
+
* `null`/omitted — a bare run with nothing to inherit from — is a no-op:
|
|
56
|
+
* `clampPriority`'s own default.
|
|
57
|
+
*/
|
|
58
|
+
priorityCeiling?: RefinedPriority | null;
|
|
24
59
|
}
|
|
25
60
|
/**
|
|
26
61
|
* Create every node in `issues`, in dependency order, with its
|
|
27
|
-
* `<prefix>:type:<kind>`
|
|
28
|
-
* see `WatchState`'s doc comment in
|
|
29
|
-
* via the tracker's native
|
|
30
|
-
*
|
|
62
|
+
* `<prefix>:type:<kind>` and `<prefix>:priority:<pN>` labels (plus
|
|
63
|
+
* `<prefix>:refined` on leaves only — see `WatchState`'s doc comment in
|
|
64
|
+
* `provider.ts`), link each to its parent via the tracker's native
|
|
65
|
+
* hierarchy, and render real `#n` references into `## Blocked by` plus the
|
|
66
|
+
* hidden `spf-refine:` marker `parseRefineMarker` reads back. Returns what
|
|
67
|
+
* it created, in creation order.
|
|
31
68
|
*
|
|
32
69
|
* Not transactional: if a create or link call throws partway through, the
|
|
33
70
|
* nodes already published stay published, orphaned from whatever hadn't run
|