@gr8ful/spf 0.5.1 → 0.6.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 (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +40 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/runner.d.ts +8 -0
  46. package/dist/core/runner.js +7 -0
  47. package/dist/core/session.d.ts +25 -0
  48. package/dist/core/session.js +97 -28
  49. package/dist/core/tiering.d.ts +145 -0
  50. package/dist/core/tiering.js +235 -0
  51. package/dist/core/tracer.d.ts +10 -0
  52. package/dist/core/tracer.js +12 -0
  53. package/dist/core/watch.d.ts +89 -9
  54. package/dist/core/watch.js +193 -19
  55. package/dist/ui/server/db.d.ts +39 -0
  56. package/dist/ui/server/db.js +61 -0
  57. package/package.json +2 -1
  58. package/dist/test/agent_cc.test.d.ts +0 -1
  59. package/dist/test/agent_cc.test.js +0 -95
  60. package/dist/test/agent_flue.test.d.ts +0 -1
  61. package/dist/test/agent_flue.test.js +0 -83
  62. package/dist/test/chains.test.d.ts +0 -12
  63. package/dist/test/chains.test.js +0 -92
  64. package/dist/test/data_types.test.d.ts +0 -10
  65. package/dist/test/data_types.test.js +0 -220
  66. package/dist/test/env_file.test.d.ts +0 -1
  67. package/dist/test/env_file.test.js +0 -74
  68. package/dist/test/fake_asker.d.ts +0 -23
  69. package/dist/test/fake_asker.js +0 -30
  70. package/dist/test/git_helper.test.d.ts +0 -1
  71. package/dist/test/git_helper.test.js +0 -59
  72. package/dist/test/hermetic_git.d.ts +0 -1
  73. package/dist/test/hermetic_git.js +0 -22
  74. package/dist/test/init_command.test.d.ts +0 -14
  75. package/dist/test/init_command.test.js +0 -136
  76. package/dist/test/interview.test.d.ts +0 -15
  77. package/dist/test/interview.test.js +0 -425
  78. package/dist/test/notify.test.d.ts +0 -1
  79. package/dist/test/notify.test.js +0 -174
  80. package/dist/test/ollama_provider.test.d.ts +0 -1
  81. package/dist/test/ollama_provider.test.js +0 -103
  82. package/dist/test/otel.test.d.ts +0 -26
  83. package/dist/test/otel.test.js +0 -512
  84. package/dist/test/paths.test.d.ts +0 -1
  85. package/dist/test/paths.test.js +0 -68
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -1,30 +0,0 @@
1
- function firstMatch(map, label) {
2
- if (!map)
3
- return undefined;
4
- const key = Object.keys(map).find((k) => label.includes(k));
5
- return key === undefined ? undefined : map[key];
6
- }
7
- export function createFakeAsker(script) {
8
- return {
9
- async text(label, opts) {
10
- const answer = firstMatch(script.text, label);
11
- return answer !== undefined ? answer : (opts?.default ?? "");
12
- },
13
- async select(label, _choices, dflt) {
14
- const answer = firstMatch(script.select, label);
15
- return (answer !== undefined ? answer : dflt);
16
- },
17
- async confirm(label, dflt) {
18
- const answer = firstMatch(script.confirm, label);
19
- if (answer !== undefined)
20
- return answer;
21
- return script.defaultConfirm ?? dflt;
22
- },
23
- async secret(label, _opts) {
24
- return firstMatch(script.secret, label) ?? "";
25
- },
26
- note() { },
27
- heading() { },
28
- close() { },
29
- };
30
- }
@@ -1 +0,0 @@
1
- import "./hermetic_git.ts";
@@ -1,59 +0,0 @@
1
- import "./hermetic_git.js";
2
- /**
3
- * `findRepoRoot()`'s contract is "never throws, always returns some root."
4
- * `isRepoAt()` only proves `git rev-parse --git-dir` succeeds, which is also
5
- * true inside a bare repo and inside a `.git/` directory itself — neither
6
- * has a work tree, so the follow-up `--show-toplevel` call fails there even
7
- * though `isRepoAt()` said yes. Regression coverage for that gap: before the
8
- * fix, `findRepoRoot()` let that failure propagate as an uncaught throw,
9
- * which — because `cli/index.ts` calls `paths.resolveAnchor()` outside its
10
- * top-level try/catch — crashed every command (including `spf --version`)
11
- * when run from a bare repo or from inside `.git/`.
12
- */
13
- import { test } from "node:test";
14
- import assert from "node:assert/strict";
15
- import { execFileSync } from "node:child_process";
16
- import { mkdtempSync, rmSync } from "node:fs";
17
- import { tmpdir } from "node:os";
18
- import path from "node:path";
19
- import { findRepoRoot } from "../core/git_helper.js";
20
- test("findRepoRoot falls back to cwd inside a bare repo (no work tree)", () => {
21
- const dir = mkdtempSync(path.join(tmpdir(), "spf-bare-"));
22
- try {
23
- execFileSync("git", ["init", "--bare", dir], { stdio: "ignore" });
24
- const root = findRepoRoot(dir);
25
- assert.equal(root, path.resolve(dir));
26
- }
27
- finally {
28
- rmSync(dir, { recursive: true, force: true });
29
- }
30
- });
31
- test("findRepoRoot falls back to cwd inside a repo's .git directory", () => {
32
- const dir = mkdtempSync(path.join(tmpdir(), "spf-dotgit-"));
33
- try {
34
- execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
35
- const gitDir = path.join(dir, ".git");
36
- const root = findRepoRoot(gitDir);
37
- assert.equal(root, path.resolve(gitDir));
38
- }
39
- finally {
40
- rmSync(dir, { recursive: true, force: true });
41
- }
42
- });
43
- test("findRepoRoot resolves the toplevel of an ordinary work tree", () => {
44
- const dir = mkdtempSync(path.join(tmpdir(), "spf-worktree-"));
45
- try {
46
- execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
47
- const sub = path.join(dir, "nested");
48
- execFileSync("node", ["-e", `require("fs").mkdirSync(${JSON.stringify(sub)})`]);
49
- const root = findRepoRoot(sub);
50
- // git may resolve symlinked tmpdirs (e.g. macOS /tmp -> /private/tmp) —
51
- // compare against what git itself reports as the raw toplevel is what
52
- // resolveAnchor ultimately does too, so lean on findRepoRoot from the
53
- // repo root itself for a stable assertion.
54
- assert.equal(root, findRepoRoot(dir));
55
- }
56
- finally {
57
- rmSync(dir, { recursive: true, force: true });
58
- }
59
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,22 +0,0 @@
1
- /**
2
- * Import this FIRST in any test file that spawns `git`.
3
- *
4
- * When the suite runs inside a git hook (lefthook's `pre-push`), git exports
5
- * GIT_DIR — and sometimes GIT_WORK_TREE/GIT_INDEX_FILE — into every child
6
- * process, and those beat `cwd` for every git invocation. A test that does
7
- * `git init` / `git remote add` / `git commit` in a scratch tmpdir then
8
- * silently operates on the REAL repository being pushed. Observed damage
9
- * before this guard existed: a stray empty "init" commit landed on the
10
- * checked-out branch, and `git init` re-initialized the shared `.git`
11
- * directory as BARE (git treats a target directory named `.git` as a bare
12
- * repo), breaking `git status` in the main checkout until `core.bare` was
13
- * flipped back.
14
- *
15
- * Deleting the variables at module load — each test file is its own
16
- * `node --test` process — makes `cwd` authoritative again for the whole
17
- * file, including git calls made by the code under test.
18
- */
19
- for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"]) {
20
- delete process.env[key];
21
- }
22
- export {};
@@ -1,14 +0,0 @@
1
- /**
2
- * Regression guard for `spf init`'s non-interactive paths — `--yes`,
3
- * `--template <name>`, and (implicitly, since the test runner's stdin/stdout
4
- * are never a TTY) plain `spf init` with no flags. All three must keep
5
- * writing the exact same content they always have, byte for byte, and must
6
- * never touch stdin — a scripted `spf init` in CI must not hang.
7
- *
8
- * The interactive interview itself is covered directly against `Asker`
9
- * in `interview.test.ts`; `initCommand`'s interactive branch always calls
10
- * the real `createAsker()` (backed by `node:readline` on `process.stdin`),
11
- * which has nothing to read in a test process — exercising it here would
12
- * just hang, so it isn't.
13
- */
14
- import "./hermetic_git.js";
@@ -1,136 +0,0 @@
1
- /**
2
- * Regression guard for `spf init`'s non-interactive paths — `--yes`,
3
- * `--template <name>`, and (implicitly, since the test runner's stdin/stdout
4
- * are never a TTY) plain `spf init` with no flags. All three must keep
5
- * writing the exact same content they always have, byte for byte, and must
6
- * never touch stdin — a scripted `spf init` in CI must not hang.
7
- *
8
- * The interactive interview itself is covered directly against `Asker`
9
- * in `interview.test.ts`; `initCommand`'s interactive branch always calls
10
- * the real `createAsker()` (backed by `node:readline` on `process.stdin`),
11
- * which has nothing to read in a test process — exercising it here would
12
- * just hang, so it isn't.
13
- */
14
- import "./hermetic_git.js";
15
- import { test, beforeEach, afterEach } from "node:test";
16
- import assert from "node:assert/strict";
17
- import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
20
- import { execFileSync } from "node:child_process";
21
- import { initCommand, EXAMPLE_CHAIN_YAML } from "../cli/commands/init.js";
22
- import { loadRepoChains } from "../chains/repo_chains.js";
23
- import { resolveAnchor } from "../core/paths.js";
24
- let dir;
25
- beforeEach(() => {
26
- dir = mkdtempSync(join(tmpdir(), "spf-init-test-"));
27
- execFileSync("git", ["init", "-q"], { cwd: dir });
28
- });
29
- afterEach(() => {
30
- rmSync(dir, { recursive: true, force: true });
31
- });
32
- test("--yes writes the plain starter config, all commented out, with no interview", async () => {
33
- const code = await initCommand(["--cwd", dir, "--yes"]);
34
- assert.equal(code, 0);
35
- const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
36
- assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
37
- assert.match(content, /^# quality:/m); // every real section stays commented out
38
- assert.doesNotMatch(content, /^defaults:/m);
39
- });
40
- test("plain `spf init` (no TTY in a test process) falls through to the same starter config as --yes", async () => {
41
- const code = await initCommand(["--cwd", dir]);
42
- assert.equal(code, 0);
43
- const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
44
- assert.match(content, /merged ON TOP of spf's packaged built-in defaults/);
45
- });
46
- test("--template writes the named template as-is, skipping the interview even without --yes", async () => {
47
- const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]);
48
- assert.equal(code, 0);
49
- const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
50
- assert.match(content, /Claude Code backend, pointed at Ollama/);
51
- });
52
- test("an existing config is left alone without --force, even for --yes", async () => {
53
- await initCommand(["--cwd", dir, "--yes"]);
54
- const before = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
55
- const code = await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
56
- assert.equal(code, 0);
57
- assert.equal(readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8"), before);
58
- });
59
- test("--force overwrites an existing config non-interactively", async () => {
60
- await initCommand(["--cwd", dir, "--yes"]);
61
- await initCommand(["--cwd", dir, "--template", "ts-cc", "--force"]);
62
- const content = readFileSync(join(dir, ".spf", "spf.config.yaml"), "utf-8");
63
- assert.match(content, /Claude Code backend, pointed at Ollama/);
64
- });
65
- test("never writes .env or .env.example on the non-interactive paths", async () => {
66
- await initCommand(["--cwd", dir, "--yes"]);
67
- assert.equal(existsSync(join(dir, ".env")), false);
68
- assert.equal(existsSync(join(dir, ".env.example")), false);
69
- });
70
- test("also installs the repo-local Claude Code skill by default, on every non-interactive path", async () => {
71
- const code = await initCommand(["--cwd", dir, "--yes"]);
72
- assert.equal(code, 0);
73
- assert.ok(existsSync(join(dir, ".claude", "skills", "spf", "SKILL.md")), "spf init should install the skill unless --no-skills is passed");
74
- });
75
- test("--no-skills skips the skill install", async () => {
76
- const code = await initCommand(["--cwd", dir, "--yes", "--no-skills"]);
77
- assert.equal(code, 0);
78
- assert.equal(existsSync(join(dir, ".claude", "skills", "spf")), false);
79
- });
80
- test("scaffolds .spf/chains/example.yaml, fully commented out (registers nothing)", async () => {
81
- const code = await initCommand(["--cwd", dir, "--yes"]);
82
- assert.equal(code, 0);
83
- const examplePath = join(dir, ".spf", "chains", "example.yaml");
84
- assert.ok(existsSync(examplePath), "spf init should scaffold .spf/chains/example.yaml");
85
- const content = readFileSync(examplePath, "utf-8");
86
- for (const line of content.split("\n")) {
87
- if (line.trim() === "")
88
- continue;
89
- assert.ok(line.startsWith("#"), `every non-blank line in the scaffolded example must be commented out: ${JSON.stringify(line)}`);
90
- }
91
- });
92
- test("never adds .spf/chains to .gitignore — chain files must stay tracked for protected_files to cover them", async () => {
93
- await initCommand(["--cwd", dir, "--yes"]);
94
- const gitignore = readFileSync(join(dir, ".gitignore"), "utf-8");
95
- assert.doesNotMatch(gitignore, /\.spf\/chains/);
96
- });
97
- test("the scaffolded example.yaml loads clean — zero chains, zero problems (it must not report a problem against spf's own scaffold)", async () => {
98
- const code = await initCommand(["--cwd", dir, "--yes"]);
99
- assert.equal(code, 0);
100
- const anchor = resolveAnchor(dir);
101
- const { chains, problems } = loadRepoChains(anchor);
102
- assert.deepEqual(chains, []);
103
- assert.deepEqual(problems, []);
104
- });
105
- test("EXAMPLE_CHAIN_YAML's commented-out template, uncommented verbatim, is a chain the loader accepts", async () => {
106
- const lines = EXAMPLE_CHAIN_YAML.split("\n");
107
- const startIdx = lines.findIndex((l) => l.startsWith("# name: example"));
108
- assert.ok(startIdx !== -1, "expected a `# name: example` line to anchor the template block");
109
- const uncommented = lines
110
- .slice(startIdx)
111
- .map((l) => l.replace(/^#\s?/, ""))
112
- .join("\n");
113
- const chainsDir = join(dir, ".spf", "chains");
114
- execFileSync("mkdir", ["-p", chainsDir]);
115
- const { writeFileSync } = await import("node:fs");
116
- writeFileSync(join(chainsDir, "uncommented.yaml"), uncommented);
117
- const anchor = resolveAnchor(dir);
118
- const { chains, problems } = loadRepoChains(anchor);
119
- assert.deepEqual(problems, [], `expected zero problems, got: ${JSON.stringify(problems)}`);
120
- assert.equal(chains.length, 1);
121
- assert.equal(chains[0]?.name, "example");
122
- });
123
- test("re-running spf init doesn't overwrite an already-scaffolded example.yaml", async () => {
124
- await initCommand(["--cwd", dir, "--yes"]);
125
- const examplePath = join(dir, ".spf", "chains", "example.yaml");
126
- const before = readFileSync(examplePath, "utf-8");
127
- await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
128
- assert.equal(readFileSync(examplePath, "utf-8"), before);
129
- });
130
- test("re-running spf init doesn't re-copy an unchanged skill install (install-skill's own idempotency)", async () => {
131
- await initCommand(["--cwd", dir, "--yes"]);
132
- const manifestPath = join(dir, ".claude", "skills", "spf", ".spf-skill-version");
133
- const before = readFileSync(manifestPath, "utf-8");
134
- await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force: config write is a no-op, skill install still runs
135
- assert.equal(readFileSync(manifestPath, "utf-8"), before);
136
- });
@@ -1,15 +0,0 @@
1
- /**
2
- * `spf init`'s interactive interview (`cli/interview.ts`) — driven through
3
- * the `Asker` seam with a scripted fake (`./fake_asker.ts`), never a real
4
- * TTY. Covers the three things a wrong answer would actually break:
5
- * 1. the pinned-roster trap (planner/reviewer/documenter overridden
6
- * whenever the backend switches to claude_code — see agent_cc.ts/
7
- * agents.ts's back-fill comment),
8
- * 2. the generated config is a genuine override — validating it requires
9
- * merging with the packaged built-in roster first, exactly like
10
- * `spf doctor` does, never the raw document alone,
11
- * 3. each `spf watch` combination collects exactly the credentials
12
- * `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
13
- * actually read — no more, no less.
14
- */
15
- import "./hermetic_git.js";