@gr8ful/spf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +321 -0
- package/assets/defaults/spf.config.yaml +141 -0
- package/assets/prompts/builder/system.md +13 -0
- package/assets/prompts/builder/user.md +34 -0
- package/assets/prompts/documenter/system.md +17 -0
- package/assets/prompts/documenter/user.md +48 -0
- package/assets/prompts/planner/system.md +21 -0
- package/assets/prompts/planner/user.md +45 -0
- package/assets/prompts/reviewer/system.md +16 -0
- package/assets/prompts/reviewer/user.md +44 -0
- package/assets/prompts/scout/system.md +20 -0
- package/assets/prompts/scout/user.md +34 -0
- package/assets/skill/SKILL.md +80 -0
- package/assets/skill/cookbooks/authoring_chains.md +193 -0
- package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
- package/assets/skill/cookbooks/roster.md +197 -0
- package/assets/skill/cookbooks/run_adw.md +92 -0
- package/assets/skill/cookbooks/spf_overview.md +111 -0
- package/assets/skill/references/config.md +188 -0
- package/assets/skill/references/handoff.md +162 -0
- package/assets/skill/references/observability.md +184 -0
- package/dist/chains/adw_build.d.ts +12 -0
- package/dist/chains/adw_build.js +27 -0
- package/dist/chains/adw_build_review.d.ts +21 -0
- package/dist/chains/adw_build_review.js +55 -0
- package/dist/chains/adw_build_test.d.ts +21 -0
- package/dist/chains/adw_build_test.js +67 -0
- package/dist/chains/adw_document.d.ts +23 -0
- package/dist/chains/adw_document.js +59 -0
- package/dist/chains/adw_plan.d.ts +12 -0
- package/dist/chains/adw_plan.js +27 -0
- package/dist/chains/adw_plan_build.d.ts +12 -0
- package/dist/chains/adw_plan_build.js +30 -0
- package/dist/chains/adw_plan_build_test.d.ts +16 -0
- package/dist/chains/adw_plan_build_test.js +65 -0
- package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
- package/dist/chains/adw_plan_build_test_quality.js +66 -0
- package/dist/chains/adw_prompt.d.ts +12 -0
- package/dist/chains/adw_prompt.js +25 -0
- package/dist/chains/adw_quality.d.ts +12 -0
- package/dist/chains/adw_quality.js +32 -0
- package/dist/chains/adw_scout.d.ts +12 -0
- package/dist/chains/adw_scout.js +27 -0
- package/dist/chains/adw_simple_sdlc.d.ts +43 -0
- package/dist/chains/adw_simple_sdlc.js +147 -0
- package/dist/chains/context.d.ts +19 -0
- package/dist/chains/context.js +1 -0
- package/dist/chains/index.d.ts +19 -0
- package/dist/chains/index.js +116 -0
- package/dist/cli/bin.d.ts +15 -0
- package/dist/cli/bin.js +24 -0
- package/dist/cli/commands/abort.d.ts +1 -0
- package/dist/cli/commands/abort.js +42 -0
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.js +165 -0
- package/dist/cli/commands/eject.d.ts +1 -0
- package/dist/cli/commands/eject.js +55 -0
- package/dist/cli/commands/events.d.ts +1 -0
- package/dist/cli/commands/events.js +49 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +62 -0
- package/dist/cli/commands/install-skill.d.ts +1 -0
- package/dist/cli/commands/install-skill.js +122 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/migrate.d.ts +1 -0
- package/dist/cli/commands/migrate.js +167 -0
- package/dist/cli/commands/phases.d.ts +1 -0
- package/dist/cli/commands/phases.js +25 -0
- package/dist/cli/commands/run.d.ts +3 -0
- package/dist/cli/commands/run.js +27 -0
- package/dist/cli/commands/sessions.d.ts +1 -0
- package/dist/cli/commands/sessions.js +20 -0
- package/dist/cli/commands/trace.d.ts +8 -0
- package/dist/cli/commands/trace.js +10 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +35 -0
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +210 -0
- package/dist/cli/gitignore.d.ts +1 -0
- package/dist/cli/gitignore.js +14 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/core/agent_cc.d.ts +75 -0
- package/dist/core/agent_cc.js +322 -0
- package/dist/core/agent_flue.d.ts +66 -0
- package/dist/core/agent_flue.js +321 -0
- package/dist/core/agents.d.ts +64 -0
- package/dist/core/agents.js +456 -0
- package/dist/core/changes.d.ts +35 -0
- package/dist/core/changes.js +98 -0
- package/dist/core/console.d.ts +36 -0
- package/dist/core/console.js +156 -0
- package/dist/core/data_types.d.ts +562 -0
- package/dist/core/data_types.js +382 -0
- package/dist/core/gates.d.ts +33 -0
- package/dist/core/gates.js +144 -0
- package/dist/core/git_helper.d.ts +59 -0
- package/dist/core/git_helper.js +115 -0
- package/dist/core/issues/github_provider.d.ts +52 -0
- package/dist/core/issues/github_provider.js +211 -0
- package/dist/core/issues/provider.d.ts +90 -0
- package/dist/core/issues/provider.js +14 -0
- package/dist/core/paths.d.ts +78 -0
- package/dist/core/paths.js +108 -0
- package/dist/core/permissions.d.ts +78 -0
- package/dist/core/permissions.js +187 -0
- package/dist/core/prompts.d.ts +4 -0
- package/dist/core/prompts.js +17 -0
- package/dist/core/quality.d.ts +65 -0
- package/dist/core/quality.js +194 -0
- package/dist/core/runner.d.ts +72 -0
- package/dist/core/runner.js +168 -0
- package/dist/core/session.d.ts +16 -0
- package/dist/core/session.js +60 -0
- package/dist/core/sqlite.d.ts +55 -0
- package/dist/core/sqlite.js +106 -0
- package/dist/core/tracer.d.ts +56 -0
- package/dist/core/tracer.js +246 -0
- package/dist/core/utils.d.ts +40 -0
- package/dist/core/utils.js +108 -0
- package/dist/core/watch.d.ts +44 -0
- package/dist/core/watch.js +194 -0
- package/dist/test/agent_cc.test.d.ts +1 -0
- package/dist/test/agent_cc.test.js +95 -0
- package/dist/test/agent_flue.test.d.ts +1 -0
- package/dist/test/agent_flue.test.js +83 -0
- package/dist/test/data_types.test.d.ts +10 -0
- package/dist/test/data_types.test.js +49 -0
- package/dist/test/ui_server.test.d.ts +1 -0
- package/dist/test/ui_server.test.js +119 -0
- package/dist/test/watch.test.d.ts +1 -0
- package/dist/test/watch.test.js +227 -0
- package/dist/ui/server/app.d.ts +3 -0
- package/dist/ui/server/app.js +98 -0
- package/dist/ui/server/db.d.ts +82 -0
- package/dist/ui/server/db.js +333 -0
- package/dist/ui/server/serve.d.ts +13 -0
- package/dist/ui/server/serve.js +80 -0
- package/dist/ui/server/static.d.ts +5 -0
- package/dist/ui/server/static.js +55 -0
- package/dist/ui/shared/types.d.ts +263 -0
- package/dist/ui/shared/types.js +8 -0
- package/package.json +52 -0
- package/web/assets/index-C7nF068F.css +1 -0
- package/web/assets/index-mzSArcnQ.js +11 -0
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
- package/web/index.html +14 -0
- package/web/logo.svg +6 -0
|
@@ -0,0 +1,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 {};
|