@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,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;