@gr8ful/spf 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +122 -27
  2. package/assets/prompts/refiner/system.md +11 -1
  3. package/assets/prompts/refiner/user.md +9 -3
  4. package/assets/skill/references/config.md +51 -13
  5. package/assets/templates/ts.spf.config.yaml +6 -2
  6. package/dist/chains/context.d.ts +26 -0
  7. package/dist/chains/simple_sdlc.js +9 -0
  8. package/dist/chains/steps.d.ts +0 -27
  9. package/dist/chains/steps.js +21 -2
  10. package/dist/cli/ask.d.ts +13 -0
  11. package/dist/cli/ask.js +15 -1
  12. package/dist/cli/commands/doctor.js +47 -9
  13. package/dist/cli/commands/fanout.js +49 -5
  14. package/dist/cli/commands/init.js +11 -3
  15. package/dist/cli/commands/list.d.ts +1 -1
  16. package/dist/cli/commands/list.js +31 -12
  17. package/dist/cli/commands/phases.d.ts +1 -1
  18. package/dist/cli/commands/phases.js +18 -4
  19. package/dist/cli/commands/run.js +30 -2
  20. package/dist/cli/commands/sessions.d.ts +1 -1
  21. package/dist/cli/commands/sessions.js +11 -3
  22. package/dist/cli/commands/watch.d.ts +8 -0
  23. package/dist/cli/commands/watch.js +93 -13
  24. package/dist/cli/index.js +4 -4
  25. package/dist/cli/interview.js +9 -5
  26. package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
  27. package/dist/cli/ui/fanout_dashboard.js +102 -0
  28. package/dist/cli/ui/ink_asker.d.ts +13 -0
  29. package/dist/cli/ui/ink_asker.js +247 -0
  30. package/dist/cli/ui/reports.d.ts +30 -0
  31. package/dist/cli/ui/reports.js +61 -0
  32. package/dist/cli/ui/run_dashboard.d.ts +15 -0
  33. package/dist/cli/ui/run_dashboard.js +131 -0
  34. package/dist/cli/ui/watch_dashboard.d.ts +22 -0
  35. package/dist/cli/ui/watch_dashboard.js +78 -0
  36. package/dist/core/console.d.ts +40 -1
  37. package/dist/core/console.js +25 -3
  38. package/dist/core/data_types.d.ts +108 -5
  39. package/dist/core/data_types.js +50 -5
  40. package/dist/core/fanout.d.ts +9 -0
  41. package/dist/core/fanout.js +6 -2
  42. package/dist/core/gates.js +24 -1
  43. package/dist/core/issues/github_provider.d.ts +39 -5
  44. package/dist/core/issues/github_provider.js +103 -4
  45. package/dist/core/issues/jira_provider.d.ts +79 -12
  46. package/dist/core/issues/jira_provider.js +97 -2
  47. package/dist/core/issues/provider.d.ts +73 -19
  48. package/dist/core/issues/provider.js +24 -7
  49. package/dist/core/notify/channel.d.ts +1 -1
  50. package/dist/core/refine.d.ts +45 -8
  51. package/dist/core/refine.js +98 -24
  52. package/dist/core/runner.d.ts +5 -1
  53. package/dist/core/runner.js +2 -1
  54. package/dist/core/session.d.ts +7 -1
  55. package/dist/core/session.js +5 -1
  56. package/dist/core/watch.d.ts +86 -3
  57. package/dist/core/watch.js +353 -29
  58. package/package.json +6 -1
@@ -22,6 +22,30 @@ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
22
22
  import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
23
23
  import { isRepoAt } from "../../core/git_helper.js";
