@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,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading/validation and agent execution.
|
|
3
|
+
*
|
|
4
|
+
* Every ADW validates its agents before running (fail fast, nothing spawns
|
|
5
|
+
* against a half-valid config). Every agent call parses against a concrete
|
|
6
|
+
* output type; parse failures and gate violations re-prompt the SAME session
|
|
7
|
+
* with a correction — context intact, bounded retries. Agent proposes, code
|
|
8
|
+
* disposes.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import * as v from "valibot";
|
|
14
|
+
import * as agentCc from "./agent_cc.js";
|
|
15
|
+
import * as agentFlue from "./agent_flue.js";
|
|
16
|
+
import * as paths from "./paths.js";
|
|
17
|
+
import * as permissions from "./permissions.js";
|
|
18
|
+
import * as prompts from "./prompts.js";
|
|
19
|
+
import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
|
|
20
|
+
import { newId } from "./utils.js";
|
|
21
|
+
const JSON_FIX_ATTEMPTS = 2; // continue-with-correction attempts for malformed JSON
|
|
22
|
+
export class GateFailure extends Error {
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A ValiError's own `.message` is only its FIRST issue — fine for a quick
|
|
26
|
+
* console line, not for something a human has to act on or a model has to
|
|
27
|
+
* repair. `v.flatten()` gives every issue, keyed by field path; this renders
|
|
28
|
+
* that as one line per field. Any other error (e.g. JSON.parse's
|
|
29
|
+
* SyntaxError) falls back to its plain message, unchanged.
|
|
30
|
+
*/
|
|
31
|
+
function describeParseError(error) {
|
|
32
|
+
if (!(error instanceof v.ValiError)) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
35
|
+
const flat = v.flatten(error.issues);
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (flat.root)
|
|
38
|
+
lines.push(...flat.root);
|
|
39
|
+
if (flat.nested) {
|
|
40
|
+
for (const [field, messages] of Object.entries(flat.nested)) {
|
|
41
|
+
for (const message of messages ?? [])
|
|
42
|
+
lines.push(`${field}: ${message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return lines.length > 0 ? lines.join("; ") : error.message;
|
|
46
|
+
}
|
|
47
|
+
// ── config ───────────────────────────────────────────────────────────────────
|
|
48
|
+
/** Same-name agents patch (shallow field overwrite); new names append. */
|
|
49
|
+
function mergeAgentLists(base, override) {
|
|
50
|
+
const merged = base.map((agent) => ({ ...agent }));
|
|
51
|
+
for (const overrideAgent of override) {
|
|
52
|
+
const i = merged.findIndex((a) => a.name === overrideAgent.name);
|
|
53
|
+
if (i === -1)
|
|
54
|
+
merged.push({ ...overrideAgent });
|
|
55
|
+
else
|
|
56
|
+
merged[i] = { ...merged[i], ...overrideAgent };
|
|
57
|
+
}
|
|
58
|
+
return merged;
|
|
59
|
+
}
|
|
60
|
+
/** `defaults`/`observability`/`quality` merge key-by-key; `agents` merges by name. */
|
|
61
|
+
function mergeRawConfig(base, override) {
|
|
62
|
+
return {
|
|
63
|
+
defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
|
|
64
|
+
observability: { ...(base.observability || {}), ...(override.observability || {}) },
|
|
65
|
+
quality: { ...(base.quality || {}), ...(override.quality || {}) },
|
|
66
|
+
watch: { ...(base.watch || {}), ...(override.watch || {}) },
|
|
67
|
+
agents: mergeAgentLists(base.agents || [], override.agents || []),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Load and merge every existing path in `configPaths`, in order — later
|
|
72
|
+
* paths override earlier ones. Built-in defaults first, an optional `.spf/`
|
|
73
|
+
* override second is the normal case; a single explicit `--config` path
|
|
74
|
+
* (the caller passes just that one path, no built-in) is used standalone.
|
|
75
|
+
* A path that doesn't exist is silently skipped, EXCEPT that if none of them
|
|
76
|
+
* exist the result is an all-defaults config — every field has a schema
|
|
77
|
+
* default, so this degrades to "zero agents defined" rather than a crash,
|
|
78
|
+
* and agents.validate() reports that plainly.
|
|
79
|
+
*/
|
|
80
|
+
export function loadConfig(configPaths) {
|
|
81
|
+
let raw = {};
|
|
82
|
+
for (const configPath of configPaths) {
|
|
83
|
+
if (!existsSync(configPath))
|
|
84
|
+
continue;
|
|
85
|
+
const parsed = parseYaml(readFileSync(configPath, "utf-8")) || {};
|
|
86
|
+
raw = mergeRawConfig(raw, parsed);
|
|
87
|
+
}
|
|
88
|
+
const defaults = raw.defaults || {};
|
|
89
|
+
for (const agent of raw.agents || []) {
|
|
90
|
+
for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes"]) {
|
|
91
|
+
if (key in defaults && !(key in agent))
|
|
92
|
+
agent[key] = defaults[key];
|
|
93
|
+
}
|
|
94
|
+
if (!("harness_engineering" in agent))
|
|
95
|
+
agent.harness_engineering = defaults.harness_engineering || [];
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return v.parse(SFConfigSchema, raw);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new Error(`invalid config (${configPaths.join(", ")}): ${describeParseError(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function resolve(cfg, name) {
|
|
105
|
+
const agent = cfg.agents.find((a) => a.name === name);
|
|
106
|
+
if (!agent) {
|
|
107
|
+
throw new Error(`agent ${JSON.stringify(name)} is not defined in the config — available: ${JSON.stringify(cfg.agents.map((a) => a.name))}`);
|
|
108
|
+
}
|
|
109
|
+
return agent;
|
|
110
|
+
}
|
|
111
|
+
/** Fail fast: every required name must resolve to a usable agent. */
|
|
112
|
+
export function validate(cfg, required, requiredSuites = [], cwd) {
|
|
113
|
+
const anchor = paths.resolveAnchor(cwd);
|
|
114
|
+
const problems = [];
|
|
115
|
+
for (const suiteName of requiredSuites) {
|
|
116
|
+
const names = cfg.quality.suites[suiteName];
|
|
117
|
+
if (!names || names.length === 0) {
|
|
118
|
+
problems.push(`quality.suites.${JSON.stringify(suiteName)} is not configured — add it to spf.config.yaml before running a chain that needs it`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const missing = names.filter((n) => !cfg.quality.checks.some((c) => c.name === n));
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
problems.push(`quality.suites.${suiteName} names check(s) not in quality.checks: ${missing.join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const name of required) {
|
|
127
|
+
let agent;
|
|
128
|
+
try {
|
|
129
|
+
agent = resolve(cfg, name);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
problems.push(error.message);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (const [label, ref] of [
|
|
136
|
+
["system", agent.prompt_engineering.system],
|
|
137
|
+
["user", agent.prompt_engineering.user],
|
|
138
|
+
]) {
|
|
139
|
+
try {
|
|
140
|
+
paths.resolvePromptRef(anchor, ref);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
problems.push(`agent ${JSON.stringify(name)}: ${label} ${error.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Model shape depends on the backend: Flue needs provider/model-id (no
|
|
147
|
+
// catalog to check against, only the static shape); claude_code takes
|
|
148
|
+
// its own bare alias/full-name vocabulary (see agent_cc.ts), so only a
|
|
149
|
+
// non-empty check applies.
|
|
150
|
+
if (agent.coding_agent === "flue") {
|
|
151
|
+
try {
|
|
152
|
+
agentFlue.resolveModel(agent.model);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
problems.push(`agent ${JSON.stringify(name)}: ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (!agent.model.trim()) {
|
|
159
|
+
problems.push(`agent ${JSON.stringify(name)}: model is empty`);
|
|
160
|
+
}
|
|
161
|
+
// harness_engineering (pi -e extensions) has no analogue on any current
|
|
162
|
+
// backend — a subagent/skill built the same way is
|
|
163
|
+
// useSubagent()/defineSkill() (Flue) or an MCP server/plugin (Claude
|
|
164
|
+
// Code) inside a custom tool, not a config path. Fail loudly rather
|
|
165
|
+
// than silently loading nothing.
|
|
166
|
+
if (agent.harness_engineering.length > 0) {
|
|
167
|
+
problems.push(`agent ${JSON.stringify(name)}: harness_engineering is not supported (${JSON.stringify(agent.harness_engineering)}) — ` +
|
|
168
|
+
`use useSubagent()/defineSkill() (flue) or an MCP server/plugin (claude_code) in a custom tool instead`);
|
|
169
|
+
}
|
|
170
|
+
const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : agentFlue.isKnownToolName;
|
|
171
|
+
for (const toolName of agent.tools ?? []) {
|
|
172
|
+
if (!isKnownToolName(toolName)) {
|
|
173
|
+
problems.push(`agent ${JSON.stringify(name)}: unknown tool ${JSON.stringify(toolName)} — known: read, write, edit, bash, grep, glob, find (alias for glob), ls (dropped, covered by bash/glob)`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (problems.length > 0) {
|
|
178
|
+
throw new Error("config validation failed:\n- " + problems.join("\n- "));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** One agent call: render prompts -> pi run -> typed parse -> gates -> envelope. */
|
|
182
|
+
export async function execute(run, phase, call) {
|
|
183
|
+
const agent = resolve(run.cfg, phase.params.owner);
|
|
184
|
+
const agentDir = path.join(run.session_dir, agent.name);
|
|
185
|
+
mkdirSync(agentDir, { recursive: true });
|
|
186
|
+
const variables = {
|
|
187
|
+
prompt: call.prompt,
|
|
188
|
+
previous_envelope: call.previous ? JSON.stringify(call.previous, null, 2) : "(none)",
|
|
189
|
+
context_handoff_dir: run.context_handoff_dir,
|
|
190
|
+
};
|
|
191
|
+
const systemText = prompts.render(paths.resolvePromptRef(run, agent.prompt_engineering.system), variables);
|
|
192
|
+
const userText = prompts.render(paths.resolvePromptRef(run, agent.prompt_engineering.user), variables);
|
|
193
|
+
prompts.save(path.join(agentDir, "prompts"), "system.md", systemText);
|
|
194
|
+
prompts.save(path.join(agentDir, "prompts"), "user.md", userText);
|
|
195
|
+
const { session_id: sessionId, is_new: isNewSession } = agentSessionId(run, agent);
|
|
196
|
+
run.tracer.event(makeEventRecord({
|
|
197
|
+
adw_id: run.adw_id,
|
|
198
|
+
phase_id: phase.phase_id,
|
|
199
|
+
type: "agent_start",
|
|
200
|
+
name: agent.name,
|
|
201
|
+
payload: {
|
|
202
|
+
model: agent.model,
|
|
203
|
+
thinking: agent.thinking,
|
|
204
|
+
color: agent.color,
|
|
205
|
+
session_id: sessionId,
|
|
206
|
+
coding_agent: agent.coding_agent,
|
|
207
|
+
purpose: agent.purpose,
|
|
208
|
+
tools: agent.tools ?? null, // null = all tools
|
|
209
|
+
harness_engineering: agent.harness_engineering,
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
run.console.agentStarted(agent.name, agent.model, sessionId);
|
|
213
|
+
// Parse retries and gate corrections re-enter the SAME Flue conversation,
|
|
214
|
+
// so the last send is the one whose context occupancy is current — while
|
|
215
|
+
// spend is the opposite: every send costs, so usage accumulates across all.
|
|
216
|
+
let latest = null;
|
|
217
|
+
const spent = new UsageBreakdown();
|
|
218
|
+
async function send(promptText) {
|
|
219
|
+
const request = {
|
|
220
|
+
prompt: promptText,
|
|
221
|
+
system_prompt: systemText,
|
|
222
|
+
model: agent.model,
|
|
223
|
+
thinking: agent.thinking,
|
|
224
|
+
session_id: sessionId,
|
|
225
|
+
resume: !isNewSession,
|
|
226
|
+
tools: agent.tools ?? undefined,
|
|
227
|
+
output_schema: call.output_type.schema,
|
|
228
|
+
output_type_name: call.output_type.name,
|
|
229
|
+
cwd: run.repo_root,
|
|
230
|
+
flue_db_path: path.join(run.data_dir, "flue.db"),
|
|
231
|
+
};
|
|
232
|
+
const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
|
|
233
|
+
const onSpawn = (pid) => run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`);
|
|
234
|
+
const onExit = (pid) => run.tracer.processEnd(run.adw_id, pid);
|
|
235
|
+
const result = agent.coding_agent === "claude_code"
|
|
236
|
+
? await agentCc.run(request, forward, onSpawn, onExit)
|
|
237
|
+
: await agentFlue.run(request, forward, onSpawn, onExit);
|
|
238
|
+
run.addUsage(result.tokens, result.cost);
|
|
239
|
+
spent.merge(result.usage);
|
|
240
|
+
latest = result;
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
// What the tree looked like before this agent got its hands on it. Every
|
|
244
|
+
// send in this phase — first prompt, JSON retries, gate corrections — is
|
|
245
|
+
// measured against this one baseline.
|
|
246
|
+
const treeBefore = permissions.snapshot(run);
|
|
247
|
+
let result = await send(userText);
|
|
248
|
+
let parsed = await parseWithRetries(run, phase, call, result, send);
|
|
249
|
+
let envelope = parsed.envelope;
|
|
250
|
+
// claim gates — violations flow back into the SAME session as corrections
|
|
251
|
+
for (let gateAttempt = 1; gateAttempt <= Math.max(1, phase.params.retries + 1); gateAttempt++) {
|
|
252
|
+
const violations = [];
|
|
253
|
+
for (const gate of call.gates || []) {
|
|
254
|
+
const report = asReport(gate(envelope, run));
|
|
255
|
+
const found = report.violations;
|
|
256
|
+
run.tracer.gateRow(phase, gate.name, report, gateAttempt);
|
|
257
|
+
run.tracer.event(makeEventRecord({
|
|
258
|
+
adw_id: run.adw_id,
|
|
259
|
+
phase_id: phase.phase_id,
|
|
260
|
+
type: found.length > 0 ? "gate_fail" : "gate_pass",
|
|
261
|
+
name: gate.name,
|
|
262
|
+
payload: { attempt: gateAttempt, violations: found, checks: report.checks },
|
|
263
|
+
}));
|
|
264
|
+
run.console.gateResult(gate.name, report);
|
|
265
|
+
violations.push(...found);
|
|
266
|
+
}
|
|
267
|
+
if (violations.length === 0)
|
|
268
|
+
break;
|
|
269
|
+
if (gateAttempt > phase.params.retries) {
|
|
270
|
+
throw new GateFailure(`${agent.name} failed gates after ${gateAttempt} attempt(s):\n- ` + violations.join("\n- "));
|
|
271
|
+
}
|
|
272
|
+
phase.attempt = gateAttempt;
|
|
273
|
+
run.console.retry(agent.name, gateAttempt, phase.params.retries, `${violations.length} gate violation(s)`);
|
|
274
|
+
const correction = "Your previous response failed validation:\n- " +
|
|
275
|
+
violations.join("\n- ") +
|
|
276
|
+
"\n\nFix these problems, then re-emit ONLY your Report JSON.";
|
|
277
|
+
result = await send(correction);
|
|
278
|
+
parsed = await parseWithRetries(run, phase, call, result, send);
|
|
279
|
+
envelope = parsed.envelope;
|
|
280
|
+
}
|
|
281
|
+
// Permission is checked after every send is done, and before the envelope is
|
|
282
|
+
// accepted: an agent does not get to report success on a phase in which it
|
|
283
|
+
// wrote somewhere it was not allowed to.
|
|
284
|
+
let touched;
|
|
285
|
+
try {
|
|
286
|
+
touched = permissions.enforce(run, phase, agent, treeBefore);
|
|
287
|
+
}
|
|
288
|
+
catch (breach) {
|
|
289
|
+
run.tracer.event(makeEventRecord({
|
|
290
|
+
adw_id: run.adw_id,
|
|
291
|
+
phase_id: phase.phase_id,
|
|
292
|
+
type: "error",
|
|
293
|
+
name: "permission_breach",
|
|
294
|
+
payload: {
|
|
295
|
+
agent: agent.name,
|
|
296
|
+
error: breach.message,
|
|
297
|
+
writes: agent.writes ?? null,
|
|
298
|
+
protected_files: run.cfg.defaults.protected_files,
|
|
299
|
+
},
|
|
300
|
+
}));
|
|
301
|
+
throw breach;
|
|
302
|
+
}
|
|
303
|
+
if (touched.length > 0) {
|
|
304
|
+
run.tracer.event(makeEventRecord({
|
|
305
|
+
adw_id: run.adw_id,
|
|
306
|
+
phase_id: phase.phase_id,
|
|
307
|
+
type: "log",
|
|
308
|
+
name: "paths_touched",
|
|
309
|
+
payload: { agent: agent.name, paths: touched },
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
persistEnvelope(run, phase, agent.name, call, envelope, parsed.attempt, true);
|
|
313
|
+
run.console.envelopeSummary(envelope, call.output_type.name);
|
|
314
|
+
const context = latest ?? result;
|
|
315
|
+
run.tracer.agentSessionRow(run.adw_id, agent, sessionId, context.context_tokens, context.context_window);
|
|
316
|
+
run.saveAgentMap(agent.name, { session_id: sessionId, model: agent.model, coding_agent: agent.coding_agent });
|
|
317
|
+
run.tracer.event(makeEventRecord({
|
|
318
|
+
adw_id: run.adw_id,
|
|
319
|
+
phase_id: phase.phase_id,
|
|
320
|
+
type: "handoff",
|
|
321
|
+
name: agent.name,
|
|
322
|
+
payload: { artifacts: envelope.artifacts, summary: envelope.summary },
|
|
323
|
+
}));
|
|
324
|
+
run.tracer.event(makeEventRecord({
|
|
325
|
+
adw_id: run.adw_id,
|
|
326
|
+
phase_id: phase.phase_id,
|
|
327
|
+
type: "agent_end",
|
|
328
|
+
name: agent.name,
|
|
329
|
+
// Phase totals, not the last send's: a retried phase paid for every attempt.
|
|
330
|
+
tokens: spent.total_tokens,
|
|
331
|
+
payload: {
|
|
332
|
+
cost: spent.total_cost,
|
|
333
|
+
usage: spent.toJSON(),
|
|
334
|
+
context_tokens: context.context_tokens,
|
|
335
|
+
context_window: context.context_window,
|
|
336
|
+
},
|
|
337
|
+
}));
|
|
338
|
+
run.console.agentFinished(agent.name, spent.total_tokens, spent.total_cost);
|
|
339
|
+
if (envelope.status !== "success") {
|
|
340
|
+
throw new Error(`${agent.name} reported status=${JSON.stringify(envelope.status)}: ${envelope.summary}`);
|
|
341
|
+
}
|
|
342
|
+
return envelope;
|
|
343
|
+
}
|
|
344
|
+
/** Accept a GateReport, or a legacy gate that returned a violations list. */
|
|
345
|
+
function asReport(result) {
|
|
346
|
+
if (result instanceof GateReport)
|
|
347
|
+
return result;
|
|
348
|
+
const report = new GateReport();
|
|
349
|
+
for (const v of result || [])
|
|
350
|
+
report.check(String(v), false);
|
|
351
|
+
return report;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `is_new` is Flue's own affair to ignore (its `init(agent, {id})` is
|
|
355
|
+
* unconditionally create-or-continue) but `claude_code` needs it explicitly
|
|
356
|
+
* — its CLI has no single create-or-continue flag, only `--session-id`
|
|
357
|
+
* (first contact) vs `--resume` (rejoin), so `send()` threads this through
|
|
358
|
+
* as `AgentRequest.resume`.
|
|
359
|
+
*/
|
|
360
|
+
function agentSessionId(run, agent) {
|
|
361
|
+
const entry = run.agent_map[agent.name];
|
|
362
|
+
if (entry && entry.model === agent.model)
|
|
363
|
+
return { session_id: entry.session_id, is_new: false }; // rejoin the existing context window
|
|
364
|
+
const session_id = agent.coding_agent === "claude_code" ? agentCc.newSessionId() : `spf-${run.adw_id}-${agent.name}-${newId(4)}`;
|
|
365
|
+
return { session_id, is_new: true };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* One tool_call event per real tool call, with its exact args and result.
|
|
369
|
+
* Picks the tracker for whichever backend this agent runs on — each
|
|
370
|
+
* backend's own module owns the ONE fold point for its raw event shape, so
|
|
371
|
+
* this stays a plain pass-through regardless of which one it's given.
|
|
372
|
+
*/
|
|
373
|
+
function eventForwarder(run, phase, agentName, codingAgent) {
|
|
374
|
+
const tracker = codingAgent === "claude_code" ? new agentCc.CcToolCallTracker() : new agentFlue.ToolCallTracker();
|
|
375
|
+
return (chunk) => {
|
|
376
|
+
const record = tracker.observe(chunk);
|
|
377
|
+
if (record === null)
|
|
378
|
+
return;
|
|
379
|
+
// The call's span rides the columns; duration_ms stays in the payload as
|
|
380
|
+
// Flue's own authoritative number.
|
|
381
|
+
const { label, started_at, ended_at, ...rest } = record;
|
|
382
|
+
run.tracer.event(makeEventRecord({
|
|
383
|
+
adw_id: run.adw_id,
|
|
384
|
+
phase_id: phase.phase_id,
|
|
385
|
+
type: "tool_call",
|
|
386
|
+
name: label,
|
|
387
|
+
started_at: started_at ?? null,
|
|
388
|
+
ended_at: ended_at ?? null,
|
|
389
|
+
payload: { ...rest, agent: agentName },
|
|
390
|
+
}));
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function extractJson(text) {
|
|
394
|
+
let candidate = text;
|
|
395
|
+
if (text.includes("```")) {
|
|
396
|
+
const parts = text.split("```");
|
|
397
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
398
|
+
let block = parts[i];
|
|
399
|
+
if (block.startsWith("json"))
|
|
400
|
+
block = block.slice(4);
|
|
401
|
+
block = block.trim();
|
|
402
|
+
if (block.startsWith("{")) {
|
|
403
|
+
candidate = block;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const start = candidate.indexOf("{");
|
|
409
|
+
const end = candidate.lastIndexOf("}");
|
|
410
|
+
if (start === -1 || end <= start)
|
|
411
|
+
throw new Error("no JSON object found in the response");
|
|
412
|
+
return JSON.parse(candidate.slice(start, end + 1));
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Parse the final response against the declared output type; on failure,
|
|
416
|
+
* continue the SAME session with a correction (bounded).
|
|
417
|
+
*/
|
|
418
|
+
async function parseWithRetries(run, phase, call, result, send) {
|
|
419
|
+
let current = result;
|
|
420
|
+
for (let attempt = 1; attempt <= JSON_FIX_ATTEMPTS + 1; attempt++) {
|
|
421
|
+
try {
|
|
422
|
+
// The model may never call sf_report at all (plain text + stop) — fall
|
|
423
|
+
// back to extracting JSON from the text in that case, same as before.
|
|
424
|
+
const payload = current.report ?? extractJson(current.text);
|
|
425
|
+
const envelope = v.parse(call.output_type.schema, payload);
|
|
426
|
+
return { envelope, attempt };
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
const message = describeParseError(error);
|
|
430
|
+
persistEnvelope(run, phase, phase.params.owner, call, null, attempt, false, current.text);
|
|
431
|
+
if (attempt > JSON_FIX_ATTEMPTS) {
|
|
432
|
+
throw new Error(`${phase.params.owner} never produced valid ${call.output_type.name} JSON: ${message}`);
|
|
433
|
+
}
|
|
434
|
+
run.console.retry(phase.params.owner, attempt, JSON_FIX_ATTEMPTS, `invalid ${call.output_type.name} JSON: ${message}`);
|
|
435
|
+
const fields = call.output_type.fields.join(", ");
|
|
436
|
+
current = await send(`Your response was not valid JSON for the required structure ` +
|
|
437
|
+
`(${message}). Respond again with ONLY a JSON object with these ` +
|
|
438
|
+
`fields: ${fields}. No prose, no code fences.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
throw new Error("unreachable");
|
|
442
|
+
}
|
|
443
|
+
function persistEnvelope(run, phase, agentName, call, envelope, attempt, valid, raw = "") {
|
|
444
|
+
const payloadJson = envelope ? JSON.stringify(envelope, null, 2) : JSON.stringify({ raw: raw.slice(-2000) });
|
|
445
|
+
run.tracer.envelopeRow(phase, agentName, call.output_type.name, payloadJson, valid, attempt);
|
|
446
|
+
if (envelope) {
|
|
447
|
+
const record = {
|
|
448
|
+
agent_name: agentName,
|
|
449
|
+
purpose: resolve(run.cfg, agentName).purpose,
|
|
450
|
+
output_type: call.output_type.name,
|
|
451
|
+
attempt,
|
|
452
|
+
...envelope,
|
|
453
|
+
};
|
|
454
|
+
writeFileSync(path.join(run.session_dir, agentName, "envelope.json"), JSON.stringify(record, null, 2));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic change capture: what was built, straight from git.
|
|
3
|
+
*
|
|
4
|
+
* "What changed since main" is not a judgement call — it is two git commands and
|
|
5
|
+
* a subtraction. So it is code, and an agent is only handed the result. The
|
|
6
|
+
* capture writes the full diff into `context_handoff/` and returns a ChangeSet;
|
|
7
|
+
* `asEnvelope` adapts that into the one door every agent handoff uses.
|
|
8
|
+
*
|
|
9
|
+
* The base is resolved, not assumed. Off the base branch the diff covers the
|
|
10
|
+
* whole branch plus the working tree; on it, the uncommitted tree; and on a clean
|
|
11
|
+
* tree, the last commit — because "document the work that was just done" still
|
|
12
|
+
* has an answer right after a chain committed. Whichever it picked rides along in
|
|
13
|
+
* `BaseRef.reason`, so the trace never leaves you guessing what a diff was
|
|
14
|
+
* measured against.
|
|
15
|
+
*/
|
|
16
|
+
import { type GitHandle } from "./git_helper.ts";
|
|
17
|
+
import { BaseRef, ChangeSet, type ChangeCapture, type ChangesOutputT } from "./data_types.ts";
|
|
18
|
+
interface RunLike {
|
|
19
|
+
context_handoff_dir: string;
|
|
20
|
+
repo_root: string;
|
|
21
|
+
git: GitHandle;
|
|
22
|
+
}
|
|
23
|
+
/** Pick the commit the work is measured from, and record why that one. */
|
|
24
|
+
export declare function resolveBase(run: RunLike, ref: string): BaseRef;
|
|
25
|
+
/** Diff the working tree against the resolved base and persist the evidence. */
|
|
26
|
+
export declare function capture(run: RunLike, params: ChangeCapture): ChangeSet;
|
|
27
|
+
/**
|
|
28
|
+
* Wrap a captured change so an agent can be handed it directly.
|
|
29
|
+
*
|
|
30
|
+
* Pure formatting over an already-captured ChangeSet — no `run`, no git
|
|
31
|
+
* call. `commit` is always a full sha here, so it's shortened the same way
|
|
32
|
+
* `BaseRef.label` shortens a pinned ref: a slice, not another subprocess.
|
|
33
|
+
*/
|
|
34
|
+
export declare function asEnvelope(changes: ChangeSet, notes?: string): ChangesOutputT;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic change capture: what was built, straight from git.
|
|
3
|
+
*
|
|
4
|
+
* "What changed since main" is not a judgement call — it is two git commands and
|
|
5
|
+
* a subtraction. So it is code, and an agent is only handed the result. The
|
|
6
|
+
* capture writes the full diff into `context_handoff/` and returns a ChangeSet;
|
|
7
|
+
* `asEnvelope` adapts that into the one door every agent handoff uses.
|
|
8
|
+
*
|
|
9
|
+
* The base is resolved, not assumed. Off the base branch the diff covers the
|
|
10
|
+
* whole branch plus the working tree; on it, the uncommitted tree; and on a clean
|
|
11
|
+
* tree, the last commit — because "document the work that was just done" still
|
|
12
|
+
* has an answer right after a chain committed. Whichever it picked rides along in
|
|
13
|
+
* `BaseRef.reason`, so the trace never leaves you guessing what a diff was
|
|
14
|
+
* measured against.
|
|
15
|
+
*/
|
|
16
|
+
import { writeFileSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import * as v from "valibot";
|
|
19
|
+
import { isRepoAt } from "./git_helper.js";
|
|
20
|
+
import { BaseRef, ChangeSet, ChangesOutput } from "./data_types.js";
|
|
21
|
+
const DIFF_FILENAME = "changes.diff";
|
|
22
|
+
/** Pick the commit the work is measured from, and record why that one. */
|
|
23
|
+
export function resolveBase(run, ref) {
|
|
24
|
+
if (!isRepoAt(run.repo_root)) {
|
|
25
|
+
throw new Error("not a git repository — change capture needs one. Run `git init` in " +
|
|
26
|
+
"the repo root before running an ADW that documents a change.");
|
|
27
|
+
}
|
|
28
|
+
if (!run.git.refExists(ref)) {
|
|
29
|
+
throw new Error(`base ref ${JSON.stringify(ref)} does not exist in this repository — pass --base ` +
|
|
30
|
+
`with a ref that does (e.g. --base master, --base HEAD~1).`);
|
|
31
|
+
}
|
|
32
|
+
// Built first, then given its reason: BaseRef.label knows how to print a
|
|
33
|
+
// pinned sha, and the reason is the line a human reads in the trace.
|
|
34
|
+
const base = new BaseRef(ref, run.git.mergeBase(ref, "HEAD"));
|
|
35
|
+
if (run.git.shortSha(base.commit) !== run.git.shortSha("HEAD")) {
|
|
36
|
+
base.reason = `HEAD is ahead of ${base.label} — diffing every commit since, plus the working tree`;
|
|
37
|
+
}
|
|
38
|
+
else if (run.git.isDirty()) {
|
|
39
|
+
base.reason = `HEAD is on ${base.label} — diffing the uncommitted working tree`;
|
|
40
|
+
}
|
|
41
|
+
else if (run.git.refExists("HEAD~1")) {
|
|
42
|
+
base.commit = run.git.rev("HEAD~1");
|
|
43
|
+
base.reason = `HEAD is on ${base.label} with a clean tree — falling back to the last commit`;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
base.reason = `HEAD is on ${base.label} with a clean tree and no parent commit`;
|
|
47
|
+
}
|
|
48
|
+
return base;
|
|
49
|
+
}
|
|
50
|
+
/** Diff the working tree against the resolved base and persist the evidence. */
|
|
51
|
+
export function capture(run, params) {
|
|
52
|
+
const base = resolveBase(run, params.base);
|
|
53
|
+
const files = run.git.diffFiles(base.commit);
|
|
54
|
+
const untracked = params.include_untracked ? run.git.untrackedFiles() : [];
|
|
55
|
+
const [insertions, deletions] = run.git.diffCounts(base.commit);
|
|
56
|
+
const stat = run.git.diffStat(base.commit);
|
|
57
|
+
let text = run.git.diffText(base.commit);
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
const truncated = lines.length > params.max_diff_lines;
|
|
60
|
+
if (truncated) {
|
|
61
|
+
text = lines.slice(0, params.max_diff_lines).join("\n");
|
|
62
|
+
text += `\n\n[truncated at ${params.max_diff_lines} lines of ${lines.length} — run \`git diff ${base.commit}\` for the rest]`;
|
|
63
|
+
}
|
|
64
|
+
// Untracked files are absent from `git diff` by construction, so they are
|
|
65
|
+
// named here rather than silently missing from the record. The reader has
|
|
66
|
+
// `read` and can open any of them.
|
|
67
|
+
const untrackedBlock = untracked.length > 0 ? untracked.map((f) => ` ${f}`).join("\n") : " (none)";
|
|
68
|
+
const diffPath = path.join(run.context_handoff_dir, DIFF_FILENAME);
|
|
69
|
+
writeFileSync(diffPath, `# changes since ${base.label} @ ${run.git.shortSha(base.commit)}\n` +
|
|
70
|
+
`# ${base.reason}\n` +
|
|
71
|
+
`# +${insertions} -${deletions} across ${files.length} tracked file(s)\n\n` +
|
|
72
|
+
`## stat\n${stat || " (no tracked changes)"}\n\n` +
|
|
73
|
+
`## untracked files\n${untrackedBlock}\n\n` +
|
|
74
|
+
`## diff\n${text}\n`);
|
|
75
|
+
return new ChangeSet(base, files, untracked, insertions, deletions, stat, diffPath, truncated);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wrap a captured change so an agent can be handed it directly.
|
|
79
|
+
*
|
|
80
|
+
* Pure formatting over an already-captured ChangeSet — no `run`, no git
|
|
81
|
+
* call. `commit` is always a full sha here, so it's shortened the same way
|
|
82
|
+
* `BaseRef.label` shortens a pinned ref: a slice, not another subprocess.
|
|
83
|
+
*/
|
|
84
|
+
export function asEnvelope(changes, notes = "") {
|
|
85
|
+
const total = changes.files.length + changes.untracked.length;
|
|
86
|
+
return v.parse(ChangesOutput.schema, {
|
|
87
|
+
status: "success",
|
|
88
|
+
summary: `${total} file(s) changed since ${changes.base.label} (+${changes.insertions} -${changes.deletions})`,
|
|
89
|
+
artifacts: [changes.diff_path],
|
|
90
|
+
notes_for_next_agent: notes,
|
|
91
|
+
base: `${changes.base.label} @ ${changes.base.commit.slice(0, 7)} — ${changes.base.reason}`,
|
|
92
|
+
changed_files: [...changes.files, ...changes.untracked],
|
|
93
|
+
insertions: changes.insertions,
|
|
94
|
+
deletions: changes.deletions,
|
|
95
|
+
stat: changes.stat,
|
|
96
|
+
diff_path: changes.diff_path,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console reporter: one narrative, two destinations.
|
|
3
|
+
*
|
|
4
|
+
* Every line an ADW prints ALSO lands in the db as a `log` event, so the swim-lane
|
|
5
|
+
* UI reads the same story the terminal does. Both go through `emit` — print and
|
|
6
|
+
* trace cannot drift. Plain sequential lines only: no spinners, no live displays,
|
|
7
|
+
* so a CI log reads exactly like a terminal.
|
|
8
|
+
*/
|
|
9
|
+
import type { EnvelopeBase, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
10
|
+
interface Tracer {
|
|
11
|
+
event(record: EventRecord): string;
|
|
12
|
+
}
|
|
13
|
+
/** Bound to one run's tracer. Reachable as `run.console` everywhere. */
|
|
14
|
+
export declare class Console {
|
|
15
|
+
private tracer;
|
|
16
|
+
private adwId;
|
|
17
|
+
private phaseId;
|
|
18
|
+
private phaseName;
|
|
19
|
+
private results;
|
|
20
|
+
private finished;
|
|
21
|
+
constructor(tracer: Tracer, adwId: string);
|
|
22
|
+
private emit;
|
|
23
|
+
sessionStarted(adwId: string, engineer: string): void;
|
|
24
|
+
sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): void;
|
|
25
|
+
phaseStarted(phase: Phase): void;
|
|
26
|
+
phaseEnded(phase: Phase, seconds: number): void;
|
|
27
|
+
/** Free-form detail inside the current phase — what `ph.log()` recorded. */
|
|
28
|
+
note(message: string): void;
|
|
29
|
+
agentStarted(name: string, model: string, sessionId: string): void;
|
|
30
|
+
agentFinished(name: string, tokens: number, cost: number): void;
|
|
31
|
+
retry(name: string, attempt: number, limit: number, reason: string): void;
|
|
32
|
+
/** A gate reports WHAT it checked, not just whether it passed. */
|
|
33
|
+
gateResult(name: string, report: GateReport): void;
|
|
34
|
+
envelopeSummary(envelope: EnvelopeBase, typeName: string): void;
|
|
35
|
+
}
|
|
36
|
+
export {};
|