@gr8ful/spf 0.5.0 → 0.6.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 (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  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 +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  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 +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +51 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/paths.d.ts +3 -0
  46. package/dist/core/paths.js +48 -1
  47. package/dist/core/runner.d.ts +8 -0
  48. package/dist/core/runner.js +7 -0
  49. package/dist/core/session.d.ts +25 -0
  50. package/dist/core/session.js +97 -28
  51. package/dist/core/tiering.d.ts +145 -0
  52. package/dist/core/tiering.js +235 -0
  53. package/dist/core/tracer.d.ts +10 -0
  54. package/dist/core/tracer.js +12 -0
  55. package/dist/core/watch.d.ts +89 -9
  56. package/dist/core/watch.js +193 -19
  57. package/dist/ui/server/db.d.ts +39 -0
  58. package/dist/ui/server/db.js +61 -0
  59. package/package.json +2 -1
  60. package/dist/test/agent_cc.test.d.ts +0 -1
  61. package/dist/test/agent_cc.test.js +0 -95
  62. package/dist/test/agent_flue.test.d.ts +0 -1
  63. package/dist/test/agent_flue.test.js +0 -83
  64. package/dist/test/chains.test.d.ts +0 -12
  65. package/dist/test/chains.test.js +0 -92
  66. package/dist/test/data_types.test.d.ts +0 -10
  67. package/dist/test/data_types.test.js +0 -220
  68. package/dist/test/env_file.test.d.ts +0 -1
  69. package/dist/test/env_file.test.js +0 -74
  70. package/dist/test/fake_asker.d.ts +0 -23
  71. package/dist/test/fake_asker.js +0 -30
  72. package/dist/test/git_helper.test.d.ts +0 -1
  73. package/dist/test/git_helper.test.js +0 -59
  74. package/dist/test/hermetic_git.d.ts +0 -1
  75. package/dist/test/hermetic_git.js +0 -22
  76. package/dist/test/init_command.test.d.ts +0 -14
  77. package/dist/test/init_command.test.js +0 -136
  78. package/dist/test/interview.test.d.ts +0 -15
  79. package/dist/test/interview.test.js +0 -425
  80. package/dist/test/notify.test.d.ts +0 -1
  81. package/dist/test/notify.test.js +0 -174
  82. package/dist/test/ollama_provider.test.d.ts +0 -1
  83. package/dist/test/ollama_provider.test.js +0 -103
  84. package/dist/test/otel.test.d.ts +0 -26
  85. package/dist/test/otel.test.js +0 -512
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -1,5 +1,5 @@
1
1
  import type { GitHandle } from "./git_helper.ts";
2
- import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
2
+ import type { CodeHostProvider, Issue, IssueComment, IssueProvider, WatchMarker, WatchState } from "./issues/provider.ts";
3
3
  import type { NotifyEvent } from "./notify/channel.ts";
4
4
  export interface ChainRunResult {
5
5
  accepted: boolean;
@@ -25,13 +25,31 @@ export interface RefinedIssueRef {
25
25
  kind: string;
26
26
  isLeaf: boolean;
27
27
  }
28
+ /**
29
+ * One open question the refiner raised instead of a tree — mirrors
30
+ * `RefineQuestionSchema` (`core/data_types.ts`) field-for-field, kept as its
31
+ * own local shape rather than importing that type, the same way
32
+ * `RefinedIssueRef` above mirrors `RefinedIssue` rather than importing it:
33
+ * this module stays deliberately decoupled from the chain layer (see the
34
+ * module doc comment), reading only what `escalateSpec`'s comment needs.
35
+ */
36
+ export interface RefinedQuestionRef {
37
+ id: string;
38
+ question: string;
39
+ why_it_matters: string;
40
+ options: string[];
41
+ recommendation: string;
42
+ evidence: string[];
43
+ }
28
44
  export interface RefineRunResult {
29
45
  accepted: boolean;
30
46
  adwId: string;
31
47
  /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
32
48
  detail: string;
33
- /** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted`. */
49
+ /** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run escalated instead of publishing. */
34
50
  created: RefinedIssueRef[];
51
+ /** What the refiner is asking, read back from its own side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted` or when the run published a tree instead of escalating; `gates.refinementWellFormed` guarantees `created` and `questions` are never both non-empty. */
52
+ questions: RefinedQuestionRef[];
35
53
  }
36
54
  export interface WatchDeps {
37
55
  provider: IssueProvider;
@@ -43,6 +61,17 @@ export interface WatchDeps {
43
61
  chain: string;
44
62
  baseBranch: string;
45
63
  concurrency: number;
64
+ /**
65
+ * `watch.chain_options` (see `WatchConfigSchema`'s doc comment) — passed
66
+ * unchanged into both `runChain`'s and `runRefine`'s opts below, exactly
67
+ * like `spf <chain> --suite <name>` builds an options map for an
68
+ * interactive dispatch (`cli/commands/run.ts`). Fixes the KNOWN
69
+ * LIMITATION from PR #20: an unattended `spf watch` dispatch used to call
70
+ * `runChainDef` with no options at all, so nothing --suite-shaped could
71
+ * ever reach it. One shared map for both lanes — `spf watch` has no
72
+ * separate `refine.chain_options` today.
73
+ */
74
+ chainOptions: Record<string, string>;
46
75
  /**
47
76
  * The second lane — decomposing a `<prefix>:spec-ready` product spec
48
77
  * instead of building a `<prefix>:ready` issue. `false` (the default) is
@@ -59,6 +88,7 @@ export interface WatchDeps {
59
88
  cwd: string;
60
89
  adwId: string;
61
90
  issueId: string;
91
+ chainOptions: Record<string, string>;
62
92
  }) => Promise<RefineRunResult>;
63
93
  worktreesDir: string;
64
94
  /**
@@ -78,6 +108,7 @@ export interface WatchDeps {
78
108
  prompt: string;
79
109
  cwd: string;
80
110
  adwId: string;
111
+ chainOptions: Record<string, string>;
81
112
  }) => Promise<ChainRunResult>;
82
113
  log: (message: string) => void;
83
114
  /**
@@ -115,19 +146,68 @@ export declare function refineBranchNameFor(issue: Issue): string;
115
146
  * merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
116
147
  */
117
148
  export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
149
+ /**
150
+ * Render the spec issue's comment thread for the refiner's prompt, replacing
151
+ * the previous bare `title\n\nbody` — the whole reason a human's answer
152
+ * could never reach a resumed run before this feature. When `feedback` names
153
+ * the timestamp of the most recent question comment, everything at or after
154
+ * it is surfaced as "answers to your open questions" (what a resumed run
155
+ * most needs to read); everything earlier is "earlier discussion" — context,
156
+ * not necessarily an answer. A spec that has never been escalated (no
157
+ * `feedback`) has no such split: any pre-existing comments are all "earlier
158
+ * discussion".
159
+ *
160
+ * Truncates the RENDERED thread, not the comment list, to roughly
161
+ * `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
162
+ * "N earlier comment(s) omitted" line, never a silent truncation.
163
+ *
164
+ * Exported and pure (no provider, no I/O) so it's directly unit-testable.
165
+ */
166
+ export declare function buildSpecPrompt(issue: Issue, comments: IssueComment[], feedback?: WatchMarker["feedback"]): string;
118
167
  /**
119
168
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
120
- * process isn't tracking is either a completed publish that crashed before
121
- * its own `transition(issue, "done")` ran (resume: finish it, no re-run),
122
- * or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
123
- * A no-op entirely when `watch.refine` is off see `WatchDeps.refineEnabled`.
169
+ * process isn't tracking is one of three things: a completed publish that
170
+ * crashed before its own `transition(issue, "done")` ran (resume: finish it,
171
+ * no re-run), a completed escalation that crashed before its own
172
+ * `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
173
+ * no re-asking — `escalateSpec` writes the marker's `feedback` before it
174
+ * transitions, so seeing `feedback` on a still-`refining` spec can only mean
175
+ * that last step didn't complete), or a genuine orphan (retry up to
176
+ * `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
177
+ * is off — see `WatchDeps.refineEnabled`.
124
178
  */
125
179
  export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
126
180
  /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
127
181
  export declare function finishReviews(deps: WatchDeps): Promise<void>;
128
182
  /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
129
183
  export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
130
- /** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
131
- export declare function claimSpecs(deps: WatchDeps, state: WatchRunState): Promise<void>;
132
- /** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
184
+ /**
185
+ * Claim as many specs in `from` as `refineConcurrency` allows, and kick off
186
+ * `runSpec` for each in the background. A no-op when `watch.refine` is off.
187
+ *
188
+ * `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
189
+ * with `"continue-refinement"` — a human's signal that they've answered a
190
+ * prior round's questions and refinement should resume. Both share this same
191
+ * function (and `state.refining`'s budget) rather than a second copy of the
192
+ * claim loop, the same way the build lane's `claim()` itself takes a
193
+ * parameterized `{from, to}` rather than a hardcoded `ready -> working`.
194
+ *
195
+ * Known, deliberate wart: `claim()` only removes `from`'s label, so while a
196
+ * resumed run is in flight the issue briefly still carries
197
+ * `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
198
+ * replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
199
+ * and the run's own terminating `transition()` — to `needs-feedback` again,
200
+ * `done`, or `blocked` — strips every state label from the fresh snapshot it
201
+ * reads at that point, so this self-heals on the very next transition rather
202
+ * than needing a second mutator alongside `transition()`.
203
+ */
204
+ export declare function claimSpecs(deps: WatchDeps, state: WatchRunState, from?: WatchState): Promise<void>;
205
+ /**
206
+ * One poll tick: reconcile both lanes, finish reviews, then claim both
207
+ * lanes — each stage independently caught, so one stage's error never blocks
208
+ * the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
209
+ * a human who already answered and is waiting) before fresh ones
210
+ * (`spec-ready`) — both share `state.refining`'s budget, so
211
+ * `refine.concurrency` still caps the lane as a whole either way.
212
+ */
133
213
  export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
@@ -7,6 +7,13 @@
7
7
  * tree -> publish those as real issues -> done/blocked. A spec is not
8
8
  * individually workable, so this lane never opens a PR — it hands the build
9
9
  * lane its next batch of `ready`-able work instead (see `core/refine.ts`).
10
+ * When the refiner raises material ambiguity instead of a tree, this lane
11
+ * loops through a human instead of guessing or blocking: `escalateSpec`
12
+ * posts the questions and moves the spec to `needs-feedback`; a human
13
+ * answers in the issue's comments and adds `continue-refinement`;
14
+ * `claimSpecs` resumes it — the SAME `adw_id`, comment thread folded into
15
+ * the prompt (`buildSpecPrompt`) — for as many rounds as it takes. See
16
+ * `provider.ts`'s `WatchState` doc comment for the full state diagram.
10
17
  *
11
18
  * Provider-agnostic (drives whatever `IssueProvider` it's given) and
12
19
  * chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
@@ -133,9 +140,10 @@ export async function reconcileOrphans(deps, state) {
133
140
  * the ids `WatchMarker.refined` recorded, and the comment degrades to a
134
141
  * bare list of `#id`s rather than blocking on a re-fetch.
135
142
  */
136
- async function finishSpec(deps, issue, created) {
143
+ async function finishSpec(deps, issue, created, rounds = 0) {
144
+ const roundsNote = rounds > 0 ? ` (after ${rounds} round${rounds === 1 ? "" : "s"} of feedback)` : "";
137
145
  const body = created.length > 0
138
- ? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
146
+ ? `spf watch refined this spec into ${created.length} issue(s)${roundsNote}:\n\n` +
139
147
  created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
140
148
  `\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
141
149
  : `spf watch refined this spec but the refiner produced no issues.`;
@@ -149,14 +157,120 @@ async function finishSpec(deps, issue, created) {
149
157
  if (!deps.dryRun) {
150
158
  await deps.provider.comment(issue, body);
151
159
  await deps.provider.transition(issue, "done");
160
+ // Best-effort and never fatal: publishing already succeeded and the spec
161
+ // is already `done` by the time this runs, so a tracker that can't close
162
+ // (or a `closeIssue` that isn't implemented at all — see `IssueProvider`'s
163
+ // doc comment) must not turn a successfully refined spec into `blocked`.
164
+ await deps.provider.closeIssue?.(issue).catch((error) => {
165
+ deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
166
+ });
167
+ }
168
+ }
169
+ const MAX_THREAD_CHARS = 20_000;
170
+ /**
171
+ * Render the spec issue's comment thread for the refiner's prompt, replacing
172
+ * the previous bare `title\n\nbody` — the whole reason a human's answer
173
+ * could never reach a resumed run before this feature. When `feedback` names
174
+ * the timestamp of the most recent question comment, everything at or after
175
+ * it is surfaced as "answers to your open questions" (what a resumed run
176
+ * most needs to read); everything earlier is "earlier discussion" — context,
177
+ * not necessarily an answer. A spec that has never been escalated (no
178
+ * `feedback`) has no such split: any pre-existing comments are all "earlier
179
+ * discussion".
180
+ *
181
+ * Truncates the RENDERED thread, not the comment list, to roughly
182
+ * `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
183
+ * "N earlier comment(s) omitted" line, never a silent truncation.
184
+ *
185
+ * Exported and pure (no provider, no I/O) so it's directly unit-testable.
186
+ */
187
+ export function buildSpecPrompt(issue, comments, feedback) {
188
+ const header = `${issue.title}\n\n${issue.body}`.trim();
189
+ if (comments.length === 0)
190
+ return header;
191
+ const render = (list) => list.map((c) => `**@${c.author}** (${c.created_at}):\n${c.body.trim()}`).join("\n\n");
192
+ let kept = comments;
193
+ let dropped = 0;
194
+ while (render(kept).length > MAX_THREAD_CHARS && kept.length > 1) {
195
+ kept = kept.slice(1);
196
+ dropped++;
197
+ }
198
+ const askedAt = feedback?.asked_at ? Date.parse(feedback.asked_at) : null;
199
+ const answers = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) >= askedAt) : [];
200
+ const earlier = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) < askedAt) : kept;
201
+ const parts = [];
202
+ if (dropped > 0)
203
+ parts.push(`_... ${dropped} earlier comment(s) omitted for length._`);
204
+ if (answers.length > 0)
205
+ parts.push(`### Answers to your open questions (round ${feedback.rounds})\n\n${render(answers)}`);
206
+ if (earlier.length > 0)
207
+ parts.push(`### Earlier discussion\n\n${render(earlier)}`);
208
+ if (parts.length === 0)
209
+ return header;
210
+ return `${header}\n\n## Discussion on the spec issue\n\n${parts.join("\n\n")}`;
211
+ }
212
+ /**
213
+ * The refine lane's own finishing move for "the refiner can't proceed
214
+ * without a human" — the escalation twin of `finishSpec` above. Posts every
215
+ * question as its own rich comment section (the question, why it matters,
216
+ * the options considered, its recommendation, and the evidence it read — so
217
+ * a human can often answer in a word or two), records the round in the
218
+ * marker so a resumed run's prompt (`buildSpecPrompt`) can tell "answers"
219
+ * from "earlier discussion", and moves the spec to `needs-feedback` rather
220
+ * than `blocked` — the refiner correctly refusing to guess is a legitimate
221
+ * outcome, not a failure. `marker` is spread first so `worktree`/`branch`
222
+ * (just written by the caller) survive into the marker this writes.
223
+ */
224
+ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
225
+ const body = `## spf needs your input (round ${round})\n\n` +
226
+ questions
227
+ .map((q) => {
228
+ const lines = [`### ${q.question}`];
229
+ if (q.why_it_matters)
230
+ lines.push(`**Why it matters:** ${q.why_it_matters}`);
231
+ if (q.options.length > 0)
232
+ lines.push(`**Options considered:**\n${q.options.map((o) => `- ${o}`).join("\n")}`);
233
+ if (q.recommendation)
234
+ lines.push(`**Recommendation:** ${q.recommendation}`);
235
+ if (q.evidence.length > 0)
236
+ lines.push(`**Evidence:**\n${q.evidence.map((e) => `- ${e}`).join("\n")}`);
237
+ return lines.join("\n\n");
238
+ })
239
+ .join("\n\n---\n\n") +
240
+ `\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
241
+ deps.notify({
242
+ // "error" level, not "info" — this is the same class of event as
243
+ // issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
244
+ // channel just as much as an `all`-scope one.
245
+ kind: "spec_needs_feedback",
246
+ level: "error",
247
+ title: `spec ${issue.id} needs feedback`,
248
+ detail: `${questions.length} question(s), round ${round}.`,
249
+ fields: [
250
+ ["issue", issue.id],
251
+ ["title", issue.title],
252
+ ["chain", deps.refineChain],
253
+ ["adw_id", adwId],
254
+ ["round", String(round)],
255
+ ],
256
+ });
257
+ if (!deps.dryRun) {
258
+ await deps.provider.comment(issue, body);
259
+ await deps.provider.writeMarker(issue, { ...marker, feedback: { rounds: round, asked_at: new Date().toISOString() } });
260
+ await deps.provider.transition(issue, "needs-feedback");
152
261
  }
153
262
  }
154
263
  /**
155
264
  * The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
156
- * process isn't tracking is either a completed publish that crashed before
157
- * its own `transition(issue, "done")` ran (resume: finish it, no re-run),
158
- * or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
159
- * A no-op entirely when `watch.refine` is off see `WatchDeps.refineEnabled`.
265
+ * process isn't tracking is one of three things: a completed publish that
266
+ * crashed before its own `transition(issue, "done")` ran (resume: finish it,
267
+ * no re-run), a completed escalation that crashed before its own
268
+ * `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
269
+ * no re-asking — `escalateSpec` writes the marker's `feedback` before it
270
+ * transitions, so seeing `feedback` on a still-`refining` spec can only mean
271
+ * that last step didn't complete), or a genuine orphan (retry up to
272
+ * `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
273
+ * is off — see `WatchDeps.refineEnabled`.
160
274
  */
161
275
  export async function reconcileRefining(deps, state) {
162
276
  if (!deps.refineEnabled)
@@ -168,7 +282,20 @@ export async function reconcileRefining(deps, state) {
168
282
  const marker = await deps.provider.readMarker(issue);
169
283
  if (marker?.refined && marker.refined.length > 0) {
170
284
  deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
171
- await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
285
+ await finishSpec(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
286
+ continue;
287
+ }
288
+ if (marker?.feedback) {
289
+ deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
290
+ deps.notify({
291
+ kind: "spec_needs_feedback",
292
+ level: "error",
293
+ title: `spec ${issue.id} needs feedback`,
294
+ detail: `Round ${marker.feedback.rounds}.`,
295
+ fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
296
+ });
297
+ if (!deps.dryRun)
298
+ await deps.provider.transition(issue, "needs-feedback");
172
299
  continue;
173
300
  }
174
301
  const attempt = (marker?.attempt ?? 0) + 1;
@@ -254,7 +381,7 @@ async function runIssue(deps, issue) {
254
381
  deps.linkDataDir(worktreePath);
255
382
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
256
383
  const prompt = `${issue.title}\n\n${issue.body}`.trim();
257
- const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
384
+ const result = await deps.runChain({ prompt, cwd: worktreePath, adwId, chainOptions: deps.chainOptions });
258
385
  if (!result.accepted) {
259
386
  deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
260
387
  const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
@@ -356,7 +483,7 @@ async function runSpec(deps, issue) {
356
483
  const existingMarker = await deps.provider.readMarker(issue);
357
484
  if (existingMarker?.refined && existingMarker.refined.length > 0) {
358
485
  deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
359
- await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
486
+ await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
360
487
  return;
361
488
  }
362
489
  // See runIssue's identical comment: worktreePath/branch are deterministic
@@ -366,9 +493,20 @@ async function runSpec(deps, issue) {
366
493
  deps.git.fetch("origin", deps.baseBranch);
367
494
  deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
368
495
  deps.linkDataDir(worktreePath);
369
- await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
370
- const prompt = `${issue.title}\n\n${issue.body}`.trim();
371
- const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
496
+ // Spread existingMarker (not a bare object) so a spec resuming after a
497
+ // prior escalation carries its `feedback` (round count, last-asked-at)
498
+ // through to whatever this run writes next escalateSpec below and the
499
+ // final writeMarker on a successful publish both build on this object.
500
+ const marker = { ...existingMarker, worktree: worktreePath, branch, attempt: 0 };
501
+ await deps.provider.writeMarker(issue, marker);
502
+ // The comment thread is what makes this a HUMAN-in-the-loop, not just a
503
+ // one-shot prompt: a resumed run needs the human's answers, and
504
+ // buildSpecPrompt tells them apart from any earlier discussion using
505
+ // existingMarker.feedback.asked_at (unset on a spec that's never been
506
+ // escalated, in which case every comment is "earlier discussion").
507
+ const comments = await deps.provider.listComments(issue);
508
+ const prompt = buildSpecPrompt(issue, comments, existingMarker?.feedback);
509
+ const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id, chainOptions: deps.chainOptions });
372
510
  if (!result.accepted) {
373
511
  deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
374
512
  const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
@@ -383,8 +521,17 @@ async function runSpec(deps, issue) {
383
521
  cleanupWorktree(deps, { worktree: worktreePath, branch });
384
522
  return;
385
523
  }
386
- await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
387
- await finishSpec(deps, issue, result.created);
524
+ // Mutually exclusive by construction gates.refinementWellFormed
525
+ // guarantees a `questions`-bearing envelope publishes no `issues` — so
526
+ // this branches before, never alongside, the publish path below.
527
+ if (result.questions.length > 0) {
528
+ const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
529
+ await escalateSpec(deps, issue, marker, result.questions, adwId, round);
530
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
531
+ return;
532
+ }
533
+ await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
534
+ await finishSpec(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
388
535
  cleanupWorktree(deps, { worktree: worktreePath, branch });
389
536
  }
390
537
  catch (error) {
@@ -431,13 +578,32 @@ export async function claimNewWork(deps, state) {
431
578
  runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
432
579
  }
433
580
  }
434
- /** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
435
- export async function claimSpecs(deps, state) {
581
+ /**
582
+ * Claim as many specs in `from` as `refineConcurrency` allows, and kick off
583
+ * `runSpec` for each in the background. A no-op when `watch.refine` is off.
584
+ *
585
+ * `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
586
+ * with `"continue-refinement"` — a human's signal that they've answered a
587
+ * prior round's questions and refinement should resume. Both share this same
588
+ * function (and `state.refining`'s budget) rather than a second copy of the
589
+ * claim loop, the same way the build lane's `claim()` itself takes a
590
+ * parameterized `{from, to}` rather than a hardcoded `ready -> working`.
591
+ *
592
+ * Known, deliberate wart: `claim()` only removes `from`'s label, so while a
593
+ * resumed run is in flight the issue briefly still carries
594
+ * `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
595
+ * replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
596
+ * and the run's own terminating `transition()` — to `needs-feedback` again,
597
+ * `done`, or `blocked` — strips every state label from the fresh snapshot it
598
+ * reads at that point, so this self-heals on the very next transition rather
599
+ * than needing a second mutator alongside `transition()`.
600
+ */
601
+ export async function claimSpecs(deps, state, from = "spec-ready") {
436
602
  if (!deps.refineEnabled)
437
603
  return;
438
604
  if (state.refining.size >= deps.refineConcurrency)
439
605
  return;
440
- const eligible = await deps.provider.listInState("spec-ready");
606
+ const eligible = await deps.provider.listInState(from);
441
607
  for (const issue of eligible) {
442
608
  if (state.refining.size >= deps.refineConcurrency)
443
609
  break;
@@ -447,7 +613,7 @@ export async function claimSpecs(deps, state) {
447
613
  deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
448
614
  continue;
449
615
  }
450
- const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
616
+ const claimed = await deps.provider.claim(issue, { from, to: "refining" });
451
617
  if (!claimed) {
452
618
  deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
453
619
  continue;
@@ -470,11 +636,19 @@ function tickErrorHandler(deps, stage) {
470
636
  deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
471
637
  };
472
638
  }
473
- /** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
639
+ /**
640
+ * One poll tick: reconcile both lanes, finish reviews, then claim both
641
+ * lanes — each stage independently caught, so one stage's error never blocks
642
+ * the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
643
+ * a human who already answered and is waiting) before fresh ones
644
+ * (`spec-ready`) — both share `state.refining`'s budget, so
645
+ * `refine.concurrency` still caps the lane as a whole either way.
646
+ */
474
647
  export async function tick(deps, state) {
475
648
  await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
476
649
  await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
477
650
  await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
651
+ await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
478
652
  await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
479
653
  await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
480
654
  }
@@ -79,4 +79,43 @@ export declare class SfDb {
79
79
  envelopes(adwId: string): Envelope[];
80
80
  gates(adwId: string): GateResult[];
81
81
  sessionCount(): number;
82
+ /**
83
+ * `spf estimate`'s one read: every session that ran EXACTLY `chainName`
84
+ * (never a joined one — see below), each with its per-phase token totals,
85
+ * most recent first. Also reports how many joined sessions (`adw_name`
86
+ * like `"a + b"`, `core/tracer.ts:215`) named `chainName` as one of their
87
+ * components — real history that cannot be attributed to this one chain
88
+ * alone (both chains' phases share one continuing `seq`), so it is
89
+ * counted for the "excluded" message rather than silently absent.
90
+ *
91
+ * Guarded by `hasColumn("sessions", "adw_name")` — a MIGRATED column
92
+ * (`core/tracer.ts:109-116`) — rather than `optionalColumn`: this method
93
+ * FILTERS on `adw_name`, and a `WHERE` clause naming a column an older db's
94
+ * `sessions` table does not have is a SQLite error, not a no-match.
95
+ * Short-circuiting to the empty shape here is what lets an old db read as
96
+ * cold start (estimate's case 3) instead of crashing.
97
+ *
98
+ * No `LIMIT` at the SQL level: capping at 20 and collapsing fanout
99
+ * siblings both depend on which of `success`-only vs any-status qualifies
100
+ * first (`spf estimate`'s own sample-selection order), which is a decision
101
+ * `estimate.ts` makes, not this method.
102
+ */
103
+ chainPhaseHistory(chainName: string): {
104
+ sessions: ChainHistorySession[];
105
+ joinedExcluded: number;
106
+ };
107
+ }
108
+ /** One EXACT-match session's history, as `chainPhaseHistory` returns it — `spf estimate`'s raw material. */
109
+ export interface ChainHistorySession {
110
+ adw_id: string;
111
+ status: string;
112
+ started_at: string | null;
113
+ total_tokens: number;
114
+ total_cost: number;
115
+ /** In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`, `fix_2`), not yet loop-normalized. */
116
+ phases: {
117
+ name: string;
118
+ seq: number;
119
+ tokens: number;
120
+ }[];
82
121
  }
@@ -325,6 +325,67 @@ export class SfDb {
325
325
  .get();
326
326
  return row?.n ?? 0;
327
327
  }
328
+ /**
329
+ * `spf estimate`'s one read: every session that ran EXACTLY `chainName`
330
+ * (never a joined one — see below), each with its per-phase token totals,
331
+ * most recent first. Also reports how many joined sessions (`adw_name`
332
+ * like `"a + b"`, `core/tracer.ts:215`) named `chainName` as one of their
333
+ * components — real history that cannot be attributed to this one chain
334
+ * alone (both chains' phases share one continuing `seq`), so it is
335
+ * counted for the "excluded" message rather than silently absent.
336
+ *
337
+ * Guarded by `hasColumn("sessions", "adw_name")` — a MIGRATED column
338
+ * (`core/tracer.ts:109-116`) — rather than `optionalColumn`: this method
339
+ * FILTERS on `adw_name`, and a `WHERE` clause naming a column an older db's
340
+ * `sessions` table does not have is a SQLite error, not a no-match.
341
+ * Short-circuiting to the empty shape here is what lets an old db read as
342
+ * cold start (estimate's case 3) instead of crashing.
343
+ *
344
+ * No `LIMIT` at the SQL level: capping at 20 and collapsing fanout
345
+ * siblings both depend on which of `success`-only vs any-status qualifies
346
+ * first (`spf estimate`'s own sample-selection order), which is a decision
347
+ * `estimate.ts` makes, not this method.
348
+ */
349
+ chainPhaseHistory(chainName) {
350
+ if (!this.hasColumn("sessions", "adw_name"))
351
+ return { sessions: [], joinedExcluded: 0 };
352
+ const joinedRow = this.db
353
+ .query(`SELECT COUNT(*) AS n FROM sessions
354
+ WHERE adw_name != ? AND (adw_name LIKE ? OR adw_name LIKE ? OR adw_name LIKE ?)`)
355
+ .get(chainName, `${chainName} + %`, `% + ${chainName}`, `% + ${chainName} + %`);
356
+ const sessionRows = this.db
357
+ .query(`SELECT adw_id, status, started_at, total_tokens, total_cost
358
+ FROM sessions WHERE adw_name = ? ORDER BY started_at DESC, rowid DESC`)
359
+ .all(chainName);
360
+ if (sessionRows.length === 0)
361
+ return { sessions: [], joinedExcluded: joinedRow?.n ?? 0 };
362
+ const ids = sessionRows.map((r) => r.adw_id);
363
+ const placeholders = ids.map(() => "?").join(", ");
364
+ const phaseRows = this.db
365
+ .query(`SELECT p.adw_id, p.name, p.seq, e.tokens
366
+ FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
367
+ WHERE p.adw_id IN (${placeholders})
368
+ ORDER BY p.seq ASC`)
369
+ .all(...ids);
370
+ const phasesByAdw = new Map();
371
+ for (const row of phaseRows) {
372
+ const list = phasesByAdw.get(row.adw_id);
373
+ const entry = { name: row.name, seq: row.seq, tokens: row.tokens ?? 0 };
374
+ if (list)
375
+ list.push(entry);
376
+ else
377
+ phasesByAdw.set(row.adw_id, [entry]);
378
+ }
379
+ const sessions = sessionRows.map((row) => ({
380
+ adw_id: row.adw_id,
381
+ status: row.status ?? "fail",
382
+ started_at: row.started_at,
383
+ total_tokens: row.total_tokens ?? 0,
384
+ total_cost: row.total_cost ?? 0,
385
+ phases: phasesByAdw.get(row.adw_id) ?? [],
386
+ }));
387
+ return { sessions, joinedExcluded: joinedRow?.n ?? 0 };
388
+ }
328
389
  }
329
390
  function clamp(value, min, max) {
330
391
  if (!Number.isFinite(value))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "!dist/test",
16
17
  "web",
17
18
  "assets",
18
19
  "README.md",
@@ -1 +0,0 @@
1
- export {};