@gr8ful/spf 0.13.0 → 0.15.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.
package/README.md CHANGED
@@ -766,9 +766,24 @@ A **classic** PAT (fine-grained tokens use different permission names — not co
766
766
  | Private | `repo` | Everything above, full read/write |
767
767
  | Public only | `public_repo` | The same, restricted to public repos |
768
768
 
769
- **Do not grant the `project` scope.** It's a separate, unrelated permission for GitHub Projects (classic/org/user boards)`spf watch` doesn't touch Projects at all, so granting it would just be more access than this tool ever uses.
769
+ There's no dedicated "issues" or "pull requests" scope on classic PATsGitHub bundles both into `repo`/`public_repo`, which is why that table covers everything by default.
770
770
 
771
- There's no dedicated "issues" or "pull requests" scope on classic PATsGitHub bundles both into `repo`/`public_repo`, which is why that's the whole table.
771
+ Native GitHub Projects v2 board status (the board's Status column) is a separate, optional layer on top, off by default labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.github.project_number` and `watch.github.status_map`:
772
+
773
+ ```yaml
774
+ watch:
775
+ issue_provider: github
776
+ repo: owner/name
777
+ github:
778
+ project_number: 3 # owner's Projects v2 board number — see the board's own URL
779
+ status_map: # optional — unset by default, per state
780
+ ready: Todo
781
+ working: In Progress
782
+ review: In Review
783
+ done: Done
784
+ ```
785
+
786
+ Only the states you map are touched; an unmapped state keeps today's label-only behavior, and `project_number: 0` (the default) disables sync outright regardless of `status_map`. This needs the **`project`** scope in addition to `repo`/`public_repo` — Projects v2 has no REST API at all, only GraphQL, and that surface is gated by its own scope; grant it only if you're using `status_map`. A configured option name with no matching Status option (or a rejected mutation) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it. `spf watch init` and `spf watch`'s own startup check both validate a configured `status_map` against the real project's Status options, read-only, and exit non-zero on a mismatch, the same way Jira's `issue_types` is validated below.
772
787
 
773
788
  ### Jira (`issue_provider: jira`)
774
789
 
@@ -777,9 +792,26 @@ export JIRA_EMAIL=you@example.com
777
792
  export JIRA_API_TOKEN=... # id.atlassian.com -> Security -> API tokens
778
793
  ```
779
794
 
780
- State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly, rather than native workflow status transitions the latter would need per-project transition-id mapping, since workflows vary by project/scheme; labels work identically everywhere with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
795
+ State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly labels are spf's actual state machine and always get written unconditionally, with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
781
796
 
782
- `spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) it reports the labels this run will use. But with `watch.refine.enabled`, it now also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above.
797
+ Native Jira workflow status (the board's Status column) is a separate, optional layer on top, off by default labels alone don't move it, so an issue's Status sits wherever it started unless you opt in with `watch.jira.status_map`:
798
+
799
+ ```yaml
800
+ watch:
801
+ issue_provider: jira
802
+ jira:
803
+ base_url: https://your-domain.atlassian.net
804
+ project_key: PROJ
805
+ status_map: # optional — unset by default, per state
806
+ ready: To Do
807
+ working: In Progress
808
+ review: In Review
809
+ done: Done
810
+ ```
811
+
812
+ Only the states you map are touched; an unmapped state keeps today's label-only behavior. This is opt-in rather than automatic because Jira workflows vary by project/scheme — status names and which transitions are reachable from where isn't something spf can assume, unlike labels. A configured name with no reachable transition (or a rejected one) is logged and skipped, never thrown — the label update is what `spf watch` actually depends on; status sync is a best-effort convenience on top of it.
813
+
814
+ `spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) — it reports the labels this run will use. With `watch.refine.enabled`, it also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above. Independent of `refine.enabled`, if `watch.jira.status_map` has any entry configured, both `spf watch init` and `spf watch`'s own startup check validate it against the real project's statuses the same way, and exit non-zero on a mismatch.
783
815
 
784
816
  ### Bitbucket (`code_host: bitbucket`)
785
817
 
@@ -839,6 +871,22 @@ Getting each channel's URL:
839
871
  - **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
840
872
  n8n, Zapier, a homegrown receiver.
841
873
 
874
+ One webhook, many `spf` instances: if several repos' `spf watch` (or
875
+ `spf run`) all post to the same Slack/Teams/webhook endpoint, set
876
+ `notifications.project` to a short label so messages from each are
877
+ distinguishable — it prefixes every title (`[api] watch: reconcileOrphans
878
+ error`) and adds a `repo` field. Left unset, it falls back to `watch.repo`,
879
+ so most `spf watch` setups need nothing extra; set it explicitly when
880
+ `watch.repo` is blank or two watched repos share a basename.
881
+
882
+ ```yaml
883
+ notifications:
884
+ events: attention
885
+ project: api # optional; defaults to watch.repo
886
+ channels:
887
+ - kind: slack
888
+ ```
889
+
842
890
  Delivery never blocks or fails a run: an unconfigured/misconfigured channel
843
891
  is skipped with one warning, and a failed POST logs one line and is
844
892
  swallowed — never changes a run's exit code. One thing worth knowing under
@@ -160,6 +160,9 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
160
160
  | `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
161
161
  | `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
162
162
  | `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task`/`spec` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` (plus `spec`, a standalone spec proposed by a split — see below) creates as on Jira — defaults `epic`/`feature` → `Epic`, `story`/`spec` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
163
+ | `jira.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: jira`. Optional, empty (unset) by default — when a state has no entry, `spf watch` updates only the `<prefix>:<state>` label on that transition, exactly as before this field existed. When a state IS mapped to a real Jira status name (e.g. `working: "In Progress"`), `spf watch` also transitions the issue's native Status field there, best-effort: no reachable transition, or a rejected one, is logged and skipped rather than failing the run — the label update is what `spf watch` actually depends on, status sync is a convenience on top. Validated against the real project's statuses by both `spf watch init` and `spf watch`'s own startup check, same as `issue_types`. |
164
+ | `github.project_number` | int ≥0 | Only consulted when `issue_provider: github`. Which of the repo OWNER's Projects v2 boards `status_map` syncs against (Projects v2 numbers are per-owner, not per-repo — see the board's own URL). Default `0`, which disables status sync outright regardless of `status_map`; there's no sane "guess the board" default. |
165
+ | `github.status_map` | map: `spec-ready`/`ready`/`working`/`review`/`done`/`blocked` -> string | Only consulted when `issue_provider: github` AND `project_number` is set. Optional, empty (unset) by default — an unmapped state stays label-only, a mapped one also sets the Projects v2 "Status" field, best-effort (no matching Status option, or a rejected mutation, is logged and skipped, never thrown — the label update is what `spf watch` actually depends on, status sync is a convenience on top). Needs `GITHUB_TOKEN` to carry the `project` scope in addition to `repo`/`public_repo` — Projects v2 is GraphQL-only. Validated against the real project's Status options by both `spf watch init` and `spf watch`'s own startup check. |
163
166
  | `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
164
167
  | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
165
168
  | `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
@@ -277,6 +280,7 @@ default; adding it is entirely additive.
277
280
  |---|---|---|
278
281
  | `events` | `"off"` \| `"errors"` \| `"attention"` \| `"all"` | The whole filter, narrowest to widest. `off` (default): nothing. `errors`: only true failures — failed runs/phases (`run_failed`/`phase_failed`), `watch_error`. `attention`: `errors` plus anything needing a human but not itself a failure — a blocked issue (`issue_blocked`) or a spec needing feedback (`spec_needs_feedback`). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus `attention` and `errors`. |
279
282
  | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
283
+ | `project` | string | Label prefixed onto every outbound title (`[api] watch: ...`) and added as a `repo` field — for disambiguating multiple `spf` instances that share one webhook. Default `""`, which falls back to `watch.repo`. |
280
284
  | `channels[]` | array | See below. |
281
285
 
282
286
  `channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
@@ -861,6 +861,14 @@ export async function doctorCommand(argv) {
861
861
  ? "set"
862
862
  : 'not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo); see README.md\'s "GITHUB_TOKEN scope" section');
863
863
  }
864
+ if (cfg.watch.issue_provider === "github") {
865
+ const statusMapEntries = Object.entries(cfg.watch.github.status_map).filter(([, v]) => Boolean(v));
866
+ check(report, "watch.github.status_map", true, statusMapEntries.length > 0
867
+ ? cfg.watch.github.project_number
868
+ ? `${statusMapEntries.length} state(s) configured to sync Projects v2 #${cfg.watch.github.project_number}'s Status field — run \`spf watch init\` to validate them, and make sure GITHUB_TOKEN has the "project" scope`
869
+ : `${statusMapEntries.length} state(s) configured but watch.github.project_number is unset — status sync stays disabled until it's set`
870
+ : "not configured — spf watch will only update labels on this repo, never a Projects v2 board (optional, off by default)", "info");
871
+ }
864
872
  if (cfg.watch.issue_provider === "jira") {
865
873
  check(report, "watch.jira", Boolean(cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()), cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()
866
874
  ? `${cfg.watch.jira.base_url} (${cfg.watch.jira.project_key})`
@@ -868,6 +876,10 @@ export async function doctorCommand(argv) {
868
876
  check(report, "JIRA_EMAIL / JIRA_API_TOKEN", Boolean(process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]), process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]
869
877
  ? "set"
870
878
  : 'not set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens); see README.md\'s "spf watch" section');
879
+ const statusMapEntries = Object.entries(cfg.watch.jira.status_map).filter(([, v]) => Boolean(v));
880
+ check(report, "watch.jira.status_map", true, statusMapEntries.length > 0
881
+ ? `${statusMapEntries.length} state(s) configured to sync a native Jira status — run \`spf watch init\` to validate them against the real project's statuses`
882
+ : "not configured — spf watch will only update labels on this project, never the Jira Status field (optional, off by default)", "info");
871
883
  }
