@gr8ful/spf 0.6.0 → 0.8.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 (58) hide show
  1. package/README.md +122 -27
  2. package/assets/prompts/refiner/system.md +11 -1
  3. package/assets/prompts/refiner/user.md +9 -3
  4. package/assets/skill/references/config.md +51 -13
  5. package/assets/templates/ts.spf.config.yaml +6 -2
  6. package/dist/chains/context.d.ts +26 -0
  7. package/dist/chains/simple_sdlc.js +9 -0
  8. package/dist/chains/steps.d.ts +0 -27
  9. package/dist/chains/steps.js +21 -2
  10. package/dist/cli/ask.d.ts +13 -0
  11. package/dist/cli/ask.js +15 -1
  12. package/dist/cli/commands/doctor.js +47 -9
  13. package/dist/cli/commands/fanout.js +49 -5
  14. package/dist/cli/commands/init.js +11 -3
  15. package/dist/cli/commands/list.d.ts +1 -1
  16. package/dist/cli/commands/list.js +31 -12
  17. package/dist/cli/commands/phases.d.ts +1 -1
  18. package/dist/cli/commands/phases.js +18 -4
  19. package/dist/cli/commands/run.js +30 -2
  20. package/dist/cli/commands/sessions.d.ts +1 -1
  21. package/dist/cli/commands/sessions.js +11 -3
  22. package/dist/cli/commands/watch.d.ts +8 -0
  23. package/dist/cli/commands/watch.js +93 -13
  24. package/dist/cli/index.js +4 -4
  25. package/dist/cli/interview.js +9 -5
  26. package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
  27. package/dist/cli/ui/fanout_dashboard.js +102 -0
  28. package/dist/cli/ui/ink_asker.d.ts +13 -0
  29. package/dist/cli/ui/ink_asker.js +247 -0
  30. package/dist/cli/ui/reports.d.ts +30 -0
  31. package/dist/cli/ui/reports.js +61 -0
  32. package/dist/cli/ui/run_dashboard.d.ts +15 -0
  33. package/dist/cli/ui/run_dashboard.js +131 -0
  34. package/dist/cli/ui/watch_dashboard.d.ts +22 -0
  35. package/dist/cli/ui/watch_dashboard.js +78 -0
  36. package/dist/core/console.d.ts +40 -1
  37. package/dist/core/console.js +25 -3
  38. package/dist/core/data_types.d.ts +108 -5
  39. package/dist/core/data_types.js +50 -5
  40. package/dist/core/fanout.d.ts +9 -0
  41. package/dist/core/fanout.js +6 -2
  42. package/dist/core/gates.js +24 -1
  43. package/dist/core/issues/github_provider.d.ts +39 -5
  44. package/dist/core/issues/github_provider.js +103 -4
  45. package/dist/core/issues/jira_provider.d.ts +79 -12
  46. package/dist/core/issues/jira_provider.js +97 -2
  47. package/dist/core/issues/provider.d.ts +73 -19
  48. package/dist/core/issues/provider.js +24 -7
  49. package/dist/core/notify/channel.d.ts +1 -1
  50. package/dist/core/refine.d.ts +45 -8
  51. package/dist/core/refine.js +98 -24
  52. package/dist/core/runner.d.ts +5 -1
  53. package/dist/core/runner.js +2 -1
  54. package/dist/core/session.d.ts +7 -1
  55. package/dist/core/session.js +5 -1
  56. package/dist/core/watch.d.ts +86 -3
  57. package/dist/core/watch.js +353 -29
  58. package/package.json +6 -1
@@ -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);
@@ -9,7 +9,7 @@
9
9
  * parsed envelope + green gates, enforced inside ph.call).
10
10
  */
11
11
  import { type GitHandle } from "./git_helper.ts";
12
- import { Console } from "./console.ts";
12
+ import { Console, type RunObserver } 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
15
  import type { TierResolution } from "./tiering.ts";
