@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
@@ -14,18 +14,37 @@
14
14
  * `runSpec` closes that gap using what this module returns).
15
15
  */
16
16
  import { GitHubProvider } from "./issues/github_provider.js";
17
+ import { JiraProvider } from "./issues/jira_provider.js";
18
+ import { clampPriority } from "./data_types.js";
17
19
  /**
18
- * `IssueAuthoringProvider` has a real implementation only on `GitHubProvider`
19
- * todaysee `jira_provider.ts`'s module comment on why Jira isn't wired up
20
- * yet. Throws rather than returning `null` so a `code` phase calling this
20
+ * `IssueAuthoringProvider` has a real implementation on `GitHubProvider` and
21
+ * `JiraProvider`any other `issue_provider` value fails here, defensively
22
+ * (the config schema's picklist already rejects it earlier). Throws rather
23
+ * than returning `null` so a `code` phase calling this
21
24
  * (`steps.publishIssues()`) fails the phase with a clear, specific reason —
22
25
  * the same "fail loudly, never silently do nothing" contract
23
26
  * `agents.validate()` uses for an unconfigured quality suite.
27
+ *
28
+ * Duplicates `cli/commands/watch.ts`'s own `resolveIssueProvider`
29
+ * construction logic for each provider — a pre-existing pattern for GitHub
30
+ * (this function has always rebuilt its own `GitHubProvider` rather than
31
+ * sharing one with the CLI layer's build-lane provider), mirrored for Jira
32
+ * rather than refactored away, to stay within this change's scope.
24
33
  */
25
34
  export function resolveAuthoringProvider(cfg) {
35
+ if (cfg.watch.issue_provider === "jira") {
36
+ if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
37
+ throw new Error(`watch.jira.base_url and watch.jira.project_key must both be set when watch.issue_provider is "jira"`);
38
+ }
39
+ const email = process.env["JIRA_EMAIL"];
40
+ const token = process.env["JIRA_API_TOKEN"];
41
+ if (!email || !token) {
42
+ throw new Error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — the refine lane needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens)');
43
+ }
44
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
45
+ }
26
46
  if (cfg.watch.issue_provider !== "github") {
27
- throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — ` +
28
- `the refine lane needs "github" (see jira_provider.ts's module comment on why Jira isn't wired up yet)`);
47
+ throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — the refine lane needs "github" or "jira"`);
29
48
  }
30
49
  // Issue authoring always targets the ISSUE tracker's repo — `issue_repo`
31
50
  // if set, falling back to plain `repo` (the common case: issue_provider