24
24
  import { allChains, findChain, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
25
+ import { isInteractive } from "../ask.js";
26
+ import { paint as paintPlain } from "../../core/console.js";
27
+ /**
28
+ * A transient "checking X..." status line around a network probe — real
29
+ * work only starts inside `run()`; this doesn't touch the promise's timing.
30
+ * TTY-only: `\r` + erase-to-end-of-line only makes sense against a real
31
+ * terminal, and a non-TTY/CI log should show nothing extra, matching
32
+ * doctor's output exactly as it always has when piped. Not Ink-based on
33
+ * purpose — this is a plain transient ANSI line, not a component tree, and
34
+ * doctor's checks accumulate into one report printed at the very end
35
+ * regardless, so there's no persistent live region for a probe status to
36
+ * join.
37
+ */
38
+ async function withProbeStatus(label, run) {
39
+ if (!isInteractive())
40
+ return run();
41
+ process.stdout.write(paintPlain("dim", ` checking ${label}...`));
42
+ try {
43
+ return await run();
44
+ }
45
+ finally {
46
+ process.stdout.write("\r\x1b[2K");
47
+ }
48
+ }
25
49
  /**
26
50
  * `severity` is a display-only axis, orthogonal to `ok`/`report.ok`: an
27
51
  * "info"/"warn" check still reports `ok: true` (it never fails `spf doctor`
@@ -174,7 +198,7 @@ export async function doctorCommand(argv) {
174
198
  }
175
199
  catch (error) {
176
200
  check(report, "config parses", false, error.message);
177
- return finish(report, flags["json"]);
201
+ return await finish(report, flags["json"]);
178
202
  }
179
203
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
180
204
  check(report, "data_dir", true, dataPaths.data_dir);
@@ -254,7 +278,7 @@ export async function doctorCommand(argv) {
254
278
  const probeBase = doubledPath ? trimmed.replace(/\/v1$/, "") : trimmed;
255
279
  const claudeCodeAgent = cfg.agents.find((a) => a.coding_agent === "claude_code");
256
280
  const probeModel = claudeCodeAgent?.model || "claude-sonnet-5";
257
- const result = await probeAnthropicMessages(probeBase, probeModel);
281
+ const result = await withProbeStatus("ANTHROPIC_BASE_URL reachability", () => probeAnthropicMessages(probeBase, probeModel));
258
282
  check(report, "ANTHROPIC_BASE_URL reachability", true, (result.ok
259
283
  ? `${doubledPath ? `dropping the trailing "/v1" makes it ` : ""}reachable: POST ${probeBase}/v1/messages -> HTTP ${result.status}${result.note ? ` (${result.note})` : ""}`
260
284
  : `unreachable: POST ${probeBase}/v1/messages -> ${result.error}`) +
@@ -288,7 +312,7 @@ export async function doctorCommand(argv) {
288
312
  // latency either way — strictly cheaper for a pure reachability check,
289
313
  // and there's no live-server dependency in this choice: doctor's probe
290
314
  // itself tolerates either endpoint being down (see `probeGet`).
291
- const result = await probeGet(`${ollamaBase}/models`);
315
+ const result = await withProbeStatus("OLLAMA_BASE_URL reachability", () => probeGet(`${ollamaBase}/models`));
292
316
  check(report, "OLLAMA_BASE_URL reachability", true, // informational/warning only — see the ANTHROPIC_BASE_URL check above for why
293
317
  result.ok ? `reachable: GET ${ollamaBase}/models -> HTTP ${result.status}` : `unreachable: GET ${ollamaBase}/models -> ${result.error}`, result.ok ? "info" : "warn");
294
318
  }
@@ -379,7 +403,7 @@ export async function doctorCommand(argv) {
379
403
  // chain+prompt is `spf estimate`'s job, not doctor's. `required` is the
380
404
  // WHOLE roster, matching the "roster + suites validate" call above —
381
405
  // every routed role doctor's job covers, not just what one chain needs.
382
- const servedOllamaTags = flags["no-probe"] ? null : await probeServedOllamaTags(cfg);
406
+ const servedOllamaTags = flags["no-probe"] ? null : await withProbeStatus("served Ollama tags", () => probeServedOllamaTags(cfg));
383
407
  const resolution = resolveTiering({
384
408
  cfg,
385
409
  chainName: "",
@@ -534,9 +558,11 @@ export async function doctorCommand(argv) {
534
558
  if (cfg.watch.refine.enabled) {
535
559
  const refineChain = findChain(cfg.watch.refine.chain);
536
560
  check(report, "watch.refine.chain", Boolean(refineChain), refineChain ? `${cfg.watch.refine.chain}${refineChain.source ? ` (repo: ${repoChainLabel(refineChain.source)})` : ""}` : `"${cfg.watch.refine.chain}" is not a registered chain`);
537
- check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github", cfg.watch.issue_provider === "github"
561
+ check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github" || cfg.watch.issue_provider === "jira", cfg.watch.issue_provider === "github"
538
562
  ? "github supports issue authoring (createIssue/sub-issues)"
539
- : `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} the refine lane needs "github" (Jira issue authoring isn't implemented yet)`);
563
+ : cfg.watch.issue_provider === "jira"
564
+ ? "jira supports issue authoring (createIssue/parent field) — run `spf watch init` to validate watch.jira.issue_types against the real project"
565
+ : `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" or "jira"`);
540
566
  }
541
567
  }
542
568
  // OTel span export: informational in every direction. It is off unless
@@ -557,7 +583,9 @@ export async function doctorCommand(argv) {
557
583
  `${cfg.observability.otel.headers ? `, headers: ${Object.keys(cfg.observability.otel.headers).join(", ")}` : ""})` +
558
584
  (insecure ? " — WARNING: plain http to a non-loopback host sends this telemetry in cleartext" : ""), insecure ? "warn" : "info");
559
585
  if (!flags["no-probe"]) {
560
- const result = await probeOtel(url, cfg.observability.otel.headers);
586
+ // Captured into a local: TS narrowing from the outer `if (cfg.observability.otel)` doesn't survive into this closure.
587
+ const otelHeaders = cfg.observability.otel.headers;
588
+ const result = await withProbeStatus("observability.otel reachability", () => probeOtel(url, otelHeaders));
561
589
  check(report, "observability.otel reachability", true, // informational/warning only — same rule as the base-URL probes above
562
590
  result.ok
563
591
  ? `reachable: POST ${endpointLabel(url)} (empty batch) -> HTTP ${result.status}${result.status >= 400 ? " — reachable but rejecting; check the path (/v1/traces) and headers" : ""}`
@@ -577,16 +605,26 @@ export async function doctorCommand(argv) {
577
605
  }
578
606
  return finish(report, flags["json"]);
579
607
  }
580
- function finish(report, json) {
608
+ async function finish(report, json) {
581
609
  if (json) {
582
610
  console.log(JSON.stringify(report, null, 2));
611
+ return report.ok ? 0 : 1;
612
+ }
613
+ const footer = { ok: report.ok, message: report.ok ? "spf doctor: clean" : "spf doctor: problems found above" };
614
+ if (isInteractive()) {
615
+ const { renderChecklist } = await import("../ui/reports.js");
616
+ await renderChecklist(report.checks.map((c) => ({
617
+ icon: c.severity === "warn" ? "warn" : c.severity === "info" ? "info" : c.ok ? "ok" : "fail",
618
+ name: c.name,
619
+ detail: c.detail,
620
+ })), footer);
583
621
  }
584
622
  else {
585
623
  for (const c of report.checks) {
586
624
  const icon = c.severity === "warn" ? "⚠" : c.severity === "info" ? "ℹ" : c.ok ? "✓" : "✗";
587
625
  console.log(`${icon} ${c.name}: ${c.detail}`);
588
626
  }
589
- console.log(report.ok ? "\nspf doctor: clean" : "\nspf doctor: problems found above");
627
+ console.log(`\n${footer.message}`);
590
628
  }
591
629
  return report.ok ? 0 : 1;
592
630
  }
@@ -31,6 +31,7 @@ import { ABORTED_EXIT, attemptAdwId, runBestOf, ZERO_METRICS, } from "../../core
31
31
  import { findChain, runChain as runChainDef } from "../../chains/index.js";
32
32
  import { SfDb } from "../../ui/server/db.js";
33
33
  import { newId, parseCli, resolvePrompt } from "../../core/utils.js";
34
+ import { isInteractive } from "../ask.js";
34
35
  /**
35
36
  * A ceiling on `--n`, not a recommendation. Each attempt is a full chain run
36
37
  * with its own agents, so `--n` multiplies spend directly — and the machine
@@ -62,9 +63,30 @@ function formatGates(attempt) {
62
63
  /**
63
64
  * The result table. Fixed column widths over a `console.table`: every other
64
65
  * `spf` listing (`sessions`, `phases`) prints plain padded columns, and this
65
- * output is read in a terminal beside them.
66
+ * output is read in a terminal beside them. On a TTY, routes through the
67
+ * same `renderTable()` (`cli/ui/reports.tsx`) those two now use instead —
68
+ * `fanout_cli.test.ts` runs with no TTY, so it keeps exercising this exact
69
+ * plain-text path unchanged.
66
70
  */
67
- function printTable(attempts) {
71
+ async function printTable(attempts) {
72
+ const rows = attempts.map((a) => [
73
+ String(a.index),
74
+ a.adw_id,
75
+ a.branch,
76
+ a.status,
77
+ formatGates(a),
78
+ agents.formatUsd(a.cost),
79
+ a.status === "skipped" ? "-" : formatDuration(a.wall_ms),
80
+ ]);
81
+ if (isInteractive()) {
82
+ const { renderTable } = await import("../ui/reports.js");
83
+ await renderTable([["#", "adw_id", "branch", "status", "gates", "cost", "time"], ...rows], { rowColor: (i) => (i > 0 && attempts[i - 1].status === "fail" ? "red" : undefined) });
84
+ for (const a of attempts) {
85
+ if (a.error)
86
+ console.log(` error: ${a.error}`);
87
+ }
88
+ return;
89
+ }
68
90
  const widths = {
69
91
  adw: Math.max(6, ...attempts.map((a) => a.adw_id.length)),
70
92
  branch: Math.max(6, ...attempts.map((a) => a.branch.length)),
@@ -343,11 +365,26 @@ export async function fanoutCommand(argv) {
343
365
  // this reliably buys, in the case it can't prevent, is telling the
344
366
  // operator the one thing the trace can't: which base adw_id to hand to
345
367
  // `--clean` afterwards.
368
+ // Mounted only on a real TTY — a live results table that updates row by
369
+ // row as each attempt settles, instead of the plain-text path's one-shot
370
+ // table at the very end. `dashboard` (not just its presence) is read by
371
+ // `onSignal` below, best-effort-unmounted before `process.exit()`: Ink's
372
+ // usual cleanup (restoring cursor visibility) never gets a chance to run
373
+ // on an abrupt exit otherwise, and this command's own SIGINT handler —
374
+ // unlike `spf watch`'s two-stage drain — already exits immediately by
375
+ // design (see its comment), so there's no "wait for it to finish" point
376
+ // to hook a graceful teardown into instead.
377
+ let dashboard;
378
+ if (isInteractive()) {
379
+ const { mountFanoutDashboard } = await import("../ui/fanout_dashboard.js");
380
+ dashboard = mountFanoutDashboard({ n, chainName: chain.name, baseBranch, baseAdwId });
381
+ }
346
382
  let interrupted = false;
347
383
  const onSignal = (signal) => {
348
384
  if (interrupted)
349
385
  return;
350
386
  interrupted = true;
387
+ dashboard?.unmountNow(); // best-effort cursor-visibility restore — see FanoutDashboard.unmountNow's own comment for why this isn't the awaited close()
351
388
  console.error(`\n[spf] fanout: ${signal} received — attempts already past their last checkpoint run to completion. ` +
352
389
  `Once everything has settled, clean up anything left under this base id with:\n` +
353
390
  ` spf fanout --clean ${baseAdwId}\n`);
@@ -370,10 +407,17 @@ export async function fanoutCommand(argv) {
370
407
  runAttempt,
371
408
  readMetrics,
372
409
  firstSuccess: flags["first-success"],
373
- log: (message) => console.log(`[spf] ${message}`),
410
+ log: (message) => (dashboard ? dashboard.log(message) : console.log(`[spf] ${message}`)),
411
+ onAttempt: dashboard?.onAttempt,
374
412
  });
375
- console.log(`\nfanout ${chain.name} — ${n} attempt(s), base ${baseBranch}`);
376
- printTable(result.attempts);
413
+ await dashboard?.close();
414
+ // A dashboard already printed the header and kept the table live and
415
+ // in scrollback throughout the run — printing it again here would just
416
+ // duplicate what's already there. Only the non-TTY path needs both.
417
+ if (!dashboard) {
418
+ console.log(`\nfanout ${chain.name} — ${n} attempt(s), base ${baseBranch}`);
419
+ await printTable(result.attempts);
420
+ }
377
421
  if (!result.winner) {
378
422
  console.error(`\nno winner: ${result.basis}`);
379
423
  console.error(`Any attempt whose chain got far enough to open a session has its trace in the shared db — ` +
@@ -27,7 +27,7 @@ import * as agents from "../../core/agents.js";
27
27
  import { ensureGitignore } from "../gitignore.js";
28
28
  import { parseCli } from "../../core/utils.js";
29
29
  import { paint } from "../../core/console.js";
30
- import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
30
+ import { createAsker, inkAvailable, isInteractive, InterviewAborted } from "../ask.js";
31
31
  import { gatherContext, runInterview } from "../interview.js";
32
32
  import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
33
33
  import { installSkillCommand } from "./install-skill.js";
@@ -99,7 +99,10 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
99
99
  # base_branch: main
100
100
  # # Optional second lane: decompose a spf:spec-ready product spec into a
101
101
  # # feature/story-or-bug tree of real issues. Off by default; needs
102
- # # issue_provider: github — issue authoring isn't implemented for Jira yet.
102
+ # # issue_provider: github OR jira both support issue authoring. On Jira,
103
+ # # each kind maps to a real issue type via watch.jira.issue_types
104
+ # # (defaults: epic/feature -> Epic, story -> Story, bug -> Bug,
105
+ # # task -> Task) — \`spf watch init\` validates this against the project.
103
106
  # refine:
104
107
  # enabled: true
105
108
  # chain: refine
@@ -252,7 +255,12 @@ export async function initCommand(argv) {
252
255
  installSkill();
253
256
  }
254
257
  else {
255
- const asker = createAsker();
258
+ // Ink needs raw-mode stdin to drive arrow-key selects and inline
259
+ // validation; `inkAvailable()` is `isInteractive()` plus one extra
260
+ // guard for the (practically never, but cheap to check) case where a
261
+ // TTY-reporting stdin still doesn't expose `setRawMode`. Either way
262
+ // falls back to the original readline asker, never to a hang.
263
+ const asker = inkAvailable() ? (await import("../ui/ink_asker.js")).createInkAsker() : createAsker();
256
264
  try {
257
265
  if (existsSync(configPath) && !flags["force"]) {
258
266
  const overwrite = await asker.confirm(`${configPath} already exists — overwrite it?`, false);
@@ -1 +1 @@
1
- export declare function listCommand(): number;
1
+ export declare function listCommand(): Promise<number>;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { allChains, repoChainProblems } from "../../chains/index.js";
3
+ import { isInteractive } from "../ask.js";
3
4
  /**
4
5
  * A repo-defined chain's `source` is an absolute path into `.spf/chains/` by
5
6
  * construction (that's the one place `loadRepoChains` ever looks) — trim it
@@ -12,28 +13,46 @@ function repoChainLabel(source) {
12
13
  const idx = source.lastIndexOf(marker);
13
14
  return idx === -1 ? source : source.slice(idx);
14
15
  }
15
- export function listCommand() {
16
+ export async function listCommand() {
16
17
  // Built-ins first, then repo chains — same order `allChains()` guarantees,
17
18
  // so this listing and `findChain`'s resolution order never disagree about
18
19
  // which chain wins on a name collision.
19
20
  const chains = allChains();
20
21
  const width = Math.max(...chains.map((c) => c.name.length));
21
- for (const chain of chains) {
22
+ const entries = chains.map((chain) => {
22
23
  const agents = typeof chain.requiredAgents === "function" ? "(--agent picks who)" : chain.requiredAgents.join(", ") || "(none)";
23
24
  const suites = typeof chain.requiredSuites === "function" ? chain.requiredSuites({}) : chain.requiredSuites;
24
25
  const suiteNote = typeof chain.requiredSuites === "function" ? " (--suite overrides)" : "";
25
- console.log(`${chain.name.padEnd(width)} ${chain.phases}`);
26
- console.log(`${"".padEnd(width)} ${chain.describe}`);
27
- console.log(`${"".padEnd(width)} agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`);
28
- // `source` is undefined for every built-in — only a chain loaded from a
29
- // `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
30
- if (chain.source) {
31
- console.log(`${"".padEnd(width)} (repo: ${repoChainLabel(chain.source)})`);
26
+ return {
27
+ name: chain.name,
28
+ phases: chain.phases,
29
+ describe: chain.describe,
30
+ agentsLine: `agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`,
31
+ // `source` is undefined for every built-in — only a chain loaded from a
32
+ // `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
33
+ repoLabel: chain.source ? repoChainLabel(chain.source) : undefined,
34
+ };
35
+ });
36
+ const usageLines = [
37
+ `spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`,
38
+ `spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`,
39
+ ];
40
+ if (isInteractive()) {
41
+ const { renderChainList } = await import("../ui/reports.js");
42
+ await renderChainList(entries, usageLines);
43
+ }
44
+ else {
45
+ for (const entry of entries) {
46
+ console.log(`${entry.name.padEnd(width)} ${entry.phases}`);
47
+ console.log(`${"".padEnd(width)} ${entry.describe}`);
48
+ console.log(`${"".padEnd(width)} ${entry.agentsLine}`);
49
+ if (entry.repoLabel)
50
+ console.log(`${"".padEnd(width)} (repo: ${entry.repoLabel})`);
51
+ console.log();
32
52
  }
33
- console.log();
53
+ for (const line of usageLines)
54
+ console.log(line);
34
55
  }
35
- console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`);
36
- console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
37
56
  // Every malformed `.spf/chains/*.yaml` file (bad YAML, a schema/params
38
57
  // mismatch, a name naming a step factory that doesn't exist) becomes a
39
58
  // problem here instead of a chain in the list above — loadRepoChains()
@@ -1 +1 @@
1
- export declare function phasesCommand(argv: string[]): number;
1
+ export declare function phasesCommand(argv: string[]): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  import { parseCli } from "../../core/utils.js";
2
2
  import { openTrace } from "./trace.js";
3
- export function phasesCommand(argv) {
3
+ import { isInteractive } from "../ask.js";
4
+ export async function phasesCommand(argv) {
4
5
  const { positionals, options, flags } = parseCli(argv, ["cwd", "config"], ["json"]);
5
6
  if (positionals.length < 1) {
6
7
  console.error("usage: spf phases <adw_id> [--cwd <dir>] [--config <path>] [--json]");
@@ -17,9 +18,22 @@ export function phasesCommand(argv) {
17
18
  console.log(`no phases recorded for ${adwId}`);
18
19
  return 1;
19
20
  }
20
- for (const p of rows) {
21
- const marker = p.status === "success" ? "✓" : p.status === "fail" ? "✗" : "…";
22
- console.log(`${String(p.seq).padStart(2, "0")} ${marker} ${(p.name ?? "").padEnd(20)} ${(p.kind ?? "").padEnd(9)} ${(p.owner ?? "").padEnd(12)} ${p.error ?? ""}`);
21
+ const table = rows.map((p) => [
22
+ String(p.seq).padStart(2, "0"),
23
+ p.status === "success" ? "" : p.status === "fail" ? "" : "",
24
+ p.name ?? "",
25
+ p.kind ?? "",
26
+ p.owner ?? "",
27
+ p.error ?? "",
28
+ ]);
29
+ if (isInteractive()) {
30
+ const { renderTable } = await import("../ui/reports.js");
31
+ await renderTable(table, { rowColor: (i) => (rows[i].status === "fail" ? "red" : undefined) });
32
+ }
33
+ else {
34
+ for (const [seq, marker, name, kind, owner, error] of table) {
35
+ console.log(`${seq} ${marker} ${name.padEnd(20)} ${kind.padEnd(9)} ${owner.padEnd(12)} ${error}`);
36
+ }
23
37
  }
24
38
  return 0;
25
39
  }
@@ -1,11 +1,12 @@
1
1
  /** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
2
2
  import * as paths from "../../core/paths.js";
3
+ import * as agents from "../../core/agents.js";
3
4
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
5
  import { runChain } from "../../chains/index.js";
5
6
  import { isInteractive } from "../ask.js";
6
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite"];
7
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite", "priority"];
7
8
  export function usageFor(chain) {
8
- return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>]`;
9
+ return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] [--priority p0|p1|p2|p3]`;
9
10
  }
10
11
  export async function dispatchChain(chain, argv) {
11
12
  const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
@@ -52,5 +53,32 @@ export async function dispatchChain(chain, argv) {
52
53
  chainOptions["base"] = options["base"];
53
54
  if (options["suite"] !== undefined)
54
55
  chainOptions["suite"] = options["suite"];
56
+ // Only `refine`'s `publishIssues()` step reads this (a ceiling clamped
57
+ // onto every node it creates — see `core/refine.ts`'s `publish()`); every
58
+ // other chain ignores it, so it's harmless to always pass through, same
59
+ // as `issue` above. `publishIssues()` itself validates the value —
60
+ // rejecting anything but p0|p1|p2|p3 — so this stays a plain passthrough.
61
+ if (options["priority"] !== undefined)
62
+ chainOptions["priority"] = options["priority"];
63
+ // A live dashboard only ever makes sense for the one dispatch a human is
64
+ // actually watching — `spf watch`'s per-issue runs and `spf fanout`'s
65
+ // per-attempt runs go through `runChain`/`startRun` directly, never this
66
+ // function, so they're untouched. Loaded here (not left to `startRun`'s
67
+ // own `loadConfig` call moments later) purely to read the ceilings the
68
+ // dashboard displays — a second, cheap parse of the same small YAML, not
69
+ // a second source of truth: `startRun` still owns the real, validated
70
+ // config the run actually executes against.
71
+ if (isInteractive()) {
72
+ const { mountRunDashboard } = await import("../ui/run_dashboard.js");
73
+ const cfg = agents.loadConfig(ctx.config_paths);
74
+ const dashboard = mountRunDashboard({ maxCost: cfg.defaults.max_run_cost, maxTokens: cfg.defaults.max_run_tokens });
75
+ ctx.render_hooks = { sink: dashboard.sink, observer: dashboard.observer, pause: dashboard.pause, resume: dashboard.resume };
76
+ try {
77
+ return await runChain(chain, ctx, chainOptions);
78
+ }
79
+ finally {
80
+ await dashboard.close();
81
+ }
82
+ }
55
83
  return runChain(chain, ctx, chainOptions);
56
84
  }
@@ -1 +1 @@
1
- export declare function sessionsCommand(argv: string[]): number;
1
+ export declare function sessionsCommand(argv: string[]): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  import { parseCli } from "../../core/utils.js";
2
2
  import { openTrace } from "./trace.js";
3
- export function sessionsCommand(argv) {
3
+ import { isInteractive } from "../ask.js";
4
+ export async function sessionsCommand(argv) {
4
5
  const { options, flags } = parseCli(argv, ["cwd", "config", "limit"], ["json"]);
5
6
  const { db } = openTrace(options);
6
7
  const limit = options["limit"] ? Number.parseInt(options["limit"], 10) : 20;
@@ -13,8 +14,15 @@ export function sessionsCommand(argv) {
13
14
  console.log("no sessions yet");
14
15
  return 0;
15
16
  }
16
- for (const s of rows) {
17
- console.log(`${s.adw_id} ${(s.status ?? "").padEnd(8)} ${s.started_at} ${(s.request ?? "").slice(0, 60)}`);
17
+ const table = rows.map((s) => [s.adw_id, s.status ?? "", s.started_at ?? "", (s.request ?? "").slice(0, 60)]);
18
+ if (isInteractive()) {
19
+ const { renderTable } = await import("../ui/reports.js");
20
+ await renderTable(table);
21
+ }
22
+ else {
23
+ for (const [adwId, status, startedAt, request] of table) {
24
+ console.log(`${adwId} ${status.padEnd(8)} ${startedAt} ${request}`);
25
+ }
18
26
  }
19
27
  return 0;
20
28
  }
@@ -21,6 +21,14 @@ export declare function formatReviewDigest(review: ReviewOutputT): string;
21
21
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
22
22
  * run anything, so it skips watchCommand's repo/chain checks entirely —
23
23
  * you can seed labels before ever wiring up a worktree-capable checkout.
24
+ *
25
+ * On Jira, with `watch.refine.enabled`, this also runs a READ-ONLY check of
26
+ * `watch.jira.issue_types` against the real project — the same check
27
+ * `watchCommand`'s own startup gate runs, exposed here too so a bad mapping
28
+ * can be caught (and fixed) before ever starting the daemon, not just at
29
+ * startup time. Labels themselves stay a pure report on Jira either way
30
+ * (Jira labels are freeform strings with no color/description registry to
31
+ * seed — see `jira_provider.ts`'s module comment).
24
32
  */
25
33
  export declare function watchInitCommand(argv: string[]): Promise<number>;
26
34
  export declare function watchCommand(argv: string[]): Promise<number>;