@gr8ful/spf 0.1.7 → 0.3.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 (94) hide show
  1. package/README.md +57 -2
  2. package/assets/skill/cookbooks/authoring_chains.md +96 -84
  3. package/assets/skill/cookbooks/roster.md +3 -1
  4. package/assets/skill/references/config.md +52 -5
  5. package/assets/templates/ts.spf.config.yaml +12 -0
  6. package/dist/chains/context.d.ts +2 -0
  7. package/dist/chains/index.d.ts +21 -2
  8. package/dist/chains/index.js +73 -104
  9. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  10. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  11. package/dist/chains/steps.d.ts +117 -0
  12. package/dist/chains/steps.js +299 -0
  13. package/dist/cli/ask.d.ts +27 -0
  14. package/dist/cli/ask.js +125 -0
  15. package/dist/cli/commands/doctor.js +14 -24
  16. package/dist/cli/commands/init.d.ts +1 -1
  17. package/dist/cli/commands/init.js +96 -9
  18. package/dist/cli/commands/run.d.ts +1 -1
  19. package/dist/cli/commands/run.js +3 -1
  20. package/dist/cli/commands/watch.js +20 -3
  21. package/dist/cli/env_file.d.ts +18 -0
  22. package/dist/cli/env_file.js +99 -0
  23. package/dist/cli/index.js +6 -2
  24. package/dist/cli/interview.d.ts +26 -0
  25. package/dist/cli/interview.js +417 -0
  26. package/dist/core/agents.js +4 -1
  27. package/dist/core/console.d.ts +13 -1
  28. package/dist/core/console.js +51 -1
  29. package/dist/core/data_types.d.ts +55 -0
  30. package/dist/core/data_types.js +30 -0
  31. package/dist/core/notify/channel.d.ts +32 -0
  32. package/dist/core/notify/channel.js +14 -0
  33. package/dist/core/notify/notifier.d.ts +42 -0
  34. package/dist/core/notify/notifier.js +100 -0
  35. package/dist/core/notify/slack_channel.d.ts +13 -0
  36. package/dist/core/notify/slack_channel.js +30 -0
  37. package/dist/core/notify/teams_channel.d.ts +17 -0
  38. package/dist/core/notify/teams_channel.js +38 -0
  39. package/dist/core/notify/webhook_channel.d.ts +13 -0
  40. package/dist/core/notify/webhook_channel.js +19 -0
  41. package/dist/core/prompts.d.ts +2 -0
  42. package/dist/core/prompts.js +2 -0
  43. package/dist/core/providers.d.ts +12 -0
  44. package/dist/core/providers.js +24 -0
  45. package/dist/core/quality.d.ts +9 -0
  46. package/dist/core/quality.js +10 -0
  47. package/dist/core/runner.d.ts +7 -0
  48. package/dist/core/runner.js +4 -1
  49. package/dist/core/session.d.ts +6 -1
  50. package/dist/core/session.js +10 -3
  51. package/dist/core/tracer.js +1 -1
  52. package/dist/core/utils.d.ts +6 -2
  53. package/dist/core/utils.js +11 -2
  54. package/dist/core/watch.d.ts +10 -0
  55. package/dist/core/watch.js +67 -4
  56. package/dist/test/chains.test.d.ts +12 -0
  57. package/dist/test/chains.test.js +86 -0
  58. package/dist/test/data_types.test.js +34 -1
  59. package/dist/test/env_file.test.d.ts +1 -0
  60. package/dist/test/env_file.test.js +74 -0
  61. package/dist/test/fake_asker.d.ts +23 -0
  62. package/dist/test/fake_asker.js +30 -0
  63. package/dist/test/init_command.test.d.ts +1 -0
  64. package/dist/test/init_command.test.js +66 -0
  65. package/dist/test/interview.test.d.ts +1 -0
  66. package/dist/test/interview.test.js +297 -0
  67. package/dist/test/notify.test.d.ts +1 -0
  68. package/dist/test/notify.test.js +174 -0
  69. package/dist/test/ui_server.test.js +1 -1
  70. package/dist/test/watch.test.js +114 -1
  71. package/dist/ui/shared/types.d.ts +1 -1
  72. package/package.json +5 -2
  73. package/dist/chains/adw_build.d.ts +0 -12
  74. package/dist/chains/adw_build.js +0 -27
  75. package/dist/chains/adw_build_review.d.ts +0 -21
  76. package/dist/chains/adw_build_review.js +0 -55
  77. package/dist/chains/adw_build_test.d.ts +0 -21
  78. package/dist/chains/adw_build_test.js +0 -67
  79. package/dist/chains/adw_document.d.ts +0 -23
  80. package/dist/chains/adw_document.js +0 -59
  81. package/dist/chains/adw_plan.d.ts +0 -12
  82. package/dist/chains/adw_plan.js +0 -27
  83. package/dist/chains/adw_plan_build.d.ts +0 -12
  84. package/dist/chains/adw_plan_build.js +0 -30
  85. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  86. package/dist/chains/adw_plan_build_test.js +0 -65
  87. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  88. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  89. package/dist/chains/adw_prompt.d.ts +0 -12
  90. package/dist/chains/adw_prompt.js +0 -25
  91. package/dist/chains/adw_quality.d.ts +0 -12
  92. package/dist/chains/adw_quality.js +0 -32
  93. package/dist/chains/adw_scout.d.ts +0 -12
  94. package/dist/chains/adw_scout.js +0 -27
