@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.
Files changed (101) hide show
  1. package/README.md +168 -29
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +42 -6
  4. package/assets/prompts/refiner/user.md +46 -8
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +192 -5
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +12 -2
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +8 -17
  14. package/dist/chains/steps.js +102 -14
  15. package/dist/cli/commands/doctor.js +112 -3
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +23 -1
  21. package/dist/cli/commands/run.js +9 -2
  22. package/dist/cli/commands/trace.d.ts +18 -0
  23. package/dist/cli/commands/trace.js +22 -3
  24. package/dist/cli/commands/watch.d.ts +8 -0
  25. package/dist/cli/commands/watch.js +95 -33
  26. package/dist/cli/index.js +13 -1
  27. package/dist/cli/interview.js +9 -5
  28. package/dist/core/agents.d.ts +56 -0
  29. package/dist/core/agents.js +152 -1
  30. package/dist/core/data_types.d.ts +340 -5
  31. package/dist/core/data_types.js +185 -5
  32. package/dist/core/fanout.d.ts +229 -0
  33. package/dist/core/fanout.js +313 -0
  34. package/dist/core/gates.d.ts +8 -0
  35. package/dist/core/gates.js +48 -2
  36. package/dist/core/git_helper.d.ts +98 -0
  37. package/dist/core/git_helper.js +127 -0
  38. package/dist/core/issues/github_provider.d.ts +46 -6
  39. package/dist/core/issues/github_provider.js +120 -5
  40. package/dist/core/issues/jira_provider.d.ts +83 -12
  41. package/dist/core/issues/jira_provider.js +110 -4
  42. package/dist/core/issues/provider.d.ts +124 -19
  43. package/dist/core/issues/provider.js +24 -7
  44. package/dist/core/notify/channel.d.ts +1 -1
  45. package/dist/core/notify/notifier.d.ts +16 -0
  46. package/dist/core/notify/notifier.js +36 -0
  47. package/dist/core/otel.d.ts +64 -9
  48. package/dist/core/otel.js +78 -14
  49. package/dist/core/refine.d.ts +45 -8
  50. package/dist/core/refine.js +98 -24
  51. package/dist/core/runner.d.ts +8 -0
  52. package/dist/core/runner.js +7 -0
  53. package/dist/core/session.d.ts +25 -0
  54. package/dist/core/session.js +97 -28
  55. package/dist/core/tiering.d.ts +145 -0
  56. package/dist/core/tiering.js +235 -0
  57. package/dist/core/tracer.d.ts +10 -0
  58. package/dist/core/tracer.js +12 -0
  59. package/dist/core/watch.d.ts +174 -11
  60. package/dist/core/watch.js +531 -33
  61. package/dist/ui/server/db.d.ts +39 -0
  62. package/dist/ui/server/db.js +61 -0
  63. package/package.json +2 -1
  64. package/dist/test/agent_cc.test.d.ts +0 -1
  65. package/dist/test/agent_cc.test.js +0 -95
  66. package/dist/test/agent_flue.test.d.ts +0 -1
  67. package/dist/test/agent_flue.test.js +0 -83
  68. package/dist/test/chains.test.d.ts +0 -12
  69. package/dist/test/chains.test.js +0 -92
  70. package/dist/test/data_types.test.d.ts +0 -10
  71. package/dist/test/data_types.test.js +0 -220
  72. package/dist/test/env_file.test.d.ts +0 -1
  73. package/dist/test/env_file.test.js +0 -74
  74. package/dist/test/fake_asker.d.ts +0 -23
  75. package/dist/test/fake_asker.js +0 -30
  76. package/dist/test/git_helper.test.d.ts +0 -1
  77. package/dist/test/git_helper.test.js +0 -59
  78. package/dist/test/hermetic_git.d.ts +0 -1
  79. package/dist/test/hermetic_git.js +0 -22
  80. package/dist/test/init_command.test.d.ts +0 -14
  81. package/dist/test/init_command.test.js +0 -136
  82. package/dist/test/interview.test.d.ts +0 -15
  83. package/dist/test/interview.test.js +0 -425
  84. package/dist/test/notify.test.d.ts +0 -1
  85. package/dist/test/notify.test.js +0 -174
  86. package/dist/test/ollama_provider.test.d.ts +0 -1
  87. package/dist/test/ollama_provider.test.js +0 -103
  88. package/dist/test/otel.test.d.ts +0 -26
  89. package/dist/test/otel.test.js +0 -512
  90. package/dist/test/paths.test.d.ts +0 -1
  91. package/dist/test/paths.test.js +0 -68
  92. package/dist/test/refine.test.d.ts +0 -1
  93. package/dist/test/refine.test.js +0 -189
  94. package/dist/test/repo_chains.test.d.ts +0 -21
  95. package/dist/test/repo_chains.test.js +0 -416
  96. package/dist/test/signoff.test.d.ts +0 -1
  97. package/dist/test/signoff.test.js +0 -329
  98. package/dist/test/ui_server.test.d.ts +0 -7
  99. package/dist/test/ui_server.test.js +0 -120
  100. package/dist/test/watch.test.d.ts +0 -1
  101. 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 both, since GitHub natively is both; `jira_provider.ts`
