@gr8ful/spf 0.3.0 → 0.4.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 (41) hide show
  1. package/README.md +55 -5
  2. package/assets/defaults/spf.config.yaml +16 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/skill/references/config.md +38 -2
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +10 -2
  8. package/dist/chains/context.d.ts +9 -0
  9. package/dist/chains/index.js +5 -0
  10. package/dist/chains/steps.d.ts +24 -0
  11. package/dist/chains/steps.js +55 -4
  12. package/dist/cli/commands/doctor.js +6 -0
  13. package/dist/cli/commands/init.js +30 -3
  14. package/dist/cli/commands/install-skill.js +5 -2
  15. package/dist/cli/commands/list.js +1 -0
  16. package/dist/cli/commands/run.js +5 -1
  17. package/dist/cli/commands/watch.js +69 -8
  18. package/dist/cli/index.js +3 -3
  19. package/dist/cli/interview.js +17 -0
  20. package/dist/core/data_types.d.ts +78 -0
  21. package/dist/core/data_types.js +42 -0
  22. package/dist/core/gates.d.ts +13 -0
  23. package/dist/core/gates.js +103 -0
  24. package/dist/core/issues/github_provider.d.ts +35 -9
  25. package/dist/core/issues/github_provider.js +76 -28
  26. package/dist/core/issues/jira_provider.d.ts +14 -1
  27. package/dist/core/issues/jira_provider.js +9 -7
  28. package/dist/core/issues/provider.d.ts +77 -15
  29. package/dist/core/issues/provider.js +7 -4
  30. package/dist/core/notify/channel.d.ts +1 -1
  31. package/dist/core/refine.d.ts +39 -0
  32. package/dist/core/refine.js +144 -0
  33. package/dist/core/watch.d.ts +56 -1
  34. package/dist/core/watch.js +200 -11
  35. package/dist/test/chains.test.js +1 -0
  36. package/dist/test/init_command.test.js +17 -0
  37. package/dist/test/interview.test.js +4 -3
  38. package/dist/test/refine.test.d.ts +1 -0
  39. package/dist/test/refine.test.js +126 -0
  40. package/dist/test/watch.test.js +173 -5
  41. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
4
  import { runChain } from "../../chains/index.js";
5
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
5
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue"];
6
6
  export function usageFor(chain) {
7
7
  return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
8
8
  }
@@ -19,6 +19,10 @@ export async function dispatchChain(chain, argv) {
19
19
  adw_id: options["adw-id"] ?? null,
20
20
  cwd: anchor.cwd,
21
21
  chain_name: chain.name,
22
+ // Only `refine` reads this (a "## Parent: #<id>" back-reference on
23
+ // every issue it creates — see ChainContext's doc comment); every
24
+ // other chain ignores it, so it's harmless to always pass through.
25
+ issue_id: options["issue"] ?? null,
22
26
  };
23
27
  const chainOptions = {};
24
28
  if (options["agent"] !== undefined)
@@ -150,6 +150,21 @@ export async function watchCommand(argv) {
150
150
  console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
151
151
  return 1;
152
152
  }
