@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.
- package/README.md +57 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +52 -5
- package/assets/templates/ts.spf.config.yaml +12 -0
- package/dist/chains/context.d.ts +2 -0
- package/dist/chains/index.d.ts +21 -2
- package/dist/chains/index.js +73 -104
- package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
- package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
- package/dist/chains/steps.d.ts +117 -0
- package/dist/chains/steps.js +299 -0
- package/dist/cli/ask.d.ts +27 -0
- package/dist/cli/ask.js +125 -0
- package/dist/cli/commands/doctor.js +14 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +96 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +20 -3
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +6 -2
- package/dist/cli/interview.d.ts +26 -0
- package/dist/cli/interview.js +417 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +55 -0
- package/dist/core/data_types.js +30 -0
- package/dist/core/notify/channel.d.ts +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/prompts.d.ts +2 -0
- package/dist/core/prompts.js +2 -0
- package/dist/core/providers.d.ts +12 -0
- package/dist/core/providers.js +24 -0
- package/dist/core/quality.d.ts +9 -0
- package/dist/core/quality.js +10 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.d.ts +6 -1
- package/dist/core/session.js +10 -3
- package/dist/core/tracer.js +1 -1
- package/dist/core/utils.d.ts +6 -2
- package/dist/core/utils.js +11 -2
- package/dist/core/watch.d.ts +10 -0
- package/dist/core/watch.js +67 -4
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/env_file.test.d.ts +1 -0
- package/dist/test/env_file.test.js +74 -0
- package/dist/test/fake_asker.d.ts +23 -0
- package/dist/test/fake_asker.js +30 -0
- package/dist/test/init_command.test.d.ts +1 -0
- package/dist/test/init_command.test.js +66 -0
- package/dist/test/interview.test.d.ts +1 -0
- package/dist/test/interview.test.js +297 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/test/watch.test.js +114 -1
- package/dist/ui/shared/types.d.ts +1 -1
- package/package.json +5 -2
- package/dist/chains/adw_build.d.ts +0 -12
- package/dist/chains/adw_build.js +0 -27
- package/dist/chains/adw_build_review.d.ts +0 -21
- package/dist/chains/adw_build_review.js +0 -55
- package/dist/chains/adw_build_test.d.ts +0 -21
- package/dist/chains/adw_build_test.js +0 -67
- package/dist/chains/adw_document.d.ts +0 -23
- package/dist/chains/adw_document.js +0 -59
- package/dist/chains/adw_plan.d.ts +0 -12
- package/dist/chains/adw_plan.js +0 -27
- package/dist/chains/adw_plan_build.d.ts +0 -12
- package/dist/chains/adw_plan_build.js +0 -30
- package/dist/chains/adw_plan_build_test.d.ts +0 -16
- package/dist/chains/adw_plan_build_test.js +0 -65
- package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
- package/dist/chains/adw_plan_build_test_quality.js +0 -66
- package/dist/chains/adw_prompt.d.ts +0 -12
- package/dist/chains/adw_prompt.js +0 -25
- package/dist/chains/adw_quality.d.ts +0 -12
- package/dist/chains/adw_quality.js +0 -32
- package/dist/chains/adw_scout.d.ts +0 -12
- package/dist/chains/adw_scout.js +0 -27
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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>]
|
|
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>;
|