@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.
- package/LICENSE +21 -0
- package/README.md +321 -0
- package/assets/defaults/spf.config.yaml +141 -0
- package/assets/prompts/builder/system.md +13 -0
- package/assets/prompts/builder/user.md +34 -0
- package/assets/prompts/documenter/system.md +17 -0
- package/assets/prompts/documenter/user.md +48 -0
- package/assets/prompts/planner/system.md +21 -0
- package/assets/prompts/planner/user.md +45 -0
- package/assets/prompts/reviewer/system.md +16 -0
- package/assets/prompts/reviewer/user.md +44 -0
- package/assets/prompts/scout/system.md +20 -0
- package/assets/prompts/scout/user.md +34 -0
- package/assets/skill/SKILL.md +80 -0
- package/assets/skill/cookbooks/authoring_chains.md +193 -0
- package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
- package/assets/skill/cookbooks/roster.md +197 -0
- package/assets/skill/cookbooks/run_adw.md +92 -0
- package/assets/skill/cookbooks/spf_overview.md +111 -0
- package/assets/skill/references/config.md +188 -0
- package/assets/skill/references/handoff.md +162 -0
- package/assets/skill/references/observability.md +184 -0
- package/dist/chains/adw_build.d.ts +12 -0
- package/dist/chains/adw_build.js +27 -0
- package/dist/chains/adw_build_review.d.ts +21 -0
- package/dist/chains/adw_build_review.js +55 -0
- package/dist/chains/adw_build_test.d.ts +21 -0
- package/dist/chains/adw_build_test.js +67 -0
- package/dist/chains/adw_document.d.ts +23 -0
- package/dist/chains/adw_document.js +59 -0
- package/dist/chains/adw_plan.d.ts +12 -0
- package/dist/chains/adw_plan.js +27 -0
- package/dist/chains/adw_plan_build.d.ts +12 -0
- package/dist/chains/adw_plan_build.js +30 -0
- package/dist/chains/adw_plan_build_test.d.ts +16 -0
- package/dist/chains/adw_plan_build_test.js +65 -0
- package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
- package/dist/chains/adw_plan_build_test_quality.js +66 -0
- package/dist/chains/adw_prompt.d.ts +12 -0
- package/dist/chains/adw_prompt.js +25 -0
- package/dist/chains/adw_quality.d.ts +12 -0
- package/dist/chains/adw_quality.js +32 -0
- package/dist/chains/adw_scout.d.ts +12 -0
- package/dist/chains/adw_scout.js +27 -0
- package/dist/chains/adw_simple_sdlc.d.ts +43 -0
- package/dist/chains/adw_simple_sdlc.js +147 -0
- package/dist/chains/context.d.ts +19 -0
- package/dist/chains/context.js +1 -0
- package/dist/chains/index.d.ts +19 -0
- package/dist/chains/index.js +116 -0
- package/dist/cli/bin.d.ts +15 -0
- package/dist/cli/bin.js +24 -0
- package/dist/cli/commands/abort.d.ts +1 -0
- package/dist/cli/commands/abort.js +42 -0
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.js +165 -0
- package/dist/cli/commands/eject.d.ts +1 -0
- package/dist/cli/commands/eject.js +55 -0
- package/dist/cli/commands/events.d.ts +1 -0
- package/dist/cli/commands/events.js +49 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +62 -0
- package/dist/cli/commands/install-skill.d.ts +1 -0
- package/dist/cli/commands/install-skill.js +122 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/migrate.d.ts +1 -0
- package/dist/cli/commands/migrate.js +167 -0
- package/dist/cli/commands/phases.d.ts +1 -0
- package/dist/cli/commands/phases.js +25 -0
- package/dist/cli/commands/run.d.ts +3 -0
- package/dist/cli/commands/run.js +27 -0
- package/dist/cli/commands/sessions.d.ts +1 -0
- package/dist/cli/commands/sessions.js +20 -0
- package/dist/cli/commands/trace.d.ts +8 -0
- package/dist/cli/commands/trace.js +10 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +35 -0
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +210 -0
- package/dist/cli/gitignore.d.ts +1 -0
- package/dist/cli/gitignore.js +14 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/core/agent_cc.d.ts +75 -0
- package/dist/core/agent_cc.js +322 -0
- package/dist/core/agent_flue.d.ts +66 -0
- package/dist/core/agent_flue.js +321 -0
- package/dist/core/agents.d.ts +64 -0
- package/dist/core/agents.js +456 -0
- package/dist/core/changes.d.ts +35 -0
- package/dist/core/changes.js +98 -0
- package/dist/core/console.d.ts +36 -0
- package/dist/core/console.js +156 -0
- package/dist/core/data_types.d.ts +562 -0
- package/dist/core/data_types.js +382 -0
- package/dist/core/gates.d.ts +33 -0
- package/dist/core/gates.js +144 -0
- package/dist/core/git_helper.d.ts +59 -0
- package/dist/core/git_helper.js +115 -0
- package/dist/core/issues/github_provider.d.ts +52 -0
- package/dist/core/issues/github_provider.js +211 -0
- package/dist/core/issues/provider.d.ts +90 -0
- package/dist/core/issues/provider.js +14 -0
- package/dist/core/paths.d.ts +78 -0
- package/dist/core/paths.js +108 -0
- package/dist/core/permissions.d.ts +78 -0
- package/dist/core/permissions.js +187 -0
- package/dist/core/prompts.d.ts +4 -0
- package/dist/core/prompts.js +17 -0
- package/dist/core/quality.d.ts +65 -0
- package/dist/core/quality.js +194 -0
- package/dist/core/runner.d.ts +72 -0
- package/dist/core/runner.js +168 -0
- package/dist/core/session.d.ts +16 -0
- package/dist/core/session.js +60 -0
- package/dist/core/sqlite.d.ts +55 -0
- package/dist/core/sqlite.js +106 -0
- package/dist/core/tracer.d.ts +56 -0
- package/dist/core/tracer.js +246 -0
- package/dist/core/utils.d.ts +40 -0
- package/dist/core/utils.js +108 -0
- package/dist/core/watch.d.ts +44 -0
- package/dist/core/watch.js +194 -0
- package/dist/test/agent_cc.test.d.ts +1 -0
- package/dist/test/agent_cc.test.js +95 -0
- package/dist/test/agent_flue.test.d.ts +1 -0
- package/dist/test/agent_flue.test.js +83 -0
- package/dist/test/data_types.test.d.ts +10 -0
- package/dist/test/data_types.test.js +49 -0
- package/dist/test/ui_server.test.d.ts +1 -0
- package/dist/test/ui_server.test.js +119 -0
- package/dist/test/watch.test.d.ts +1 -0
- package/dist/test/watch.test.js +227 -0
- package/dist/ui/server/app.d.ts +3 -0
- package/dist/ui/server/app.js +98 -0
- package/dist/ui/server/db.d.ts +82 -0
- package/dist/ui/server/db.js +333 -0
- package/dist/ui/server/serve.d.ts +13 -0
- package/dist/ui/server/serve.js +80 -0
- package/dist/ui/server/static.d.ts +5 -0
- package/dist/ui/server/static.js +55 -0
- package/dist/ui/shared/types.d.ts +263 -0
- package/dist/ui/shared/types.js +8 -0
- package/package.json +52 -0
- package/web/assets/index-C7nF068F.css +1 -0
- package/web/assets/index-mzSArcnQ.js +11 -0
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
- package/web/index.html +14 -0
- 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;
|