13
- * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
- * `CodeHostProvider` any tracker x host combination is just config
15
- * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
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
- * and orthogonal to which one is the code host.
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
- * All eight still live in one `WatchState` union (not two separate unions)
33
- * because `transition()`'s "strip every `<prefix>:<state>` label, then add
34
- * one" logic (see `github_provider.ts`/`jira_provider.ts`) has to know about
35
- * every one of them to strip correctly, and `ensureLabels()` seeds all of
36
- * them from one `STATES` array.
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 (Jira could, in principle, via its native issue
162
- * types + `parent` field, but that is a real future implementation, not a
163
- * one-line stubsee `jira_provider.ts`'s module comment), and a tracker
164
- * that can't should be a `null` from `resolveIssueAuthoringProvider()`
165
- * (`cli/commands/watch.ts`), not a method that throws at call time.
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 today. */
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 both, since GitHub natively is both; `jira_provider.ts`
13
- * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
- * `CodeHostProvider` any tracker x host combination is just config
15
- * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
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
- * and orthogonal to which one is the code host.
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
- export {};
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
+ }
@@ -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). A module-level
96
- * LIVE registry holds every exporter this process created; `flushAll()` is
97
- * awaited in `src/cli/index.ts`'s existing `finally` block next to
98
- * `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
99
- * timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
100
- * that second one today its in-flight webhooks are dropped on SIGTERM; only
101
- * the otel path is fixed here, on purpose, to keep this change to one seam).
102
- * Send failures log ONE line for the life of the exporter, with the endpoint
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). A module-level
96
- * LIVE registry holds every exporter this process created; `flushAll()` is
97
- * awaited in `src/cli/index.ts`'s existing `finally` block next to
98
- * `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
99
- * timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
100
- * that second one today its in-flight webhooks are dropped on SIGTERM; only
101
- * the otel path is fixed here, on purpose, to keep this change to one seam).
102
- * Send failures log ONE line for the life of the exporter, with the endpoint
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
- const LIVE = [];
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.push(exporter);
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.length = 0;
856
+ LIVE.clear();
793
857
  }
@@ -1,5 +1,5 @@
1
1
  import type { Issue, IssueAuthoringProvider } from "./issues/provider.ts";
2
- import type { RefinedIssue, SFConfig } from "./data_types.ts";
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 only on `GitHubProvider`
13
- * todaysee `jira_provider.ts`'s module comment on why Jira isn't wired up
14
- * yet. Throws rather than returning `null` so a `code` phase calling this
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>` label (plus `<prefix>:refined` on leaves only —
28
- * see `WatchState`'s doc comment in `provider.ts`), link each to its parent
29
- * via the tracker's native hierarchy, and render real `#n` references into
30
- * `## Blocked by`. Returns what it created, in creation order.
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