@gr8ful/spf 0.1.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/assets/defaults/spf.config.yaml +141 -0
  4. package/assets/prompts/builder/system.md +13 -0
  5. package/assets/prompts/builder/user.md +34 -0
  6. package/assets/prompts/documenter/system.md +17 -0
  7. package/assets/prompts/documenter/user.md +48 -0
  8. package/assets/prompts/planner/system.md +21 -0
  9. package/assets/prompts/planner/user.md +45 -0
  10. package/assets/prompts/reviewer/system.md +16 -0
  11. package/assets/prompts/reviewer/user.md +44 -0
  12. package/assets/prompts/scout/system.md +20 -0
  13. package/assets/prompts/scout/user.md +34 -0
  14. package/assets/skill/SKILL.md +80 -0
  15. package/assets/skill/cookbooks/authoring_chains.md +193 -0
  16. package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
  17. package/assets/skill/cookbooks/roster.md +197 -0
  18. package/assets/skill/cookbooks/run_adw.md +92 -0
  19. package/assets/skill/cookbooks/spf_overview.md +111 -0
  20. package/assets/skill/references/config.md +188 -0
  21. package/assets/skill/references/handoff.md +162 -0
  22. package/assets/skill/references/observability.md +184 -0
  23. package/dist/chains/adw_build.d.ts +12 -0
  24. package/dist/chains/adw_build.js +27 -0
  25. package/dist/chains/adw_build_review.d.ts +21 -0
  26. package/dist/chains/adw_build_review.js +55 -0
  27. package/dist/chains/adw_build_test.d.ts +21 -0
  28. package/dist/chains/adw_build_test.js +67 -0
  29. package/dist/chains/adw_document.d.ts +23 -0
  30. package/dist/chains/adw_document.js +59 -0
  31. package/dist/chains/adw_plan.d.ts +12 -0
  32. package/dist/chains/adw_plan.js +27 -0
  33. package/dist/chains/adw_plan_build.d.ts +12 -0
  34. package/dist/chains/adw_plan_build.js +30 -0
  35. package/dist/chains/adw_plan_build_test.d.ts +16 -0
  36. package/dist/chains/adw_plan_build_test.js +65 -0
  37. package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
  38. package/dist/chains/adw_plan_build_test_quality.js +66 -0
  39. package/dist/chains/adw_prompt.d.ts +12 -0
  40. package/dist/chains/adw_prompt.js +25 -0
  41. package/dist/chains/adw_quality.d.ts +12 -0
  42. package/dist/chains/adw_quality.js +32 -0
  43. package/dist/chains/adw_scout.d.ts +12 -0
  44. package/dist/chains/adw_scout.js +27 -0
  45. package/dist/chains/adw_simple_sdlc.d.ts +43 -0
  46. package/dist/chains/adw_simple_sdlc.js +147 -0
  47. package/dist/chains/context.d.ts +19 -0
  48. package/dist/chains/context.js +1 -0
  49. package/dist/chains/index.d.ts +19 -0
  50. package/dist/chains/index.js +116 -0
  51. package/dist/cli/bin.d.ts +15 -0
  52. package/dist/cli/bin.js +24 -0
  53. package/dist/cli/commands/abort.d.ts +1 -0
  54. package/dist/cli/commands/abort.js +42 -0
  55. package/dist/cli/commands/doctor.d.ts +1 -0
  56. package/dist/cli/commands/doctor.js +165 -0
  57. package/dist/cli/commands/eject.d.ts +1 -0
  58. package/dist/cli/commands/eject.js +55 -0
  59. package/dist/cli/commands/events.d.ts +1 -0
  60. package/dist/cli/commands/events.js +49 -0
  61. package/dist/cli/commands/init.d.ts +1 -0
  62. package/dist/cli/commands/init.js +62 -0
  63. package/dist/cli/commands/install-skill.d.ts +1 -0
  64. package/dist/cli/commands/install-skill.js +122 -0
  65. package/dist/cli/commands/list.d.ts +1 -0
  66. package/dist/cli/commands/list.js +13 -0
  67. package/dist/cli/commands/migrate.d.ts +1 -0
  68. package/dist/cli/commands/migrate.js +167 -0
  69. package/dist/cli/commands/phases.d.ts +1 -0
  70. package/dist/cli/commands/phases.js +25 -0
  71. package/dist/cli/commands/run.d.ts +3 -0
  72. package/dist/cli/commands/run.js +27 -0
  73. package/dist/cli/commands/sessions.d.ts +1 -0
  74. package/dist/cli/commands/sessions.js +20 -0
  75. package/dist/cli/commands/trace.d.ts +8 -0
  76. package/dist/cli/commands/trace.js +10 -0
  77. package/dist/cli/commands/ui.d.ts +1 -0
  78. package/dist/cli/commands/ui.js +35 -0
  79. package/dist/cli/commands/version.d.ts +1 -0
  80. package/dist/cli/commands/version.js +8 -0
  81. package/dist/cli/commands/watch.d.ts +8 -0
  82. package/dist/cli/commands/watch.js +210 -0
  83. package/dist/cli/gitignore.d.ts +1 -0
  84. package/dist/cli/gitignore.js +14 -0
  85. package/dist/cli/index.d.ts +1 -0
  86. package/dist/cli/index.js +156 -0
  87. package/dist/core/agent_cc.d.ts +75 -0
  88. package/dist/core/agent_cc.js +322 -0
  89. package/dist/core/agent_flue.d.ts +66 -0
  90. package/dist/core/agent_flue.js +321 -0
  91. package/dist/core/agents.d.ts +64 -0
  92. package/dist/core/agents.js +456 -0
  93. package/dist/core/changes.d.ts +35 -0
  94. package/dist/core/changes.js +98 -0
  95. package/dist/core/console.d.ts +36 -0
  96. package/dist/core/console.js +156 -0
  97. package/dist/core/data_types.d.ts +562 -0
  98. package/dist/core/data_types.js +382 -0
  99. package/dist/core/gates.d.ts +33 -0
  100. package/dist/core/gates.js +144 -0
  101. package/dist/core/git_helper.d.ts +59 -0
  102. package/dist/core/git_helper.js +115 -0
  103. package/dist/core/issues/github_provider.d.ts +52 -0
  104. package/dist/core/issues/github_provider.js +211 -0
  105. package/dist/core/issues/provider.d.ts +90 -0
  106. package/dist/core/issues/provider.js +14 -0
  107. package/dist/core/paths.d.ts +78 -0
  108. package/dist/core/paths.js +108 -0
  109. package/dist/core/permissions.d.ts +78 -0
  110. package/dist/core/permissions.js +187 -0
  111. package/dist/core/prompts.d.ts +4 -0
  112. package/dist/core/prompts.js +17 -0
  113. package/dist/core/quality.d.ts +65 -0
  114. package/dist/core/quality.js +194 -0
  115. package/dist/core/runner.d.ts +72 -0
  116. package/dist/core/runner.js +168 -0
  117. package/dist/core/session.d.ts +16 -0
  118. package/dist/core/session.js +60 -0
  119. package/dist/core/sqlite.d.ts +55 -0
  120. package/dist/core/sqlite.js +106 -0
  121. package/dist/core/tracer.d.ts +56 -0
  122. package/dist/core/tracer.js +246 -0
  123. package/dist/core/utils.d.ts +40 -0
  124. package/dist/core/utils.js +108 -0
  125. package/dist/core/watch.d.ts +44 -0
  126. package/dist/core/watch.js +194 -0
  127. package/dist/test/agent_cc.test.d.ts +1 -0
  128. package/dist/test/agent_cc.test.js +95 -0
  129. package/dist/test/agent_flue.test.d.ts +1 -0
  130. package/dist/test/agent_flue.test.js +83 -0
  131. package/dist/test/data_types.test.d.ts +10 -0
  132. package/dist/test/data_types.test.js +49 -0
  133. package/dist/test/ui_server.test.d.ts +1 -0
  134. package/dist/test/ui_server.test.js +119 -0
  135. package/dist/test/watch.test.d.ts +1 -0
  136. package/dist/test/watch.test.js +227 -0
  137. package/dist/ui/server/app.d.ts +3 -0
  138. package/dist/ui/server/app.js +98 -0
  139. package/dist/ui/server/db.d.ts +82 -0
  140. package/dist/ui/server/db.js +333 -0
  141. package/dist/ui/server/serve.d.ts +13 -0
  142. package/dist/ui/server/serve.js +80 -0
  143. package/dist/ui/server/static.d.ts +5 -0
  144. package/dist/ui/server/static.js +55 -0
  145. package/dist/ui/shared/types.d.ts +263 -0
  146. package/dist/ui/shared/types.js +8 -0
  147. package/package.json +52 -0
  148. package/web/assets/index-C7nF068F.css +1 -0
  149. package/web/assets/index-mzSArcnQ.js +11 -0
  150. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  151. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
  152. package/web/index.html +14 -0
  153. package/web/logo.svg +6 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `spf watch` — poll a `watch.repo` for issues labeled `<prefix>:ready`,