@@ -1,6 +1,7 @@
1
1
  /** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
+ import { runChain } from "../../chains/index.js";
4
5
  const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
5
6
  export function usageFor(chain) {
6
7
  return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
@@ -17,11 +18,12 @@ export async function dispatchChain(chain, argv) {
17
18
  config_paths: paths.resolveConfigPaths(anchor, options["config"]).paths,
18
19
  adw_id: options["adw-id"] ?? null,
19
20
  cwd: anchor.cwd,
21
+ chain_name: chain.name,
20
22
  };
21
23
  const chainOptions = {};
22
24
  if (options["agent"] !== undefined)
23
25
  chainOptions["agent"] = options["agent"];
24
26
  if (options["base"] !== undefined)
25
27
  chainOptions["base"] = options["base"];
26
- return chain.run(ctx, chainOptions);
28
+ return runChain(chain, ctx, chainOptions);
27
29
  }
@@ -10,12 +10,13 @@ import { homedir } from "node:os";
10
10
  import path from "node:path";
11
11
  import * as agents from "../../core/agents.js";
12
12
  import * as paths from "../../core/paths.js";
13
+ import { resolveNotifier } from "../../core/notify/notifier.js";
13
14
  import { isRepoAt, makeGit } from "../../core/git_helper.js";
14
15
  import { GitHubProvider } from "../../core/issues/github_provider.js";
15
16
  import { JiraProvider } from "../../core/issues/jira_provider.js";
16
17
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
17
18
  import { createWatchState, tick } from "../../core/watch.js";
18
- import { findChain } from "../../chains/index.js";
19
+ import { findChain, runChain as runChainDef } from "../../chains/index.js";
19
20
  import { SfDb } from "../../ui/server/db.js";
20
21
  import { parseCli } from "../../core/utils.js";
21
22
  /**
@@ -161,6 +162,9 @@ export async function watchCommand(argv) {
161
162
  const git = makeGit(anchor.repo_root);
162
163
  const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
163
164
  mkdirSync(worktreesDir, { recursive: true });
165
+ // `null` when notifications are off/unconfigured — every call below is a
166
+ // no-op fallback to `console.error` in that case (see notify()`s default).
167
+ const notifier = resolveNotifier(cfg, { dryRun: Boolean(flags["dry-run"]) });
164
168
  /**
165
169
  * Without this, a claimed issue's chain resolves its session/trace data
166
170
  * relative to `cwd` (the worktree, not the main repo — see
@@ -184,8 +188,8 @@ export async function watchCommand(argv) {
184
188
  }
185
189
  const runChain = async (opts) => {
186
190
  const chainDef = findChain(cfg.watch.chain); // checked above
187
- const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd };
188
- const code = await chainDef.run(ctx);
191
+ const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
192
+ const code = await runChainDef(chainDef, ctx);
189
193
  if (code === 0)
190
194
  return { accepted: true, adwId: opts.adwId, detail: "" };
191
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.`;
@@ -216,6 +220,7 @@ export async function watchCommand(argv) {
216
220
  dryRun: Boolean(flags["dry-run"]),
217
221
  runChain,
218
222
  log: (message) => console.log(message),
223
+ notify: (event) => notifier?.send(event),
219
224
  };
220
225
  const state = createWatchState();
221
226
  let stopping = false;
@@ -250,6 +255,16 @@ export async function watchCommand(argv) {
250
255
  process.on("SIGINT", stop);
251
256
  process.on("SIGTERM", stop);
252
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)" : ""}`);
258
+ deps.notify({
259
+ kind: "watch_started",
260
+ level: "info",
261
+ title: "watch started",
262
+ fields: [
263
+ ["repo", cfg.watch.repo],
264
+ ["label_prefix", cfg.watch.label_prefix],
265
+ ["chain", cfg.watch.chain],
266
+ ],
267
+ });
253
268
  try {
254
269
  for (;;) {
255
270
  await tick(deps, state);
@@ -268,6 +283,8 @@ export async function watchCommand(argv) {
268
283
  return 0;
269
284
  }
270
285
  finally {
286
+ deps.notify({ kind: "watch_stopped", level: "info", title: "watch stopped", fields: [["repo", cfg.watch.repo]] });
287
+ await notifier?.flush();
271
288
  process.off("SIGINT", stop);
272
289
  process.off("SIGTERM", stop);
273
290
  releaseLock(lockPath);
@@ -0,0 +1,18 @@
1
+ /** Parses simple `KEY=VALUE` lines; blank lines and `#` comments are ignored (and preserved verbatim on write). */
2
+ export declare function readEnvFile(p: string): Map<string, string>;
3
+ /** Redacts a secret for display: keeps the last 4 characters, masks the rest. Short values are fully masked. */
4
+ export declare function maskSecret(value: string): string;
5
+ export interface EnvUpsertResult {
6
+ added: string[];
7
+ updated: string[];
8
+ unchanged: string[];
9
+ }
10
+ /**
11
+ * Upsert `entries` into `<repoRoot>/.env`, preserving every existing line —
12
+ * comments, ordering, unrelated keys — verbatim. An existing key is
13
+ * rewritten in place (never duplicated); a new key is appended under a
14
+ * `# spf` header, matching `ensureGitignore`'s idempotent-append shape.
15
+ */
16
+ export declare function upsertEnvFile(repoRoot: string, entries: Record<string, string>): EnvUpsertResult;
17
+ /** Fills the `!.env.example` slot `.gitignore` already reserves — key names only, empty values, safe to commit. */
18
+ export declare function writeEnvExample(repoRoot: string, keys: string[]): void;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Idempotent `.env` upsert for `spf init`'s interview — the write-side
3
+ * counterpart of `process.loadEnvFile()` in `src/cli/index.ts:66`, which
4
+ * already loads `<repo_root>/.env` on every command. Modeled directly on
5
+ * `ensureGitignore` (`./gitignore.ts`): read, compute only what's missing or
6
+ * changed, log a one-line summary of key NAMES only — a secret value must
7
+ * never reach stdout, a log line, or a trace event.
8
+ */
9
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
12
+ function unquote(raw) {
13
+ const trimmed = raw.trim();
14
+ if (trimmed.length >= 2 && ((trimmed[0] === '"' && trimmed.endsWith('"')) || (trimmed[0] === "'" && trimmed.endsWith("'")))) {
15
+ return trimmed.slice(1, -1);
16
+ }
17
+ return trimmed;
18
+ }
19
+ function quoteIfNeeded(value) {
20
+ if (/[\s#"]/.test(value))
21
+ return `"${value.replace(/"/g, '\\"')}"`;
22
+ return value;
23
+ }
24
+ /** Parses simple `KEY=VALUE` lines; blank lines and `#` comments are ignored (and preserved verbatim on write). */
25
+ export function readEnvFile(p) {
26
+ const values = new Map();
27
+ if (!existsSync(p))
28
+ return values;
29
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
30
+ const match = KEY_LINE.exec(line.trim());
31
+ if (match)
32
+ values.set(match[1], unquote(match[2]));
33
+ }
34
+ return values;
35
+ }
36
+ /** Redacts a secret for display: keeps the last 4 characters, masks the rest. Short values are fully masked. */
37
+ export function maskSecret(value) {
38
+ if (value.length <= 4)
39
+ return "•".repeat(Math.max(value.length, 1));
40
+ return `${"•".repeat(value.length - 4)}${value.slice(-4)}`;
41
+ }
42
+ /**
43
+ * Upsert `entries` into `<repoRoot>/.env`, preserving every existing line —
44
+ * comments, ordering, unrelated keys — verbatim. An existing key is
45
+ * rewritten in place (never duplicated); a new key is appended under a
46
+ * `# spf` header, matching `ensureGitignore`'s idempotent-append shape.
47
+ */
48
+ export function upsertEnvFile(repoRoot, entries) {
49
+ const envPath = path.join(repoRoot, ".env");
50
+ const hadFile = existsSync(envPath);
51
+ const lines = hadFile ? readFileSync(envPath, "utf-8").split("\n") : [];
52
+ const result = { added: [], updated: [], unchanged: [] };
53
+ const remaining = new Map(Object.entries(entries));
54
+ const rewritten = lines.map((line) => {
55
+ const match = KEY_LINE.exec(line.trim());
56
+ if (!match || !remaining.has(match[1]))
57
+ return line;
58
+ const key = match[1];
59
+ const newValue = remaining.get(key);
60
+ remaining.delete(key);
61
+ if (unquote(match[2]) === newValue) {
62
+ result.unchanged.push(key);
63
+ return line;
64
+ }
65
+ result.updated.push(key);
66
+ return `${key}=${quoteIfNeeded(newValue)}`;
67
+ });
68
+ const newKeys = [...remaining.keys()];
69
+ if (newKeys.length > 0) {
70
+ result.added.push(...newKeys);
71
+ const body = hadFile ? rewritten.join("\n").replace(/\n*$/, "\n") + "\n" : "";
72
+ const appended = newKeys.map((key) => `${key}=${quoteIfNeeded(remaining.get(key))}`);
73
+ writeFileSync(envPath, `${body}# spf\n${appended.join("\n")}\n`);
74
+ }
75
+ else if (result.updated.length > 0) {
76
+ writeFileSync(envPath, rewritten.join("\n"));
77
+ }
78
+ if (!hadFile) {
79
+ try {
80
+ chmodSync(envPath, 0o600);
81
+ }
82
+ catch {
83
+ // best-effort — some filesystems (or Windows) don't support unix perms
84
+ }
85
+ }
86
+ if (result.added.length > 0 || result.updated.length > 0) {
87
+ console.log(`updated ${envPath} (added: ${result.added.join(", ") || "none"}; updated: ${result.updated.join(", ") || "none"})`);
88
+ }
89
+ return result;
90
+ }
91
+ /** Fills the `!.env.example` slot `.gitignore` already reserves — key names only, empty values, safe to commit. */
92
+ export function writeEnvExample(repoRoot, keys) {
93
+ if (keys.length === 0)
94
+ return;
95
+ const examplePath = path.join(repoRoot, ".env.example");
96
+ const unique = [...new Set(keys)].sort();
97
+ writeFileSync(examplePath, `# spf — keys required by this repo's spf.config.yaml; fill in real values in .env (gitignored)\n${unique.map((k) => `${k}=`).join("\n")}\n`);
98
+ console.log(`wrote ${examplePath}`);
99
+ }
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import * as agentCc from "../core/agent_cc.js";
9
9
  import * as agentFlue from "../core/agent_flue.js";
10
+ import * as notify from "../core/notify/notifier.js";
10
11
  import * as paths from "../core/paths.js";
11
12
  import { findChain } from "../chains/index.js";
12
13
  import { dispatchChain, usageFor } from "./commands/run.js";
@@ -27,7 +28,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
27
28
 
28
29
  spf list the chain registry — names, phases, what each needs
29
30
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
30
- spf init [--force] [--template <name>] seed .spf/spf.config.yaml (from a packaged template, if named)
31
+ spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
31
32
  spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
32
33
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
33
34
  spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
@@ -90,7 +91,7 @@ export async function main() {
90
91
  process.exitCode = listCommand();
91
92
  return;
92
93
  case "init":
93
- process.exitCode = initCommand(rest);
94
+ process.exitCode = await initCommand(rest);
94
95
  return;
95
96
  case "install-skill":
96
97
  process.exitCode = installSkillCommand(rest);
@@ -152,5 +153,8 @@ export async function main() {
152
153
  // agent_cc.ts's shutdown() just kills any still-running claude children.
153
154
  await agentFlue.shutdown();
154
155
  await agentCc.shutdown();
156
+ // A no-op if notifications are off/unconfigured — awaits any in-flight
157
+ // webhook POST so a fast-exiting command doesn't drop it mid-flight.
158
+ await notify.flushAll();
155
159
  }
156
160
  }
@@ -0,0 +1,26 @@
1
+ import type { Asker } from "./ask.ts";
2
+ export interface DetectedContext {
3
+ repoSlug?: string;
4
+ currentBranch: string;
5
+ gitEmail?: string;
6
+ gitName?: string;
7
+ scripts: Record<string, string>;
8
+ claudeOnPath: boolean;
9
+ /** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
10
+ existingEnv: Map<string, string>;
11
+ /** The packaged roster's agent names (planner, builder, scout, reviewer, documenter today) — read from the built-in config so a 6th agent added there needs no interview change. */
12
+ rosterNames: string[];
13
+ }
14
+ export declare function gatherContext(repoRoot: string, existingEnv?: Map<string, string>): DetectedContext;
15
+ export interface InterviewResult {
16
+ config: Record<string, unknown>;
17
+ env: Record<string, string>;
18
+ /** Key names only (no values) — written to a committable `.env.example`. */
19
+ envExampleKeys: string[];
20
+ }
21
+ /**
22
+ * Runs the interview and returns the config/env to write, or `null` if the
23
+ * user declines the final confirmation. Throws `InterviewAborted` (from
24
+ * `./ask.ts`) on Ctrl-C/EOF — the caller decides the exit code for that.
25
+ */
26
+ export declare function runInterview(asker: Asker, ctx: DetectedContext): Promise<InterviewResult | null>;