agentforge-framework 0.2.0__py3-none-any.whl
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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""What tables a statement reads from or writes to, and what columns it names.
|
|
2
|
+
|
|
3
|
+
There is no SQL parser here and there is deliberately not going to be one. A
|
|
4
|
+
Context Pack is a head start, so an extractor that is right about ordinary
|
|
5
|
+
statements and silent about exotic ones is worth more than a dependency and a
|
|
6
|
+
dialect argument. What it must never do is claim something that is not there,
|
|
7
|
+
which is why it strips comments and string literals before it looks: a table
|
|
8
|
+
name inside a quoted string is prose, not a reference.
|
|
9
|
+
|
|
10
|
+
Tables are references — what the query reads from and writes to. Columns are
|
|
11
|
+
symbols — the names inside it a change is likely to be about.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from .base import Extraction, ordered
|
|
19
|
+
|
|
20
|
+
#: An identifier as the dialects AgentForge cares about write one:
|
|
21
|
+
#: `orders`, `analytics.orders`, `catalog.schema.table`, and the bracketed or
|
|
22
|
+
#: backticked forms of each.
|
|
23
|
+
_NAME = r'[A-Za-z_][\w$]*|"[^"]+"|`[^`]+`|\[[^\]]+\]'
|
|
24
|
+
_QUALIFIED = rf"(?:{_NAME})(?:\s*\.\s*(?:{_NAME}))*"
|
|
25
|
+
|
|
26
|
+
#: Where a table name follows a keyword. `INTO` covers both `INSERT INTO` and
|
|
27
|
+
#: `MERGE INTO`; `UPDATE` and `TABLE` cover the statements that name their
|
|
28
|
+
#: target without one.
|
|
29
|
+
_TABLE = re.compile(
|
|
30
|
+
rf"\b(?:FROM|JOIN|INTO|UPDATE|TABLE|USING)\s+(?!SELECT\b)({_QUALIFIED})",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: `alias.column` anywhere in the statement. The right-hand side is the column;
|
|
35
|
+
#: the left is an alias this extractor does not try to resolve back to a table.
|
|
36
|
+
_QUALIFIED_COLUMN = re.compile(rf"\b({_NAME})\s*\.\s*({_NAME})\b")
|
|
37
|
+
|
|
38
|
+
#: The projection of a `SELECT`, which is where a bare column name is a column
|
|
39
|
+
#: rather than a keyword. Bounded by the first `FROM` at any depth, because a
|
|
40
|
+
#: subquery in the projection is not what this is trying to read.
|
|
41
|
+
_SELECT = re.compile(r"\bSELECT\b(?:\s+DISTINCT\b)?(.*?)(?:\bFROM\b|$)", re.IGNORECASE | re.DOTALL)
|
|
42
|
+
|
|
43
|
+
_COMMENTS = re.compile(r"--[^\n]*|/\*.*?\*/", re.DOTALL)
|
|
44
|
+
_STRINGS = re.compile(r"'(?:[^']|'')*'")
|
|
45
|
+
|
|
46
|
+
#: Words that appear where a column name would and are not one.
|
|
47
|
+
_NOT_A_COLUMN = frozenset(
|
|
48
|
+
{"as", "case", "when", "then", "else", "end", "null", "distinct", "all", "and", "or", "not"}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract(text: str) -> Extraction:
|
|
53
|
+
"""The tables a statement touches and the columns it names."""
|
|
54
|
+
statement = _STRINGS.sub("''", _COMMENTS.sub(" ", text))
|
|
55
|
+
|
|
56
|
+
tables = [_unquote(match.group(1)) for match in _TABLE.finditer(statement)]
|
|
57
|
+
columns = [_unquote(match.group(2)) for match in _QUALIFIED_COLUMN.finditer(statement)]
|
|
58
|
+
|
|
59
|
+
for projection in _SELECT.findall(statement):
|
|
60
|
+
columns.extend(_projected(projection))
|
|
61
|
+
|
|
62
|
+
# A qualified name is `schema.table`, so its right-hand side reached the
|
|
63
|
+
# column list as well. Dropping anything that is also a table keeps the pack
|
|
64
|
+
# from telling a Role that `orders` is a column of itself.
|
|
65
|
+
named = {_unquote(part) for table in tables for part in table.split(".")}
|
|
66
|
+
return Extraction(
|
|
67
|
+
symbols=tuple(column for column in ordered(columns) if column not in named),
|
|
68
|
+
references=ordered(tables),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _projected(projection: str) -> list[str]:
|
|
73
|
+
"""The columns a `SELECT` list names, where it names them plainly.
|
|
74
|
+
|
|
75
|
+
One bare or qualified identifier per item is read as a column. An expression
|
|
76
|
+
or a function call is skipped rather than guessed at: `SUM(o.total)` has
|
|
77
|
+
already given up `total` through the qualified-column pass, and reading
|
|
78
|
+
`SUM` as a column would put a keyword in the pack.
|
|
79
|
+
"""
|
|
80
|
+
columns = []
|
|
81
|
+
for item in _split_top_level(projection):
|
|
82
|
+
candidate = item.strip().rstrip(",").strip()
|
|
83
|
+
if not candidate or "(" in candidate or candidate.endswith("*"):
|
|
84
|
+
continue
|
|
85
|
+
# `o.total AS revenue` is about `total`; the alias is a name the query
|
|
86
|
+
# invents, and nothing downstream of the query is in the pack.
|
|
87
|
+
head = candidate.split()[0]
|
|
88
|
+
name = _unquote(head.rsplit(".", 1)[-1])
|
|
89
|
+
if name and name.lower() not in _NOT_A_COLUMN and re.fullmatch(r"[\w$]+", name):
|
|
90
|
+
columns.append(name)
|
|
91
|
+
return columns
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _split_top_level(projection: str) -> list[str]:
|
|
95
|
+
"""Split a `SELECT` list on the commas that separate its items."""
|
|
96
|
+
items, depth, start = [], 0, 0
|
|
97
|
+
for index, char in enumerate(projection):
|
|
98
|
+
if char == "(":
|
|
99
|
+
depth += 1
|
|
100
|
+
elif char == ")":
|
|
101
|
+
depth = max(0, depth - 1)
|
|
102
|
+
elif char == "," and depth == 0:
|
|
103
|
+
items.append(projection[start:index])
|
|
104
|
+
start = index + 1
|
|
105
|
+
items.append(projection[start:])
|
|
106
|
+
return items
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _unquote(name: str) -> str:
|
|
110
|
+
"""`"orders"`, `` `orders` ``, and `[orders]` are all `orders`."""
|
|
111
|
+
name = " ".join(name.split()).replace(" . ", ".").replace(". ", ".").replace(" .", ".")
|
|
112
|
+
for opening, closing in (('"', '"'), ("`", "`"), ("[", "]")):
|
|
113
|
+
parts = [
|
|
114
|
+
part[1:-1] if part.startswith(opening) and part.endswith(closing) else part
|
|
115
|
+
for part in name.split(".")
|
|
116
|
+
]
|
|
117
|
+
name = ".".join(parts)
|
|
118
|
+
return name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["extract"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""What keys a YAML file sets, as dotted paths.
|
|
2
|
+
|
|
3
|
+
A config file's shape is the useful thing about it — `gates.tests.suite` tells
|
|
4
|
+
a Role where a setting lives without opening the file. The values are not
|
|
5
|
+
carried: they are what the Role is about to change, and a pack that quoted them
|
|
6
|
+
would be a copy of the file with extra steps.
|
|
7
|
+
|
|
8
|
+
A file that will not parse yields nothing rather than failing. It is still
|
|
9
|
+
carried in the pack by path, and a Role that needs it reads it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import yaml as pyyaml
|
|
15
|
+
|
|
16
|
+
from .base import Extraction, ordered
|
|
17
|
+
|
|
18
|
+
#: How deep a dotted path is followed. Deep enough for a config file's shape,
|
|
19
|
+
#: shallow enough that a document nesting twenty levels does not become the
|
|
20
|
+
#: whole pack. The cap belongs here rather than in the resolver because it is
|
|
21
|
+
#: about what a key path means, not about how large a pack may grow.
|
|
22
|
+
MAX_DEPTH = 4
|
|
23
|
+
|
|
24
|
+
#: How many entries of a list are walked. A list of two hundred jobs has the
|
|
25
|
+
#: same shape as a list of two, and the pack only needs the shape.
|
|
26
|
+
MAX_ITEMS = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract(text: str) -> Extraction:
|
|
30
|
+
"""Every key the document sets, in the order the file writes them."""
|
|
31
|
+
document = pyyaml.safe_load(text)
|
|
32
|
+
return Extraction(symbols=ordered(_keys(document, prefix="", depth=1)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _keys(node, prefix: str, depth: int) -> list[str]:
|
|
36
|
+
if depth > MAX_DEPTH:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
if isinstance(node, dict):
|
|
40
|
+
found = []
|
|
41
|
+
for key, value in node.items():
|
|
42
|
+
path = f"{prefix}.{key}" if prefix else str(key)
|
|
43
|
+
found.append(path)
|
|
44
|
+
found.extend(_keys(value, path, depth + 1))
|
|
45
|
+
return found
|
|
46
|
+
|
|
47
|
+
if isinstance(node, list):
|
|
48
|
+
# A list index is not a key, so the prefix does not grow: two mappings
|
|
49
|
+
# in a list of steps contribute the same paths, which is the shape.
|
|
50
|
+
return [
|
|
51
|
+
path
|
|
52
|
+
for item in node[:MAX_ITEMS]
|
|
53
|
+
for path in _keys(item, prefix, depth)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["MAX_DEPTH", "MAX_ITEMS", "extract"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The Context Pack as a Role is handed it.
|
|
2
|
+
|
|
3
|
+
One renderer for every Role, because the pack is the same head start whatever
|
|
4
|
+
the Role does with it, and five copies of this would drift the first time one of
|
|
5
|
+
them was improved.
|
|
6
|
+
|
|
7
|
+
Symbols are grouped under the file they came from rather than listed as forty
|
|
8
|
+
`path::name` strings. The pack is stored qualified — that is what survives an
|
|
9
|
+
Issue body and what a resolver can be deterministic about — but a prompt that
|
|
10
|
+
repeated one path twenty-five times would spend on punctuation the tokens this
|
|
11
|
+
whole milestone exists to save.
|
|
12
|
+
|
|
13
|
+
The two sentences at the top of the block matter as much as the lists. A Role
|
|
14
|
+
that reads the pack as an exhaustive account of the repository stops looking,
|
|
15
|
+
and a resolver mistake then costs correctness rather than tokens — so the block
|
|
16
|
+
says outright what the pack is and what it is not.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ..core.contracts import ContextPack
|
|
22
|
+
|
|
23
|
+
PREAMBLE = """\
|
|
24
|
+
AgentForge resolved this from the frozen Plan before you were invoked, so you \
|
|
25
|
+
do not have to rediscover it. It is a head start and not a boundary: if you \
|
|
26
|
+
need a file it does not name, read that file.\
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
#: How a symbol names the file it was read out of. The resolver writes it and
|
|
30
|
+
#: this module is the only thing that takes it apart again.
|
|
31
|
+
QUALIFIER = "::"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_context_block(context: ContextPack) -> str:
|
|
35
|
+
"""The `## Context Pack` section of a Role's prompt, or nothing.
|
|
36
|
+
|
|
37
|
+
An empty pack renders as an empty string rather than as a heading with
|
|
38
|
+
nothing under it. A Run started with no pack should not spend its prompt
|
|
39
|
+
telling a Role that AgentForge has nothing for it.
|
|
40
|
+
"""
|
|
41
|
+
if not context:
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
parts = [PREAMBLE]
|
|
45
|
+
|
|
46
|
+
if context.files:
|
|
47
|
+
parts.append("**Read these files:**\n\n" + "\n".join(_file_lines(context)))
|
|
48
|
+
|
|
49
|
+
loose = _unqualified(context)
|
|
50
|
+
if loose:
|
|
51
|
+
parts.append("**The work is in these symbols:** " + ", ".join(loose))
|
|
52
|
+
|
|
53
|
+
if context.references:
|
|
54
|
+
parts.append("**Those files reach for:** " + ", ".join(context.references))
|
|
55
|
+
|
|
56
|
+
if context.conventions:
|
|
57
|
+
parts.append("**Follow these conventions:** " + ", ".join(context.conventions))
|
|
58
|
+
|
|
59
|
+
# Last in the block, and under its own heading. The Orchestrator's
|
|
60
|
+
# conventions above are about this Task; these are about the technology, and
|
|
61
|
+
# a Role reading one run-on list of both cannot tell which of them the Plan
|
|
62
|
+
# actually asked for.
|
|
63
|
+
if context.fragments:
|
|
64
|
+
parts.append(
|
|
65
|
+
"**This repository's technology is held to these conventions:**\n\n"
|
|
66
|
+
+ "\n\n".join(context.fragments)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return "\n## Context Pack\n\n" + "\n\n".join(parts) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _file_lines(context: ContextPack) -> list[str]:
|
|
73
|
+
"""One line per file, carrying the symbols read out of it.
|
|
74
|
+
|
|
75
|
+
A file with no symbols is still a line. It is either a file the Run is about
|
|
76
|
+
to create or one of a type no Extractor claims, and in both cases the path
|
|
77
|
+
is the whole of what AgentForge knows.
|
|
78
|
+
"""
|
|
79
|
+
lines = []
|
|
80
|
+
for path in context.files:
|
|
81
|
+
prefix = f"{path}{QUALIFIER}"
|
|
82
|
+
names = [
|
|
83
|
+
symbol.removeprefix(prefix) for symbol in context.symbols
|
|
84
|
+
if symbol.startswith(prefix)
|
|
85
|
+
]
|
|
86
|
+
listed = ", ".join(names)
|
|
87
|
+
lines.append(f"- `{path}` — {listed}" if listed else f"- `{path}`")
|
|
88
|
+
return lines
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _unqualified(context: ContextPack) -> list[str]:
|
|
92
|
+
"""Symbols that name no file in the pack — the Orchestrator's own.
|
|
93
|
+
|
|
94
|
+
It declares symbols while it plans and does not always say where they live.
|
|
95
|
+
Dropping those would throw away the one part of the pack a human wrote.
|
|
96
|
+
"""
|
|
97
|
+
return [
|
|
98
|
+
symbol
|
|
99
|
+
for symbol in context.symbols
|
|
100
|
+
if not any(symbol.startswith(f"{path}{QUALIFIER}") for path in context.files)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["PREAMBLE", "QUALIFIER", "render_context_block"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Turning a frozen Plan into the Context Pack its Agents are handed.
|
|
2
|
+
|
|
3
|
+
Six Roles run against one Issue, and before this existed each of them opened the
|
|
4
|
+
repository and rediscovered the same files. The frozen Plan already names what
|
|
5
|
+
the work touches (ADR-0003), so the reading can be done once, by AgentForge,
|
|
6
|
+
and handed to every Role. That is the whole idea; ADR-0010 records why it is
|
|
7
|
+
resolved here rather than declared by the Orchestrator or scanned per Step.
|
|
8
|
+
|
|
9
|
+
Three properties this module owes its callers:
|
|
10
|
+
|
|
11
|
+
- **Deterministic.** The same Plan against the same repository resolves to the
|
|
12
|
+
same pack, so two Runs of one Issue can be compared to each other. Nothing
|
|
13
|
+
here iterates a set or walks a directory in filesystem order.
|
|
14
|
+
- **Bounded.** A Plan naming forty files must not produce a pack larger than
|
|
15
|
+
the repository, so every list has a cap and the caps are constants up here
|
|
16
|
+
where a project can argue with them.
|
|
17
|
+
- **Inside the repository.** A Plan naming `../../.ssh/id_rsa` resolves to
|
|
18
|
+
nothing. An Issue body is editable by anyone who can comment on it, and a
|
|
19
|
+
resolver that read whatever it was pointed at would be the way in.
|
|
20
|
+
|
|
21
|
+
What it is not is a search. Nothing here guesses at files the Plan did not
|
|
22
|
+
name — a pack assembled from a fresh scan would drift between Steps, and the
|
|
23
|
+
frozen Plan exists so that it does not.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Callable, Mapping
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from ..core.contracts import ContextPack, Plan
|
|
32
|
+
from .extractors import extract
|
|
33
|
+
from .extractors.base import Extraction
|
|
34
|
+
|
|
35
|
+
#: How large a pack may get. These bound the pack rather than the repository:
|
|
36
|
+
#: the point of a Context Pack is that it is smaller than looking, and a cap of
|
|
37
|
+
#: a thousand symbols would be a pack nobody saved anything by reading.
|
|
38
|
+
MAX_FILES = 40
|
|
39
|
+
MAX_SYMBOLS = 120
|
|
40
|
+
MAX_SYMBOLS_PER_FILE = 25
|
|
41
|
+
MAX_REFERENCES = 60
|
|
42
|
+
|
|
43
|
+
#: Files past this size are carried by path and not read. A generated migration
|
|
44
|
+
#: or a checked-in dataset yields hundreds of symbols and no understanding, and
|
|
45
|
+
#: reading one is slower than every other thing this module does.
|
|
46
|
+
MAX_BYTES = 200_000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_pack(
|
|
50
|
+
plan: Plan,
|
|
51
|
+
root: Path | str,
|
|
52
|
+
declared: ContextPack | None = None,
|
|
53
|
+
extractors: Mapping[str, Callable[[str], Extraction]] | None = None,
|
|
54
|
+
) -> ContextPack:
|
|
55
|
+
"""The pack for this Plan: what it names, and what those files contain.
|
|
56
|
+
|
|
57
|
+
`declared` is the Orchestrator's own pack, carried in the Issue body. It is
|
|
58
|
+
kept rather than replaced — the Orchestrator read the repository while it
|
|
59
|
+
planned, and its conventions are a judgement no extractor produces — and
|
|
60
|
+
what the Plan's own steps name comes first, because that is the work.
|
|
61
|
+
|
|
62
|
+
A file the Plan names and the repository does not have is still carried. It
|
|
63
|
+
is a file the Run is about to create, and dropping it would leave a Role
|
|
64
|
+
reading a pack that disagrees with the plan it was handed.
|
|
65
|
+
|
|
66
|
+
`extractors` is the table to read with, and `None` means the built-in three.
|
|
67
|
+
A Run with active Plugins passes the wider table `core.registry` assembles,
|
|
68
|
+
which is the whole of how a Plugin's reader reaches a pack: nothing else in
|
|
69
|
+
this module knows a Plugin exists, and the caps below fall on a Plugin's
|
|
70
|
+
output exactly as they fall on a built-in extractor's.
|
|
71
|
+
"""
|
|
72
|
+
root = Path(root)
|
|
73
|
+
declared = declared or ContextPack()
|
|
74
|
+
|
|
75
|
+
paths = _paths(plan, declared, root)
|
|
76
|
+
symbols = list(declared.symbols)
|
|
77
|
+
references: list[str] = list(declared.references)
|
|
78
|
+
|
|
79
|
+
for path in paths:
|
|
80
|
+
extraction = _read(root / path, extractors)
|
|
81
|
+
symbols.extend(
|
|
82
|
+
f"{path}::{name}" for name in extraction.symbols[:MAX_SYMBOLS_PER_FILE]
|
|
83
|
+
)
|
|
84
|
+
references.extend(extraction.references)
|
|
85
|
+
|
|
86
|
+
return ContextPack(
|
|
87
|
+
files=tuple(paths),
|
|
88
|
+
symbols=_capped(symbols, MAX_SYMBOLS),
|
|
89
|
+
references=_capped(references, MAX_REFERENCES),
|
|
90
|
+
conventions=declared.conventions,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _paths(plan: Plan, declared: ContextPack, root: Path) -> list[str]:
|
|
95
|
+
"""Every file the Plan and the declared pack name, in that order.
|
|
96
|
+
|
|
97
|
+
Plan first because a step's files are the work itself, and the cap should
|
|
98
|
+
fall on the Orchestrator's supporting reading rather than on the thing the
|
|
99
|
+
Run was filed to change.
|
|
100
|
+
"""
|
|
101
|
+
named = [path for step in plan.steps for path in step.files]
|
|
102
|
+
named += list(declared.files)
|
|
103
|
+
|
|
104
|
+
resolved: dict[str, None] = {}
|
|
105
|
+
for raw in named:
|
|
106
|
+
path = inside(raw, root)
|
|
107
|
+
if path is not None:
|
|
108
|
+
resolved.setdefault(path, None)
|
|
109
|
+
return list(resolved)[:MAX_FILES]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def inside(raw: str, root: Path) -> str | None:
|
|
113
|
+
"""The path as a repository-relative posix string, or `None` if it escapes.
|
|
114
|
+
|
|
115
|
+
Absolute paths and `..` are refused rather than clamped. A Plan that names
|
|
116
|
+
one is wrong about the repository, and a resolver that quietly reinterpreted
|
|
117
|
+
it would hand a Role a file nobody asked for.
|
|
118
|
+
"""
|
|
119
|
+
text = str(raw).strip().replace("\\", "/")
|
|
120
|
+
if not text:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
candidate = Path(text)
|
|
124
|
+
if candidate.is_absolute() or ".." in candidate.parts:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Resolving both sides catches a symlink pointing out of the tree, which the
|
|
128
|
+
# `..` check above does not see.
|
|
129
|
+
try:
|
|
130
|
+
(root / candidate).resolve().relative_to(root.resolve())
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
return candidate.as_posix().removeprefix("./")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read(
|
|
138
|
+
path: Path, extractors: Mapping[str, Callable[[str], Extraction]] | None = None
|
|
139
|
+
) -> Extraction:
|
|
140
|
+
"""What one file contains, or an empty extraction if it cannot be read.
|
|
141
|
+
|
|
142
|
+
A missing file, a directory, an unreadable one, and a file too large to be
|
|
143
|
+
worth reading all land here, and all of them mean the same thing: the pack
|
|
144
|
+
carries the path and claims nothing about the contents.
|
|
145
|
+
"""
|
|
146
|
+
text = file_text(path)
|
|
147
|
+
return extract(path, text, extractors) if text else Extraction()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def file_text(path: Path) -> str:
|
|
151
|
+
"""One file's text, or empty where reading it is not worth it or not possible.
|
|
152
|
+
|
|
153
|
+
Public because `core.registry` reads the same files when it detects a Plugin
|
|
154
|
+
by what the blast radius imports, and the two must agree about which files
|
|
155
|
+
are readable. A detection that read a two-hundred-megabyte file the pack
|
|
156
|
+
skips would be paying for an answer the pack never uses.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
if not path.is_file() or path.stat().st_size > MAX_BYTES:
|
|
160
|
+
return ""
|
|
161
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
162
|
+
except OSError:
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _capped(values, limit: int) -> tuple[str, ...]:
|
|
167
|
+
"""Deduplicated in first-seen order, then truncated to the cap."""
|
|
168
|
+
seen: dict[str, None] = {}
|
|
169
|
+
for value in values:
|
|
170
|
+
text = str(value).strip()
|
|
171
|
+
if text:
|
|
172
|
+
seen.setdefault(text, None)
|
|
173
|
+
return tuple(seen)[:limit]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
"MAX_BYTES",
|
|
178
|
+
"MAX_FILES",
|
|
179
|
+
"MAX_REFERENCES",
|
|
180
|
+
"MAX_SYMBOLS",
|
|
181
|
+
"MAX_SYMBOLS_PER_FILE",
|
|
182
|
+
"file_text",
|
|
183
|
+
"inside",
|
|
184
|
+
"resolve_pack",
|
|
185
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core AgentForge abstractions."""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Running a Command: a repeated chore, with no inference anywhere in it.
|
|
2
|
+
|
|
3
|
+
`agentforge run scaffold-dbt-model orders` writes the files and exits. No Issue
|
|
4
|
+
is filed, no Run starts, no Provider is invoked, and nothing here reads the
|
|
5
|
+
repository to decide what to write — a Command is a template and an argument
|
|
6
|
+
vector, and the whole of what it will do is readable in the Plugin that declares
|
|
7
|
+
it. That is what makes its output reviewable as an ordinary diff rather than as
|
|
8
|
+
a thing somebody has to check for hallucination.
|
|
9
|
+
|
|
10
|
+
Three rules this module owes whoever types one:
|
|
11
|
+
|
|
12
|
+
- **It never overwrites.** A Command that clobbered a file would be a Command
|
|
13
|
+
nobody dares run twice, and the failure would be silent in the one place — a
|
|
14
|
+
working tree — where the tool has already promised the diff is the review.
|
|
15
|
+
- **It never writes outside the repository.** The same containment rule the
|
|
16
|
+
Context Pack resolver applies, for the same reason: a template path is data,
|
|
17
|
+
and data that renders to `../../.ssh/authorized_keys` is refused rather than
|
|
18
|
+
clamped.
|
|
19
|
+
- **It runs processes through the Command Runner, under ADR-0007.** A Command
|
|
20
|
+
that starts something is subject to the same default-deny as everything else,
|
|
21
|
+
and the human typing `agentforge run` is the grant. Nothing else here starts a
|
|
22
|
+
process, and nothing here imports `subprocess`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from string import Template
|
|
30
|
+
|
|
31
|
+
from ..context.resolver import inside
|
|
32
|
+
from .contracts import Command
|
|
33
|
+
from .process import CommandResult, CommandRunner, MissingBinary
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CommandOutcome:
|
|
38
|
+
"""What running one Command did, or why it did nothing.
|
|
39
|
+
|
|
40
|
+
`written` is what a human is about to read in `git status`, so it is carried
|
|
41
|
+
even when the process that followed the writing failed: files that reached
|
|
42
|
+
the tree are the tree's now, and a report that hid them would send somebody
|
|
43
|
+
looking for a mess they were not told about.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
command: str
|
|
47
|
+
written: tuple[str, ...] = ()
|
|
48
|
+
result: CommandResult | None = None
|
|
49
|
+
error: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def ok(self) -> bool:
|
|
53
|
+
return not self.error and (self.result is None or self.result.ok)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_command(
|
|
57
|
+
command: Command,
|
|
58
|
+
arguments: list[str] | tuple[str, ...],
|
|
59
|
+
root: Path | str,
|
|
60
|
+
runner: CommandRunner,
|
|
61
|
+
allow_commands: bool = False,
|
|
62
|
+
) -> CommandOutcome:
|
|
63
|
+
"""Run one Command in one repository, and say what it did.
|
|
64
|
+
|
|
65
|
+
`allow_commands` is ADR-0007's posture and matters only to a Command that
|
|
66
|
+
declares an `argv`: writing a file is what an Agent does anyway, and starting
|
|
67
|
+
a process is the thing the gate is about. `agentforge run` passes it because
|
|
68
|
+
a human typed the name — an explicit, attended grant is exactly what that
|
|
69
|
+
ADR asks for — and a Run passes its own flag, so a Plugin cannot become the
|
|
70
|
+
route by which an unattended Agent executes arbitrary code.
|
|
71
|
+
|
|
72
|
+
Every failure is a returned outcome rather than an exception. The caller is
|
|
73
|
+
a CLI that has to print something and pick an exit status, and a traceback
|
|
74
|
+
is neither.
|
|
75
|
+
"""
|
|
76
|
+
root = Path(root)
|
|
77
|
+
|
|
78
|
+
if len(arguments) != len(command.arguments):
|
|
79
|
+
return CommandOutcome(command=command.name, error=_usage(command))
|
|
80
|
+
|
|
81
|
+
values = dict(zip(command.arguments, arguments, strict=True))
|
|
82
|
+
|
|
83
|
+
# Before anything is written, rather than after. A Command refused halfway
|
|
84
|
+
# leaves files in a tree whose author was told the Command did not run.
|
|
85
|
+
if command.argv and not allow_commands:
|
|
86
|
+
return CommandOutcome(
|
|
87
|
+
command=command.name,
|
|
88
|
+
error=(
|
|
89
|
+
f"{command.name} runs `{' '.join(command.argv)}`, and command execution "
|
|
90
|
+
"is denied here (ADR-0007). Run it yourself, or start the Run with "
|
|
91
|
+
"--allow-commands."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
planned = [_render(template, values, root) for template in command.templates]
|
|
97
|
+
except KeyError as exc:
|
|
98
|
+
# A placeholder no argument answers for. A declaration fault rather than
|
|
99
|
+
# a typing one, and it names the placeholder so whoever wrote the Plugin
|
|
100
|
+
# can find it.
|
|
101
|
+
return CommandOutcome(
|
|
102
|
+
command=command.name,
|
|
103
|
+
error=f"{command.name} names a placeholder its arguments do not define: {exc}",
|
|
104
|
+
)
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
return CommandOutcome(command=command.name, error=str(exc))
|
|
107
|
+
|
|
108
|
+
for path, _ in planned:
|
|
109
|
+
if (root / path).exists():
|
|
110
|
+
return CommandOutcome(
|
|
111
|
+
command=command.name,
|
|
112
|
+
error=(
|
|
113
|
+
f"{path} already exists. {command.name} writes files and never "
|
|
114
|
+
"replaces one; move it aside, or name something else."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
written: list[str] = []
|
|
119
|
+
for path, text in planned:
|
|
120
|
+
target = root / path
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
target.write_text(text, encoding="utf-8")
|
|
123
|
+
written.append(path)
|
|
124
|
+
|
|
125
|
+
if not command.argv:
|
|
126
|
+
return CommandOutcome(command=command.name, written=tuple(written))
|
|
127
|
+
|
|
128
|
+
argv = tuple(_substitute(part, values) for part in command.argv)
|
|
129
|
+
try:
|
|
130
|
+
result = runner.run(argv, cwd=root)
|
|
131
|
+
except MissingBinary as exc:
|
|
132
|
+
return CommandOutcome(command=command.name, written=tuple(written), error=str(exc))
|
|
133
|
+
|
|
134
|
+
return CommandOutcome(command=command.name, written=tuple(written), result=result)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _render(template, values: dict[str, str], root: Path) -> tuple[str, str]:
|
|
138
|
+
"""One template as the path it writes and the text it writes there."""
|
|
139
|
+
rendered = _substitute(template.path, values)
|
|
140
|
+
path = inside(rendered, root)
|
|
141
|
+
if path is None:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{rendered!r} is outside the repository. A Command writes into the tree "
|
|
144
|
+
"it was run in and nowhere else."
|
|
145
|
+
)
|
|
146
|
+
return path, _substitute(template.text, values)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _substitute(source: str, values: dict[str, str]) -> str:
|
|
150
|
+
"""`$name` and `${name}`, and `$$` for a literal dollar.
|
|
151
|
+
|
|
152
|
+
`string.Template` rather than `str.format`, because a dbt model is Jinja and
|
|
153
|
+
a template full of `{{ ref(...) }}` would have to double every brace it
|
|
154
|
+
already carries.
|
|
155
|
+
"""
|
|
156
|
+
return Template(source).substitute(values)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _usage(command: Command) -> str:
|
|
160
|
+
"""What to type instead, in the shape the CLI's own help uses."""
|
|
161
|
+
named = " ".join(f"<{name}>" for name in command.arguments)
|
|
162
|
+
takes = (
|
|
163
|
+
f"takes {len(command.arguments)} argument(s)"
|
|
164
|
+
if command.arguments
|
|
165
|
+
else "takes no arguments"
|
|
166
|
+
)
|
|
167
|
+
return f"{command.name} {takes}: agentforge run {command.name} {named}".rstrip()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["CommandOutcome", "run_command"]
|