153
+ if (cfg.watch.refine.enabled) {
154
+ if (cfg.watch.issue_provider !== "github") {
155
+ // Fail loudly at startup, not silently every tick: JiraProvider
156
+ // doesn't implement IssueAuthoringProvider yet (see its module
157
+ // comment) — a refine lane that can never publish would otherwise
158
+ // just claim every spec-ready spec and block it, forever.
159
+ console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
160
+ `the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
161
+ return 1;
162
+ }
163
+ if (!findChain(cfg.watch.refine.chain)) {
164
+ console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
165
+ return 1;
166
+ }
167
+ }
153
168
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
154
169
  const lockPath = path.join(dataPaths.data_dir, "watch.lock");
155
170
  try {
@@ -186,25 +201,65 @@ export async function watchCommand(argv) {
186
201
  mkdirSync(path.dirname(target), { recursive: true });
187
202
  symlinkSync(dataPaths.data_dir, target, "dir");
188
203
  }
204
+ /** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
205
+ function detailFromFailedPhase(cwd, adwId, prefix) {
206
+ let detail = prefix;
207
+ try {
208
+ const wtAnchor = paths.resolveAnchor(cwd);
209
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
210
+ const db = new SfDb(wtDataPaths.db_path);
211
+ const failed = db.phases(adwId).find((p) => p.status === "fail");
212
+ if (failed)
213
+ detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
214
+ }
215
+ catch {
216
+ // best-effort — the generic message above still points at where to look
217
+ }
218
+ return detail;
219
+ }
189
220
  const runChain = async (opts) => {
190
221
  const chainDef = findChain(cfg.watch.chain); // checked above
191
222
  const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
192
223
  const code = await runChainDef(chainDef, ctx);
193
224
  if (code === 0)
194
225
  return { accepted: true, adwId: opts.adwId, detail: "" };
195
- let detail = `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`;
226
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
227
+ return { accepted: false, adwId: opts.adwId, detail };
228
+ };
229
+ /**
230
+ * Same shape as `runChain`, for the refine lane — with one extra step on
231
+ * success: a chain's return value is just an exit code, so the created-
232
+ * issues list `steps.publishIssues()` actually produced has to come back
233
+ * through the side channel it wrote (`refine_publish.json`, under the
234
+ * SAME symlinked session dir `linkDataDir` already wires up), not through
235
+ * `runChainDef`'s return value.
236
+ */
237
+ const runRefine = async (opts) => {
238
+ const chainDef = findChain(cfg.watch.refine.chain); // checked above
239
+ const ctx = {
240
+ prompt: opts.prompt,
241
+ config_paths: configPaths,
242
+ adw_id: opts.adwId,
243
+ cwd: opts.cwd,
244
+ chain_name: chainDef.name,
245
+ issue_id: opts.issueId,
246
+ };
247
+ const code = await runChainDef(chainDef, ctx);
248
+ if (code !== 0) {
249
+ const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
250
+ return { accepted: false, adwId: opts.adwId, detail, created: [] };
251
+ }
252
+ let created = [];
196
253
  try {
197
254
  const wtAnchor = paths.resolveAnchor(opts.cwd);
198
255
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
199
- const db = new SfDb(wtDataPaths.db_path);
200
- const failed = db.phases(opts.adwId).find((p) => p.status === "fail");
201
- if (failed)
202
- detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
256
+ const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
257
+ created = JSON.parse(readFileSync(summaryPath, "utf-8"));
203
258
  }
204
259
  catch {
205
- // best-effort — the generic message above still points at where to look
260
+ // best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
206
261
  }
207
- return { accepted: false, adwId: opts.adwId, detail };
262
+ return { accepted: true, adwId: opts.adwId, detail: "", created };
208
263
  };
209
264
  const deps = {
210
265
  provider,
@@ -215,6 +270,10 @@ export async function watchCommand(argv) {
215
270
  chain: cfg.watch.chain,
216
271
  baseBranch: cfg.watch.base_branch,
217
272
  concurrency: cfg.watch.concurrency,
273
+ refineEnabled: cfg.watch.refine.enabled,
274
+ refineConcurrency: cfg.watch.refine.concurrency,
275
+ refineChain: cfg.watch.refine.chain,
276
+ runRefine,
218
277
  worktreesDir,
219
278
  linkDataDir,
220
279
  dryRun: Boolean(flags["dry-run"]),
@@ -254,7 +313,9 @@ export async function watchCommand(argv) {
254
313
  };
255
314
  process.on("SIGINT", stop);
256
315
  process.on("SIGTERM", stop);
257
- console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
316
+ console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
317
+ (cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
318
+ (flags["dry-run"] ? " (dry run)" : ""));
258
319
  deps.notify({
259
320
  kind: "watch_started",
260
321
  level: "info",
package/dist/cli/index.js CHANGED
@@ -28,8 +28,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
28
28
 
29
29
  spf list the chain registry — names, phases, what each needs
30
30
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
31
- spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
32
- spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
31
+ spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
32
+ spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
33
33
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
34
34
  spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
35
35
  spf doctor [--json] check everything that fails silently otherwise
@@ -42,7 +42,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
42
42
  spf abort <adw_id> signal a run's process to stop
43
43
  spf version print the installed version
44
44
 
45
- Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
45
+ Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
46
46
  Run \`spf list\` to see every chain and what it needs.`;
47
47
  /** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
48
48
  function findCwdFlag(argv) {
@@ -231,6 +231,18 @@ export async function runInterview(asker, ctx) {
231
231
  const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
232
232
  const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
233
233
  watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
234
+ // Issue authoring (create + link a hierarchy) is only implemented on
235
+ // GitHubProvider today — see jira_provider.ts's module comment — so
236
+ // this lane isn't offered at all on a Jira tracker rather than asking a
237
+ // question that would just fail at `spf watch` startup.
238
+ if (issueProvider === "github") {
239
+ const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
240
+ if (enableRefine) {
241
+ const refineChainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
242
+ const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
243
+ watch.refine = { enabled: true, chain: refineChain };
244
+ }
245
+ }
234
246
  if (issueProvider === "jira") {
235
247
  const baseUrl = await asker.text("Jira base URL", {
236
248
  default: "https://your-domain.atlassian.net",
@@ -334,6 +346,11 @@ export async function runInterview(asker, ctx) {
334
346
  const concurrency = await asker.text("watch.concurrency", { default: "2" });
335
347
  if (concurrency !== "2")
336
348
  watch.concurrency = Number(concurrency);
349
+ if (watch.refine?.enabled) {
350
+ const refineConcurrency = await asker.text("watch.refine.concurrency", { default: "1" });
351
+ if (refineConcurrency !== "1")
352
+ watch.refine.concurrency = Number(refineConcurrency);
353
+ }
337
354
  }
338
355
  const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
339
356
  if (engineerName && engineerName !== ctx.gitName)
@@ -150,6 +150,45 @@ export declare const DocumentOutput: EnvelopeType<{
150
150
  commit_message: string;
151
151
  }>;
152
152
  export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
153
+ /**
154
+ * One node in a decomposed product spec — a feature/epic container, or a
155
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
156
+ * emits a flat list far more reliably than a recursive tree, and a flat
157
+ * shape is what lets `blocked_by` reference ANY other node, container or
158
+ * leaf, not just siblings under the same parent.
159
+ *
160
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
161
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
162
+ * resolves `key`s to real issue numbers as it creates them, in dependency
163
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
164
+ * this list before publish ever runs (unique keys, resolvable references,
165
+ * no cycles, container/leaf kind agreement, at least one leaf).
166
+ */
167
+ export declare const RefinedIssueSchema: v.ObjectSchema<{
168
+ readonly key: v.StringSchema<undefined>;
169
+ readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
170
+ readonly title: v.StringSchema<undefined>;
171
+ readonly body: v.StringSchema<undefined>;
172
+ readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
173
+ readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
174
+ }, undefined>;
175
+ export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
176
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
177
+ export declare const RefineOutput: EnvelopeType<{
178
+ status: "fail" | "success";
179
+ summary: string;
180
+ artifacts: string[];
181
+ notes_for_next_agent: string;
182
+ issues: {
183
+ key: string;
184
+ kind: "bug" | "epic" | "feature" | "story" | "task";
185
+ title: string;
186
+ body: string;
187
+ parent: string;
188
+ blocked_by: string[];
189
+ }[];
190
+ }>;
191
+ export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
153
192
  export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
154
193
  export type QualityArea = v.InferOutput<typeof QualityAreaSchema>;
155
194
  export declare const QualityOperationSchema: v.PicklistSchema<["lint", "typecheck", "build"], undefined>;
@@ -383,6 +422,22 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
383
422
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
384
423
  }, undefined>;
385
424
  export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
425
+ /**
426
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
427
+ * spec into a feature/story tree of real issues, instead of running
428
+ * `watch.chain` against it directly (a spec is not individually workable —
429
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
430
+ * behavior is unchanged by upgrading; turning it on with
431
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
432
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
433
+ * yet — see its module comment.
434
+ */
435
+ export declare const WatchRefineConfigSchema: v.ObjectSchema<{
436
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
437
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
438
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
439
+ }, undefined>;
440
+ export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
386
441
  export declare const WatchConfigSchema: v.ObjectSchema<{
387
442
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
388
443
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
@@ -399,6 +454,15 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
399
454
  base_url: string;
400
455
  project_key: string;
401
456
  }>;
457
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
458
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
459
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
460
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
461
+ }, undefined>, () => {
462
+ enabled: boolean;
463
+ chain: string;
464
+ concurrency: number;
465
+ }>;
402
466
  }, undefined>;