@@ -38,6 +38,10 @@ export interface RunInit {
38
38
  chainName?: string;
39
39
  /** `null`/omitted when notifications are off (the default) or no channel resolved. */
40
40
  notifier?: Notifier | null;
41
+ /** Where a printed line goes — see `Console`'s own constructor doc. Omitted everywhere except an interactive `cli/commands/run.ts` dispatch. */
42
+ sink?: (line: string) => void;
43
+ /** See `RunObserver`'s doc comment (`core/console.ts`). `null`/omitted outside an interactive dispatch. */
44
+ observer?: RunObserver | null;
41
45
  }
42
46
  export declare class Run {
43
47
  cfg: SFConfig;
@@ -78,7 +78,7 @@ export class Run {
78
78
  this.adw_id = init.adwId;
79
79
  this.tracer = init.tracer;
80
80
  this.notify = init.notifier ?? null;
81
- this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw");
81
+ this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw", init.sink, init.observer);
82
82
  this.engineer = init.engineer;
83
83
  this.seq = init.tracer.maxPhaseSeq(init.adwId);
84
84
  this.repo_root = init.repoRoot;
@@ -100,6 +100,7 @@ export class Run {
100
100
  this.tokens += tokens;
101
101
  this.cost += cost;
102
102
  this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
103
+ this.console.notifyUsage(this.tokens, this.cost);
103
104
  }
104
105
  // ── the phase primitive ─────────────────────────────────────────────────
105
106
  async phase(params, fn) {
@@ -6,6 +6,7 @@
6
6
  * minted and printed so the next ADW can pick it up.
7
7
  */
8
8
  import { Run } from "./runner.ts";
9
+ import type { RunObserver } from "./console.ts";
9
10
  import type { SFConfig } from "./data_types.ts";
10
11
  /**
11
12
  * The symmetric teardown for `finalizeWhenKilled()` above: drop `adwId` from
@@ -43,4 +44,9 @@ export declare function activeRunIdsForTest(): string[];
43
44
  * longer a `process.argv[1]` basename that means anything. Direct callers
44
45
  * that have no chain of their own fall back to `"adw"`.
45
46
  */
46
- export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string): Run;
47
+ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string,
48
+ /** See `RunObserver`'s doc comment (`core/console.ts`). Omitted for every caller except an interactive `cli/commands/run.ts` dispatch — a `spf watch` per-issue run, `spf fanout`'s per-attempt runs, and every test all continue to build a plain, unobserved `Console`. */
49
+ renderHooks?: {
50
+ sink?: (line: string) => void;
51
+ observer?: RunObserver | null;
52
+ }): Run;
@@ -138,7 +138,9 @@ export function activeRunIdsForTest() {
138
138
  * longer a `process.argv[1]` basename that means anything. Direct callers
139
139
  * that have no chain of their own fall back to `"adw"`.
140
140
  */
141
- export function ensure(cfg, adwId, cwd, chainName) {
141
+ export function ensure(cfg, adwId, cwd, chainName,
142
+ /** See `RunObserver`'s doc comment (`core/console.ts`). Omitted for every caller except an interactive `cli/commands/run.ts` dispatch — a `spf watch` per-issue run, `spf fanout`'s per-attempt runs, and every test all continue to build a plain, unobserved `Console`. */
143
+ renderHooks) {
142
144
  const id = adwId || newId(8);
143
145
  const anchor = paths.resolveAnchor(cwd);
144
146
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
@@ -161,6 +163,8 @@ export function ensure(cfg, adwId, cwd, chainName) {
161
163
  dataDir: dataPaths.data_dir,
162
164
  chainName: chainName || "adw",
163
165
  notifier: resolveNotifier(cfg),
166
+ sink: renderHooks?.sink,
167
+ observer: renderHooks?.observer,
164
168
  });
165
169
  const scriptPath = process.argv[1] || "adw";
166
170
  tracer.sessionStart(id, run.engineer, chainName || "adw");
@@ -1,6 +1,7 @@
1
1
  import type { GitHandle } from "./git_helper.ts";
2
2
  import type { CodeHostProvider, Issue, IssueComment, IssueProvider, WatchMarker, WatchState } from "./issues/provider.ts";
3
3
  import type { NotifyEvent } from "./notify/channel.ts";
4
+ import { type RefinedPriority } from "./data_types.ts";
4
5
  export interface ChainRunResult {
5
6
  accepted: boolean;
6
7
  adwId: string;
@@ -18,7 +19,7 @@ export interface ChainRunResult {
18
19
  /** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
19
20
  reviewRequired?: boolean;
20
21
  }
21
- /** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
22
+ /** One issue the refine lane created — enough for `announceRefined`'s summary comment and the marker's idempotency record. */
22
23
  export interface RefinedIssueRef {
23
24
  id: string;
24
25
  title: string;
@@ -110,6 +111,18 @@ export interface WatchDeps {
110
111
  adwId: string;
111
112
  chainOptions: Record<string, string>;
112
113
  }) => Promise<ChainRunResult>;
114
+ /**
115
+ * `IssueAuthoringProvider.listChildren`'s read-back, injected as a bound
116
+ * function rather than a whole provider object — same reasoning as
117
+ * `runChain`/`runRefine`/`linkDataDir`: this module drives whatever it's
118
+ * given without importing a concrete provider type. `undefined` on any
119
+ * tracker that doesn't implement `IssueAuthoringProvider` (Jira today —
120
+ * see `jira_provider.ts`'s module comment); `rollUp` treats that as a
121
+ * logged no-op, not a failure — the build lane still functions without
122
+ * container roll-up, unlike the refine lane, which cannot function
123
+ * without authoring at all (see `cli/commands/watch.ts`'s startup check).
124
+ */
125
+ listChildren?: (parent: Issue) => Promise<Issue[]>;
113
126
  log: (message: string) => void;
114
127
  /**
115
128
  * Structured push, alongside `log`'s plain string — a required field, like
@@ -134,6 +147,20 @@ export interface WatchRunState {
134
147
  * that isn't why this is separate — the budgets are what require it.
135
148
  */
136
149
  refining: Set<string>;
150
+ /**
151
+ * Issue id -> its container's real issue id, recorded the moment
152
+ * `claimNewWork` claims a leaf whose hidden `spf-refine:` marker names a
153
+ * parent, deleted in the same `.finally()` that clears `inflight`. Zero
154
+ * API cost — no tracker read needed to populate it — and it's the whole
155
+ * basis of sibling affinity in `orderEligible`: a sibling of something
156
+ * already in flight sorts ahead of an equal-priority issue from an
157
+ * unrelated feature, so a feature already underway tends to finish before
158
+ * the daemon starts a new one instead of interleaving both. This holds
159
+ * only while a sibling is ACTUALLY in flight — a daemon restart begins
160
+ * with this empty, so ordering falls back to priority + created-asc until
161
+ * the in-memory picture rebuilds itself over the next few ticks.
162
+ */
163
+ inflightParents: Map<string, string>;
137
164
  }
138
165
  export declare function createWatchState(): WatchRunState;
139
166
  export declare function branchNameFor(issue: Issue): string;
@@ -146,6 +173,29 @@ export declare function refineBranchNameFor(issue: Issue): string;
146
173
  * merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
147
174
  */
148
175
  export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
176
+ /**
177
+ * Poll every `spec-in-progress` spec: once every id `WatchMarker.refined`
178
+ * recorded — every issue the refiner produced, leaf or container — carries
179
+ * `<prefix>:done`, the spec's own decomposed work is actually finished, and
180
+ * only then does this move it `-> done`. This is the whole point of
181
+ * `announceRefined` landing on `spec-in-progress` rather than `done`
182
+ * straight away: the spec's status is what a product manager reads to know
183
+ * whether the work is finished, and "done" the instant a tree gets PUBLISHED
184
+ * would be a lie — the work hasn't started yet, let alone finished.
185
+ *
186
+ * A container in the refined list is done exactly when `rollUp` (see
187
+ * `finishReviews`) has already rolled it up — by the time every id here is
188
+ * `<prefix>:done`, every leaf beneath every container is too, transitively,
189
+ * with no need to walk the hierarchy again from this side.
190
+ *
191
+ * A referenced id that 404s (deleted from the tracker) is treated as
192
+ * satisfied — same policy as `frontierBlockedOn`'s blockers: a removed issue
193
+ * must not wedge the spec's completion forever. A spec with no marker, or an
194
+ * empty `refined` list, is left alone with a log line rather than assumed
195
+ * done — data that shouldn't exist given the gate's at-least-one-leaf rule,
196
+ * but never silently marked complete on that assumption.
197
+ */
198
+ export declare function finishTrackedSpecs(deps: WatchDeps): Promise<void>;
149
199
  /**
150
200
  * Render the spec issue's comment thread for the refiner's prompt, replacing
151
201
  * the previous bare `title\n\nbody` — the whole reason a human's answer
@@ -161,9 +211,17 @@ export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState):
161
211
  * `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
162
212
  * "N earlier comment(s) omitted" line, never a silent truncation.
163
213
  *
214
+ * `priority` — the spec's own `<prefix>:priority:pN` label, read by
215
+ * `runSpec` below — renders as its own `## Priority` section right after the
216
+ * header, present or absent independent of whether there's any comment
217
+ * thread at all: a spec with no priority label (the common case today) omits
218
+ * the section entirely, exactly the prompt this function produced before
219
+ * priority existed. `core/refine.ts`'s `publish()` is what actually ENFORCES
220
+ * the ceiling this section only asks for — see its own doc comment.
221
+ *
164
222
  * Exported and pure (no provider, no I/O) so it's directly unit-testable.
165
223
  */
166
- export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"]): string;
224
+ export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"], priority?: RefinedPriority | null): string;
167
225
  /**
168
226
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
169
227
  * process isn't tracking is one of three things: a completed publish that
@@ -179,7 +237,32 @@ export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[],
179
237
  export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
180
238
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
181
239
  export declare function finishReviews(deps: WatchDeps): Promise<void>;
182
- /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
240
+ /**
241
+ * Sort `issues` the way `claimNewWork` walks them: priority first (p0 ahead
242
+ * of p2 regardless of creation order), then sibling affinity (a leaf whose
243
+ * hidden marker names a parent already in `inflightParents.values()` sorts
244
+ * ahead of an equal-priority leaf from an unrelated feature — the mechanism
245
+ * that tends to finish one feature before starting the next, without giving
246
+ * up the one-PR-per-story design), then creation order (oldest first,
247
+ * matching `listByLabel`'s own `sort=created&direction=asc` — the final,
248
+ * stable tiebreaker when priority and affinity both tie).
249
+ *
250
+ * Pure and exported so it's directly unit-testable without a provider, same
251
+ * spirit as `buildSpecPrompt` below. Two honest limits, both already true of
252
+ * what feeds it: affinity only reflects a sibling ACTUALLY in flight in this
253
+ * process right now — a daemon restart begins with `inflightParents` empty,
254
+ * so ordering degrades to priority + created-asc until it rebuilds itself
255
+ * over the next few ticks; and priority is read from the label, so an issue
256
+ * a human just relabeled sorts by its NEW priority starting next tick, never
257
+ * retroactively re-ordering claims a previous tick already made.
258
+ */
259
+ export declare function orderEligible(issues: Issue[], inflightParents: Map<string, string>, labelPrefix: string): Issue[];
260
+ /**
261
+ * Claim as many `ready` issues as the concurrency budget allows, in priority
262
+ * + sibling-affinity + created-asc order (`orderEligible`), skipping any
263
+ * whose `blocked_by` isn't fully `<prefix>:done` yet (`frontierBlockedOn`),
264
+ * and kick off `runIssue` for each claimed one in the background.
265
+ */
183
266
  export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
184
267
  /**
185
268
  * Claim as many specs in `from` as `refineConcurrency` allows, and kick off