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.
Files changed (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. 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"]