403
467
  export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
404
468
  /**
@@ -516,6 +580,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
516
580
  base_url: string;
517
581
  project_key: string;
518
582
  }>;
583
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
584
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
585
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
586
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
587
+ }, undefined>, () => {
588
+ enabled: boolean;
589
+ chain: string;
590
+ concurrency: number;
591
+ }>;
519
592
  }, undefined>, () => {
520
593
  issue_provider: "github" | "jira";
521
594
  code_host: "bitbucket" | "github";
@@ -529,6 +602,11 @@ export declare const SFConfigSchema: v.ObjectSchema<{
529
602
  base_url: string;
530
603
  project_key: string;
531
604
  };
605
+ refine: {
606
+ enabled: boolean;
607
+ chain: string;
608
+ concurrency: number;
609
+ };
532
610
  }>;
533
611
  readonly notifications: v.OptionalSchema<v.ObjectSchema<{
534
612
  readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
@@ -103,6 +103,32 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
103
103
  documented_files: v.optional(v.array(v.string()), () => []),
104
104
  commit_message: v.optional(v.string(), ""),
105
105
  });
106
+ /**
107
+ * One node in a decomposed product spec — a feature/epic container, or a
108
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
109
+ * emits a flat list far more reliably than a recursive tree, and a flat
110
+ * shape is what lets `blocked_by` reference ANY other node, container or
111
+ * leaf, not just siblings under the same parent.
112
+ *
113
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
114
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
115
+ * resolves `key`s to real issue numbers as it creates them, in dependency
116
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
117
+ * this list before publish ever runs (unique keys, resolvable references,
118
+ * no cycles, container/leaf kind agreement, at least one leaf).
119
+ */
120
+ export const RefinedIssueSchema = v.object({
121
+ key: v.string(),
122
+ kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
123
+ title: v.string(),
124
+ body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
125
+ parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
126
+ blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
127
+ });
128
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
129
+ export const RefineOutput = envelopeType("RefineOutput", {
130
+ issues: v.optional(v.array(RefinedIssueSchema), () => []),
131
+ });
106
132
  // ── Deterministic quality blocks ─────────────────────────────────────────────
