@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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/assets/defaults/spf.config.yaml +141 -0
  4. package/assets/prompts/builder/system.md +13 -0
  5. package/assets/prompts/builder/user.md +34 -0
  6. package/assets/prompts/documenter/system.md +17 -0
  7. package/assets/prompts/documenter/user.md +48 -0
  8. package/assets/prompts/planner/system.md +21 -0
  9. package/assets/prompts/planner/user.md +45 -0
  10. package/assets/prompts/reviewer/system.md +16 -0
  11. package/assets/prompts/reviewer/user.md +44 -0
  12. package/assets/prompts/scout/system.md +20 -0
  13. package/assets/prompts/scout/user.md +34 -0
  14. package/assets/skill/SKILL.md +80 -0
  15. package/assets/skill/cookbooks/authoring_chains.md +193 -0
  16. package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
  17. package/assets/skill/cookbooks/roster.md +197 -0
  18. package/assets/skill/cookbooks/run_adw.md +92 -0
  19. package/assets/skill/cookbooks/spf_overview.md +111 -0
  20. package/assets/skill/references/config.md +188 -0
  21. package/assets/skill/references/handoff.md +162 -0
  22. package/assets/skill/references/observability.md +184 -0
  23. package/dist/chains/adw_build.d.ts +12 -0
  24. package/dist/chains/adw_build.js +27 -0
  25. package/dist/chains/adw_build_review.d.ts +21 -0
  26. package/dist/chains/adw_build_review.js +55 -0
  27. package/dist/chains/adw_build_test.d.ts +21 -0
  28. package/dist/chains/adw_build_test.js +67 -0
  29. package/dist/chains/adw_document.d.ts +23 -0
  30. package/dist/chains/adw_document.js +59 -0
  31. package/dist/chains/adw_plan.d.ts +12 -0
  32. package/dist/chains/adw_plan.js +27 -0
  33. package/dist/chains/adw_plan_build.d.ts +12 -0
  34. package/dist/chains/adw_plan_build.js +30 -0
  35. package/dist/chains/adw_plan_build_test.d.ts +16 -0
  36. package/dist/chains/adw_plan_build_test.js +65 -0
  37. package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
  38. package/dist/chains/adw_plan_build_test_quality.js +66 -0
  39. package/dist/chains/adw_prompt.d.ts +12 -0
  40. package/dist/chains/adw_prompt.js +25 -0
  41. package/dist/chains/adw_quality.d.ts +12 -0
  42. package/dist/chains/adw_quality.js +32 -0
  43. package/dist/chains/adw_scout.d.ts +12 -0
  44. package/dist/chains/adw_scout.js +27 -0
  45. package/dist/chains/adw_simple_sdlc.d.ts +43 -0
  46. package/dist/chains/adw_simple_sdlc.js +147 -0
  47. package/dist/chains/context.d.ts +19 -0
  48. package/dist/chains/context.js +1 -0
  49. package/dist/chains/index.d.ts +19 -0
  50. package/dist/chains/index.js +116 -0
  51. package/dist/cli/bin.d.ts +15 -0
  52. package/dist/cli/bin.js +24 -0
  53. package/dist/cli/commands/abort.d.ts +1 -0
  54. package/dist/cli/commands/abort.js +42 -0
  55. package/dist/cli/commands/doctor.d.ts +1 -0
  56. package/dist/cli/commands/doctor.js +165 -0
  57. package/dist/cli/commands/eject.d.ts +1 -0
  58. package/dist/cli/commands/eject.js +55 -0
  59. package/dist/cli/commands/events.d.ts +1 -0
  60. package/dist/cli/commands/events.js +49 -0
  61. package/dist/cli/commands/init.d.ts +1 -0
  62. package/dist/cli/commands/init.js +62 -0
  63. package/dist/cli/commands/install-skill.d.ts +1 -0
  64. package/dist/cli/commands/install-skill.js +122 -0
  65. package/dist/cli/commands/list.d.ts +1 -0
  66. package/dist/cli/commands/list.js +13 -0
  67. package/dist/cli/commands/migrate.d.ts +1 -0
  68. package/dist/cli/commands/migrate.js +167 -0
  69. package/dist/cli/commands/phases.d.ts +1 -0
  70. package/dist/cli/commands/phases.js +25 -0
  71. package/dist/cli/commands/run.d.ts +3 -0
  72. package/dist/cli/commands/run.js +27 -0
  73. package/dist/cli/commands/sessions.d.ts +1 -0
  74. package/dist/cli/commands/sessions.js +20 -0
  75. package/dist/cli/commands/trace.d.ts +8 -0
  76. package/dist/cli/commands/trace.js +10 -0
  77. package/dist/cli/commands/ui.d.ts +1 -0
  78. package/dist/cli/commands/ui.js +35 -0
  79. package/dist/cli/commands/version.d.ts +1 -0
  80. package/dist/cli/commands/version.js +8 -0
  81. package/dist/cli/commands/watch.d.ts +8 -0
  82. package/dist/cli/commands/watch.js +210 -0
  83. package/dist/cli/gitignore.d.ts +1 -0
  84. package/dist/cli/gitignore.js +14 -0
  85. package/dist/cli/index.d.ts +1 -0
  86. package/dist/cli/index.js +156 -0
  87. package/dist/core/agent_cc.d.ts +75 -0
  88. package/dist/core/agent_cc.js +322 -0
  89. package/dist/core/agent_flue.d.ts +66 -0
  90. package/dist/core/agent_flue.js +321 -0
  91. package/dist/core/agents.d.ts +64 -0
  92. package/dist/core/agents.js +456 -0
  93. package/dist/core/changes.d.ts +35 -0
  94. package/dist/core/changes.js +98 -0
  95. package/dist/core/console.d.ts +36 -0
  96. package/dist/core/console.js +156 -0
  97. package/dist/core/data_types.d.ts +562 -0
  98. package/dist/core/data_types.js +382 -0
  99. package/dist/core/gates.d.ts +33 -0
  100. package/dist/core/gates.js +144 -0
  101. package/dist/core/git_helper.d.ts +59 -0
  102. package/dist/core/git_helper.js +115 -0
  103. package/dist/core/issues/github_provider.d.ts +52 -0
  104. package/dist/core/issues/github_provider.js +211 -0
  105. package/dist/core/issues/provider.d.ts +90 -0
  106. package/dist/core/issues/provider.js +14 -0
  107. package/dist/core/paths.d.ts +78 -0
  108. package/dist/core/paths.js +108 -0
  109. package/dist/core/permissions.d.ts +78 -0
  110. package/dist/core/permissions.js +187 -0
  111. package/dist/core/prompts.d.ts +4 -0
  112. package/dist/core/prompts.js +17 -0
  113. package/dist/core/quality.d.ts +65 -0
  114. package/dist/core/quality.js +194 -0
  115. package/dist/core/runner.d.ts +72 -0
  116. package/dist/core/runner.js +168 -0
  117. package/dist/core/session.d.ts +16 -0
  118. package/dist/core/session.js +60 -0
  119. package/dist/core/sqlite.d.ts +55 -0
  120. package/dist/core/sqlite.js +106 -0
  121. package/dist/core/tracer.d.ts +56 -0
  122. package/dist/core/tracer.js +246 -0
  123. package/dist/core/utils.d.ts +40 -0
  124. package/dist/core/utils.js +108 -0
  125. package/dist/core/watch.d.ts +44 -0
  126. package/dist/core/watch.js +194 -0
  127. package/dist/test/agent_cc.test.d.ts +1 -0
  128. package/dist/test/agent_cc.test.js +95 -0
  129. package/dist/test/agent_flue.test.d.ts +1 -0
  130. package/dist/test/agent_flue.test.js +83 -0
  131. package/dist/test/data_types.test.d.ts +10 -0
  132. package/dist/test/data_types.test.js +49 -0
  133. package/dist/test/ui_server.test.d.ts +1 -0
  134. package/dist/test/ui_server.test.js +119 -0
  135. package/dist/test/watch.test.d.ts +1 -0
  136. package/dist/test/watch.test.js +227 -0
  137. package/dist/ui/server/app.d.ts +3 -0
  138. package/dist/ui/server/app.js +98 -0
  139. package/dist/ui/server/db.d.ts +82 -0
  140. package/dist/ui/server/db.js +333 -0
  141. package/dist/ui/server/serve.d.ts +13 -0
  142. package/dist/ui/server/serve.js +80 -0
  143. package/dist/ui/server/static.d.ts +5 -0
  144. package/dist/ui/server/static.js +55 -0
  145. package/dist/ui/shared/types.d.ts +263 -0
  146. package/dist/ui/shared/types.js +8 -0
  147. package/package.json +52 -0
  148. package/web/assets/index-C7nF068F.css +1 -0
  149. package/web/assets/index-mzSArcnQ.js +11 -0
  150. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  151. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
  152. package/web/index.html +14 -0
  153. 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 {};