@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,78 @@
1
+ /**
2
+ * What an agent may CHANGE, enforced in code after the fact.
3
+ *
4
+ * `tools:` is a capability list, not a sandbox, and two holes make it
5
+ * unenforceable on its own:
6
+ *
7
+ * * `bash` runs anything. A builder handed bash to run a test suite can also
8
+ * run `git checkout adws/` — which is not hypothetical: one did, discarding
9
+ * uncommitted changes to the very quality check it was about to be judged by.
10
+ * * `write` reaches any path, not just the one report file an agent was given
11
+ * it for. A reviewer configured with "no edit, so it cannot quietly fix"
12
+ * could still rewrite the code it was reviewing.
13
+ *
14
+ * So permission is verified the way every other claim in this system is —
15
+ * after the fact, against the repo itself. `snapshot()` fingerprints the working
16
+ * tree's change-set before an agent runs; `enforce()` compares it afterwards and
17
+ * fails the phase if the agent touched anything outside its allowlist.
18
+ *
19
+ * Comparing change-sets, rather than watching for writes, is what catches the
20
+ * `git checkout` case: a path that was modified before the agent ran and is clean
21
+ * afterwards has been reverted, and a reversion is a modification. Appearing,
22
+ * disappearing, and changing all count.
23
+ *
24
+ * A breach is NOT a gate violation. Gates are for work an agent can be asked to
25
+ * redo; a breach cannot be corrected by re-prompting, because the write already
26
+ * happened. It aborts the phase and names every offending path.
27
+ *
28
+ * Two keys drive it, both in spf.config.yaml:
29
+ * defaults.protected_files paths no agent may touch unless it names them itself
30
+ * agents[].writes undefined = unrestricted · [] = read-only · [...] = only these
31
+ */
32
+ import type { AgentConfig, SFConfig } from "./data_types.ts";
33
+ export declare class PermissionBreach extends Error {
34
+ }
35
+ interface RunLike {
36
+ repo_root: string;
37
+ cfg: SFConfig;
38
+ }
39
+ /**
40
+ * Fingerprint every path the working tree currently differs on.
41
+ *
42
+ * Tracked files carry their numstat counts, so an edit to an already-dirty
43
+ * file still registers as a change. Untracked files are listed by name.
44
+ * Gitignored paths never appear, which is why the session runtime under
45
+ * `data_dir` — where handoff files legitimately land — needs no special case.
46
+ */
47
+ export declare function snapshot(run: RunLike): Record<string, string>;
48
+ /** Every path whose state differs — appeared, vanished, or was rewritten. */
49
+ export declare function changedPaths(before: Record<string, string>, after: Record<string, string>): string[];
50
+ /**
51
+ * The session runtime, which EVERY agent must be able to write.
52
+ *
53
+ * `context_handoff/` is the one place agents hand work to each other, and an
54
+ * agent's own prompts, raw_output.jsonl, and envelope.json land beside it.
55
+ * Scout writes its findings there, the reviewer its review, the planner its
56
+ * plan — a read-only agent is read-only with respect to the REPO, never with
57
+ * respect to its own report.
58
+ *
59
+ * This is granted from `data_dir` rather than left to .gitignore. The runtime
60
+ * is normally ignored, so it never even appears in a snapshot — but an agent's
61
+ * ability to record its work must not hang on a gitignore entry that someone
62
+ * can delete or that a changed `data_dir` can outgrow.
63
+ */
64
+ export declare function alwaysWritable(cfg: SFConfig): string[];
65
+ /** Session runtime first, then the agent's own list, then what is protected. */
66
+ export declare function permitted(p: string, agent: AgentConfig, cfg: SFConfig): boolean;
67
+ /**
68
+ * Compare the tree against `before`; undo and raise if the agent overstepped.
69
+ *
70
+ * Returns the paths it legitimately changed, so the trace records what an
71
+ * agent actually touched rather than only what it claimed in its envelope.
72
+ *
73
+ * Detection alone would leave the repo holding the unauthorized change while
74
+ * reporting a failure, so anything the agent introduced outside its allowlist
75
+ * is rolled back before the phase dies. What it cannot undo, it names.
76
+ */
77
+ export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>): string[];
78
+ export {};
@@ -0,0 +1,187 @@
1
+ /**
2
+ * What an agent may CHANGE, enforced in code after the fact.
3
+ *
4
+ * `tools:` is a capability list, not a sandbox, and two holes make it
5
+ * unenforceable on its own:
6
+ *
7
+ * * `bash` runs anything. A builder handed bash to run a test suite can also
8
+ * run `git checkout adws/` — which is not hypothetical: one did, discarding
9
+ * uncommitted changes to the very quality check it was about to be judged by.
10
+ * * `write` reaches any path, not just the one report file an agent was given
11
+ * it for. A reviewer configured with "no edit, so it cannot quietly fix"
12
+ * could still rewrite the code it was reviewing.
13
+ *
14
+ * So permission is verified the way every other claim in this system is —
15
+ * after the fact, against the repo itself. `snapshot()` fingerprints the working
16
+ * tree's change-set before an agent runs; `enforce()` compares it afterwards and
17
+ * fails the phase if the agent touched anything outside its allowlist.
18
+ *
19
+ * Comparing change-sets, rather than watching for writes, is what catches the
20
+ * `git checkout` case: a path that was modified before the agent ran and is clean
21
+ * afterwards has been reverted, and a reversion is a modification. Appearing,
22
+ * disappearing, and changing all count.
23
+ *
24
+ * A breach is NOT a gate violation. Gates are for work an agent can be asked to
25
+ * redo; a breach cannot be corrected by re-prompting, because the write already
26
+ * happened. It aborts the phase and names every offending path.
27
+ *
28
+ * Two keys drive it, both in spf.config.yaml:
29
+ * defaults.protected_files paths no agent may touch unless it names them itself
30
+ * agents[].writes undefined = unrestricted · [] = read-only · [...] = only these
31
+ */
32
+ import { spawnSync } from "node:child_process";
33
+ import { unlinkSync } from "node:fs";
34
+ import path from "node:path";
35
+ export class PermissionBreach extends Error {
36
+ }
37
+ function git(args, cwd) {
38
+ const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
39
+ return result.status === 0 ? result.stdout : "";
40
+ }
41
+ /**
42
+ * Fingerprint every path the working tree currently differs on.
43
+ *
44
+ * Tracked files carry their numstat counts, so an edit to an already-dirty
45
+ * file still registers as a change. Untracked files are listed by name.
46
+ * Gitignored paths never appear, which is why the session runtime under
47
+ * `data_dir` — where handoff files legitimately land — needs no special case.
48
+ */
49
+ export function snapshot(run) {
50
+ const fingerprints = {};
51
+ for (const line of git(["diff", "HEAD", "--numstat"], run.repo_root).split("\n")) {
52
+ const fields = line.split("\t");
53
+ if (fields.length >= 3) {
54
+ const p = fields[fields.length - 1].trim();
55
+ fingerprints[p] = `${fields[0]},${fields[1]}`;
56
+ }
57
+ }
58
+ for (const p of git(["ls-files", "--others", "--exclude-standard"], run.repo_root).split("\n")) {
59
+ if (p.trim())
60
+ fingerprints[p.trim()] = "untracked";
61
+ }
62
+ return fingerprints;
63
+ }
64
+ /** Every path whose state differs — appeared, vanished, or was rewritten. */
65
+ export function changedPaths(before, after) {
66
+ const all = new Set([...Object.keys(before), ...Object.keys(after)]);
67
+ return [...all].filter((p) => before[p] !== after[p]).sort();
68
+ }
69
+ /**
70
+ * Translate a pattern, with `*` stopping at a path separator.
71
+ *
72
+ * A naive glob-to-regex would let `*` cross `/`, which quietly widens every
73
+ * pattern: `adws/adw_*.ts` would match `adws/adw_data/sessions/x/y.ts` as well
74
+ * as the ADW scripts it means. `**` is the way to say "cross directories".
75
+ */
76
+ function globToRegex(pattern) {
77
+ let out = "";
78
+ let i = 0;
79
+ while (i < pattern.length) {
80
+ const char = pattern[i];
81
+ if (pattern.startsWith("**", i)) {
82
+ out += ".*";
83
+ i += 2;
84
+ }
85
+ else if (char === "*") {
86
+ out += "[^/]*";
87
+ i += 1;
88
+ }
89
+ else if (char === "?") {
90
+ out += "[^/]";
91
+ i += 1;
92
+ }
93
+ else {
94
+ out += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
+ i += 1;
96
+ }
97
+ }
98
+ return new RegExp(`^${out}$`);
99
+ }
100
+ function matches(p, pattern) {
101
+ if (pattern.endsWith("/"))
102
+ return p.startsWith(pattern); // directory prefix
103
+ if (pattern.includes("*") || pattern.includes("?"))
104
+ return globToRegex(pattern).test(p);
105
+ return p === pattern;
106
+ }
107
+ /**
108
+ * The session runtime, which EVERY agent must be able to write.
109
+ *
110
+ * `context_handoff/` is the one place agents hand work to each other, and an
111
+ * agent's own prompts, raw_output.jsonl, and envelope.json land beside it.
112
+ * Scout writes its findings there, the reviewer its review, the planner its
113
+ * plan — a read-only agent is read-only with respect to the REPO, never with
114
+ * respect to its own report.
115
+ *
116
+ * This is granted from `data_dir` rather than left to .gitignore. The runtime
117
+ * is normally ignored, so it never even appears in a snapshot — but an agent's
118
+ * ability to record its work must not hang on a gitignore entry that someone
119
+ * can delete or that a changed `data_dir` can outgrow.
120
+ */
121
+ export function alwaysWritable(cfg) {
122
+ return [cfg.defaults.data_dir.replace(/\/+$/, "") + "/"];
123
+ }
124
+ /** Session runtime first, then the agent's own list, then what is protected. */
125
+ export function permitted(p, agent, cfg) {
126
+ if (alwaysWritable(cfg).some((pattern) => matches(p, pattern)))
127
+ return true;
128
+ if ((agent.writes || []).some((pattern) => matches(p, pattern)))
129
+ return true; // naming a path is what unlocks a protected one
130
+ if (cfg.defaults.protected_files.some((pattern) => matches(p, pattern)))
131
+ return false;
132
+ return agent.writes === undefined || agent.writes === null; // unrestricted = undefined/null, [] = no repo writes
133
+ }
134
+ /**
135
+ * Undo one unauthorized change. Returns a word describing what happened.
136
+ *
137
+ * Only changes the agent INTRODUCED are undone. A path that was already dirty
138
+ * when the agent started is left exactly as it is: the operator had
139
+ * uncommitted work there, and discarding it to tidy up would be the same harm
140
+ * this module exists to prevent, committed by the cleanup instead of the agent.
141
+ */
142
+ function rollBack(run, p, before, after) {
143
+ if (p in before) {
144
+ // Already dirty beforehand. If it is gone from the diff now, the agent
145
+ // reverted an engineer's uncommitted work and the content is not ours
146
+ // to reconstruct — say so loudly rather than pretend it was handled.
147
+ return !(p in after)
148
+ ? "REVERTED-BY-AGENT (uncommitted work lost, cannot restore)"
149
+ : "left as-is (was already modified)";
150
+ }
151
+ if (after[p] === "untracked") {
152
+ try {
153
+ unlinkSync(path.join(run.repo_root, p));
154
+ return "deleted";
155
+ }
156
+ catch (error) {
157
+ return `could not delete (${error.message})`;
158
+ }
159
+ }
160
+ const result = spawnSync("git", ["checkout", "--", p], { cwd: run.repo_root, encoding: "utf-8" });
161
+ return result.status === 0 ? "rolled back" : "could not roll back";
162
+ }
163
+ /**
164
+ * Compare the tree against `before`; undo and raise if the agent overstepped.
165
+ *
166
+ * Returns the paths it legitimately changed, so the trace records what an
167
+ * agent actually touched rather than only what it claimed in its envelope.
168
+ *
169
+ * Detection alone would leave the repo holding the unauthorized change while
170
+ * reporting a failure, so anything the agent introduced outside its allowlist
171
+ * is rolled back before the phase dies. What it cannot undo, it names.
172
+ */
173
+ export function enforce(run, _phase, agent, before) {
174
+ const after = snapshot(run);
175
+ const touched = changedPaths(before, after);
176
+ const breaches = touched.filter((p) => !permitted(p, agent, run.cfg));
177
+ if (breaches.length === 0)
178
+ return touched;
179
+ const outcomes = new Map(breaches.map((p) => [p, rollBack(run, p, before, after)]));
180
+ const scope = agent.writes && agent.writes.length === 0
181
+ ? "read-only"
182
+ : agent.writes
183
+ ? `limited to ${JSON.stringify(agent.writes)}`
184
+ : `barred from ${JSON.stringify(run.cfg.defaults.protected_files)}`;
185
+ const detail = [...outcomes.entries()].map(([p, outcome]) => ` - ${p} — ${outcome}`).join("\n");
186
+ throw new PermissionBreach(`${agent.name} is ${scope} but modified ${breaches.length} path(s):\n${detail}`);
187
+ }
@@ -0,0 +1,4 @@
1
+ /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
+ export declare function render(templatePath: string, variables: Record<string, string>): string;
3
+ /** Save the exact prompt sent, before execution — the audit copy. */
4
+ export declare function save(directory: string, name: string, content: string): string;
@@ -0,0 +1,17 @@
1
+ /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ export function render(templatePath, variables) {
5
+ let text = readFileSync(templatePath, "utf-8");
6
+ for (const [key, value] of Object.entries(variables)) {
7
+ text = text.split(`{{${key}}}`).join(value);
8
+ }
9
+ return text;
10
+ }
11
+ /** Save the exact prompt sent, before execution — the audit copy. */
12
+ export function save(directory, name, content) {
13
+ mkdirSync(directory, { recursive: true });
14
+ const filePath = path.join(directory, name);
15
+ writeFileSync(filePath, content);
16
+ return filePath;
17
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Deterministic lint, typecheck, build, and test blocks — configured, not
3
+ * hardcoded. A known command is not a judgement call. Anything whose
4
+ * invocation you can write down belongs here as code — it runs in
5
+ * milliseconds, costs nothing, and returns the same answer every time.
6
+ * Agents are for the parts that need reading and deciding.
7
+ *
8
+ * `quality.checks`/`quality.suites` in spf.config.yaml name the commands and
9
+ * group them into what a chain actually runs. There is no packaged default
10
+ * suite — an unconfigured suite fails at agents.validate() time, before
11
+ * anything spawns, rather than silently reporting green the way a
12
+ * placeholder echo command used to.
13
+ */
14
+ import type { EventRecord, Phase, QualityCheckSpec, QualityConfig, QualityResult, VerifyOutputT } from "./data_types.ts";
15
+ export declare class QualityNotConfigured extends Error {
16
+ }
17
+ interface RunLike {
18
+ cfg: {
19
+ quality: QualityConfig;
20
+ };
21
+ phases: Phase[];
22
+ context_handoff_dir: string;
23
+ repo_root: string;
24
+ console: {
25
+ note: (message: string) => void;
26
+ };
27
+ tracer: {
28
+ event: (record: EventRecord) => string;
29
+ };
30
+ adw_id: string;
31
+ }
32
+ /** Every suite name declared anywhere in `quality.suites`, checked names included. */
33
+ export declare function resolveSuite(run: RunLike, suiteName: string): QualityCheckSpec[];
34
+ /**
35
+ * Run every check in a configured suite and collect ALL failures — one pass
36
+ * tells you everything.
37
+ *
38
+ * Ordering contract for the caller: a failing check does NOT fail the phase.
39
+ * The runner did its job; the CODE is what failed. Hand this result to the
40
+ * builder and let the bounded repair loop decide the run's fate. Throws
41
+ * QualityNotConfigured (via resolveSuite) if the suite or any of its checks
42
+ * isn't in the config — before any check runs, let alone any agent spawns.
43
+ */
44
+ export declare function runSuite(run: RunLike, suiteName: string): QualityResult;
45
+ /**
46
+ * The `test` suite alone — the deterministic test phase most chains use.
47
+ *
48
+ * This is what replaces a `tester` agent once the command is written down. An
49
+ * agent rediscovering the runner on every run costs a fortune to learn what a
50
+ * subprocess already knows; the repair loop is unchanged, because a failure
51
+ * still reaches the builder through `asEnvelope` below.
52
+ */
53
+ export declare function runTests(run: RunLike): QualityResult;
54
+ /** Every configured check, across every configured suite's union — the `all` suite. */
55
+ export declare function runQuality(run: RunLike): QualityResult;
56
+ /**
57
+ * Wrap a deterministic result so an agent can be handed it directly.
58
+ *
59
+ * Agents hand each other typed envelopes; code blocks return QualityResult.
60
+ * This is the adapter, so a failing lint or test run flows back into the
61
+ * builder through exactly the same door an agent's report would — the ADW
62
+ * script is the only thing that knows the difference.
63
+ */
64
+ export declare function asEnvelope(result: QualityResult, what: string): VerifyOutputT;
65
+ export {};
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Deterministic lint, typecheck, build, and test blocks — configured, not
3
+ * hardcoded. A known command is not a judgement call. Anything whose
4
+ * invocation you can write down belongs here as code — it runs in
5
+ * milliseconds, costs nothing, and returns the same answer every time.
6
+ * Agents are for the parts that need reading and deciding.
7
+ *
8
+ * `quality.checks`/`quality.suites` in spf.config.yaml name the commands and
9
+ * group them into what a chain actually runs. There is no packaged default
10
+ * suite — an unconfigured suite fails at agents.validate() time, before
11
+ * anything spawns, rather than silently reporting green the way a
12
+ * placeholder echo command used to.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { mkdirSync, writeFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import * as v from "valibot";
18
+ import { VerifyOutput, makeEventRecord } from "./data_types.js";
19
+ import { nowIso, operatorEnv } from "./utils.js";
20
+ // How much of a failing command's output rides back inside the envelope. Enough
21
+ // for a builder to act on without opening the artifact; bounded so a runaway
22
+ // stack trace can't swamp the next agent's context.
23
+ const TAIL_CHARS = 4_000;
24
+ export class QualityNotConfigured extends Error {
25
+ }
26
+ /** Every suite name declared anywhere in `quality.suites`, checked names included. */
27
+ export function resolveSuite(run, suiteName) {
28
+ const names = run.cfg.quality.suites[suiteName];
29
+ if (!names || names.length === 0) {
30
+ const known = Object.keys(run.cfg.quality.suites);
31
+ throw new QualityNotConfigured(`quality.suites.${suiteName} is not configured — add it to spf.config.yaml` +
32
+ (known.length > 0 ? ` (configured suites: ${known.join(", ")})` : " (no suites are configured yet)"));
33
+ }
34
+ const specs = [];
35
+ const missing = [];
36
+ for (const name of names) {
37
+ const spec = run.cfg.quality.checks.find((c) => c.name === name);
38
+ if (spec)
39
+ specs.push(spec);
40
+ else
41
+ missing.push(name);
42
+ }
43
+ if (missing.length > 0) {
44
+ throw new QualityNotConfigured(`quality.suites.${suiteName} names check(s) not in quality.checks: ${missing.join(", ")}`);
45
+ }
46
+ return specs;
47
+ }
48
+ function checkDir(run, name) {
49
+ const seq = run.phases.length > 0 ? run.phases[run.phases.length - 1].seq : 0;
50
+ const dirPath = path.join(run.context_handoff_dir, "quality", `${String(seq).padStart(2, "0")}_${name}`);
51
+ mkdirSync(dirPath, { recursive: true });
52
+ return dirPath;
53
+ }
54
+ function shellJoin(argv) {
55
+ return argv
56
+ .map((arg) => (/[\s"'$`\\!*?[\](){}<>|&;#~]/.test(arg) ? `'${arg.replace(/'/g, `'\\''`)}'` : arg))
57
+ .join(" ");
58
+ }
59
+ function runCheck(spec, run) {
60
+ const phase = run.phases[run.phases.length - 1];
61
+ const outputDir = checkDir(run, spec.name);
62
+ const outputArtifact = path.join(outputDir, "command.log");
63
+ const command = shellJoin(spec.argv);
64
+ const env = operatorEnv(); // the engineer's own shell environment
65
+ run.console.note(`quality ${spec.name}: ${command}`);
66
+ const startedAt = nowIso();
67
+ const clock = performance.now();
68
+ let stdout = "";
69
+ let stderr = "";
70
+ let returncode;
71
+ try {
72
+ const completed = spawnSync(spec.argv[0], spec.argv.slice(1), {
73
+ cwd: run.repo_root,
74
+ env,
75
+ encoding: "utf-8",
76
+ timeout: spec.timeout_seconds * 1000,
77
+ });
78
+ if (completed.error && completed.error.code === "ENOENT") {
79
+ // A missing binary lands here as exit 127 with the real message — no
80
+ // pre-flight probe needed, and none wanted.
81
+ returncode = 127;
82
+ stderr = completed.error.message;
83
+ }
84
+ else if (completed.signal === "SIGTERM" && completed.status === null) {
85
+ returncode = 124;
86
+ stdout = completed.stdout || "";
87
+ stderr = (completed.stderr || "") + `\nTimed out after ${spec.timeout_seconds}s.`;
88
+ }
89
+ else {
90
+ returncode = completed.status ?? 1;
91
+ stdout = completed.stdout || "";
92
+ stderr = completed.stderr || "";
93
+ }
94
+ }
95
+ catch (error) {
96
+ returncode = 127;
97
+ stderr = error.message;
98
+ }
99
+ const duration = (performance.now() - clock) / 1000;
100
+ writeFileSync(outputArtifact, `$ ${command}\nexit: ${returncode}\nduration_seconds: ${duration.toFixed(3)}\n` +
101
+ `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`);
102
+ const passed = returncode === 0;
103
+ run.tracer.event(makeEventRecord({
104
+ adw_id: run.adw_id,
105
+ phase_id: phase.phase_id,
106
+ type: "tool_call",
107
+ name: `quality:${spec.name}`,
108
+ payload: {
109
+ area: spec.area,
110
+ operation: spec.operation,
111
+ command,
112
+ returncode,
113
+ passed,
114
+ output_artifact: outputArtifact,
115
+ },
116
+ started_at: startedAt,
117
+ ended_at: nowIso(),
118
+ }));
119
+ run.console.note(`quality ${spec.name}: ${passed ? "passed" : "failed"} (exit ${returncode}, ${duration.toFixed(1)}s)`);
120
+ return {
121
+ name: spec.name,
122
+ area: spec.area,
123
+ operation: spec.operation,
124
+ command,
125
+ returncode,
126
+ passed,
127
+ duration_seconds: duration,
128
+ output_artifact: outputArtifact,
129
+ output_tail: (stdout + stderr).slice(-TAIL_CHARS),
130
+ };
131
+ }
132
+ /**
133
+ * Run every check in a configured suite and collect ALL failures — one pass
134
+ * tells you everything.
135
+ *
136
+ * Ordering contract for the caller: a failing check does NOT fail the phase.
137
+ * The runner did its job; the CODE is what failed. Hand this result to the
138
+ * builder and let the bounded repair loop decide the run's fate. Throws
139
+ * QualityNotConfigured (via resolveSuite) if the suite or any of its checks
140
+ * isn't in the config — before any check runs, let alone any agent spawns.
141
+ */
142
+ export function runSuite(run, suiteName) {
143
+ const specs = resolveSuite(run, suiteName);
144
+ const checks = specs.map((spec) => runCheck(spec, run));
145
+ // A failure is the command, its exit code, and what it actually printed —
146
+ // everything a builder needs to repair without opening a log or being told
147
+ // what the error "means" by a parser that guessed.
148
+ const failures = checks
149
+ .filter((check) => !check.passed)
150
+ .map((check) => `${check.name}: \`${check.command}\` exited ${check.returncode}\n${check.output_tail}`.trimEnd());
151
+ return {
152
+ passed: failures.length === 0,
153
+ checks,
154
+ failures,
155
+ artifacts: checks.map((check) => check.output_artifact),
156
+ };
157
+ }
158
+ /**
159
+ * The `test` suite alone — the deterministic test phase most chains use.
160
+ *
161
+ * This is what replaces a `tester` agent once the command is written down. An
162
+ * agent rediscovering the runner on every run costs a fortune to learn what a
163
+ * subprocess already knows; the repair loop is unchanged, because a failure
164
+ * still reaches the builder through `asEnvelope` below.
165
+ */
166
+ export function runTests(run) {
167
+ return runSuite(run, "test");
168
+ }
169
+ /** Every configured check, across every configured suite's union — the `all` suite. */
170
+ export function runQuality(run) {
171
+ return runSuite(run, "all");
172
+ }
173
+ /**
174
+ * Wrap a deterministic result so an agent can be handed it directly.
175
+ *
176
+ * Agents hand each other typed envelopes; code blocks return QualityResult.
177
+ * This is the adapter, so a failing lint or test run flows back into the
178
+ * builder through exactly the same door an agent's report would — the ADW
179
+ * script is the only thing that knows the difference.
180
+ */
181
+ export function asEnvelope(result, what) {
182
+ return v.parse(VerifyOutput.schema, {
183
+ status: result.passed ? "success" : "fail",
184
+ summary: result.passed
185
+ ? `${what}: all ${result.checks.length} check(s) passed`
186
+ : `${what}: ${result.failures.length} of ${result.checks.length} check(s) failed`,
187
+ artifacts: result.artifacts,
188
+ notes_for_next_agent: result.passed
189
+ ? ""
190
+ : "Fix every failure below. The output is verbatim from the command — trust it over any summary.",
191
+ passed: result.passed,
192
+ failures: result.failures,
193
+ });
194
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The Run object: config + adw_id + agent_map + tracer + console, bound once.
3
+ *
4
+ * `run.phase(params, fn)` is the ONE phase primitive — an async scope for all
5
+ * three kinds (engineer, agent, code), replacing Python's `with run.phase(...)
6
+ * as ph:` context manager (JS has no direct equivalent, so the callback form
7
+ * is the idiomatic stand-in). Success must be earned: every phase defaults to
8
+ * fail; only a clean exit flips it (agent phases additionally require a
9
+ * parsed envelope + green gates, enforced inside ph.call).
10
+ */
11
+ import { type GitHandle } from "./git_helper.ts";
12
+ import { Console } from "./console.ts";
13
+ import { Tracer } from "./tracer.ts";
14
+ import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
15
+ interface AgentMapEntry {
16
+ session_id: string;
17
+ model: string;
18
+ coding_agent: string;
19
+ }
20
+ export interface PhaseHandle {
21
+ log(payload: Record<string, unknown>): void;
22
+ call<T extends EnvelopeBase>(call: AgentCall<T>): Promise<T>;
23
+ }
24
+ export interface RunInit {
25
+ cfg: SFConfig;
26
+ adwId: string;
27
+ tracer: Tracer;
28
+ engineer: string;
29
+ /** Absolute. Resolved once, upstream, by paths.resolveAnchor(). */
30
+ repoRoot: string;
31
+ /** From the same paths.resolveAnchor() call as repoRoot — for prompts.resolveRef(). */
32
+ sfDir: string | null;
33
+ /** Absolute. Resolved once, upstream, by paths.resolveDataPaths(). */
34
+ dataDir: string;
35
+ }
36
+ export declare class Run {
37
+ cfg: SFConfig;
38
+ adw_id: string;
39
+ tracer: Tracer;
40
+ console: Console;
41
+ engineer: string;
42
+ phases: Phase[];
43
+ tokens: number;
44
+ cost: number;
45
+ repo_root: string;
46
+ /** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
47
+ git: GitHandle;
48
+ /** From the same anchor as repo_root — null if no .spf/ dir exists. */
49
+ spf_dir: string | null;
50
+ /** Absolute. Sibling of it: flue.db (Flue's own conversation store), sessions/. */
51
+ data_dir: string;
52
+ session_dir: string;
53
+ context_handoff_dir: string;
54
+ agent_map: Record<string, AgentMapEntry>;
55
+ private seq;
56
+ private agentMapPath;
57
+ constructor(init: RunInit);
58
+ saveAgentMap(agent: string, entry: AgentMapEntry): void;
59
+ addUsage(tokens: number, cost: number): void;
60
+ phase<T>(params: PhaseParams, fn: (ph: PhaseHandle) => Promise<T>): Promise<T>;
61
+ /**
62
+ * Finalize the run and return its exit code. Call this exactly once.
63
+ *
64
+ * Two criteria, not one. Every phase must have passed, AND the ADW's own
65
+ * acceptance test must hold. They are different questions on purpose: a
66
+ * test phase that ran a red suite succeeded at its job. Pass `accepted=`
67
+ * so the exit code, the session status, and the banner are decided
68
+ * together and cannot disagree.
69
+ */
70
+ finish(accepted?: boolean, reason?: string): number;
71
+ }
72
+ export {};