872
884
  if (cfg.watch.code_host === "bitbucket") {
873
885
  check(report, "BITBUCKET_EMAIL / BITBUCKET_API_TOKEN", Boolean(process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]), process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]
@@ -1023,6 +1035,15 @@ export async function doctorCommand(argv) {
1023
1035
  const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
1024
1036
  check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
1025
1037
  }
1038
+ // See `NotificationsConfigSchema.project`'s doc comment / `resolveNotifier`'s
1039
+ // same fallback — informational only (never fails doctor), since an
1040
+ // unset project tag is harmless unless this webhook ends up shared.
1041
+ if (cfg.notifications.channels.length > 0) {
1042
+ const project = cfg.notifications.project.trim() || cfg.watch.repo.trim();
1043
+ check(report, "notifications.project", true, project
1044
+ ? `tag: "${project}"`
1045
+ : "not set, and watch.repo is empty — outbound messages won't carry a repo/project tag; if this webhook is ever shared across multiple spf instances, set notifications.project to tell them apart", project ? "info" : "warn");
1046
+ }
1026
1047
  }
1027
1048
  return finish(report, flags["json"]);
1028
1049
  }
@@ -100,7 +100,7 @@ export function resolveIssueProvider(cfg) {
100
100
  console.error('GITHUB_TOKEN is not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo). See README.md\'s "GITHUB_TOKEN scope" section.');
101
101
  return null;
102
102
  }
