@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,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concrete data types for the SPF ADW system.
|
|
3
|
+
*
|
|
4
|
+
* RULE (four-param rule): any function that takes more than 4 parameters takes
|
|
5
|
+
* ONE of these objects instead. AgentCall and PhaseParams are the pattern.
|
|
6
|
+
*
|
|
7
|
+
* Every agent call declares a concrete output type — an envelope built with
|
|
8
|
+
* envelopeType() — that its final JSON response is parsed against. No untyped
|
|
9
|
+
* handoffs. Validation is Valibot's job here (zod's/Python's pydantic
|
|
10
|
+
* equivalent) — chosen because Flue's own structured-output wiring is built
|
|
11
|
+
* on Valibot, so an envelope type doubles as the tool schema handed to it
|
|
12
|
+
* with no second definition.
|
|
13
|
+
*/
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
// ── Phases ────────────────────────────────────────────────────────────────────
|
|
16
|
+
/** Everything run.phase() needs. Passed as one object, never loose params. */
|
|
17
|
+
const PhaseParamsShape = v.object({
|
|
18
|
+
name: v.string(), // short id, unique within the run: "plan", "build"
|
|
19
|
+
kind: v.picklist(["engineer", "agent", "code"]),
|
|
20
|
+
owner: v.string(), // engineer's name, "git", or an agent name from config
|
|
21
|
+
description: v.string(), // REQUIRED: what this phase does and why — see below
|
|
22
|
+
retries: v.optional(v.pipe(v.number(), v.integer()), 0), // agent phases: gate-failure retries via continue
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* A phase name identifies; a description explains. Both are required.
|
|
26
|
+
*
|
|
27
|
+
* The description is the only sentence the trace, the console, and the
|
|
28
|
+
* phase block in the UI ever show about intent — everything else is ids,
|
|
29
|
+
* statuses, and timings. `commit_plan: "Commit the plan"` tells a reader
|
|
30
|
+
* nothing they could not already see, so an echo is rejected the same way
|
|
31
|
+
* a blank one is. This is a construction-time error on purpose: it fires
|
|
32
|
+
* before the phase opens, not after a run is already in the trace.
|
|
33
|
+
*/
|
|
34
|
+
export const PhaseParamsSchema = v.pipe(PhaseParamsShape, v.rawTransform(({ dataset, addIssue, NEVER }) => {
|
|
35
|
+
const value = dataset.value;
|
|
36
|
+
const text = value.description.split(/\s+/).filter(Boolean).join(" ");
|
|
37
|
+
const name = value.name || "?";
|
|
38
|
+
if (!text) {
|
|
39
|
+
addIssue({
|
|
40
|
+
message: `phase ${JSON.stringify(name)}: description is required — one sentence on what this ` +
|
|
41
|
+
`phase does and why. It is what the trace and the UI show.`,
|
|
42
|
+
});
|
|
43
|
+
return NEVER;
|
|
44
|
+
}
|
|
45
|
+
if (text.replace(/\.+$/, "").toLowerCase() === name.replace(/_/g, " ").toLowerCase()) {
|
|
46
|
+
addIssue({
|
|
47
|
+
message: `phase ${JSON.stringify(name)}: description ${JSON.stringify(text)} only restates the phase name — ` +
|
|
48
|
+
`say what it does and why instead.`,
|
|
49
|
+
});
|
|
50
|
+
return NEVER;
|
|
51
|
+
}
|
|
52
|
+
return { ...value, description: text };
|
|
53
|
+
}));
|
|
54
|
+
export function makePhaseParams(input) {
|
|
55
|
+
return v.parse(PhaseParamsSchema, input);
|
|
56
|
+
}
|
|
57
|
+
// ── Envelopes (agent output types) ───────────────────────────────────────────
|
|
58
|
+
const EnvelopeBaseEntries = {
|
|
59
|
+
status: v.picklist(["success", "fail"]),
|
|
60
|
+
summary: v.optional(v.string(), ""),
|
|
61
|
+
artifacts: v.optional(v.array(v.string()), () => []),
|
|
62
|
+
notes_for_next_agent: v.optional(v.string(), ""),
|
|
63
|
+
};
|
|
64
|
+
export const EnvelopeBaseSchema = v.object(EnvelopeBaseEntries);
|
|
65
|
+
function envelopeType(name, entries) {
|
|
66
|
+
const schema = v.object({ ...EnvelopeBaseEntries, ...entries });
|
|
67
|
+
return { name, schema, fields: Object.keys(schema.entries) };
|
|
68
|
+
}
|
|
69
|
+
export const GenericOutput = envelopeType("GenericOutput", {});
|
|
70
|
+
export const PlanOutput = envelopeType("PlanOutput", {
|
|
71
|
+
// Subject for committing the PLAN — the spec file the planner wrote, not the
|
|
72
|
+
// implementation it describes. Each agent's commit_message covers its own
|
|
73
|
+
// work product, so a chain that commits per step never reuses one agent's
|
|
74
|
+
// words for another agent's diff.
|
|
75
|
+
commit_message: v.optional(v.string(), ""),
|
|
76
|
+
});
|
|
77
|
+
export const BuildOutput = envelopeType("BuildOutput", {
|
|
78
|
+
changed_files: v.optional(v.array(v.string()), () => []),
|
|
79
|
+
commit_message: v.optional(v.string(), ""), // consumed by the git commit phase
|
|
80
|
+
});
|
|
81
|
+
export const ScoutFindingSchema = v.object({
|
|
82
|
+
file: v.string(),
|
|
83
|
+
note: v.optional(v.string(), ""),
|
|
84
|
+
});
|
|
85
|
+
export const ScoutOutput = envelopeType("ScoutOutput", {
|
|
86
|
+
findings: v.optional(v.array(ScoutFindingSchema), () => []),
|
|
87
|
+
});
|
|
88
|
+
/** One thing the request (or plan) asked for, and whether it is there. */
|
|
89
|
+
export const ReviewFindingSchema = v.object({
|
|
90
|
+
requirement: v.string(), // the ask, in the requester's words
|
|
91
|
+
met: v.boolean(),
|
|
92
|
+
evidence: v.optional(v.string(), ""), // where it lives, or what is missing
|
|
93
|
+
});
|
|
94
|
+
/** Confirmation that what was built is what was asked for — not a test run. */
|
|
95
|
+
export const ReviewOutput = envelopeType("ReviewOutput", {
|
|
96
|
+
approved: v.optional(v.boolean(), false),
|
|
97
|
+
findings: v.optional(v.array(ReviewFindingSchema), () => []),
|
|
98
|
+
blocking: v.optional(v.array(v.string()), () => []), // what must change before approval
|
|
99
|
+
});
|
|
100
|
+
/** Where the write-up of a completed change landed. */
|
|
101
|
+
export const DocumentOutput = envelopeType("DocumentOutput", {
|
|
102
|
+
document_path: v.optional(v.string(), ""), // the doc in the repo, e.g. app_docs/<adw_id>_<slug>.md
|
|
103
|
+
documented_files: v.optional(v.array(v.string()), () => []),
|
|
104
|
+
commit_message: v.optional(v.string(), ""),
|
|
105
|
+
});
|
|
106
|
+
// ── Deterministic quality blocks ─────────────────────────────────────────────
|
|
107
|
+
export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
|
|
108
|
+
export const QualityOperationSchema = v.picklist(["lint", "typecheck", "build"]);
|
|
109
|
+
/** One deterministic quality command, as configured in spf.config.yaml's `quality.checks`. */
|
|
110
|
+
export const QualityCheckSpecSchema = v.object({
|
|
111
|
+
name: v.string(),
|
|
112
|
+
area: v.optional(QualityAreaSchema, "backend"),
|
|
113
|
+
operation: QualityOperationSchema,
|
|
114
|
+
argv: v.pipe(v.array(v.string()), v.minLength(1, "argv must name at least the binary to run")),
|
|
115
|
+
timeout_seconds: v.optional(v.number(), 120),
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* `quality.checks` names the deterministic commands; `quality.suites` groups
|
|
119
|
+
* them into what a chain actually runs (`test`, `all`, ...). An unconfigured
|
|
120
|
+
* suite is a hard error at validate() time — see agents.validate() — not a
|
|
121
|
+
* silent placeholder that reports green. There is deliberately no packaged
|
|
122
|
+
* default suite: "no quality commands configured yet" must fail loudly on
|
|
123
|
+
* the first quality-gated chain, not quietly pass one.
|
|
124
|
+
*/
|
|
125
|
+
export const QualityConfigSchema = v.object({
|
|
126
|
+
checks: v.optional(v.array(QualityCheckSpecSchema), () => []),
|
|
127
|
+
suites: v.optional(v.record(v.string(), v.array(v.string())), () => ({})),
|
|
128
|
+
});
|
|
129
|
+
export function makeChangeCapture(input) {
|
|
130
|
+
return {
|
|
131
|
+
base: input.base ?? "main",
|
|
132
|
+
max_diff_lines: input.max_diff_lines ?? 2000,
|
|
133
|
+
include_untracked: input.include_untracked ?? true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The commit a change is measured from, and why that one.
|
|
138
|
+
*
|
|
139
|
+
* `reason` is the line the trace shows. A diff is only as trustworthy as the
|
|
140
|
+
* thing it was taken against, so the ADW records that choice instead of
|
|
141
|
+
* leaving the reader to infer it.
|
|
142
|
+
*/
|
|
143
|
+
export class BaseRef {
|
|
144
|
+
ref;
|
|
145
|
+
commit;
|
|
146
|
+
reason;
|
|
147
|
+
constructor(ref, commit, reason = "") {
|
|
148
|
+
this.ref = ref;
|
|
149
|
+
this.commit = commit;
|
|
150
|
+
this.reason = reason;
|
|
151
|
+
}
|
|
152
|
+
/** Display form — a named ref as itself, a pinned raw sha shortened. */
|
|
153
|
+
get label() {
|
|
154
|
+
if (this.ref.length === 40 && /^[0-9a-f]+$/.test(this.ref)) {
|
|
155
|
+
return this.ref.slice(0, 7);
|
|
156
|
+
}
|
|
157
|
+
return this.ref;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** What changed since the base commit — pure git facts, no judgement. */
|
|
161
|
+
export class ChangeSet {
|
|
162
|
+
base;
|
|
163
|
+
files;
|
|
164
|
+
untracked;
|
|
165
|
+
insertions;
|
|
166
|
+
deletions;
|
|
167
|
+
stat;
|
|
168
|
+
diff_path;
|
|
169
|
+
truncated;
|
|
170
|
+
constructor(base, files = [], untracked = [], insertions = 0, deletions = 0, stat = "", diff_path = "", // the full diff, written into context_handoff/
|
|
171
|
+
truncated = false) {
|
|
172
|
+
this.base = base;
|
|
173
|
+
this.files = files;
|
|
174
|
+
this.untracked = untracked;
|
|
175
|
+
this.insertions = insertions;
|
|
176
|
+
this.deletions = deletions;
|
|
177
|
+
this.stat = stat;
|
|
178
|
+
this.diff_path = diff_path;
|
|
179
|
+
this.truncated = truncated;
|
|
180
|
+
}
|
|
181
|
+
get empty() {
|
|
182
|
+
return this.files.length === 0 && this.untracked.length === 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A ChangeSet shaped as an envelope so an agent can be handed it directly.
|
|
187
|
+
*
|
|
188
|
+
* Same adapter idea as VerifyOutput: code computes the diff, the documenter
|
|
189
|
+
* consumes it through the one door every agent handoff uses.
|
|
190
|
+
*/
|
|
191
|
+
export const ChangesOutput = envelopeType("ChangesOutput", {
|
|
192
|
+
base: v.optional(v.string(), ""), // "<ref> @ <commit> — <reason>"
|
|
193
|
+
changed_files: v.optional(v.array(v.string()), () => []),
|
|
194
|
+
insertions: v.optional(v.number(), 0),
|
|
195
|
+
deletions: v.optional(v.number(), 0),
|
|
196
|
+
stat: v.optional(v.string(), ""),
|
|
197
|
+
diff_path: v.optional(v.string(), ""), // read this for the full diff
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* A deterministic result, shaped as an envelope so an agent can consume it.
|
|
201
|
+
*
|
|
202
|
+
* Agents hand each other typed envelopes; code blocks return QualityResult.
|
|
203
|
+
* This is the adapter, so a failing lint or test run flows back into the
|
|
204
|
+
* builder through exactly the same door a tester agent's report used to —
|
|
205
|
+
* the ADW script is the only thing that knows the difference.
|
|
206
|
+
*/
|
|
207
|
+
export const VerifyOutput = envelopeType("VerifyOutput", {
|
|
208
|
+
passed: v.optional(v.boolean(), false),
|
|
209
|
+
failures: v.optional(v.array(v.string()), () => []),
|
|
210
|
+
});
|
|
211
|
+
/**
|
|
212
|
+
* What every gate returns: the checks it ran. Violations are derived.
|
|
213
|
+
*
|
|
214
|
+
* Authoring stays a one-liner per item — `report.check(...)` appends and
|
|
215
|
+
* returns self, so a gate is a loop and a return.
|
|
216
|
+
*/
|
|
217
|
+
export class GateReport {
|
|
218
|
+
checks = [];
|
|
219
|
+
check(item, ok, note = "") {
|
|
220
|
+
this.checks.push({ item, ok, note });
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
get violations() {
|
|
224
|
+
return this.checks.filter((c) => !c.ok).map((c) => `${c.item}: ${c.note || "failed"}`);
|
|
225
|
+
}
|
|
226
|
+
get passed() {
|
|
227
|
+
return this.violations.length === 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export function makeAgentCall(input) {
|
|
231
|
+
return { previous: null, gates: [], ...input };
|
|
232
|
+
}
|
|
233
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
234
|
+
export const PromptEngineeringSchema = v.object({
|
|
235
|
+
system: v.string(), // path to system.md
|
|
236
|
+
user: v.string(), // path to user.md
|
|
237
|
+
});
|
|
238
|
+
export const ThinkingLevelSchema = v.picklist(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
239
|
+
export const AgentConfigSchema = v.object({
|
|
240
|
+
name: v.string(),
|
|
241
|
+
coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
|
|
242
|
+
model: v.optional(v.string(), "google/gemini-3.6-flash"),
|
|
243
|
+
thinking: v.optional(ThinkingLevelSchema, "medium"),
|
|
244
|
+
color: v.optional(v.string(), ""), // hex swatch for this agent's lane in the UI
|
|
245
|
+
purpose: v.optional(v.string(), ""),
|
|
246
|
+
prompt_engineering: PromptEngineeringSchema,
|
|
247
|
+
harness_engineering: v.optional(v.array(v.string()), () => []),
|
|
248
|
+
tools: v.optional(v.nullable(v.array(v.string()))), // allowlist; undefined/null = all tools usable
|
|
249
|
+
// What this agent may MODIFY in the repo, enforced in code after every call
|
|
250
|
+
// (see core/permissions.ts). `tools` cannot express this: `bash` runs
|
|
251
|
+
// anything and `write` reaches any path, so an agent's capability list is a
|
|
252
|
+
// statement of intent that nothing checks.
|
|
253
|
+
// undefined -> unrestricted, except the roster-wide `protected_files` paths
|
|
254
|
+
// [] -> read-only: may modify nothing tracked
|
|
255
|
+
// [...] -> only these. A trailing "/" means a directory prefix; a "*"
|
|
256
|
+
// makes it a glob; anything else is an exact path.
|
|
257
|
+
writes: v.optional(v.nullable(v.array(v.string()))),
|
|
258
|
+
});
|
|
259
|
+
export const ConfigDefaultsSchema = v.object({
|
|
260
|
+
coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
|
|
261
|
+
model: v.optional(v.string(), "google/gemini-3.6-flash"),
|
|
262
|
+
thinking: v.optional(ThinkingLevelSchema, "medium"),
|
|
263
|
+
color: v.optional(v.string(), ""),
|
|
264
|
+
harness_engineering: v.optional(v.array(v.string()), () => []),
|
|
265
|
+
tools: v.optional(v.nullable(v.array(v.string()))), // roster-wide allowlist; unset = all tools usable
|
|
266
|
+
// Off-limits to every agent that has not named them in its own `writes`.
|
|
267
|
+
// The factory's own code is the default: an agent must not be able to edit
|
|
268
|
+
// the machinery that decides whether its work passed.
|
|
269
|
+
// .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
|
|
270
|
+
protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
|
|
271
|
+
data_dir: v.optional(v.string(), ".spf/data"),
|
|
272
|
+
});
|
|
273
|
+
export const ObservabilityConfigSchema = v.object({
|
|
274
|
+
db: v.optional(v.string(), ".spf/data/spf.db"),
|
|
275
|
+
poll_ms: v.optional(v.number(), 500),
|
|
276
|
+
});
|
|
277
|
+
/**
|
|
278
|
+
* `spf watch`'s configuration. `provider` is a small enum today (just
|
|
279
|
+
* `"github"`) by design — adding Jira/Linear later is a new provider
|
|
280
|
+
* module (`core/issues/*_provider.ts`) implementing `IssueProvider` plus
|
|
281
|
+
* one more enum entry here, not a rewrite of the poll loop.
|
|
282
|
+
*
|
|
283
|
+
* `repo` has no sensible default and is validated as required at `spf
|
|
284
|
+
* watch` startup, not here — an empty string parses fine (this schema has
|
|
285
|
+
* no opinion on whether watch is even configured), matching the same
|
|
286
|
+
* "fails loudly before anything spawns, not eagerly at parse time" pattern
|
|
287
|
+
* `quality:` already uses.
|
|
288
|
+
*/
|
|
289
|
+
export const WatchProviderSchema = v.picklist(["github"]);
|
|
290
|
+
export const WatchConfigSchema = v.object({
|
|
291
|
+
provider: v.optional(WatchProviderSchema, "github"),
|
|
292
|
+
repo: v.optional(v.string(), ""), // "owner/name"
|
|
293
|
+
label_prefix: v.optional(v.string(), "spf"),
|
|
294
|
+
chain: v.optional(v.string(), "plan-build-test"),
|
|
295
|
+
base_branch: v.optional(v.string(), "main"),
|
|
296
|
+
poll_ms: v.optional(v.number(), 60_000),
|
|
297
|
+
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
|
|
298
|
+
});
|
|
299
|
+
export const SFConfigSchema = v.object({
|
|
300
|
+
defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
|
|
301
|
+
observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
|
|
302
|
+
agents: v.optional(v.array(AgentConfigSchema), () => []),
|
|
303
|
+
quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
|
|
304
|
+
watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
|
|
305
|
+
});
|
|
306
|
+
export function makeEventRecord(input) {
|
|
307
|
+
return {
|
|
308
|
+
phase_id: "",
|
|
309
|
+
name: "",
|
|
310
|
+
payload: {},
|
|
311
|
+
parent_id: "",
|
|
312
|
+
tokens: null,
|
|
313
|
+
started_at: null,
|
|
314
|
+
ended_at: null,
|
|
315
|
+
...input,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Tokens and the dollars they cost, per component, summed over a call.
|
|
320
|
+
*
|
|
321
|
+
* Mirrors pi's `usage` shape one-for-one so the numbers reconcile with what
|
|
322
|
+
* pi itself reports: `input` EXCLUDES cache reads, which bill at their own
|
|
323
|
+
* (cheaper) rate — add them to learn the size of the prompt that was sent.
|
|
324
|
+
*/
|
|
325
|
+
export class UsageBreakdown {
|
|
326
|
+
input_tokens = 0;
|
|
327
|
+
output_tokens = 0;
|
|
328
|
+
cache_read_tokens = 0;
|
|
329
|
+
cache_write_tokens = 0;
|
|
330
|
+
// Thinking tokens. NOT a fifth component: measured across every session on
|
|
331
|
+
// disk, reasoning is always <= output and the four components above always
|
|
332
|
+
// sum to totalTokens, so reasoning is the thinking SHARE of output, billed
|
|
333
|
+
// at the output rate. Report it nested under output, never added to it.
|
|
334
|
+
reasoning_tokens = 0;
|
|
335
|
+
total_tokens = 0;
|
|
336
|
+
input_cost = 0.0;
|
|
337
|
+
output_cost = 0.0;
|
|
338
|
+
cache_read_cost = 0.0;
|
|
339
|
+
cache_write_cost = 0.0;
|
|
340
|
+
total_cost = 0.0;
|
|
341
|
+
/**
|
|
342
|
+
* Fold in one pi `message_end` usage object.
|
|
343
|
+
*
|
|
344
|
+
* `totalTokens` is passed in rather than re-derived: the caller already
|
|
345
|
+
* computes it pi's way (totalTokens, else the sum of the parts).
|
|
346
|
+
*/
|
|
347
|
+
add_turn(usage, totalTokens) {
|
|
348
|
+
const cost = usage.cost || {};
|
|
349
|
+
this.input_tokens += usage.input || 0;
|
|
350
|
+
this.output_tokens += usage.output || 0;
|
|
351
|
+
this.cache_read_tokens += usage.cacheRead || 0;
|
|
352
|
+
this.cache_write_tokens += usage.cacheWrite || 0;
|
|
353
|
+
this.reasoning_tokens += usage.reasoning || 0;
|
|
354
|
+
this.total_tokens += totalTokens;
|
|
355
|
+
this.input_cost += cost.input || 0.0;
|
|
356
|
+
this.output_cost += cost.output || 0.0;
|
|
357
|
+
this.cache_read_cost += cost.cacheRead || 0.0;
|
|
358
|
+
this.cache_write_cost += cost.cacheWrite || 0.0;
|
|
359
|
+
this.total_cost += cost.total || 0.0;
|
|
360
|
+
}
|
|
361
|
+
/** Add another call's usage — a phase that retries spends more than once. */
|
|
362
|
+
merge(other) {
|
|
363
|
+
for (const key of Object.keys(this)) {
|
|
364
|
+
this[key] = this[key] + other[key];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
toJSON() {
|
|
368
|
+
return { ...this };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
export function makeAgentResult(input) {
|
|
372
|
+
return {
|
|
373
|
+
text: "",
|
|
374
|
+
report: null,
|
|
375
|
+
tokens: 0,
|
|
376
|
+
cost: 0,
|
|
377
|
+
usage: new UsageBreakdown(),
|
|
378
|
+
context_tokens: 0,
|
|
379
|
+
context_window: 0,
|
|
380
|
+
...input,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation gates: verify the envelope's CLAIMS, never guesses.
|
|
3
|
+
*
|
|
4
|
+
* A gate is `gate(envelope, run) -> GateReport` — one check per item it looked at.
|
|
5
|
+
* Violations are derived from the failed checks and sent back to the SAME agent
|
|
6
|
+
* session as a correction. Every check is recorded either way, so a green gate
|
|
7
|
+
* says WHAT it verified instead of only that it passed.
|
|
8
|
+
*
|
|
9
|
+
* Gates check what is mechanically checkable; plan quality is a reviewer's job.
|
|
10
|
+
*
|
|
11
|
+
* An envelope's artifact/file paths are the AGENT's claims, and the agent runs
|
|
12
|
+
* with `run.repo_root` as its cwd — so a relative claim is resolved against
|
|
13
|
+
* that root, never against this process's own cwd. A claim that resolves
|
|
14
|
+
* outside the repo entirely is reported as a violation rather than silently
|
|
15
|
+
* stat'd (or not found) somewhere unexpected.
|
|
16
|
+
*/
|
|
17
|
+
import { GateReport, type EnvelopeBase, type GateFn, type RunContext } from "./data_types.ts";
|
|
18
|
+
export declare function artifactsExist(envelope: EnvelopeBase, run: RunContext): GateReport;
|
|
19
|
+
export declare function filesNonEmpty(envelope: EnvelopeBase, run: RunContext): GateReport;
|
|
20
|
+
export declare function jsonParses(envelope: EnvelopeBase, run: RunContext): GateReport;
|
|
21
|
+
/** Every file claimed changed must exist on disk. */
|
|
22
|
+
export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContext): GateReport;
|
|
23
|
+
/**
|
|
24
|
+
* A review's verdict must agree with the findings it just wrote down.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here judges the code — that is the reviewer's job. This checks the
|
|
27
|
+
* envelope against itself: an approval that ships blocking items, or a
|
|
28
|
+
* rejection that names no problem, is a claim the harness can refute without
|
|
29
|
+
* reading a line of the diff.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
|
|
32
|
+
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
33
|
+
export declare function testsPass(command: string): GateFn;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation gates: verify the envelope's CLAIMS, never guesses.
|
|
3
|
+
*
|
|
4
|
+
* A gate is `gate(envelope, run) -> GateReport` — one check per item it looked at.
|
|
5
|
+
* Violations are derived from the failed checks and sent back to the SAME agent
|
|
6
|
+
* session as a correction. Every check is recorded either way, so a green gate
|
|
7
|
+
* says WHAT it verified instead of only that it passed.
|
|
8
|
+
*
|
|
9
|
+
* Gates check what is mechanically checkable; plan quality is a reviewer's job.
|
|
10
|
+
*
|
|
11
|
+
* An envelope's artifact/file paths are the AGENT's claims, and the agent runs
|
|
12
|
+
* with `run.repo_root` as its cwd — so a relative claim is resolved against
|
|
13
|
+
* that root, never against this process's own cwd. A claim that resolves
|
|
14
|
+
* outside the repo entirely is reported as a violation rather than silently
|
|
15
|
+
* stat'd (or not found) somewhere unexpected.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { GateReport } from "./data_types.js";
|
|
21
|
+
import { operatorEnv } from "./utils.js";
|
|
22
|
+
const TAIL_CHARS = 1000; // command output kept as evidence on a failure
|
|
23
|
+
/**
|
|
24
|
+
* Anchor an envelope-declared path to `run.repo_root`. Absolute paths pass
|
|
25
|
+
* through unchanged (an agent that reports an absolute path meant exactly
|
|
26
|
+
* that path). Returns `null` when the resolved path escapes the repo — a
|
|
27
|
+
* gate should report that as a violation, not silently check outside it.
|
|
28
|
+
*/
|
|
29
|
+
function resolveClaim(p, run) {
|
|
30
|
+
const resolved = path.isAbsolute(p) ? p : path.resolve(run.repo_root, p);
|
|
31
|
+
const rel = path.relative(run.repo_root, resolved);
|
|
32
|
+
if (rel === "..")
|
|
33
|
+
return null;
|
|
34
|
+
if (rel.startsWith(`..${path.sep}`))
|
|
35
|
+
return null;
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
function size(p) {
|
|
39
|
+
const n = statSync(p).size;
|
|
40
|
+
return n < 1024 ? `${n}B` : `${(n / 1024).toFixed(1)}KB`;
|
|
41
|
+
}
|
|
42
|
+
export function artifactsExist(envelope, run) {
|
|
43
|
+
const report = new GateReport();
|
|
44
|
+
for (const a of envelope.artifacts) {
|
|
45
|
+
const resolved = resolveClaim(a, run);
|
|
46
|
+
if (resolved === null) {
|
|
47
|
+
report.check(a, false, `declared artifact resolves outside the repo (${run.repo_root})`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const exists = existsSync(resolved);
|
|
51
|
+
report.check(a, exists, exists ? `exists, ${size(resolved)}` : "declared artifact does not exist");
|
|
52
|
+
}
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
55
|
+
export function filesNonEmpty(envelope, run) {
|
|
56
|
+
const report = new GateReport();
|
|
57
|
+
for (const a of envelope.artifacts) {
|
|
58
|
+
const resolved = resolveClaim(a, run);
|
|
59
|
+
if (resolved === null)
|
|
60
|
+
continue; // artifactsExist's job to report the escape
|
|
61
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile())
|
|
62
|
+
continue; // existence is artifactsExist's job
|
|
63
|
+
const empty = statSync(resolved).size === 0;
|
|
64
|
+
report.check(a, !empty, empty ? "declared artifact is empty" : size(resolved));
|
|
65
|
+
}
|
|
66
|
+
return report;
|
|
67
|
+
}
|
|
68
|
+
export function jsonParses(envelope, run) {
|
|
69
|
+
const report = new GateReport();
|
|
70
|
+
for (const a of envelope.artifacts) {
|
|
71
|
+
if (!a.endsWith(".json"))
|
|
72
|
+
continue;
|
|
73
|
+
const resolved = resolveClaim(a, run);
|
|
74
|
+
if (resolved === null || !existsSync(resolved))
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(readFileSync(resolved, "utf-8"));
|
|
78
|
+
const kind = Array.isArray(parsed) ? "list" : typeof parsed === "object" && parsed !== null ? "dict" : typeof parsed;
|
|
79
|
+
report.check(a, true, `parses, ${kind}`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
report.check(a, false, `declared JSON artifact does not parse: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return report;
|
|
86
|
+
}
|
|
87
|
+
/** Every file claimed changed must exist on disk. */
|
|
88
|
+
export function diffMatchesClaims(envelope, run) {
|
|
89
|
+
const report = new GateReport();
|
|
90
|
+
const changedFiles = envelope.changed_files ?? [];
|
|
91
|
+
for (const f of changedFiles) {
|
|
92
|
+
const resolved = resolveClaim(f, run);
|
|
93
|
+
if (resolved === null) {
|
|
94
|
+
report.check(f, false, `claimed changed file resolves outside the repo (${run.repo_root})`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const exists = existsSync(resolved);
|
|
98
|
+
report.check(f, exists, exists ? `exists, ${size(resolved)}` : "claimed changed file does not exist");
|
|
99
|
+
}
|
|
100
|
+
return report;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A review's verdict must agree with the findings it just wrote down.
|
|
104
|
+
*
|
|
105
|
+
* Nothing here judges the code — that is the reviewer's job. This checks the
|
|
106
|
+
* envelope against itself: an approval that ships blocking items, or a
|
|
107
|
+
* rejection that names no problem, is a claim the harness can refute without
|
|
108
|
+
* reading a line of the diff.
|
|
109
|
+
*/
|
|
110
|
+
export function verdictConsistent(envelope, _run) {
|
|
111
|
+
const report = new GateReport();
|
|
112
|
+
const approved = Boolean(envelope.approved ?? false);
|
|
113
|
+
const blocking = envelope.blocking ?? [];
|
|
114
|
+
const findings = envelope.findings ?? [];
|
|
115
|
+
const unmet = findings.filter((f) => !f.met).map((f) => f.requirement);
|
|
116
|
+
report.check("approved vs blocking", !(approved && blocking.length > 0), blocking.length === 0
|
|
117
|
+
? "no blocking items"
|
|
118
|
+
: approved
|
|
119
|
+
? `${blocking.length} blocking item(s) while approved=true`
|
|
120
|
+
: `${blocking.length} blocking item(s), not approved`);
|
|
121
|
+
report.check("approved vs findings", !(approved && unmet.length > 0), unmet.length === 0
|
|
122
|
+
? "every requirement met"
|
|
123
|
+
: approved
|
|
124
|
+
? `${unmet.length} unmet requirement(s) while approved=true`
|
|
125
|
+
: `${unmet.length} unmet requirement(s), not approved`);
|
|
126
|
+
report.check("rejection names a problem", approved || blocking.length > 0 || unmet.length > 0, approved || blocking.length > 0 || unmet.length > 0
|
|
127
|
+
? "verdict is supported"
|
|
128
|
+
: "approved=false but no blocking item or unmet requirement was given");
|
|
129
|
+
return report;
|
|
130
|
+
}
|
|
131
|
+
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
132
|
+
export function testsPass(command) {
|
|
133
|
+
const gate = (_envelope, run) => {
|
|
134
|
+
const result = spawnSync(command, { shell: true, encoding: "utf-8", cwd: run.repo_root, env: operatorEnv() });
|
|
135
|
+
const ok = result.status === 0;
|
|
136
|
+
let note = `exit ${result.status}`;
|
|
137
|
+
if (!ok) {
|
|
138
|
+
note += "\n" + ((result.stdout || "") + (result.stderr || "")).slice(-TAIL_CHARS);
|
|
139
|
+
}
|
|
140
|
+
return new GateReport().check(command, ok, note);
|
|
141
|
+
};
|
|
142
|
+
Object.defineProperty(gate, "name", { value: `tests_pass(${command})` });
|
|
143
|
+
return gate;
|
|
144
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
/** True when `cwd` is inside a git working tree. Never throws — this is a question. */
|
|
13
|
+
export declare function isRepoAt(cwd: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Absolute root of the git repo containing `cwd`, or `cwd` itself if it is
|
|
16
|
+
* not inside one — ADWs run fine in a non-git dir; only a commit phase
|
|
17
|
+
* requires a repo. Always absolute, so it is safe to hand to a subprocess
|
|
18
|
+
* regardless of where the ADW was launched from.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findRepoRoot(cwd: string): string;
|
|
21
|
+
export interface GitHandle {
|
|
22
|
+
currentBranch(): string;
|
|
23
|
+
createBranch(name: string): string;
|
|
24
|
+
isRepo(): boolean;
|
|
25
|
+
/** Stage the working tree and commit it. Returns the new short sha. */
|
|
26
|
+
commitAll(message: string): string;
|
|
27
|
+
changedFiles(): string[];
|
|
28
|
+
/** True when `ref` resolves to a commit. Never throws — this is a question. */
|
|
29
|
+
refExists(ref: string): boolean;
|
|
30
|
+
rev(ref?: string): string;
|
|
31
|
+
shortSha(ref?: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* The commit where `ref` and `other` diverged — the honest base of a branch.
|
|
34
|
+
*
|
|
35
|
+
* On the base branch itself this returns HEAD, which makes the diff exactly
|
|
36
|
+
* "what is not committed yet". Off it, the diff is the whole branch plus the
|
|
37
|
+
* working tree. One command covers both cases, so no caller has to branch on it.
|
|
38
|
+
*/
|
|
39
|
+
mergeBase(ref: string, other?: string): string;
|
|
40
|
+
isDirty(): boolean;
|
|
41
|
+
untrackedFiles(): string[];
|
|
42
|
+
/** Tracked files that differ between `base` and the working tree. */
|
|
43
|
+
diffFiles(base: string): string[];
|
|
44
|
+
diffStat(base: string): string;
|
|
45
|
+
/** [insertions, deletions] across the diff. Binary files count as neither. */
|
|
46
|
+
diffCounts(base: string): [number, number];
|
|
47
|
+
diffText(base: string): string;
|
|
48
|
+
/** `git fetch <remote> <ref>` — needed before branching off a remote base that may have moved. */
|
|
49
|
+
fetch(remote: string, ref: string): void;
|
|
50
|
+
/** `git worktree add <path> -b <branch> <startPoint>` — used by `spf watch` to isolate one issue's work outside the repo. */
|
|
51
|
+
worktreeAdd(worktreePath: string, branch: string, startPoint: string): void;
|
|
52
|
+
/** `git worktree remove <path> --force`. Never throws if the path is already gone. */
|
|
53
|
+
worktreeRemove(worktreePath: string): void;
|
|
54
|
+
/** `git branch -D <name>`. Never throws if the branch is already gone. */
|
|
55
|
+
deleteLocalBranch(name: string): void;
|
|
56
|
+
push(remote: string, branch: string): void;
|
|
57
|
+
}
|
|
58
|
+
/** Every operation this returns is bound to `repoRoot` — never `process.cwd()`. */
|
|
59
|
+
export declare function makeGit(repoRoot: string): GitHandle;
|