3
+ * run `watch.chain` against each in its own git worktree, open a PR, and
4
+ * track it through to merged/blocked. See `src/core/watch.ts` for the
5
+ * actual state machine; this file is just the wiring: config, the GitHub
6
+ * provider, the chain-dispatch callback, the lockfile, and the CLI loop.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+ import * as agents from "../../core/agents.js";
12
+ import * as paths from "../../core/paths.js";
13
+ import { isRepoAt, makeGit } from "../../core/git_helper.js";
14
+ import { GitHubProvider } from "../../core/issues/github_provider.js";
15
+ import { createWatchState, tick } from "../../core/watch.js";
16
+ import { findChain } from "../../chains/index.js";
17
+ import { SfDb } from "../../ui/server/db.js";
18
+ import { parseCli } from "../../core/utils.js";
19
+ /**
20
+ * Shared by `watch` and `watch init`: resolve config and build the
21
+ * provider, checking only what BOTH need — a supported provider, a
22
+ * non-empty `watch.repo`, and `GITHUB_TOKEN`. `watch`'s own extra checks
23
+ * (a real git repo, a registered chain) don't apply to seeding labels.
24
+ * Prints its own error and returns `null` on failure — the caller just
25
+ * needs to `return 1`.
26
+ */
27
+ function resolveWatchProvider(cfg) {
28
+ if (cfg.watch.provider !== "github") {
29
+ console.error(`watch.provider ${JSON.stringify(cfg.watch.provider)} is not supported`);
30
+ return null;
31
+ }
32
+ if (!cfg.watch.repo.trim()) {
33
+ console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
34
+ return null;
35
+ }
36
+ const token = process.env["GITHUB_TOKEN"];
37
+ if (!token) {
38
+ 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.');
39
+ return null;
40
+ }
41
+ return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
42
+ }
43
+ function isPidAlive(pid) {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /** Stale-steal, like the reference implementation's daemon lock: a dead pid never blocks a restart. */
53
+ function acquireLock(lockPath) {
54
+ if (existsSync(lockPath)) {
55
+ const pid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
56
+ if (Number.isInteger(pid) && isPidAlive(pid)) {
57
+ throw new Error(`another \`spf watch\` is already running (pid ${pid}) — lock at ${lockPath}`);
58
+ }
59
+ }
60
+ mkdirSync(path.dirname(lockPath), { recursive: true });
61
+ writeFileSync(lockPath, String(process.pid));
62
+ }
63
+ function releaseLock(lockPath) {
64
+ try {
65
+ unlinkSync(lockPath);
66
+ }
67
+ catch {
68
+ // already gone — fine
69
+ }
70
+ }
71
+ /**
72
+ * `spf watch init` — idempotently seed the `<prefix>:*` labels the state
73
+ * machine needs, with sensible colors/descriptions. Doesn't touch git or
74
+ * run anything, so it skips watchCommand's repo/chain checks entirely —
75
+ * you can seed labels before ever wiring up a worktree-capable checkout.
76
+ */
77
+ export async function watchInitCommand(argv) {
78
+ const { options } = parseCli(argv, ["cwd", "config"], []);
79
+ const anchor = paths.resolveAnchor(options["cwd"]);
80
+ const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
81
+ const provider = resolveWatchProvider(cfg);
82
+ if (!provider)
83
+ return 1;
84
+ const result = await provider.ensureLabels();
85
+ console.log(`spf watch init: ${cfg.watch.repo} (label prefix "${cfg.watch.label_prefix}")`);
86
+ for (const name of result.created)
87
+ console.log(` + ${name} (created)`);
88
+ for (const name of result.updated)
89
+ console.log(` ~ ${name} (updated color/description)`);
90
+ for (const name of result.unchanged)
91
+ console.log(` = ${name} (already correct)`);
92
+ return 0;
93
+ }
94
+ export async function watchCommand(argv) {
95
+ const { options, flags } = parseCli(argv, ["cwd", "config"], ["dry-run", "once"]);
96
+ const anchor = paths.resolveAnchor(options["cwd"]);
97
+ const configPaths = paths.resolveConfigPaths(anchor, options["config"]).paths;
98
+ const cfg = agents.loadConfig(configPaths);
99
+ const provider = resolveWatchProvider(cfg);
100
+ if (!provider)
101
+ return 1;
102
+ if (!isRepoAt(anchor.repo_root)) {
103
+ console.error(`${anchor.repo_root} is not a git repository`);
104
+ return 1;
105
+ }
106
+ if (!findChain(cfg.watch.chain)) {
107
+ console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
108
+ return 1;
109
+ }
110
+ const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
111
+ const lockPath = path.join(dataPaths.data_dir, "watch.lock");
112
+ try {
113
+ acquireLock(lockPath);
114
+ }
115
+ catch (error) {
116
+ console.error(error.message);
117
+ return 1;
118
+ }
119
+ const git = makeGit(anchor.repo_root);
120
+ const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
121
+ mkdirSync(worktreesDir, { recursive: true });
122
+ const runChain = async (opts) => {
123
+ const chainDef = findChain(cfg.watch.chain); // checked above
124
+ const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd };
125
+ const code = await chainDef.run(ctx);
126
+ if (code === 0)
127
+ return { accepted: true, adwId: opts.adwId, detail: "" };
128
+ let detail = `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`;
129
+ try {
130
+ const wtAnchor = paths.resolveAnchor(opts.cwd);
131
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
132
+ const db = new SfDb(wtDataPaths.db_path);
133
+ const failed = db.phases(opts.adwId).find((p) => p.status === "fail");
134
+ if (failed)
135
+ detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
136
+ }
137
+ catch {
138
+ // best-effort — the generic message above still points at where to look
139
+ }
140
+ return { accepted: false, adwId: opts.adwId, detail };
141
+ };
142
+ const deps = {
143
+ provider,
144
+ git,
145
+ worktreeGit: makeGit,
146
+ labelPrefix: cfg.watch.label_prefix,
147
+ chain: cfg.watch.chain,
148
+ baseBranch: cfg.watch.base_branch,
149
+ concurrency: cfg.watch.concurrency,
150
+ worktreesDir,
151
+ dryRun: Boolean(flags["dry-run"]),
152
+ runChain,
153
+ log: (message) => console.log(message),
154
+ };
155
+ const state = createWatchState();
156
+ let stopping = false;
157
+ // `sleep()`'s pending setTimeout keeps running regardless of `stopping` —
158
+ // setting the flag alone doesn't wake it, which is exactly why Ctrl-C
159
+ // used to appear to do nothing for up to poll_ms (default 60s): the
160
+ // signal landed, but nothing was listening for it to cancel the wait.
161
+ let interrupt = null;
162
+ function interruptibleSleep(ms) {
163
+ return new Promise((resolve) => {
164
+ const timer = setTimeout(resolve, ms);
165
+ interrupt = () => {
166
+ clearTimeout(timer);
167
+ resolve();
168
+ };
169
+ });
170
+ }
171
+ let sigints = 0;
172
+ const stop = () => {
173
+ stopping = true;
174
+ interrupt?.();
175
+ sigints++;
176
+ if (sigints >= 2) {
177
+ console.error("\n[spf] watch second interrupt — exiting immediately, without draining");
178
+ releaseLock(lockPath);
179
+ process.exit(130);
180
+ }
181
+ else {
182
+ console.error("\n[spf] watch stopping after the current tick (press again to force-quit without draining)");
183
+ }
184
+ };
185
+ process.on("SIGINT", stop);
186
+ process.on("SIGTERM", stop);
187
+ console.log(`[spf] watch ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
188
+ try {
189
+ for (;;) {
190
+ await tick(deps, state);
191
+ if (flags["once"] || stopping)
192
+ break;
193
+ await interruptibleSleep(cfg.watch.poll_ms);
194
+ if (stopping)
195
+ break;
196
+ }
197
+ while (state.inflight.size > 0) {
198
+ console.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s)...`);
199
+ await interruptibleSleep(1000);
200
+ if (stopping && sigints >= 2)
201
+ break; // stop() itself already exits on the 2nd signal; this is belt-and-suspenders
202
+ }
203
+ return 0;
204
+ }
205
+ finally {
206
+ process.off("SIGINT", stop);
207
+ process.off("SIGTERM", stop);
208
+ releaseLock(lockPath);
209
+ }
210
+ }
@@ -0,0 +1 @@
1
+ export declare function ensureGitignore(repoRoot: string, entries: string[]): void;
@@ -0,0 +1,14 @@
1
+ /** Shared by `init` and `eject` — append missing entries to the repo's `.gitignore`, idempotently. */
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ export function ensureGitignore(repoRoot, entries) {
5
+ const gitignorePath = path.join(repoRoot, ".gitignore");
6
+ const hadFile = existsSync(gitignorePath);
7
+ const existing = hadFile ? readFileSync(gitignorePath, "utf-8").split("\n") : [];
8
+ const missing = entries.filter((entry) => !existing.includes(entry));
9
+ if (missing.length === 0)
10
+ return;
11
+ const body = hadFile ? existing.join("\n").replace(/\n*$/, "\n") + "\n" : "";
12
+ writeFileSync(gitignorePath, `${body}# spf runtime\n${missing.join("\n")}\n`);
13
+ console.log(`updated ${gitignorePath} (added ${missing.join(", ")})`);
14
+ }
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `spf` — the command surface. `spf <chain-name> "<prompt>"` and
3
+ * `spf run <chain-name> "<prompt>"` are identical; the bare form exists
4
+ * because typing the chain name IS choosing what to run, and that's the
5
+ * common case. Everything else is a named subcommand.
6
+ */
7
+ import path from "node:path";
8
+ import * as agentCc from "../core/agent_cc.js";
9
+ import * as agentFlue from "../core/agent_flue.js";
10
+ import * as paths from "../core/paths.js";
11
+ import { findChain } from "../chains/index.js";
12
+ import { dispatchChain, usageFor } from "./commands/run.js";
13
+ import { listCommand } from "./commands/list.js";
14
+ import { initCommand } from "./commands/init.js";
15
+ import { installSkillCommand } from "./commands/install-skill.js";
16
+ import { migrateCommand } from "./commands/migrate.js";
17
+ import { ejectCommand } from "./commands/eject.js";
18
+ import { doctorCommand } from "./commands/doctor.js";
19
+ import { sessionsCommand } from "./commands/sessions.js";
20
+ import { phasesCommand } from "./commands/phases.js";
21
+ import { eventsCommand } from "./commands/events.js";
22
+ import { abortCommand } from "./commands/abort.js";
23
+ import { uiCommand } from "./commands/ui.js";
24
+ import { watchCommand, watchInitCommand } from "./commands/watch.js";
25
+ import { versionCommand } from "./commands/version.js";
26
+ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
27
+
28
+ spf list the chain registry — names, phases, what each needs
29
+ spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
30
+ spf init [--force] seed .spf/spf.config.yaml in the current repo
31
+ spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
32
+ spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
33
+ spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
34
+ spf doctor [--json] check everything that fails silently otherwise
35
+ spf ui [--port N] [--no-open] [--db path] open the trace visualizer
36
+ spf watch init idempotently seed the <prefix>:* labels watch.repo needs
37
+ spf watch [--dry-run] [--once] poll watch.repo for labeled issues, run watch.chain on each
38
+ spf sessions [--limit N] [--json] recent runs
39
+ spf phases <adw_id> [--json] one run's phases
40
+ spf events <adw_id> [--follow] [--json] one run's trace events
41
+ spf abort <adw_id> signal a run's process to stop
42
+ spf version print the installed version
43
+
44
+ Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
45
+ Run \`spf list\` to see every chain and what it needs.`;
46
+ /** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
47
+ function findCwdFlag(argv) {
48
+ const idx = argv.indexOf("--cwd");
49
+ return idx !== -1 ? argv[idx + 1] : undefined;
50
+ }
51
+ export async function main() {
52
+ const [cmd, ...rest] = process.argv.slice(2);
53
+ // `process.loadEnvFile()` with no argument reads from `process.cwd()` —
54
+ // the OS process's actual working directory, NOT spf's own `--cwd`
55
+ // option. Every other path in this codebase anchors to `--cwd` (that's
56
+ // the whole point of paths.resolveAnchor()); loading .env from the
57
+ // invoking shell's directory instead of the target repo's is exactly the
58
+ // anchor-mismatch bug that principle exists to close. Resolve the same
59
+ // way every command does, and load .env from the repo root it finds.
60
+ try {
61
+ const anchor = paths.resolveAnchor(findCwdFlag(rest));
62
+ process.loadEnvFile(path.join(anchor.repo_root, ".env"));
63
+ }
64
+ catch {
65
+ // no .env there — fine, nothing to load
66
+ }
67
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
68
+ console.log(HELP);
69
+ process.exitCode = 0;
70
+ return;
71
+ }
72
+ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
73
+ process.exitCode = versionCommand();
74
+ return;
75
+ }
76
+ try {
77
+ switch (cmd) {
78
+ case "run": {
79
+ const [chainName, ...chainArgs] = rest;
80
+ const chain = chainName ? findChain(chainName) : undefined;
81
+ if (!chain) {
82
+ console.error(`unknown chain: ${chainName ?? "(none given)"} — run \`spf list\` to see every chain`);
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ process.exitCode = await dispatchChain(chain, chainArgs);
87
+ return;
88
+ }
89
+ case "list":
90
+ process.exitCode = listCommand();
91
+ return;
92
+ case "init":
93
+ process.exitCode = initCommand(rest);
94
+ return;
95
+ case "install-skill":
96
+ process.exitCode = installSkillCommand(rest);
97
+ return;
98
+ case "migrate":
99
+ process.exitCode = migrateCommand(rest);
100
+ return;
101
+ case "eject":
102
+ process.exitCode = ejectCommand(rest);
103
+ return;
104
+ case "doctor":
105
+ process.exitCode = doctorCommand(rest);
106
+ return;
107
+ case "ui":
108
+ process.exitCode = await uiCommand(rest);
109
+ return;
110
+ case "watch": {
111
+ const [sub, ...watchArgs] = rest;
112
+ process.exitCode = sub === "init" ? await watchInitCommand(watchArgs) : await watchCommand(rest);
113
+ return;
114
+ }
115
+ case "sessions":
116
+ process.exitCode = sessionsCommand(rest);
117
+ return;
118
+ case "phases":
119
+ process.exitCode = phasesCommand(rest);
120
+ return;
121
+ case "events":
122
+ process.exitCode = await eventsCommand(rest);
123
+ return;
124
+ case "abort":
125
+ process.exitCode = abortCommand(rest);
126
+ return;
127
+ default: {
128
+ const chain = findChain(cmd);
129
+ if (!chain) {
130
+ console.error(`unknown command or chain: ${cmd}\n`);
131
+ console.error(HELP);
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ if (rest.length < 1) {
136
+ console.error(usageFor(chain));
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+ process.exitCode = await dispatchChain(chain, rest);
141
+ return;
142
+ }
143
+ }
144
+ }
145
+ catch (error) {
146
+ console.error(error instanceof Error ? error.message : error);
147
+ process.exitCode = 1;
148
+ }
149
+ finally {
150
+ // A no-op if this invocation never touched an agent (e.g. spf quality,
151
+ // spf list, spf doctor) — the Flue runtime only ever starts lazily, and
152
+ // agent_cc.ts's shutdown() just kills any still-running claude children.
153
+ await agentFlue.shutdown();
154
+ await agentCc.shutdown();
155
+ }
156
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Claude Code coding agent interface — a second backend alongside
3
+ * `agent_flue.ts` (`coding_agent: claude_code` in config), for running
4
+ * agents on the `claude` CLI instead of Flue. Subprocess-based, on purpose:
5
+ * the official SDK's own platform-specific optional dependency is 300MB+
6
+ * per platform, on top of the `claude` CLI itself (which anyone using this
7
+ * backend needs installed anyway) — shelling out costs SPF zero new
8
+ * dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
9
+ *
10
+ * Every flag below was verified against a REAL local run of this exact
11
+ * machine's `claude` CLI (v2.1.237) before being written — not assumed from
12
+ * the SDK's docs, which describe a related but separately-versioned
13
+ * product. Findings that shaped this file:
14
+ *
15
+ * - `--json-schema` is real, and structured output arrives as a BUILT-IN
16
+ * tool call named `StructuredOutput` (visible in the `system/init`
17
+ * message's `tools` list the moment `--json-schema` is passed) — no
18
+ * custom tool registration needed, unlike Flue's injected `sf_report`.
19
+ * The final `result` message's `structured_output` field carries the
20
+ * validated object directly.
21
+ * - `--strict-mcp-config` (with no `--mcp-config` given) is REQUIRED, not
22
+ * optional: without it, a spawned `claude` process inherits this
23
+ * machine's entire user-level MCP server configuration (GitHub, Slack,
24
+ * Google Drive, whatever the operator has configured globally) — a real
25
+ * capability leak into what's supposed to be a `tools:`-bounded headless
26
+ * agent. `--tools ""` alone does NOT strip these; confirmed by a live
27
+ * run showing `mcp__github__*`/`mcp__claude_ai_*` tools still listed in
28
+ * `system/init` without it, and gone with it.
29
+ * - `--resume <uuid>` genuinely continues context (confirmed: a follow-up
30
+ * call answered from a file read in the FIRST call, never re-reading it)
31
+ * — the same continuity Flue's `init(agent, {id})` gives, so the
32
+ * same-session-correction design carries over unchanged.
33
+ * - The result message's `total_cost_usd` has no per-component breakdown
34
+ * (unlike Flue/pi-ai's `cost.{input,output,cacheRead,cacheWrite}`) — it's
35
+ * folded into `UsageBreakdown` under `total_cost` only, honestly, rather
36
+ * than fabricating a split CC doesn't provide.
37
+ */
38
+ import type { AgentRequest, AgentResult } from "./data_types.ts";
39
+ /** One line of `claude -p --output-format stream-json` output, loosely typed — see the module doc comment for what's actually verified. */
40
+ export type CcStreamMessage = Record<string, any>;
41
+ /**
42
+ * Folds a `tool_use` content block (on an `assistant` message) + its
43
+ * matching `tool_result` block (on a LATER `user` message, linked by
44
+ * `tool_use_id`) into one record — the same shape
45
+ * `agent_flue.ToolCallTracker` produces, so `agents.ts`'s `eventForwarder`
46
+ * needs no backend-specific branching downstream of picking which tracker
47
+ * to use. `StructuredOutput` calls fold through here too, deliberately
48
+ * unfiltered — Flue's `sf_report` tool shows up as an ordinary `tool_call`
49
+ * event for the same reason, and that's useful trace information, not noise.
50
+ */
51
+ export declare class CcToolCallTracker {
52
+ private open;
53
+ observe(message: CcStreamMessage): Record<string, any> | null;
54
+ private finish;
55
+ }
56
+ export declare function isKnownToolName(name: string): boolean;
57
+ /** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
58
+ export declare function shutdown(): Promise<void>;
59
+ /**
60
+ * Run one `claude -p` turn against `request.session_id` (a UUID minted by
61
+ * `agents.ts`'s `agentSessionId()` for a fresh session, or carried over from
62
+ * `agent_map.json` for a rejoin) — `--session-id` on first contact,
63
+ * `--resume` when `request.resume` says this session already exists. CC has
64
+ * no single create-or-continue flag the way Flue's `init(id)` is, which is
65
+ * exactly why `AgentRequest.resume` exists — see its doc comment in
66
+ * `data_types.ts`.
67
+ *
68
+ * `onEvent` receives each parsed stream-json line UNFOLDED, exactly as
69
+ * `agent_flue.run()` forwards raw `ConversationStreamChunk`s — folding into
70
+ * one record per tool call is `eventForwarder`'s job (`agents.ts`), via
71
+ * this module's `CcToolCallTracker`. Folding here too would fold twice.
72
+ */
73
+ export declare function run(request: AgentRequest, onEvent?: (message: CcStreamMessage) => void, onSpawn?: (pid: number) => void, onExit?: (pid: number) => void): Promise<AgentResult>;
74
+ /** CC requires a real UUID for `--session-id`/`--resume` — `agents.ts`'s `agentSessionId()` calls this to mint a fresh one for a claude_code agent's first contact. */
75
+ export declare function newSessionId(): string;