@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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level git operations for code phases. All low-level logic lives in core/.
|
|
3
|
+
*
|
|
4
|
+
* Two discovery functions (`isRepoAt`, `findRepoRoot`) take an explicit `cwd`
|
|
5
|
+
* because they are how a repo root gets established in the first place —
|
|
6
|
+
* nothing has one yet to bind to. Everything else is repo-root-bound: call
|
|
7
|
+
* `makeGit(repoRoot)` once you have that root, and every operation it returns
|
|
8
|
+
* runs there, never against `process.cwd()`. This is deliberate — the ADW
|
|
9
|
+
* process's cwd and the repo it is operating on are two different things,
|
|
10
|
+
* and conflating them is exactly the anchor bug this factory exists to close.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
function git(args, cwd) {
|
|
15
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
16
|
+
if (result.status !== 0) {
|
|
17
|
+
throw new Error(`git ${args.join(" ")} failed: ${(result.stderr || "").trim()}`);
|
|
18
|
+
}
|
|
19
|
+
return result.stdout.trim();
|
|
20
|
+
}
|
|
21
|
+
/** True when `cwd` is inside a git working tree. Never throws — this is a question. */
|
|
22
|
+
export function isRepoAt(cwd) {
|
|
23
|
+
const result = spawnSync("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf-8" });
|
|
24
|
+
return result.status === 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Absolute root of the git repo containing `cwd`, or `cwd` itself if it is
|
|
28
|
+
* not inside one — ADWs run fine in a non-git dir; only a commit phase
|
|
29
|
+
* requires a repo. Always absolute, so it is safe to hand to a subprocess
|
|
30
|
+
* regardless of where the ADW was launched from.
|
|
31
|
+
*/
|
|
32
|
+
export function findRepoRoot(cwd) {
|
|
33
|
+
if (isRepoAt(cwd)) {
|
|
34
|
+
return path.resolve(git(["rev-parse", "--show-toplevel"], cwd));
|
|
35
|
+
}
|
|
36
|
+
return path.resolve(cwd);
|
|
37
|
+
}
|
|
38
|
+
/** Every operation this returns is bound to `repoRoot` — never `process.cwd()`. */
|
|
39
|
+
export function makeGit(repoRoot) {
|
|
40
|
+
const run = (args) => git(args, repoRoot);
|
|
41
|
+
return {
|
|
42
|
+
currentBranch: () => run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
43
|
+
createBranch: (name) => {
|
|
44
|
+
run(["checkout", "-b", name]);
|
|
45
|
+
return name;
|
|
46
|
+
},
|
|
47
|
+
isRepo: () => isRepoAt(repoRoot),
|
|
48
|
+
commitAll: (message) => {
|
|
49
|
+
if (!isRepoAt(repoRoot)) {
|
|
50
|
+
throw new Error("not a git repository — a commit phase needs one. Run `git init` in the " +
|
|
51
|
+
"repo root (and make a first commit) before running an ADW that commits.");
|
|
52
|
+
}
|
|
53
|
+
run(["add", "-A"]);
|
|
54
|
+
if (!run(["status", "--porcelain"])) {
|
|
55
|
+
throw new Error("nothing to commit — the preceding phases changed no files");
|
|
56
|
+
}
|
|
57
|
+
run(["commit", "-m", message]);
|
|
58
|
+
return run(["rev-parse", "--short", "HEAD"]);
|
|
59
|
+
},
|
|
60
|
+
changedFiles: () => {
|
|
61
|
+
const out = run(["status", "--porcelain"]);
|
|
62
|
+
return out.split("\n").filter(Boolean).map((line) => line.slice(3));
|
|
63
|
+
},
|
|
64
|
+
refExists: (ref) => {
|
|
65
|
+
const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
66
|
+
cwd: repoRoot,
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
});
|
|
69
|
+
return result.status === 0;
|
|
70
|
+
},
|
|
71
|
+
rev: (ref = "HEAD") => run(["rev-parse", ref]),
|
|
72
|
+
shortSha: (ref = "HEAD") => run(["rev-parse", "--short", ref]),
|
|
73
|
+
mergeBase: (ref, other = "HEAD") => run(["merge-base", ref, other]),
|
|
74
|
+
isDirty: () => Boolean(run(["status", "--porcelain"])),
|
|
75
|
+
untrackedFiles: () => {
|
|
76
|
+
const out = run(["ls-files", "--others", "--exclude-standard"]);
|
|
77
|
+
return out.split("\n").filter(Boolean);
|
|
78
|
+
},
|
|
79
|
+
diffFiles: (base) => {
|
|
80
|
+
const out = run(["diff", "--name-only", base]);
|
|
81
|
+
return out.split("\n").filter(Boolean);
|
|
82
|
+
},
|
|
83
|
+
diffStat: (base) => run(["diff", "--stat", base]),
|
|
84
|
+
diffCounts: (base) => {
|
|
85
|
+
let insertions = 0;
|
|
86
|
+
let deletions = 0;
|
|
87
|
+
for (const line of run(["diff", "--numstat", base]).split("\n")) {
|
|
88
|
+
if (!line)
|
|
89
|
+
continue;
|
|
90
|
+
const [added, removed] = line.split("\t");
|
|
91
|
+
if (/^\d+$/.test(added))
|
|
92
|
+
insertions += parseInt(added, 10);
|
|
93
|
+
if (/^\d+$/.test(removed))
|
|
94
|
+
deletions += parseInt(removed, 10);
|
|
95
|
+
}
|
|
96
|
+
return [insertions, deletions];
|
|
97
|
+
},
|
|
98
|
+
diffText: (base) => run(["diff", base]),
|
|
99
|
+
fetch: (remote, ref) => {
|
|
100
|
+
run(["fetch", remote, ref]);
|
|
101
|
+
},
|
|
102
|
+
worktreeAdd: (worktreePath, branch, startPoint) => {
|
|
103
|
+
run(["worktree", "add", worktreePath, "-b", branch, startPoint]);
|
|
104
|
+
},
|
|
105
|
+
worktreeRemove: (worktreePath) => {
|
|
106
|
+
spawnSync("git", ["worktree", "remove", worktreePath, "--force"], { cwd: repoRoot, encoding: "utf-8" });
|
|
107
|
+
},
|
|
108
|
+
deleteLocalBranch: (name) => {
|
|
109
|
+
spawnSync("git", ["branch", "-D", name], { cwd: repoRoot, encoding: "utf-8" });
|
|
110
|
+
},
|
|
111
|
+
push: (remote, branch) => {
|
|
112
|
+
run(["push", "-u", remote, branch]);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub REST implementation of `IssueProvider`, via Node 22's native
|
|
3
|
+
* `fetch()` — deliberately not `octokit`: the full `octokit` meta-package
|
|
4
|
+
* resolves to ~82MB of installed dependencies (`@octokit/app`,
|
|
5
|
+
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
|
|
6
|
+
* is six REST calls. `spf`'s own package stays dependency-free either way.
|
|
7
|
+
*
|
|
8
|
+
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
9
|
+
* construction — matching the reference implementation's pattern and this
|
|
10
|
+
* project's existing env-var-for-credentials philosophy. No pagination
|
|
11
|
+
* beyond one page of 100: fine for the label-scoped queries a lean v1
|
|
12
|
+
* makes (a repo with >100 open `<prefix>:ready` issues at once is not this
|
|
13
|
+
* version's problem to solve).
|
|
14
|
+
*/
|
|
15
|
+
import type { EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
16
|
+
export declare class GitHubProvider implements IssueProvider {
|
|
17
|
+
private readonly repo;
|
|
18
|
+
private readonly labelPrefix;
|
|
19
|
+
private readonly token;
|
|
20
|
+
constructor(repo: string, // "owner/name"
|
|
21
|
+
labelPrefix: string, token: string);
|
|
22
|
+
private gh;
|
|
23
|
+
private label;
|
|
24
|
+
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
25
|
+
private getLabel;
|
|
26
|
+
/**
|
|
27
|
+
* Idempotent by inspection, not by "create and catch a 422": GET each
|
|
28
|
+
* label first, then create/update/leave alone depending on what's
|
|
29
|
+
* actually there. One fewer request in the common "already correct"
|
|
30
|
+
* case, and no brittle matching against GitHub's error-message text.
|
|
31
|
+
*/
|
|
32
|
+
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
33
|
+
private toIssue;
|
|
34
|
+
private listByLabel;
|
|
35
|
+
listEligible(): Promise<Issue[]>;
|
|
36
|
+
listInState(state: WatchState, opts?: {
|
|
37
|
+
includeAll?: boolean;
|
|
38
|
+
}): Promise<Issue[]>;
|
|
39
|
+
claim(issue: Issue): Promise<boolean>;
|
|
40
|
+
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
41
|
+
comment(issue: Issue, body: string): Promise<void>;
|
|
42
|
+
openPr(issue: Issue, opts: {
|
|
43
|
+
branch: string;
|
|
44
|
+
title: string;
|
|
45
|
+
body: string;
|
|
46
|
+
base: string;
|
|
47
|
+
}): Promise<PrRef>;
|
|
48
|
+
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
49
|
+
private findMarkerComment;
|
|
50
|
+
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
51
|
+
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const API = "https://api.github.com";
|
|
2
|
+
const STATES = ["ready", "working", "review", "done", "blocked"];
|
|
3
|
+
const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
|
|
4
|
+
// GitHub label colors are 6 hex digits, no leading '#'.
|
|
5
|
+
const LABEL_META = {
|
|
6
|
+
ready: { color: "0e8a16", description: "spf watch will claim this issue on its next poll" },
|
|
7
|
+
working: { color: "fbca04", description: "spf watch has claimed this issue and is running a chain against it" },
|
|
8
|
+
review: { color: "1d76db", description: "spf watch opened a PR for this issue — awaiting merge" },
|
|
9
|
+
done: { color: "5319e7", description: "spf watch's PR for this issue merged" },
|
|
10
|
+
blocked: { color: "d93f0b", description: "spf watch gave up — needs a human" },
|
|
11
|
+
};
|
|
12
|
+
export class GitHubProvider {
|
|
13
|
+
repo;
|
|
14
|
+
labelPrefix;
|
|
15
|
+
token;
|
|
16
|
+
constructor(repo, // "owner/name"
|
|
17
|
+
labelPrefix, token) {
|
|
18
|
+
this.repo = repo;
|
|
19
|
+
this.labelPrefix = labelPrefix;
|
|
20
|
+
this.token = token;
|
|
21
|
+
}
|
|
22
|
+
async gh(path, init) {
|
|
23
|
+
const response = await fetch(`${API}${path}`, {
|
|
24
|
+
...init,
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${this.token}`,
|
|
27
|
+
Accept: "application/vnd.github+json",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
30
|
+
...init?.headers,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const detail = await response.text().catch(() => "");
|
|
35
|
+
throw new Error(`GitHub ${init?.method ?? "GET"} ${path} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
36
|
+
}
|
|
37
|
+
if (response.status === 204)
|
|
38
|
+
return undefined;
|
|
39
|
+
return (await response.json());
|
|
40
|
+
}
|
|
41
|
+
label(state) {
|
|
42
|
+
return `${this.labelPrefix}:${state}`;
|
|
43
|
+
}
|
|
44
|
+
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
45
|
+
async getLabel(name) {
|
|
46
|
+
const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
47
|
+
headers: { Authorization: `Bearer ${this.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
48
|
+
});
|
|
49
|
+
if (response.status === 404)
|
|
50
|
+
return null;
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const detail = await response.text().catch(() => "");
|
|
53
|
+
throw new Error(`GitHub GET /repos/${this.repo}/labels/${name} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
54
|
+
}
|
|
55
|
+
return (await response.json());
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Idempotent by inspection, not by "create and catch a 422": GET each
|
|
59
|
+
* label first, then create/update/leave alone depending on what's
|
|
60
|
+
* actually there. One fewer request in the common "already correct"
|
|
61
|
+
* case, and no brittle matching against GitHub's error-message text.
|
|
62
|
+
*/
|
|
63
|
+
async ensureLabels() {
|
|
64
|
+
const created = [];
|
|
65
|
+
const updated = [];
|
|
66
|
+
const unchanged = [];
|
|
67
|
+
for (const state of STATES) {
|
|
68
|
+
const name = this.label(state);
|
|
69
|
+
const { color, description } = LABEL_META[state];
|
|
70
|
+
const existing = await this.getLabel(name);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
await this.gh(`/repos/${this.repo}/labels`, { method: "POST", body: JSON.stringify({ name, color, description }) });
|
|
73
|
+
created.push(name);
|
|
74
|
+
}
|
|
75
|
+
else if (existing.color !== color || (existing.description ?? "") !== description) {
|
|
76
|
+
await this.gh(`/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
77
|
+
method: "PATCH",
|
|
78
|
+
body: JSON.stringify({ color, description }),
|
|
79
|
+
});
|
|
80
|
+
updated.push(name);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
unchanged.push(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { created, updated, unchanged };
|
|
87
|
+
}
|
|
88
|
+
toIssue(raw) {
|
|
89
|
+
return {
|
|
90
|
+
number: raw.number,
|
|
91
|
+
title: raw.title,
|
|
92
|
+
body: raw.body ?? "",
|
|
93
|
+
labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async listByLabel(label, state) {
|
|
97
|
+
const raw = await this.gh(`/repos/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=${state}&per_page=100`);
|
|
98
|
+
return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
|
|
99
|
+
}
|
|
100
|
+
async listEligible() {
|
|
101
|
+
return this.listByLabel(this.label("ready"), "open");
|
|
102
|
+
}
|
|
103
|
+
async listInState(state, opts) {
|
|
104
|
+
return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
|
|
105
|
+
}
|
|
106
|
+
async claim(issue) {
|
|
107
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/labels/${encodeURIComponent(this.label("ready"))}`, {
|
|
108
|
+
method: "DELETE",
|
|
109
|
+
}).catch(() => undefined); // already gone is fine
|
|
110
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/labels`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: JSON.stringify({ labels: [this.label("working")] }),
|
|
113
|
+
});
|
|
114
|
+
const fresh = await this.gh(`/repos/${this.repo}/issues/${issue.number}`);
|
|
115
|
+
const labels = this.toIssue(fresh).labels;
|
|
116
|
+
const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
|
|
117
|
+
if (!claimed) {
|
|
118
|
+
// Lost the race (or something else relabeled it) — put ready back so it's not stuck.
|
|
119
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/labels`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
body: JSON.stringify({ labels: [this.label("ready")] }),
|
|
122
|
+
}).catch(() => undefined);
|
|
123
|
+
}
|
|
124
|
+
return claimed;
|
|
125
|
+
}
|
|
126
|
+
async transition(issue, to, detail) {
|
|
127
|
+
for (const state of STATES) {
|
|
128
|
+
const label = this.label(state);
|
|
129
|
+
if (issue.labels.includes(label)) {
|
|
130
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/labels/${encodeURIComponent(label)}`, { method: "DELETE" }).catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/labels`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify({ labels: [this.label(to)] }),
|
|
136
|
+
});
|
|
137
|
+
if (detail)
|
|
138
|
+
await this.comment(issue, detail);
|
|
139
|
+
}
|
|
140
|
+
async comment(issue, body) {
|
|
141
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/comments`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify({ body }),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async openPr(issue, opts) {
|
|
147
|
+
// Belt-and-suspenders: ensure the PR body closes the issue even if the
|
|
148
|
+
// caller's body forgot to say so — this is how `finishReviews`-style
|
|
149
|
+
// merge polling knows an issue's work landed at all.
|
|
150
|
+
const closesTag = `Closes #${issue.number}`;
|
|
151
|
+
const body = opts.body.includes(closesTag) ? opts.body : `${opts.body}\n\n${closesTag}`;
|
|
152
|
+
const pr = await this.gh(`/repos/${this.repo}/pulls`, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify({ title: opts.title, body, head: opts.branch, base: opts.base }),
|
|
155
|
+
});
|
|
156
|
+
return { number: pr.number, branch: opts.branch, url: pr.html_url };
|
|
157
|
+
}
|
|
158
|
+
async prStatus(pr) {
|
|
159
|
+
const detail = await this.gh(`/repos/${this.repo}/pulls/${pr.number}`);
|
|
160
|
+
let ciStatus = "pending";
|
|
161
|
+
try {
|
|
162
|
+
const checks = await this.gh(`/repos/${this.repo}/commits/${detail.head.sha}/check-runs?per_page=100`);
|
|
163
|
+
if (checks.check_runs.length === 0) {
|
|
164
|
+
ciStatus = "pending"; // no checks configured — never blocks a lean v1's own polling
|
|
165
|
+
}
|
|
166
|
+
else if (checks.check_runs.some((c) => c.status !== "completed")) {
|
|
167
|
+
ciStatus = "pending";
|
|
168
|
+
}
|
|
169
|
+
else if (checks.check_runs.some((c) => ["failure", "timed_out", "cancelled", "action_required"].includes(c.conclusion ?? ""))) {
|
|
170
|
+
ciStatus = "failure";
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
ciStatus = "success";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
ciStatus = "pending"; // check-runs lookup failing shouldn't block merge/close detection
|
|
178
|
+
}
|
|
179
|
+
return { merged: detail.merged, state: detail.state, ciStatus };
|
|
180
|
+
}
|
|
181
|
+
async findMarkerComment(issueNumber) {
|
|
182
|
+
const comments = await this.gh(`/repos/${this.repo}/issues/${issueNumber}/comments?per_page=100`);
|
|
183
|
+
let found = null;
|
|
184
|
+
for (const c of comments) {
|
|
185
|
+
const match = MARKER_RE.exec(c.body || "");
|
|
186
|
+
if (!match)
|
|
187
|
+
continue;
|
|
188
|
+
try {
|
|
189
|
+
found = { id: c.id, marker: JSON.parse(match[1]) };
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// malformed marker JSON — tolerate it and keep looking, like the reference implementation does
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return found;
|
|
196
|
+
}
|
|
197
|
+
async readMarker(issue) {
|
|
198
|
+
const found = await this.findMarkerComment(issue.number);
|
|
199
|
+
return found?.marker ?? null;
|
|
200
|
+
}
|
|
201
|
+
async writeMarker(issue, marker) {
|
|
202
|
+
const body = `<!-- spf-watch: ${JSON.stringify(marker)} -->`;
|
|
203
|
+
const existing = await this.findMarkerComment(issue.number);
|
|
204
|
+
if (existing) {
|
|
205
|
+
await this.gh(`/repos/${this.repo}/issues/comments/${existing.id}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await this.gh(`/repos/${this.repo}/issues/${issue.number}/comments`, { method: "POST", body: JSON.stringify({ body }) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The issue-tracker seam `spf watch` drives — the abstraction the user's own
|
|
3
|
+
* reference implementation (a GitHub-issues SDLC poller) never had: its
|
|
4
|
+
* GitHub client is a concrete class referenced by type everywhere, so
|
|
5
|
+
* adding a second tracker would mean reworking the poll loop itself. Here,
|
|
6
|
+
* nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
|
|
7
|
+
* knows it's talking to GitHub.
|
|
8
|
+
*
|
|
9
|
+
* The label-as-state-machine design is deliberate, copied from that same
|
|
10
|
+
* reference: `transition()` is the ONE mutator, so every state change is
|
|
11
|
+
* traceable to one call site, and a provider can layer notifications
|
|
12
|
+
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
13
|
+
*/
|
|
14
|
+
export type WatchState = "ready" | "working" | "review" | "done" | "blocked";
|
|
15
|
+
export interface Issue {
|
|
16
|
+
number: number;
|
|
17
|
+
title: string;
|
|
18
|
+
body: string;
|
|
19
|
+
labels: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface PrRef {
|
|
22
|
+
number: number;
|
|
23
|
+
branch: string;
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PrStatus {
|
|
27
|
+
merged: boolean;
|
|
28
|
+
state: "open" | "closed";
|
|
29
|
+
ciStatus: "pending" | "success" | "failure";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The durable scratch state for one issue, stored as a hidden HTML comment
|
|
33
|
+
* on the issue itself — zero infrastructure, survives a daemon crash,
|
|
34
|
+
* human-readable. `attempt` bounds orphan-retry (see `watch.ts`); `ciFixes`
|
|
35
|
+
* is reserved for a future fix-loop, unused by the lean v1 poll logic.
|
|
36
|
+
*/
|
|
37
|
+
export interface WatchMarker {
|
|
38
|
+
worktree?: string;
|
|
39
|
+
branch?: string;
|
|
40
|
+
pr?: number;
|
|
41
|
+
attempt?: number;
|
|
42
|
+
}
|
|
43
|
+
/** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
|
|
44
|
+
export interface EnsureLabelsResult {
|
|
45
|
+
created: string[];
|
|
46
|
+
updated: string[];
|
|
47
|
+
unchanged: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface IssueProvider {
|
|
50
|
+
/**
|
|
51
|
+
* Idempotently seed whatever this tracker needs for the state machine to
|
|
52
|
+
* work at all — GitHub: the five `<prefix>:*` labels, with a color and
|
|
53
|
+
* description, created if missing and corrected if drifted. A tracker
|
|
54
|
+
* with no such concept (a future one might model state entirely as a
|
|
55
|
+
* workflow field) can make this a no-op — `spf watch init` just reports
|
|
56
|
+
* whatever comes back, empty results included.
|
|
57
|
+
*/
|
|
58
|
+
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
59
|
+
/** Issues currently labeled `<prefix>:ready`. */
|
|
60
|
+
listEligible(): Promise<Issue[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Issues currently in `state`. `includeAll` queries closed issues too —
|
|
63
|
+
* required for `review`, since GitHub (or any tracker with a `Closes #n`
|
|
64
|
+
* convention) can auto-close an issue the instant its PR merges, often
|
|
65
|
+
* before the next poll tick runs; an open-only query would let it vanish
|
|
66
|
+
* from tracking forever.
|
|
67
|
+
*/
|
|
68
|
+
listInState(state: WatchState, opts?: {
|
|
69
|
+
includeAll?: boolean;
|
|
70
|
+
}): Promise<Issue[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Move `ready` -> `working`, with a read-back verify (like the reference
|
|
73
|
+
* implementation's `claimIssue`) — not a true atomic claim, but enough to
|
|
74
|
+
* catch the common case; the real safety net against two daemons racing
|
|
75
|
+
* the same issue is `spf watch`'s own single-instance lockfile.
|
|
76
|
+
*/
|
|
77
|
+
claim(issue: Issue): Promise<boolean>;
|
|
78
|
+
/** The one state-mutating call. `detail`, if given, is also posted as a comment. */
|
|
79
|
+
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
80
|
+
comment(issue: Issue, body: string): Promise<void>;
|
|
81
|
+
openPr(issue: Issue, opts: {
|
|
82
|
+
branch: string;
|
|
83
|
+
title: string;
|
|
84
|
+
body: string;
|
|
85
|
+
base: string;
|
|
86
|
+
}): Promise<PrRef>;
|
|
87
|
+
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
88
|
+
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
89
|
+
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The issue-tracker seam `spf watch` drives — the abstraction the user's own
|
|
3
|
+
* reference implementation (a GitHub-issues SDLC poller) never had: its
|
|
4
|
+
* GitHub client is a concrete class referenced by type everywhere, so
|
|
5
|
+
* adding a second tracker would mean reworking the poll loop itself. Here,
|
|
6
|
+
* nothing outside `github_provider.ts` (or a future `jira_provider.ts`)
|
|
7
|
+
* knows it's talking to GitHub.
|
|
8
|
+
*
|
|
9
|
+
* The label-as-state-machine design is deliberate, copied from that same
|
|
10
|
+
* reference: `transition()` is the ONE mutator, so every state change is
|
|
11
|
+
* traceable to one call site, and a provider can layer notifications
|
|
12
|
+
* (Slack, a webhook, whatever) on top of it without the poll loop caring.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace anchor: the one place `process.cwd()` is read, and the one
|
|
3
|
+
* place that decides what "the repo" means for a run.
|
|
4
|
+
*
|
|
5
|
+
* Everything downstream — session dirs, the trace db, gate checks, git
|
|
6
|
+
* operations — is anchored to `repo_root`, an absolute path, never to
|
|
7
|
+
* whatever directory the process happened to be started from. Before this
|
|
8
|
+
* module existed, those two were silently assumed to be the same directory;
|
|
9
|
+
* a chain launched from a subdirectory (or, later, by a globally-installed
|
|
10
|
+
* CLI run from an arbitrary cwd) would resolve config/prompts/gates against
|
|
11
|
+
* one anchor and git/agent-subprocess work against another.
|
|
12
|
+
*
|
|
13
|
+
* This is also where the packaged built-in defaults live, relative to this
|
|
14
|
+
* module's own location — `PACKAGE_ROOT` is two directories up from
|
|
15
|
+
* `dist/core/paths.js` (or `src/core/paths.ts` run directly; both are two
|
|
16
|
+
* levels below the package root, so the same relative path resolves either
|
|
17
|
+
* way), with `assets/` a direct child of it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const PACKAGE_ROOT: string;
|
|
20
|
+
export declare const ASSETS_DIR: string;
|
|
21
|
+
/** The prebuilt SPA — a sibling of dist/ and assets/, built once at publish time (see app/). */
|
|
22
|
+
export declare const WEB_DIR: string;
|
|
23
|
+
export declare const BUILTIN_CONFIG_PATH: string;
|
|
24
|
+
export declare const BUILTIN_PROMPTS_DIR: string;
|
|
25
|
+
export interface RepoAnchor {
|
|
26
|
+
/** The invocation cwd this anchor was resolved from (already absolute). */
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Git toplevel containing `cwd`, or `cwd` itself if it's not a repo. Always absolute. */
|
|
29
|
+
repo_root: string;
|
|
30
|
+
/** Nearest `.spf/` directory walking up from `cwd`, capped at `repo_root`. `null` if none exists. */
|
|
31
|
+
spf_dir: string | null;
|
|
32
|
+
}
|
|
33
|
+
/** Resolve the repo anchor. `cwd` defaults to `process.cwd()` — read here, once. */
|
|
34
|
+
export declare function resolveAnchor(cwd?: string): RepoAnchor;
|
|
35
|
+
export interface ConfigResolution {
|
|
36
|
+
/** In load order — later paths override earlier ones (agents.loadConfig). */
|
|
37
|
+
paths: string[];
|
|
38
|
+
source: "explicit" | "spf-dir" | "built-in-only";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Where the config is, given an optional explicit `--config`.
|
|
42
|
+
*
|
|
43
|
+
* An explicit path is used STANDALONE — no merge with the built-in defaults,
|
|
44
|
+
* because naming a file is "use exactly this." Otherwise the built-in
|
|
45
|
+
* packaged config always loads first, and a discovered `.spf/spf.config.yaml`
|
|
46
|
+
* merges on top of it if one exists — this is the "override one field"
|
|
47
|
+
* path, via agents.loadConfig's merge.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveConfigPaths(anchor: RepoAnchor, explicit?: string): ConfigResolution;
|
|
50
|
+
export interface DataPaths {
|
|
51
|
+
/** Absolute — for every fs call. */
|
|
52
|
+
data_dir: string;
|
|
53
|
+
/** Repo-root-relative, forward-slash — for matching against git's own path output. */
|
|
54
|
+
data_dir_rel: string;
|
|
55
|
+
/** Absolute. */
|
|
56
|
+
db_path: string;
|
|
57
|
+
/** Absolute. ALWAYS a sibling of `db_path` — relocating `data_dir` must never break this. */
|
|
58
|
+
sessions_dir: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Anchor a config's own `data_dir`/`observability.db` (as loaded — relative
|
|
62
|
+
* or absolute, whichever the YAML says) to `repo_root`.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately does NOT touch the config object itself: `permissions.
|
|
65
|
+
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
66
|
+
* output, so that field stays exactly as configured. This produces a
|
|
67
|
+
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDbPath: string): DataPaths;
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a prompt ref (a `prompt_engineering.system`/`.user` value from
|
|
72
|
+
* config) to an absolute path, trying every root a ref could sensibly be
|
|
73
|
+
* written against. An absolute ref passes through unchanged. Throws naming
|
|
74
|
+
* every path it tried on a total miss — closing the historical "no fallback
|
|
75
|
+
* if a prompt file is missing" gap, where a bad ref just threw ENOENT deep
|
|
76
|
+
* inside readFileSync with no context.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolvePromptRef(anchor: Pick<RepoAnchor, "repo_root" | "spf_dir">, ref: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace anchor: the one place `process.cwd()` is read, and the one
|
|
3
|
+
* place that decides what "the repo" means for a run.
|
|
4
|
+
*
|
|
5
|
+
* Everything downstream — session dirs, the trace db, gate checks, git
|
|
6
|
+
* operations — is anchored to `repo_root`, an absolute path, never to
|
|
7
|
+
* whatever directory the process happened to be started from. Before this
|
|
8
|
+
* module existed, those two were silently assumed to be the same directory;
|
|
9
|
+
* a chain launched from a subdirectory (or, later, by a globally-installed
|
|
10
|
+
* CLI run from an arbitrary cwd) would resolve config/prompts/gates against
|
|
11
|
+
* one anchor and git/agent-subprocess work against another.
|
|
12
|
+
*
|
|
13
|
+
* This is also where the packaged built-in defaults live, relative to this
|
|
14
|
+
* module's own location — `PACKAGE_ROOT` is two directories up from
|
|
15
|
+
* `dist/core/paths.js` (or `src/core/paths.ts` run directly; both are two
|
|
16
|
+
* levels below the package root, so the same relative path resolves either
|
|
17
|
+
* way), with `assets/` a direct child of it.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, statSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { findRepoRoot } from "./git_helper.js";
|
|
22
|
+
export const PACKAGE_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
|
23
|
+
export const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
|
|
24
|
+
/** The prebuilt SPA — a sibling of dist/ and assets/, built once at publish time (see app/). */
|
|
25
|
+
export const WEB_DIR = path.join(PACKAGE_ROOT, "web");
|
|
26
|
+
export const BUILTIN_CONFIG_PATH = path.join(ASSETS_DIR, "defaults", "spf.config.yaml");
|
|
27
|
+
export const BUILTIN_PROMPTS_DIR = path.join(ASSETS_DIR, "prompts");
|
|
28
|
+
/** Walk up from `cwd` to (and including) `repoRoot` looking for a `.spf/` directory. */
|
|
29
|
+
function findSfDir(cwd, repoRoot) {
|
|
30
|
+
let dir = cwd;
|
|
31
|
+
while (true) {
|
|
32
|
+
const candidate = path.join(dir, ".spf");
|
|
33
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory())
|
|
34
|
+
return candidate;
|
|
35
|
+
if (dir === repoRoot)
|
|
36
|
+
return null;
|
|
37
|
+
const parent = path.dirname(dir);
|
|
38
|
+
if (parent === dir)
|
|
39
|
+
return null; // filesystem root — stop
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Resolve the repo anchor. `cwd` defaults to `process.cwd()` — read here, once. */
|
|
44
|
+
export function resolveAnchor(cwd) {
|
|
45
|
+
const resolvedCwd = path.resolve(cwd ?? process.cwd());
|
|
46
|
+
const repo_root = findRepoRoot(resolvedCwd);
|
|
47
|
+
return { cwd: resolvedCwd, repo_root, spf_dir: findSfDir(resolvedCwd, repo_root) };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Where the config is, given an optional explicit `--config`.
|
|
51
|
+
*
|
|
52
|
+
* An explicit path is used STANDALONE — no merge with the built-in defaults,
|
|
53
|
+
* because naming a file is "use exactly this." Otherwise the built-in
|
|
54
|
+
* packaged config always loads first, and a discovered `.spf/spf.config.yaml`
|
|
55
|
+
* merges on top of it if one exists — this is the "override one field"
|
|
56
|
+
* path, via agents.loadConfig's merge.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveConfigPaths(anchor, explicit) {
|
|
59
|
+
if (explicit)
|
|
60
|
+
return { paths: [path.resolve(anchor.cwd, explicit)], source: "explicit" };
|
|
61
|
+
if (anchor.spf_dir) {
|
|
62
|
+
const override = path.join(anchor.spf_dir, "spf.config.yaml");
|
|
63
|
+
if (existsSync(override))
|
|
64
|
+
return { paths: [BUILTIN_CONFIG_PATH, override], source: "spf-dir" };
|
|
65
|
+
}
|
|
66
|
+
return { paths: [BUILTIN_CONFIG_PATH], source: "built-in-only" };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Anchor a config's own `data_dir`/`observability.db` (as loaded — relative
|
|
70
|
+
* or absolute, whichever the YAML says) to `repo_root`.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately does NOT touch the config object itself: `permissions.
|
|
73
|
+
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
74
|
+
* output, so that field stays exactly as configured. This produces a
|
|
75
|
+
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveDataPaths(anchor, rawDataDir, rawDbPath) {
|
|
78
|
+
const data_dir = path.resolve(anchor.repo_root, rawDataDir);
|
|
79
|
+
const data_dir_rel = path.relative(anchor.repo_root, data_dir).split(path.sep).join("/");
|
|
80
|
+
const db_path = path.resolve(anchor.repo_root, rawDbPath);
|
|
81
|
+
const sessions_dir = path.resolve(path.dirname(db_path), "sessions");
|
|
82
|
+
return { data_dir, data_dir_rel, db_path, sessions_dir };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a prompt ref (a `prompt_engineering.system`/`.user` value from
|
|
86
|
+
* config) to an absolute path, trying every root a ref could sensibly be
|
|
87
|
+
* written against. An absolute ref passes through unchanged. Throws naming
|
|
88
|
+
* every path it tried on a total miss — closing the historical "no fallback
|
|
89
|
+
* if a prompt file is missing" gap, where a bad ref just threw ENOENT deep
|
|
90
|
+
* inside readFileSync with no context.
|
|
91
|
+
*/
|
|
92
|
+
export function resolvePromptRef(anchor, ref) {
|
|
93
|
+
if (path.isAbsolute(ref)) {
|
|
94
|
+
if (existsSync(ref))
|
|
95
|
+
return ref;
|
|
96
|
+
throw new Error(`prompt ref not found: ${ref}`);
|
|
97
|
+
}
|
|
98
|
+
const candidates = [path.join(anchor.repo_root, ref)];
|
|
99
|
+
if (anchor.spf_dir) {
|
|
100
|
+
candidates.push(path.join(anchor.spf_dir, ref), path.join(anchor.spf_dir, "prompt_engineering", ref));
|
|
101
|
+
}
|
|
102
|
+
candidates.push(path.join(ASSETS_DIR, ref), path.join(BUILTIN_PROMPTS_DIR, ref));
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
if (existsSync(candidate) && statSync(candidate).isFile())
|
|
105
|
+
return candidate;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`prompt ref ${JSON.stringify(ref)} not found — tried:\n ${candidates.join("\n ")}`);
|
|
108
|
+
}
|