103
- return new GitHubProvider(repo, cfg.watch.label_prefix, token);
103
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
104
104
  }
105
105
  if (cfg.watch.issue_provider === "jira") {
106
106
  if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
@@ -113,7 +113,7 @@ export function resolveIssueProvider(cfg) {
113
113
  console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens). See README.md\'s "spf watch" section.');
114
114
  return null;
115
115
  }
116
- return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
116
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
117
117
  }
118
118
  console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
119
119
  return null;
@@ -136,7 +136,7 @@ function resolveCodeHostProvider(cfg) {
136
136
  console.error('GITHUB_TOKEN is not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo). See README.md\'s "GITHUB_TOKEN scope" section.');
137
137
  return null;
138
138
  }
139
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
139
+ return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
140
140
  }
141
141
  if (cfg.watch.code_host === "bitbucket") {
142
142
  const email = process.env["BITBUCKET_EMAIL"];
@@ -196,6 +196,24 @@ export async function watchInitCommand(argv) {
196
196
  if (checks.some((c) => !c.exists))
197
197
  return 1;
198
198
  }
199
+ if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider && Object.values(cfg.watch.jira.status_map).some(Boolean)) {
200
+ const checks = await provider.validateStatusMap();
201
+ console.log(`\nvalidating watch.jira.status_map against ${cfg.watch.jira.project_key}:`);
202
+ for (const c of checks) {
203
+ console.log(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.jiraStatus}${c.exists ? "" : ` — no status named "${c.jiraStatus}" in ${cfg.watch.jira.project_key}`}`);
204
+ }
205
+ if (checks.some((c) => !c.exists))
206
+ return 1;
207
+ }
208
+ if (cfg.watch.issue_provider === "github" && provider instanceof GitHubProvider && Object.values(cfg.watch.github.status_map).some(Boolean)) {
209
+ const checks = await provider.validateStatusMap();
210
+ console.log(`\nvalidating watch.github.status_map against Projects v2 #${cfg.watch.github.project_number}:`);
211
+ for (const c of checks) {
212
+ console.log(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.githubStatus}${c.exists ? "" : ` — no Status option named "${c.githubStatus}" on Projects v2 #${cfg.watch.github.project_number}`}`);
213
+ }
214
+ if (checks.some((c) => !c.exists))
215
+ return 1;
216
+ }
199
217
  return 0;
200
218
  }
201
219
  /** Shared by `runChain`/`runRefine`/the fan-out lane's dispatch: 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. Top-level (not a `watchCommand` local) so `makeWatchFanoutDispatch` below can share it — see that factory's own doc comment for why. */
@@ -396,6 +414,49 @@ export async function watchCommand(argv) {
396
414
  }
397
415
  }
398
416
  }
417
+ // watch.jira.status_map applies to every build-lane transition, independent
418
+ // of watch.refine.enabled — validated at startup for the same reason
419
+ // issue_types is: a bad status name should stop the daemon before it
420
+ // starts, not fail silently (well, log-and-skip — see jira_provider.ts's
421
+ // syncStatus()) on every single transition once it's running. Silent when
422
+ // status_map is empty (the default, opt-in feature) or has no mismatches.
423
+ if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
424
+ const configured = Object.values(cfg.watch.jira.status_map).some(Boolean);
425
+ if (configured) {
426
+ const checks = await provider.validateStatusMap();
427
+ const mismatches = checks.filter((c) => !c.exists);
428
+ if (mismatches.length > 0) {
429
+ console.error(`watch.jira.status_map has ${mismatches.length} mismatch(es) against ${cfg.watch.jira.project_key}:`);
430
+ for (const c of checks) {
431
+ console.error(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.jiraStatus}${c.exists ? "" : ` — no status named "${c.jiraStatus}" in ${cfg.watch.jira.project_key}`}`);
432
+ }
433
+ console.error(`Fix watch.jira.status_map, or the project's statuses, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
434
+ return 1;
435
+ }
436
+ }
437
+ }
438
+ // watch.github.status_map applies to every build-lane transition,
439
+ // independent of watch.refine.enabled — validated at startup for the same
440
+ // reason watch.jira.issue_types is: a bad Status option name should stop
441
+ // the daemon before it starts, not fail silently (well, log-and-skip —
442
+ // see github_provider.ts's syncStatus()) on every single transition once
443
+ // it's running. Silent when status_map is empty (the default, opt-in
444
+ // feature) or has no mismatches.
445
+ if (cfg.watch.issue_provider === "github" && provider instanceof GitHubProvider) {
446
+ const configured = Object.values(cfg.watch.github.status_map).some(Boolean);
447
+ if (configured) {
448
+ const checks = await provider.validateStatusMap();
449
+ const mismatches = checks.filter((c) => !c.exists);
450
+ if (mismatches.length > 0) {
451
+ console.error(`watch.github.status_map has ${mismatches.length} mismatch(es) against Projects v2 #${cfg.watch.github.project_number}:`);
452
+ for (const c of checks) {
453
+ console.error(` ${c.exists ? "✓" : "✗"} ${c.state} → ${c.githubStatus}${c.exists ? "" : ` — no Status option named "${c.githubStatus}" on Projects v2 #${cfg.watch.github.project_number}`}`);
454
+ }
455
+ console.error(`Fix watch.github.status_map (and watch.github.project_number), or the project's Status options, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
456
+ return 1;
457
+ }
458
+ }
459
+ }
399
460
  // watch.fanout.n > 1 startup gates — all guarded so watch.fanout.n: 1 (the
400
461
  // default) reaches none of them, and a bad configuration stops the daemon
401
462
  // before it starts rather than failing silently every tick.
@@ -412,14 +412,22 @@ export async function runInterview(asker, ctx) {
412
412
  });
413
413
  const projectKey = await asker.text("Jira project key", { validate: (val) => (val.trim() ? null : "required") });
414
414
  watch.jira = { base_url: baseUrl.replace(/\/+$/, ""), project_key: projectKey.toUpperCase() };
415
+ asker.note("Jira workflow status (the board's Status column) stays untouched by default — labels alone drive spf watch. " +
416
+ "Opt in with watch.jira.status_map (per-state -> Jira status name) to also sync it, then run `spf watch init` to validate.");
415
417
  }
416
418
  const needsGithub = issueProvider === "github" || codeHost === "github";
417
419
  if (needsGithub) {
418
- asker.note('classic PAT, "repo" scope (private) or "public_repo" (public-only) — never "project".');
420
+ asker.note(issueProvider === "github"
421
+ ? 'classic PAT, "repo" scope (private) or "public_repo" (public-only) — add "project" too if you plan to use watch.github.status_map (native Projects v2 board sync).'
422
+ : 'classic PAT, "repo" scope (private) or "public_repo" (public-only).');
419
423
  const token = await asker.secret("GITHUB_TOKEN", { current: ctx.existingEnv.get("GITHUB_TOKEN") });
420
424
  if (token)
421
425
  env["GITHUB_TOKEN"] = token;
422
426
  envExampleKeys.push("GITHUB_TOKEN");
427
+ if (issueProvider === "github") {
428
+ asker.note("GitHub Projects v2 board status (the board's Status column) stays untouched by default — labels alone drive spf watch. " +
429
+ "Opt in with watch.github.project_number + watch.github.status_map (per-state -> Status option name) to also sync it, then run `spf watch init` to validate.");
430
+ }
423
431
  }
424
432
  if (issueProvider === "jira") {
425
433
  const email = await asker.text("JIRA_EMAIL", { default: ctx.gitEmail ?? "" });
@@ -527,6 +535,15 @@ export async function runInterview(asker, ctx) {
527
535
  const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
528
536
  if (timeoutMs !== "5000")
529
537
  notifications.timeout_ms = Number(timeoutMs);
538
+ // Only matters when one Slack/Teams/webhook endpoint is shared across
539
+ // several `spf` instances — defaults to watch.repo (resolveNotifier's
540
+ // own fallback), so most single-repo setups can just accept it and
541
+ // write nothing extra into the generated config.
542
+ const repoDefault = watch?.repo || "";
543
+ asker.note("Tags every outbound title/field so messages are distinguishable if this webhook is shared across repos — defaults to watch.repo.");
544
+ const project = await asker.text("notifications.project", { default: repoDefault });
545
+ if (project !== repoDefault)
546
+ notifications.project = project;
530
547
  }
531
548
  }
532
549
  // ── 6. review + confirm ─────────────────────────────────────────────────────
@@ -839,14 +839,43 @@ export declare const JiraIssueTypeMapSchema: v.ObjectSchema<{
839
839
  readonly spec: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
840
840
  }, undefined>;
841
841
  export type JiraIssueTypeMap = v.InferOutput<typeof JiraIssueTypeMapSchema>;
842
+ /**
843
+ * Optional, per-project `WatchState` -> Jira workflow-status-name map.
844
+ * Unlike `JiraIssueTypeMapSchema`, this has no sane universal default:
845
+ * "To Do" vs "Open" vs "Backlog" (and everything in between) is entirely
846
+ * per-project workflow configuration in Jira, so every field defaults to
847
+ * unset. A `WatchState` with no entry here keeps today's behavior exactly —
848
+ * `jira_provider.ts`'s `transition()`/`claim()` update the label only and
849
+ * never attempt a status change for it.
850
+ *
851
+ * Deliberately covers only the states a human's Jira board status would
852
+ * plausibly want to reflect (the build lane, `spec-ready` through
853
+ * `blocked`) — the refine-lane states (`refining`, `split-proposed`, ...)
854
+ * are internal bookkeeping for `core/refine.ts`, not board-visible work.
855
+ *
856
+ * `jira_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
857
+ * is what `spf watch init` and `spf watch`'s own startup check should call
858
+ * to confirm each configured name is a real status on the project before an
859
+ * unattended run relies on it — same shape as `issue_types`/
860
+ * `validateIssueTypes()`.
861
+ */
862
+ export declare const JiraStatusMapSchema: v.ObjectSchema<{
863
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
864
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
865
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
866
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
867
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
868
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
869
+ }, undefined>;
870
+ export type JiraStatusMap = v.InferOutput<typeof JiraStatusMapSchema>;
842
871
  /**
843
872
  * Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
844
873
  * `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
845
874
  * object replace on config-file-layer merge, like `refine`/
846
875
  * `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
847
- * file that touches `watch.jira` at all must repeat `issue_types` too if it
848
- * wants to keep a customized mapping, same caveat that already applies to
849
- * `base_url`/`project_key` today.
876
+ * file that touches `watch.jira` at all must repeat `issue_types`/
877
+ * `status_map` too if it wants to keep a customized mapping, same caveat
878
+ * that already applies to `base_url`/`project_key` today.
850
879
  */
851
880
  export declare const WatchJiraConfigSchema: v.ObjectSchema<{
852
881
  readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -873,8 +902,85 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
873
902
  task: string;
874
903
  spec: string;
875
904
  }>;
905
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
906
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
907
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
908
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
909
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
910
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
911
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
912
+ }, undefined>, () => {
913
+ "spec-ready"?: string | undefined;
914
+ ready?: string | undefined;
915
+ working?: string | undefined;
916
+ review?: string | undefined;
917
+ done?: string | undefined;
918
+ blocked?: string | undefined;
919
+ }>;
876
920
  }, undefined>;
877
921
  export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
922
+ /**
923
+ * Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
924
+ * map — the GitHub-side twin of `JiraIssueTypeMap`'s sibling on the Jira
925
+ * provider (see `jira_provider.ts`'s `status_map`). No sane universal
926
+ * default: a board's Status column options ("Todo"/"In Progress"/"Done", or
927
+ * anything else) are per-project configuration, so every field defaults to
928
+ * unset. A `WatchState` with no entry keeps today's behavior exactly —
929
+ * `github_provider.ts`'s `transition()`/`claim()` update the label only and
930
+ * never touch Projects v2 for it.
931
+ *
932
+ * Same six build-lane states as Jira's map — the refine-lane's own
933
+ * bookkeeping states (`refining`, `split-proposed`, ...) aren't board-visible
934
+ * work, same reasoning as `jira_provider.ts`'s `JiraStatusMapSchema`.
935
+ *
936
+ * `github_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
937
+ * is what `spf watch init` and `spf watch`'s own startup check call to
938
+ * confirm each configured name is a real Status option on the configured
939
+ * project before an unattended run relies on it.
940
+ */
941
+ export declare const GithubStatusMapSchema: v.ObjectSchema<{
942
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
943
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
944
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
945
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
946
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
947
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
948
+ }, undefined>;
949
+ export type GithubStatusMap = v.InferOutput<typeof GithubStatusMapSchema>;
950
+ /**
951
+ * Only consulted when `issue_provider: github` (or `code_host: github`) AND
952
+ * `status_map` actually has an entry configured — `project_number: 0` (the
953
+ * default) disables status sync outright regardless of `status_map`, since
954
+ * there's no sane "guess the board" default: a repo can have zero, one, or
955
+ * many Projects v2 boards, and none of them is canonical. Scoped to the
956
+ * repo's OWNER (`watch.repo`'s "owner/name", the owner half) — GitHub
957
+ * Projects v2 numbers are per-owner, not per-repo, so `project_number: 3`
958
+ * means owner's project #3, which may or may not have this repo's issues on
959
+ * it yet (`github_provider.ts`'s `syncStatus()` adds the issue to the
960
+ * project itself the first time it needs to). Whole-object replace on
961
+ * config-file-layer merge, like `jira`/`refine` above — an override file
962
+ * that touches `watch.github` at all must repeat `status_map` too if it
963
+ * wants to keep a customized mapping.
964
+ */
965
+ export declare const WatchGithubConfigSchema: v.ObjectSchema<{
966
+ readonly project_number: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 0>;
967
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
968
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
969
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
970
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
971
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
972
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
973
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
974
+ }, undefined>, () => {
975
+ "spec-ready"?: string | undefined;
976
+ ready?: string | undefined;
977
+ working?: string | undefined;
978
+ review?: string | undefined;
979
+ done?: string | undefined;
980
+ blocked?: string | undefined;
981
+ }>;
982
+ }, undefined>;
983
+ export type WatchGithubConfig = v.InferOutput<typeof WatchGithubConfigSchema>;
878
984
  /**
879
985
  * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
880
986
  * spec into a feature/story tree of real issues, instead of running
@@ -1024,6 +1130,21 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
1024
1130
  task: string;
1025
1131
  spec: string;
1026
1132
  }>;
1133
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
1134
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1135
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1136
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1137
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1138
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1139
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1140
+ }, undefined>, () => {
1141
+ "spec-ready"?: string | undefined;
1142
+ ready?: string | undefined;
1143
+ working?: string | undefined;
1144
+ review?: string | undefined;
1145
+ done?: string | undefined;
1146
+ blocked?: string | undefined;
1147
+ }>;
1027
1148
  }, undefined>, () => {
1028
1149
  base_url: string;
1029
1150
  project_key: string;
@@ -1035,6 +1156,42 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
1035
1156
  task: string;
1036
1157
  spec: string;
1037
1158
  };
1159
+ status_map: {
1160
+ "spec-ready"?: string | undefined;
1161
+ ready?: string | undefined;
1162
+ working?: string | undefined;
1163
+ review?: string | undefined;
1164
+ done?: string | undefined;
1165
+ blocked?: string | undefined;
1166
+ };
1167
+ }>;
1168
+ readonly github: v.OptionalSchema<v.ObjectSchema<{
1169
+ readonly project_number: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 0>;
1170
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
1171
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1172
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1173
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1174
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1175
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1176
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1177
+ }, undefined>, () => {
1178
+ "spec-ready"?: string | undefined;
1179
+ ready?: string | undefined;
1180
+ working?: string | undefined;
1181
+ review?: string | undefined;
1182
+ done?: string | undefined;
1183
+ blocked?: string | undefined;
1184
+ }>;
1185
+ }, undefined>, () => {
1186
+ project_number: number;
1187
+ status_map: {
1188
+ "spec-ready"?: string | undefined;
1189
+ ready?: string | undefined;
1190
+ working?: string | undefined;
1191
+ review?: string | undefined;
1192
+ done?: string | undefined;
1193
+ blocked?: string | undefined;
1194
+ };
1038
1195
  }>;
1039
1196
  readonly refine: v.OptionalSchema<v.ObjectSchema<{
1040
1197
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -1099,6 +1256,7 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
1099
1256
  readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
1100
1257
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1101
1258
  }, undefined>, undefined>, () => never[]>;
1259
+ readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
1102
1260
  }, undefined>;
1103
1261
  export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
1104
1262
  /**
@@ -1362,6 +1520,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1362
1520
  task: string;
1363
1521
  spec: string;
1364
1522
  }>;
1523
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
1524
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1525
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1526
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1527
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1528
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1529
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1530
+ }, undefined>, () => {
1531
+ "spec-ready"?: string | undefined;
1532
+ ready?: string | undefined;
1533
+ working?: string | undefined;
1534
+ review?: string | undefined;
1535
+ done?: string | undefined;
1536
+ blocked?: string | undefined;
1537
+ }>;
1365
1538
  }, undefined>, () => {
1366
1539
  base_url: string;
1367
1540
  project_key: string;
@@ -1373,6 +1546,42 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1373
1546
  task: string;
1374
1547
  spec: string;
1375
1548
  };
1549
+ status_map: {
1550
+ "spec-ready"?: string | undefined;
1551
+ ready?: string | undefined;
1552
+ working?: string | undefined;
1553
+ review?: string | undefined;
1554
+ done?: string | undefined;
1555
+ blocked?: string | undefined;
1556
+ };
1557
+ }>;
1558
+ readonly github: v.OptionalSchema<v.ObjectSchema<{
1559
+ readonly project_number: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 0>;
1560
+ readonly status_map: v.OptionalSchema<v.ObjectSchema<{
1561
+ readonly "spec-ready": v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1562
+ readonly ready: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1563
+ readonly working: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1564
+ readonly review: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1565
+ readonly done: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1566
+ readonly blocked: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1567
+ }, undefined>, () => {
1568
+ "spec-ready"?: string | undefined;
1569
+ ready?: string | undefined;
1570
+ working?: string | undefined;
1571
+ review?: string | undefined;
1572
+ done?: string | undefined;
1573
+ blocked?: string | undefined;
1574
+ }>;
1575
+ }, undefined>, () => {
1576
+ project_number: number;
1577
+ status_map: {
1578
+ "spec-ready"?: string | undefined;
1579
+ ready?: string | undefined;
1580
+ working?: string | undefined;
1581
+ review?: string | undefined;
1582
+ done?: string | undefined;
1583
+ blocked?: string | undefined;
1584
+ };
1376
1585
  }>;
1377
1586
  readonly refine: v.OptionalSchema<v.ObjectSchema<{
1378
1587
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -1420,6 +1629,25 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1420
1629
  task: string;
1421
1630
  spec: string;
1422
1631
  };
1632
+ status_map: {
1633
+ "spec-ready"?: string | undefined;
1634
+ ready?: string | undefined;
1635
+ working?: string | undefined;
1636
+ review?: string | undefined;
1637
+ done?: string | undefined;
1638
+ blocked?: string | undefined;
1639
+ };
1640
+ };
1641
+ github: {
1642
+ project_number: number;
1643
+ status_map: {
1644
+ "spec-ready"?: string | undefined;
1645
+ ready?: string | undefined;
1646
+ working?: string | undefined;
1647
+ review?: string | undefined;
1648
+ done?: string | undefined;
1649
+ blocked?: string | undefined;
1650
+ };
1423
1651
  };
1424
1652
  refine: {
1425
1653
  enabled: boolean;
@@ -1443,6 +1671,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1443
1671
  readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
1444
1672
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1445
1673
  }, undefined>, undefined>, () => never[]>;
1674
+ readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
1446
1675
  }, undefined>, () => {
1447
1676
  events: "all" | "attention" | "errors" | "off";
1448
1677
  timeout_ms: number;
@@ -1452,6 +1681,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1452
1681
  events?: "all" | "attention" | "errors" | "off" | null | undefined;
1453
1682
  name: string;
1454
1683
  }[];
1684
+ project: string;
1455
1685
  }>;
1456
1686
  readonly review: v.OptionalSchema<v.ObjectSchema<{
1457
1687
  readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;