@@ -47,31 +66,78 @@ export function resolveAuthoringProvider(cfg) {
47
66
  function typeLabel(labelPrefix, kind) {
48
67
  return `${labelPrefix}:type:${kind}`;
49
68
  }
69
+ /** `spf:priority:p0..p3` — see `RefinedPrioritySchema`'s doc comment for what each rung means. Mirrors `typeLabel` above, and `github_provider.ts`'s own private `priorityLabel()` method (used by `ensureLabels()` to seed these) — the two aren't unified for the same reason `typeLabel` isn't: this file stays provider-agnostic, building label strings by convention rather than reaching into a concrete `GitHubProvider`. */
70
+ function priorityLabel(labelPrefix, priority) {
71
+ return `${labelPrefix}:priority:${priority}`;
72
+ }
73
+ /**
74
+ * Sentinel distinct from `github_provider.ts`'s `MARKER_RE` on purpose: that
75
+ * one matches a hidden comment (`spf watch`'s own scratch state — worktree,
76
+ * branch, PR number); this one matches a hidden block INSIDE the issue BODY
77
+ * this file renders — the graph `spf watch`'s build lane needs to schedule
78
+ * correctly (`parent`, `blocked_by`, `priority`) but that gets lost once
79
+ * `blocked_by` is flattened to the human-readable `## Blocked by` prose
80
+ * below. The two live in different places on the issue and are read by
81
+ * different code (`readMarker`'s comment scan vs. a plain `Issue.body`
82
+ * parse), so a single tracker `GET` — which already returns the body — is
83
+ * all `claimNewWork`'s ordering needs, no per-issue marker-comment fetch.
84
+ */
85
+ const REFINE_MARKER_RE = /<!--\s*spf-refine:\s*(\{.*?\})\s*-->/s;
86
+ /** What `parseRefineMarker` returns absent (or on a malformed/hand-edited) marker — exactly today's pre-priority, pre-frontier behavior: no parent, no blockers, the default priority. */
87
+ const NO_REFINE_MARKER = { parent: null, blocked_by: [], priority: "p2" };
88
+ /**
89
+ * Pure and exported so it's directly unit-testable without a provider —
90
+ * `core/watch.ts`'s `claimNewWork` and `rollUp` are the real callers, reading
91
+ * it straight out of the `Issue.body` a `listEligible`/`getIssue` call
92
+ * already returned. Never throws: a body with no marker (any issue not
93
+ * created by this lane, or one whose marker a human stripped while editing)
94
+ * degrades to `NO_REFINE_MARKER`, same as malformed JSON inside one.
95
+ */
96
+ export function parseRefineMarker(body) {
97
+ const match = REFINE_MARKER_RE.exec(body);
98
+ if (!match)
99
+ return NO_REFINE_MARKER;
100
+ try {
101
+ const parsed = JSON.parse(match[1]);
102
+ return {
103
+ parent: typeof parsed.parent === "string" ? parsed.parent : null,
104
+ blocked_by: Array.isArray(parsed.blocked_by) ? parsed.blocked_by.filter((b) => typeof b === "string") : [],
105
+ priority: ["p0", "p1", "p2", "p3"].includes(parsed.priority) ? parsed.priority : "p2",
106
+ };
107
+ }
108
+ catch {
109
+ return NO_REFINE_MARKER; // malformed marker JSON — tolerate it, same policy as github_provider.ts's own findMarkerComment
110
+ }
111
+ }
50
112
  /**
51
113
  * The body GitHub actually stores: the refiner's own `## What to build` /
52
114
  * `## Acceptance criteria` text, plus a `## Parent` back-reference to the
53
115
  * source spec (when there is one — a bare `spf refine` run with no
54
- * `--issue` has none), plus a `## Blocked by` section with real `#n`
55
- * references — `to-tickets`' own template shape, ported. Every `blocked_by`
56
- * key is guaranteed to already be in `byKey` by the time this runs:
57
- * `topoOrder` visits a node's dependencies before the node itself.
116
+ * `--issue` has none), a `## Blocked by` section with real `#n` references
117
+ * — `to-tickets`' own template shape, ported and finally the hidden
118
+ * `spf-refine:` marker `parseRefineMarker` reads back. Every `blocked_by`
119
+ * key (and `node.parent`) is guaranteed to already be in `byKey` by the time
120
+ * this runs: `topoOrder` visits a node's dependencies before the node itself.
58
121
  */
59
122
  function renderBody(node, byKey, specIssueId) {
60
123
  const parts = [node.body.trim()];
61
124
  if (specIssueId)
62
125
  parts.push(`## Parent\n\nDecomposed from #${specIssueId}.`);
63
- if (node.blocked_by.length > 0) {
64
- const refs = node.blocked_by.map((key) => {
65
- const published = byKey.get(key);
66
- // Defensive only: gates.refinementWellFormed already rejects a
67
- // blocked_by key that doesn't resolve to another node in the list.
68
- return published ? `#${published.issue.id}` : key;
69
- });
70
- parts.push(`## Blocked by\n\n${refs.map((r) => `- ${r}`).join("\n")}`);
126
+ const blockedByIds = node.blocked_by.map((key) => {
127
+ const published = byKey.get(key);
128
+ // Defensive only: gates.refinementWellFormed already rejects a
129
+ // blocked_by key that doesn't resolve to another node in the list.
130
+ return published ? published.issue.id : key;
131
+ });
132
+ if (blockedByIds.length > 0) {
133
+ parts.push(`## Blocked by\n\n${blockedByIds.map((id) => `- #${id}`).join("\n")}`);
71
134
  }
72
135
  else {
73
136
  parts.push(`## Blocked by\n\nNone (can start immediately).`);
74
137
  }
138
+ const parentId = node.parent ? byKey.get(node.parent)?.issue.id ?? node.parent : null;
139
+ const marker = { parent: parentId, blocked_by: blockedByIds, priority: node.priority };
140
+ parts.push(`<!-- spf-refine: ${JSON.stringify(marker)} -->`);
75
141
  return parts.join("\n\n");
76
142
  }
77
143
  /**
@@ -112,10 +178,12 @@ function topoOrder(issues) {
112
178
  }
113
179
  /**
114
180
  * Create every node in `issues`, in dependency order, with its
115
- * `<prefix>:type:<kind>` label (plus `<prefix>:refined` on leaves only —
116
- * see `WatchState`'s doc comment in `provider.ts`), link each to its parent
117
- * via the tracker's native hierarchy, and render real `#n` references into
118
- * `## Blocked by`. Returns what it created, in creation order.
181
+ * `<prefix>:type:<kind>` and `<prefix>:priority:<pN>` labels (plus
182
+ * `<prefix>:refined` on leaves only — see `WatchState`'s doc comment in
183
+ * `provider.ts`), link each to its parent via the tracker's native
184
+ * hierarchy, and render real `#n` references into `## Blocked by` plus the
185
+ * hidden `spf-refine:` marker `parseRefineMarker` reads back. Returns what
186
+ * it created, in creation order.
119
187
  *
120
188
  * Not transactional: if a create or link call throws partway through, the
121
189
  * nodes already published stay published, orphaned from whatever hadn't run
@@ -129,13 +197,19 @@ export async function publish(tracker, issues, opts) {
129
197
  const childKeys = new Set(issues.filter((i) => i.parent).map((i) => i.parent));
130
198
  const byKey = new Map();
131
199
  const created = [];
132
- for (const node of ordered) {
200
+ for (const rawNode of ordered) {
201
+ // Clamp once, up front — every downstream use (the label AND the hidden
202
+ // marker's own `priority`) must agree, or `claimNewWork`'s label-based
203
+ // ordering and a human reading the marker would disagree about what this
204
+ // issue's priority actually is.
205
+ const priority = clampPriority(rawNode.priority, opts.priorityCeiling);
206
+ const node = { ...rawNode, priority };
133
207
  const isLeaf = !childKeys.has(node.key);
134
- const labels = [typeLabel(opts.labelPrefix, node.kind)];
208
+ const labels = [typeLabel(opts.labelPrefix, node.kind), priorityLabel(opts.labelPrefix, priority)];
135
209
  if (isLeaf)
136
210
  labels.push(`${opts.labelPrefix}:refined`);
137
211
  const body = renderBody(node, byKey, opts.specIssueId);
138
- const issue = await tracker.createIssue({ title: node.title, body, labels });
212
+ const issue = await tracker.createIssue({ title: node.title, body, labels, kind: node.kind });
139
213
  const published = { key: node.key, issue, kind: node.kind, isLeaf };
140
214
  byKey.set(node.key, published);
141
215
  created.push(published);
@@ -12,6 +12,7 @@ import { type GitHandle } from "./git_helper.ts";
12
12
  import { Console } from "./console.ts";
13
13
  import { Tracer } from "./tracer.ts";
14
14
  import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
15
+ import type { TierResolution } from "./tiering.ts";
15
16
  import type { Notifier } from "./notify/notifier.ts";
16
17
  interface AgentMapEntry {
17
18
  session_id: string;
@@ -59,6 +60,13 @@ export declare class Run {
59
60
  session_dir: string;
60
61
  context_handoff_dir: string;
61
62
  agent_map: Record<string, AgentMapEntry>;
63
+ /**
64
+ * Set once by `startRun` (`src/chains/steps.ts`), before any phase opens —
65
+ * `null` until then. Not a `RunInit` field: it is computed FROM the `Run`
66
+ * (it needs `run.tracer`/`run.console` to trace and print its own
67
+ * finding), not passed into its construction. See `core/tiering.ts`.
68
+ */
69
+ tiering: TierResolution | null;
62
70
  private seq;
63
71
  private agentMapPath;
64
72
  constructor(init: RunInit);
@@ -64,6 +64,13 @@ export class Run {
64
64
  session_dir;
65
65
  context_handoff_dir;
66
66
  agent_map;
67
+ /**
68
+ * Set once by `startRun` (`src/chains/steps.ts`), before any phase opens —
69
+ * `null` until then. Not a `RunInit` field: it is computed FROM the `Run`
70
+ * (it needs `run.tracer`/`run.console` to trace and print its own
71
+ * finding), not passed into its construction. See `core/tiering.ts`.
72
+ */
73
+ tiering = null;
67
74
  seq; // a joined run continues the sequence
68
75
  agentMapPath;
69
76
  constructor(init) {
@@ -7,6 +7,31 @@
7
7
  */
8
8
  import { Run } from "./runner.ts";
9
9
  import type { SFConfig } from "./data_types.ts";
10
+ /**
11
+ * The symmetric teardown for `finalizeWhenKilled()` above: drop `adwId` from
12
+ * `ACTIVE` (so a later signal can no longer reach it) and close its Tracer's
13
+ * sqlite handle. Call once a run's own dispatch has fully settled — success
14
+ * or thrown error alike; `chains/index.ts`'s `runChain()` finally is the one
15
+ * seam every dispatch path (one-shot CLI and `spf watch` alike) shares on
16
+ * the way out, exactly where `otel.releaseOtelExporter()` already lives for
17
+ * the identical reason (see otel.ts's RUN-SCOPED CLEANUP note / #26).
18
+ *
19
+ * Without this, `spf watch` held every finished run's `Run` (and its
20
+ * Tracer, its open sqlite handle, and its Notifier) strongly reachable from
21
+ * the signal listener for the rest of the daemon's life — the listener
22
+ * closes over the `run` a fresh `finalizeWhenKilled()` call captured, but
23
+ * since #26 only that one listener installs once now, and `ACTIVE` is the
24
+ * only thing keeping a finished run reachable from it.
25
+ *
26
+ * A one-shot invocation with no explicit `--adw-id` makes `adwId` here the
27
+ * caller's `ctx.adw_id` (`null`) rather than the id `session.ensure()`
28
+ * actually minted, so this is a harmless no-op for it — same caveat as
29
+ * `releaseOtelExporter`, and harmless for the same reason: that process
30
+ * exits right after anyway.
31
+ */
32
+ export declare function finalize(adwId: string | null | undefined): void;
33
+ /** Tests only: which adw_ids the process-wide signal handler currently considers active. */
34
+ export declare function activeRunIdsForTest(): string[];
10
35
  /**
11
36
  * `cwd` anchors this run's repo_root and data_dir — it is NOT where the
12
37
  * process happened to start; it is an explicit decision, threaded down from
@@ -10,16 +10,35 @@ import * as paths from "./paths.js";
10
10
  import { Run } from "./runner.js";
11
11
  import { Tracer } from "./tracer.js";
12
12
  import { engineerName, newId } from "./utils.js";
13
- import { resolveNotifier } from "./notify/notifier.js";
13
+ import { resolveNotifier, drainAll as drainNotifiers } from "./notify/notifier.js";
14
14
  import * as otel from "./otel.js";
15
15
  /**
16
- * How long a signalled run may spend pushing spans before it exits anyway.
17
- * Short on purpose: someone who just pressed ^C is waiting, and an
18
- * observability projection is never worth making a kill feel broken. The
19
- * budget is enforced inside `otel.flushAll()` (a raced, unref'd deadline), so
20
- * an unreachable collector costs exactly this and not one tick more.
16
+ * How long a signalled run may spend pushing spans / webhook sends before it
17
+ * exits anyway. Short on purpose: someone who just pressed ^C is waiting, and
18
+ * neither an observability projection nor a Slack ping is ever worth making a
19
+ * kill feel broken. The budget is enforced inside `otel.flushAll()` and
20
+ * `notify.drainAll()` (each a raced, unref'd deadline), so an unreachable
21
+ * collector or webhook host costs exactly this and not one tick more.
21
22
  */
22
23
  const SIGNAL_DRAIN_MS = 750;
24
+ /**
25
+ * Runs currently in flight in THIS process, keyed by adw_id — what the one
26
+ * shared signal handler below acts on, and what `finalize()` removes a run
27
+ * from once its own dispatch has settled.
28
+ *
29
+ * `spf watch`'s daemon loop calls `ensure()` once per claimed issue, in the
30
+ * same process, for the life of the daemon. Installing a FRESH
31
+ * SIGTERM/SIGINT listener per `ensure()` call (the old shape) meant every
32
+ * listener ever registered stayed registered and fired on the NEXT signal —
33
+ * so ^C during issue 72 would also re-run issue 70 and 71's long-finished
34
+ * handlers, flipping their already-`success` sessions back to `fail`, on top
35
+ * of leaking a `MaxListenersExceededWarning` at the 11th run. One listener,
36
+ * installed once, acting on whichever runs are still in `ACTIVE`, fixes
37
+ * both: a signal only ever finalizes runs that are actually still running.
38
+ */
39
+ const ACTIVE = new Map();
40
+ let installed = false;
41
+ let draining = false;
23
42
  /**
24
43
  * A killed run still closes its own trace.
25
44
  *
@@ -31,32 +50,82 @@ const SIGNAL_DRAIN_MS = 750;
31
50
  * (best-effort: a signal can still land mid-write).
32
51
  *
33
52
  * SQLite is written FIRST and synchronously, exactly as before — the otel
34
- * drain is appended after it and can only ever cost time, never correctness.
35
- * (`notify` has no equivalent drain on this path: its in-flight webhooks are
36
- * dropped on a signal today. Fixing that means touching the notifier's
37
- * lifecycle, which is outside this change; only the otel path is drained here.)
53
+ * and notify drains are appended after it and can only ever cost time, never
54
+ * correctness. `notify`'s in-flight Slack/Teams/webhook sends get the SAME
55
+ * timeout-and-swallow discipline as the otel drain (see `Notifier.drain()` /
56
+ * `notify.drainAll()` in `notify/notifier.ts`): bounded by the same
57
+ * `SIGNAL_DRAIN_MS` budget, run concurrently with the otel drain (not after
58
+ * it, so a killed run never pays both budgets back to back), and never able
59
+ * to throw into this handler.
38
60
  * A second signal during the drain exits immediately — someone pressing ^C
39
61
  * twice means "now", and a shutdown path that ignores that is a hang.
40
62
  */
41
- function finalizeWhenKilled(run) {
42
- let draining = false;
43
- const handler = (signal) => {
44
- const code = 128 + (signal === "SIGINT" ? 2 : 15);
45
- if (draining)
46
- process.exit(code);
47
- draining = true;
63
+ function handleSignal(signal) {
64
+ const code = 128 + (signal === "SIGINT" ? 2 : 15);
65
+ if (draining)
66
+ process.exit(code);
67
+ draining = true;
68
+ const runs = [...ACTIVE.values()]; // snapshot: finalize() may mutate ACTIVE mid-drain
69
+ for (const run of runs)
48
70
  run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
49
- // Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
50
- // otel existed — no extra tick between the signal and the exit for the
51
- // repos that never opted in.
52
- if (!run.tracer.otel)
53
- process.exit(code);
54
- // Bounded and never-throwing: flushAll() swallows its own failures and
55
- // resolves on its own deadline, so this always reaches process.exit().
56
- void otel.flushAll(SIGNAL_DRAIN_MS).then(() => process.exit(code), () => process.exit(code));
57
- };
58
- process.on("SIGTERM", handler);
59
- process.on("SIGINT", handler);
71
+ const drains = [];
72
+ if (runs.some((run) => run.tracer.otel))
73
+ drains.push(otel.flushAll(SIGNAL_DRAIN_MS));
74
+ if (runs.some((run) => run.notify))
75
+ drains.push(drainNotifiers(SIGNAL_DRAIN_MS));
76
+ // Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
77
+ // otel/notify existed no extra tick between the signal and the exit for
78
+ // the repos that never opted in to either.
79
+ if (drains.length === 0) {
80
+ process.exit(code);
81
+ return;
82
+ }
83
+ // Bounded and never-throwing: both drains swallow their own failures and
84
+ // resolve on their own deadline, so this always reaches process.exit().
85
+ void Promise.all(drains).then(() => process.exit(code), () => process.exit(code));
86
+ }
87
+ function finalizeWhenKilled(run) {
88
+ ACTIVE.set(run.adw_id, run);
89
+ if (installed)
90
+ return;
91
+ installed = true;
92
+ process.on("SIGTERM", handleSignal);
93
+ process.on("SIGINT", handleSignal);
94
+ }
95
+ /**
96
+ * The symmetric teardown for `finalizeWhenKilled()` above: drop `adwId` from
97
+ * `ACTIVE` (so a later signal can no longer reach it) and close its Tracer's
98
+ * sqlite handle. Call once a run's own dispatch has fully settled — success
99
+ * or thrown error alike; `chains/index.ts`'s `runChain()` finally is the one
100
+ * seam every dispatch path (one-shot CLI and `spf watch` alike) shares on
101
+ * the way out, exactly where `otel.releaseOtelExporter()` already lives for
102
+ * the identical reason (see otel.ts's RUN-SCOPED CLEANUP note / #26).
103
+ *
104
+ * Without this, `spf watch` held every finished run's `Run` (and its
105
+ * Tracer, its open sqlite handle, and its Notifier) strongly reachable from
106
+ * the signal listener for the rest of the daemon's life — the listener
107
+ * closes over the `run` a fresh `finalizeWhenKilled()` call captured, but
108
+ * since #26 only that one listener installs once now, and `ACTIVE` is the
109
+ * only thing keeping a finished run reachable from it.
110
+ *
111
+ * A one-shot invocation with no explicit `--adw-id` makes `adwId` here the
112
+ * caller's `ctx.adw_id` (`null`) rather than the id `session.ensure()`
113
+ * actually minted, so this is a harmless no-op for it — same caveat as
114
+ * `releaseOtelExporter`, and harmless for the same reason: that process
115
+ * exits right after anyway.
116
+ */
117
+ export function finalize(adwId) {
118
+ if (!adwId)
119
+ return;
120
+ const run = ACTIVE.get(adwId);
121
+ if (!run)
122
+ return;
123
+ ACTIVE.delete(adwId);
124
+ run.tracer.close();
125
+ }
126
+ /** Tests only: which adw_ids the process-wide signal handler currently considers active. */
127
+ export function activeRunIdsForTest() {
128
+ return [...ACTIVE.keys()];
60
129
  }
61
130
  /**
62
131
  * `cwd` anchors this run's repo_root and data_dir — it is NOT where the
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Risk-tiered per-role model routing — SPF #14.
3
+ *
4
+ * PURE except for `probeServedOllamaTags`, the one async/impure export (a
5
+ * network probe — see its own doc comment below). Everything else here is a
6
+ * function of its own arguments: no `Run`, no `ChainContext`, no git handle,
7
+ * no filesystem. That purity is deliberate — it is what lets `startRun`
8
+ * (`src/chains/steps.ts`) and `spf estimate` call the IDENTICAL
9
+ * `resolveTiering` and get identical answers with no drift between "what
10
+ * will dispatch" and "what would dispatch".
11
+ *
12
+ * No imports from `src/chains/` and none from `src/cli/` — `spf doctor` and
13
+ * `startRun` both import FROM this module, never the reverse.
14
+ *
15
+ * ── The mechanism, in one paragraph ─────────────────────────────────────
16
+ * A run's `risk` (`low`/`standard`/`high`) is a single run-global scalar,
17
+ * classified once from the chain's name and the prompt's word count
18
+ * (`classifyRisk`). Separately, `tiering.roles` names a baseline tier per
19
+ * ROLE (an agent name) on `tiering.tiers`, a ladder ordered weakest first.
20
+ * `resolveTiering` shifts every routed role's baseline by the SAME step in
21
+ * the SAME direction (down for `low`, up for `high`), walking down to the
22
+ * nearest rung that is both available (§ probe) and backend-compatible
23
+ * (rule T) — never up, because a degradation must never silently escalate
24
+ * spend. An agent not named in `roles` is never touched: its own `model:`
25
+ * stands. `effectiveAgent` is the one place that turns a resolution into an
26
+ * actual `AgentConfig` — overriding `model` and NOTHING else.
27
+ */
28
+ import type { AgentConfig, SFConfig } from "./data_types.ts";
29
+ /**
30
+ * One explicit `name -> weight` table entry per BUILT-IN chain
31
+ * (`src/chains/index.ts`'s `CHAINS`), including every chain that weighs
32
+ * `0` — an entry, not a fallthrough. A name absent from this table (any
33
+ * repo-local `.spf/chains/*.yaml` chain, or a built-in nobody has wired in
34
+ * yet) falls to `0` through `chainWeight`'s own default — a DIFFERENT code
35
+ * path from being explicitly listed as `0`, which is what lets a test
36
+ * assert "every built-in chain has a real entry here" rather than merely
37
+ * "this returned 0", so a new built-in nobody wired in cannot land in the
38
+ * `0` bucket unnoticed.
39
+ */
40
+ export declare const CHAIN_WEIGHTS: Record<string, -1 | 0 | 1>;
41
+ /** `name in CHAIN_WEIGHTS` distinguishes "explicitly 0" from "fell through" — see `CHAIN_WEIGHTS`'s own comment. */
42
+ export declare function chainWeight(name: string): number;
43
+ /** Whitespace-delimited word count, deliberately not tokens — no tokenizer exists for an arbitrary ollama/<tag>, and a word count is free, stable, and reproducible from the trace. */
44
+ export declare function promptWords(prompt: string): number;
45
+ /** `-1` for a terse ask, `+1` for a long one, `0` otherwise. */
46
+ export declare function promptWeight(words: number): number;
47
+ export type Risk = "low" | "standard" | "high";
48
+ export interface TierSignals {
49
+ chain: string;
50
+ chain_weight: number;
51
+ prompt_words: number;
52
+ prompt_weight: number;
53
+ sum: number;
54
+ }
55
+ /**
56
+ * The classifier. Asymmetric on purpose: demotion (`low`) requires TWO
57
+ * agreeing signals (`sum <= -2` — both `chain_weight` and `prompt_weight`
58
+ * at `-1`); promotion (`high`) requires only ONE. Concretely that means a
59
+ * long prompt (`prompt_weight === 1`, ≥400 words) forces `high`
60
+ * UNCONDITIONALLY, even riding on top of a `-1` chain (`spf scout "<≥400
61
+ * words>"` → `high`, not the `sum === 0` "standard" a naive `sum >= 1`
62
+ * threshold would give) — the design doc's own §2 3×3 table and its §3.1
63
+ * reachability table both pin this cell as `high`, so the table (not the
64
+ * simpler-looking `sum` arithmetic sketched alongside it) is what
65
+ * `src/test/tiering.test.ts`'s full-grid test treats as the specification.
66
+ * An unnecessarily weak model produces a wrong answer that costs a whole
67
+ * re-run — the expensive failure; an unnecessarily strong one just costs
68
+ * some tokens — the cheap failure. Skewed toward the cheap one.
69
+ */
70
+ export declare function classifyRisk(chainName: string, prompt: string): {
71
+ risk: Risk;
72
+ signals: TierSignals;
73
+ };
74
+ /** PURE input — no `Run`, no `ChainContext`. `required` scopes everything: an agent no phase in this run will dispatch is neither routed nor reported. */
75
+ export interface TierInput {
76
+ cfg: SFConfig;
77
+ chainName: string;
78
+ prompt: string;
79
+ /** `null` == "not probed / probe failed" == fail open, drop nothing. */
80
+ servedOllamaTags: Set<string> | null;
81
+ required: string[];
82
+ }
83
+ export interface TierRoute {
84
+ /** the tier NAME finally selected, after the shift and any walk-down */
85
+ tier: string;
86
+ /** agent.model as CONFIGURED (post-back-fill) */
87
+ configured: string;
88
+ /** the model that will actually dispatch */
89
+ effective: string;
90
+ }
91
+ export interface TierResolution {
92
+ risk: Risk;
93
+ signals: TierSignals;
94
+ /**
95
+ * FULL routing: one entry for every agent in `required` that tiering
96
+ * routes — including entries where `effective === configured`. NOT a
97
+ * diff; `changedModels()` below is the derived diff view. An agent not in
98
+ * `required`, or not named in `roles`, has no entry.
99
+ */
100
+ routing: Record<string, TierRoute>;
101
+ /** One line per degradation or non-routable role. Empty when tiering is disabled or has nothing to say. */
102
+ notes: string[];
103
+ }
104
+ /**
105
+ * The ladder walk (design doc §4.3). Never throws and never reports a
106
+ * severity — a backend mismatch (rule T) or an unknown tier name is a
107
+ * `notes` line plus a role left unrouted; `agents.validate()` owns
108
+ * severity, this owns detection.
109
+ */
110
+ export declare function resolveTiering(input: TierInput): TierResolution;
111
+ /**
112
+ * The diff view, derived — `{agent: effective}` for entries where
113
+ * `configured !== effective`. Empty means tiering changed nothing this run.
114
+ * Deriving it (rather than storing it) is what keeps this view and the
115
+ * full `routing` map from disagreeing: one source, one filter.
116
+ */
117
+ export declare function changedModels(res: TierResolution): Record<string, string>;
118
+ /**
119
+ * The single dispatch-site change (design doc §4.6): `model` and ONLY
120
+ * `model`. `run` is a minimal structural shape — deliberately not
121
+ * `agents.ts`'s `RunForAgents`, which would import this module and create a
122
+ * cycle — so any run-shaped object with an (optional) `tiering` field
123
+ * satisfies it.
124
+ */
125
+ export declare function effectiveAgent(run: {
126
+ tiering?: TierResolution | null;
127
+ }, base: AgentConfig): AgentConfig;
128
+ /**
129
+ * `Promise<Set<string> | null>` of bare (unprefixed) served Ollama tags, or
130
+ * `null` on any failure — non-200, timeout, connection refused, an
131
+ * unparseable/`data`-less body. `null` means "fail open, drop nothing",
132
+ * never "everything is unserved": an unreachable probe silently degrading
133
+ * every agent to the bottom rung is a worse outcome than the honest
134
+ * "Unknown model ID" error Ollama itself raises at first dispatch.
135
+ *
136
+ * Only actually probes when tiering is enabled AND at least one declared
137
+ * tier's model starts with `ollama/` — never on a run whose tiers are all
138
+ * hosted, and never when tiering is off. Memoized once per process.
139
+ *
140
+ * A NEW helper, not a reuse of `spf doctor`'s `probeGet`: that helper
141
+ * discards the response body, which is the only part that matters here.
142
+ */
143
+ export declare function probeServedOllamaTags(cfg: SFConfig): Promise<Set<string> | null>;
144
+ /** Test-only: `probeServedOllamaTags` memoizes once per process, so a test that stubs `fetch` differently across cases must reset the cache between them. */
145
+ export declare function resetProbeCacheForTest(): void;