107
133
  export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
108
134
  export const QualityOperationSchema = v.picklist(["lint", "typecheck", "build"]);
@@ -299,6 +325,21 @@ export const WatchJiraConfigSchema = v.object({
299
325
  base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
300
326
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
301
327
  });
328
+ /**
329
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
330
+ * spec into a feature/story tree of real issues, instead of running
331
+ * `watch.chain` against it directly (a spec is not individually workable —
332
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
333
+ * behavior is unchanged by upgrading; turning it on with
334
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
335
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
336
+ * yet — see its module comment.
337
+ */
338
+ export const WatchRefineConfigSchema = v.object({
339
+ enabled: v.optional(v.boolean(), false),
340
+ chain: v.optional(v.string(), "refine"),
341
+ concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
342
+ });
302
343
  export const WatchConfigSchema = v.object({
303
344
  issue_provider: v.optional(WatchIssueProviderSchema, "github"),
304
345
  code_host: v.optional(WatchCodeHostSchema, "github"),
@@ -309,6 +350,7 @@ export const WatchConfigSchema = v.object({
309
350
  poll_ms: v.optional(v.number(), 60_000),
310
351
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
311
352
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
353
+ refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
312
354
  });
313
355
  /**
314
356
  * Optional outbound push for unattended work (`spf watch`, any chain run) —
@@ -29,5 +29,18 @@ export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContex
29
29
  * reading a line of the diff.
30
30
  */
31
31
  export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
+ /**
33
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
34
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
35
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
36
+ * against itself, never anything already published: `core/refine.ts` never
37
+ * gets a chance to publish a malformed tree in the first place, because a
38
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
39
+ * ever hands off to `steps.publishIssues()`.
40
+ *
41
+ * "Container" and "leaf" are derived from the graph, not asserted by the
42
+ * agent: a node is a container iff some other node names it as `parent`.
43
+ */
44
+ export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
45
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
33
46
  export declare function testsPass(command: string): GateFn;
@@ -128,6 +128,109 @@ export function verdictConsistent(envelope, _run) {
128
128
  : "approved=false but no blocking item or unmet requirement was given");
129
129
  return report;
130
130
  }
131
+ const CONTAINER_KINDS = new Set(["epic", "feature"]);
132
+ const LEAF_KINDS = new Set(["story", "bug", "task"]);
133
+ /**
134
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
135
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
136
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
137
+ * against itself, never anything already published: `core/refine.ts` never
138
+ * gets a chance to publish a malformed tree in the first place, because a
139
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
140
+ * ever hands off to `steps.publishIssues()`.
141
+ *
142
+ * "Container" and "leaf" are derived from the graph, not asserted by the
143
+ * agent: a node is a container iff some other node names it as `parent`.
144
+ */
145
+ export function refinementWellFormed(envelope, _run) {
146
+ const report = new GateReport();
147
+ const issues = envelope.issues ?? [];
148
+ if (issues.length === 0) {
149
+ report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
150
+ return report;
151
+ }
152
+ const byKey = new Map();
153
+ for (const issue of issues) {
154
+ if (byKey.has(issue.key)) {
155
+ report.check(`key ${JSON.stringify(issue.key)}`, false, "duplicate key — every node needs a unique key within this refinement");
156
+ }
157
+ else {
158
+ byKey.set(issue.key, issue);
159
+ }
160
+ }
161
+ const childKeys = new Set(); // keys named as some OTHER node's parent -> that node is a container
162
+ for (const issue of issues) {
163
+ if (!issue.parent)
164
+ continue;
165
+ if (!byKey.has(issue.parent)) {
166
+ report.check(`${issue.key}.parent`, false, `parent ${JSON.stringify(issue.parent)} does not match any issue's key`);
167
+ continue;
168
+ }
169
+ childKeys.add(issue.parent);
170
+ }
171
+ for (const issue of issues) {
172
+ for (const blocker of issue.blocked_by) {
173
+ if (!byKey.has(blocker)) {
174
+ report.check(`${issue.key}.blocked_by`, false, `blocked_by ${JSON.stringify(blocker)} does not match any issue's key`);
175
+ }
176
+ }
177
+ }
178
+ // Cycle check over the union of parent + blocked_by edges — both mean
179
+ // "must exist before this node" from core/refine.ts's own topological
180
+ // publish order, so a cycle in either (or across both) would hang it.
181
+ const WHITE = 0;
182
+ const GRAY = 1;
183
+ const BLACK = 2;
184
+ const color = new Map();
185
+ let cyclic = false;
186
+ const edgesFrom = (key) => {
187
+ const issue = byKey.get(key);
188
+ if (!issue)
189
+ return [];
190
+ const out = [];
191
+ if (issue.parent && byKey.has(issue.parent))
192
+ out.push(issue.parent);
193
+ for (const b of issue.blocked_by)
194
+ if (byKey.has(b))
195
+ out.push(b);
196
+ return out;
197
+ };
198
+ const visit = (key) => {
199
+ if (cyclic)
200
+ return;
201
+ color.set(key, GRAY);
202
+ for (const next of edgesFrom(key)) {
203
+ const c = color.get(next) ?? WHITE;
204
+ if (c === GRAY) {
205
+ cyclic = true;
206
+ return;
207
+ }
208
+ if (c === WHITE)
209
+ visit(next);
210
+ }
211
+ color.set(key, BLACK);
212
+ };
213
+ for (const issue of issues) {
214
+ if ((color.get(issue.key) ?? WHITE) === WHITE)
215
+ visit(issue.key);
216
+ }
217
+ report.check("dependency graph", !cyclic, cyclic ? "parent/blocked_by edges form a cycle — nothing to publish first" : "acyclic");
218
+ for (const issue of issues) {
219
+ const isContainer = childKeys.has(issue.key);
220
+ if (isContainer && !CONTAINER_KINDS.has(issue.kind)) {
221
+ report.check(`${issue.key}.kind`, false, `has children but kind is ${JSON.stringify(issue.kind)} — a container must be "epic" or "feature"`);
222
+ }
223
+ else if (!isContainer && !LEAF_KINDS.has(issue.kind)) {
224
+ report.check(`${issue.key}.kind`, false, `has no children but kind is ${JSON.stringify(issue.kind)} — a leaf must be "story", "bug", or "task"`);
225
+ }
226
+ else {
227
+ report.check(`${issue.key}.kind`, true, isContainer ? "container" : "leaf");
228
+ }
229
+ }
230
+ const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
231
+ report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
232
+ return report;
233
+ }
131
234
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
132
235
  export function testsPass(command) {
133
236
  const gate = (_envelope, run) => {
@@ -1,10 +1,12 @@
1
1
  /**
2
- * GitHub REST implementation of both `IssueProvider` and `CodeHostProvider`
3
- * — one class, since GitHub natively is both an issue tracker and a code
4
- * host via Node 22's native `fetch()`: deliberately not `octokit`, whose
2
+ * GitHub REST implementation of `IssueProvider`, `CodeHostProvider`, AND
3
+ * `IssueAuthoringProvider` — one class, since GitHub natively is an issue
4
+ * tracker, a code host, and (via sub-issues) an issue-hierarchy API all at
5
+ * once — via Node 22's native `fetch()`: deliberately not `octokit`, whose
5
6
  * full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
6
- * `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
7
- * is six REST calls. `spf`'s own package stays dependency-free either way.
7
+ * `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which is
8
+ * a couple dozen REST calls, none of them exotic. `spf`'s own package stays
9
+ * dependency-free either way.
8
10
  *
9
11
  * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
10
12
  * construction — matching the reference implementation's pattern and this
@@ -13,8 +15,11 @@
13
15
  * makes (a repo with >100 open `<prefix>:ready` issues at once is not this
14
16
  * version's problem to solve).
15
17
  */
16
- import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
17
- export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
18
+ import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
19
+ /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
20
+ export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
21
+ export type IssueKind = (typeof ISSUE_KINDS)[number];
22
+ export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
18
23
  private readonly repo;
19
24
  private readonly labelPrefix;
20
25
  private readonly token;
@@ -22,14 +27,17 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
22
27
  labelPrefix: string, token: string);
23
28
  private gh;
24
29
  private label;
30
+ private typeLabel;
25
31
  /** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
26
32
  private getLabel;
27
33
  /**
28
- * Idempotent by inspection, not by "create and catch a 422": GET each
34
+ * Idempotent by inspection, not by "create and catch a 422": GET the
29
35
  * label first, then create/update/leave alone depending on what's
30
36
  * actually there. One fewer request in the common "already correct"
31
37
  * case, and no brittle matching against GitHub's error-message text.
38
+ * Shared by `ensureLabels()`'s state-label and type-label passes.
32
39
  */
40
+ private ensureOneLabel;
33
41
  ensureLabels(): Promise<EnsureLabelsResult>;
34
42
  private toIssue;
35
43
  private listByLabel;
@@ -37,7 +45,10 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
37
45
  listInState(state: WatchState, opts?: {
38
46
  includeAll?: boolean;
39
47
  }): Promise<Issue[]>;
40
- claim(issue: Issue): Promise<boolean>;
48
+ claim(issue: Issue, opts?: {
49
+ from?: WatchState;
50
+ to?: WatchState;
51
+ }): Promise<boolean>;
41
52
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
42
53
  comment(issue: Issue, body: string): Promise<void>;
43
54
  openPr(opts: {
@@ -47,6 +58,21 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
47
58
  base: string;
48
59
  }): Promise<PrRef>;
49
60
  prStatus(pr: PrRef): Promise<PrStatus>;
61
+ /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
62
+ createIssue(input: {
63
+ title: string;
64
+ body: string;
65
+ labels: string[];
66
+ }): Promise<Issue>;
67
+ /**
68
+ * `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
69
+ * native sub-issue link. Confirmed against GitHub's own REST docs: the
70
+ * body param is `sub_issue_id`, the CHILD's database id, not its issue
71
+ * number — hence `linkChild` requiring `child.internal_id` rather than
72
+ * `child.id`. GitHub's documented limits (not enforced client-side here):
73
+ * 100 sub-issues per parent, 8 levels of nesting.
74
+ */
75
+ linkChild(parent: Issue, child: Issue): Promise<void>;
50
76
  private findMarkerComment;
51
77
  readMarker(issue: Issue): Promise<WatchMarker | null>;
52
78
  writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;