agentic-dev-loop 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.
@@ -0,0 +1 @@
1
+ """Opinionated LangGraph engine for OpenHands agent pipelines."""
@@ -0,0 +1,189 @@
1
+ """Built-in pipeline actions. Node-shaped: (state, params) -> state update.
2
+
3
+ `commit` policy (ported unchanged from yosk): agents never commit mid-run;
4
+ the pipeline commits exactly once, and only when the worktree was clean at
5
+ run start — a dirty-at-start run is committed by the human, so the
6
+ auto-commit can never swallow pre-existing unrelated changes (run 004: the
7
+ dev agent's b4acb62a swept up an unrelated .gitignore edit).
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from .proc import child_env, is_pid_token
18
+
19
+
20
+ def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
21
+ """Best-effort git invocation — callers inspect returncode/stdout rather
22
+ than raising, so a git problem never crashes a verified run. ``git`` is
23
+ resolved via ``shutil.which`` and run argv-style with a scrubbed child
24
+ environment (BR-5)."""
25
+ git = shutil.which("git")
26
+ if git is None:
27
+ return subprocess.CompletedProcess(["git", *args], 127, "", "")
28
+ return subprocess.run([git, *args], cwd=cwd, capture_output=True,
29
+ text=True, check=False, shell=False,
30
+ env=child_env())
31
+
32
+
33
+ def git_worktree_clean(path: Path) -> bool:
34
+ """True only when `path` is a git repo with no uncommitted changes.
35
+ Outside a repo there is no clean baseline — False (fail-safe, keeps the
36
+ commit node disabled)."""
37
+ result = _git(["status", "--porcelain"], cwd=path)
38
+ return result.returncode == 0 and not result.stdout.strip()
39
+
40
+
41
+ def _parse_cwd_output(out: str) -> str | None:
42
+ """The cwd path from `lsof -a -p PID -d cwd -Fn` output, or None when the
43
+ payload doesn't carry one. Two formats occur in the wild: macOS prints
44
+ `p<PID>` / `fcwd` / `n<path>` (the path is the `n` line immediately after
45
+ `fcwd`); Linux omits the fd line and prints `p<PID>` / `n<path>` (the
46
+ path is the first `n` line)."""
47
+ lines = out.splitlines()
48
+ for i, line in enumerate(lines):
49
+ if line == "fcwd" and i + 1 < len(lines):
50
+ nxt = lines[i + 1]
51
+ if nxt.startswith("n"):
52
+ return nxt[1:]
53
+ if line.startswith("n"):
54
+ return line[1:]
55
+ return None
56
+
57
+
58
+ def _pid_cwd(pid: str) -> str | None:
59
+ """The working directory of `pid`, or None on any failure (lsof missing,
60
+ the process gone, or an unreadable cwd). Positive-only attribution: a pid
61
+ whose cwd can't be determined is spared by the caller, never killed.
62
+ Only a validated pid token reaches ``lsof`` (BR-5.2)."""
63
+ if not is_pid_token(pid):
64
+ return None
65
+ lsof = shutil.which("lsof")
66
+ if lsof is None:
67
+ return None
68
+ try:
69
+ out = subprocess.run(
70
+ [lsof, "-a", "-p", pid, "-d", "cwd", "-Fn"],
71
+ capture_output=True, text=True, check=False, shell=False,
72
+ env=child_env()).stdout
73
+ except FileNotFoundError:
74
+ return None
75
+ return _parse_cwd_output(out)
76
+
77
+
78
+ def _listening_pids(port: int, match: str | None = None,
79
+ cwd: Path | str | None = None) -> list[str]:
80
+ """PIDs of processes listening on `port`, optionally only those whose
81
+ command line contains `match` and whose working directory resolves to
82
+ `cwd`. The current process is always excluded — a pipeline must never
83
+ sweep its own listener (e.g. the live viz server), only orphaned
84
+ listeners left behind by earlier runs or agent subprocesses.
85
+
86
+ `cwd` is positive-only: a pid whose cwd can't be determined is spared,
87
+ so a multi-instance setup (two agentic-dev-loop runs from different
88
+ workspaces) never kills the other run's server.
89
+
90
+ Every token from ``lsof`` output is validated numeric before it can reach
91
+ ``ps``/``kill`` (BR-5.2); binaries resolve via ``shutil.which`` and run
92
+ argv-style with a scrubbed environment (BR-5.1, BR-5.3, BR-5.4)."""
93
+ lsof = shutil.which("lsof")
94
+ if lsof is None:
95
+ return []
96
+ out = subprocess.run(
97
+ [lsof, "-nP", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
98
+ capture_output=True, text=True, check=False, shell=False,
99
+ env=child_env()).stdout
100
+ self_pid = str(os.getpid())
101
+ ps = shutil.which("ps")
102
+ pids = []
103
+ for token in out.split():
104
+ if not is_pid_token(token) or token == self_pid:
105
+ continue
106
+ if match is not None:
107
+ if ps is None:
108
+ continue
109
+ cmd = subprocess.run(
110
+ [ps, "-p", token, "-o", "command="],
111
+ capture_output=True, text=True, check=False, shell=False,
112
+ env=child_env()).stdout
113
+ if match not in cmd:
114
+ continue
115
+ if cwd is not None:
116
+ pid_cwd = _pid_cwd(token)
117
+ if pid_cwd is None or Path(pid_cwd).resolve() != Path(cwd).resolve():
118
+ continue
119
+ pids.append(token)
120
+ return pids
121
+
122
+
123
+ def kill_listeners_on_port(port: int, match: str | None = None,
124
+ term_timeout: float = 3.0,
125
+ cwd: Path | str | None = None) -> None:
126
+ """Kill processes listening on `port`, optionally only those whose command
127
+ line contains `match` and whose working directory resolves to `cwd`. A
128
+ stage that was cut short can orphan its server; the next attempt's server
129
+ would fail to bind and probes would hit the stale process serving a
130
+ previous attempt's build.
131
+
132
+ SIGTERM first, then SIGKILL whatever still holds the port after
133
+ `term_timeout` — a server that ignores TERM must not hold the port
134
+ indefinitely. Port freedom is judged by re-running lsof, so zombies
135
+ (dead, unreaped children) never look like survivors."""
136
+ if shutil.which("lsof") is None:
137
+ print(f"WARNING: lsof not found — cannot sweep orphaned listeners on "
138
+ f"port {port}.", file=sys.stderr, flush=True)
139
+ return
140
+ kill = shutil.which("kill")
141
+ pids = _listening_pids(port, match, cwd)
142
+ for pid in pids:
143
+ if kill is not None:
144
+ subprocess.run([kill, pid], capture_output=True, check=False,
145
+ shell=False, env=child_env())
146
+ survivors = pids
147
+ deadline = time.monotonic() + term_timeout
148
+ while survivors and time.monotonic() < deadline:
149
+ survivors = _listening_pids(port, match, cwd)
150
+ if survivors:
151
+ time.sleep(0.05)
152
+ for pid in survivors:
153
+ if kill is not None:
154
+ subprocess.run([kill, "-9", pid], capture_output=True, check=False,
155
+ shell=False, env=child_env())
156
+
157
+
158
+ def commit(state: dict, params: dict) -> dict:
159
+ """The pipeline's only commit point. Skipped loudly when the worktree was
160
+ dirty at run start, so the auto-commit can never swallow pre-existing
161
+ unrelated changes; a dirty-at-start run is committed by the human.
162
+
163
+ `params["message"]` is required — a template rendered with `{subject}`.
164
+ Runs in the process's cwd (the consumer repo the pipeline was launched
165
+ from)."""
166
+ subject = state["subject"]
167
+ if not state.get("commit_allowed", False):
168
+ print(f"[{subject}] Skipping auto-commit — worktree was dirty at run "
169
+ f"start; commit the constructed work manually.", flush=True)
170
+ return {}
171
+ if "message" not in params:
172
+ raise ValueError("commit action requires params.message "
173
+ "(a template rendered with {subject})")
174
+ cwd = Path.cwd()
175
+ _git(["add", "-A"], cwd=cwd)
176
+ if not _git(["status", "--porcelain"], cwd=cwd).stdout.strip():
177
+ print(f"[{subject}] Nothing to commit.", flush=True)
178
+ return {}
179
+ message = params["message"].format(subject=subject)
180
+ result = _git(["commit", "-m", message], cwd=cwd)
181
+ if result.returncode != 0:
182
+ # The work is built and verified — a git error is a loud warning,
183
+ # not a pipeline failure.
184
+ print(f"WARNING: [{subject}] auto-commit failed — commit manually.\n"
185
+ f"{result.stderr.strip()}", file=sys.stderr, flush=True)
186
+ return {}
187
+ short = _git(["rev-parse", "--short", "HEAD"], cwd=cwd).stdout.strip()
188
+ print(f"[{subject}] Committed constructed work as {short}.", flush=True)
189
+ return {}
@@ -0,0 +1,49 @@
1
+ """Agent file loading + SDK factory mechanics.
2
+
3
+ The SDK's built-in tools are registered at import (its registry is global);
4
+ consumer tools are registered by extensions via `Registry.add_tool`."""
5
+
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from openhands.sdk import LLM
10
+ from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
11
+ from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
12
+ from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
13
+
14
+ if TYPE_CHECKING:
15
+ from openhands.sdk.agent.agent import Agent
16
+
17
+
18
+ def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
19
+ """Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
20
+ factory (frontmatter tool names resolved against the registry, unknown →
21
+ ValueError; the markdown body is delivered as the system prompt suffix).
22
+
23
+ Tool surface (BR-1): the frontmatter `tools:` list is the exact
24
+ capability allowlist — every name must resolve to a registered SDK tool.
25
+ `FinishTool` (done signal) and `ThinkTool` (reasoning scaffold) are added
26
+ by the SDK as default infra, never part of the allowlist and never
27
+ removable via `tools:`. There is no regex or default-tools knob.
28
+
29
+ Two pipeline policies on top of the SDK's native AgentDefinition loader:
30
+ the file must exist (fail fast — the SDK's dir loader would silently
31
+ skip it) and its frontmatter name must match its filename. A file with
32
+ no frontmatter or no name gets the SDK's stem fallback ("<name>.agent"),
33
+ which can never match the stem — so both degenerate cases fail the name
34
+ check."""
35
+ from openhands.sdk.subagent.registry import agent_definition_to_factory
36
+ from openhands.sdk.subagent.schema import AgentDefinition
37
+
38
+ path = Path(agents_dir) / f"{name}.agent.md"
39
+ if not path.is_file():
40
+ raise FileNotFoundError(f"Agent file not found: {path}")
41
+
42
+ agent_def = AgentDefinition.load(path)
43
+
44
+ stem = path.name.removesuffix(".agent.md")
45
+ if agent_def.name != stem:
46
+ raise ValueError(
47
+ f"Agent file {path} name {agent_def.name!r} does not match "
48
+ f"filename {stem!r}")
49
+ return agent_definition_to_factory(agent_def)(llm)
@@ -0,0 +1,82 @@
1
+ """Two-phase CLI: phase 1 finds the pipeline YAML; phase 2 builds the full
2
+ parser — core flags plus every document loader's contributed args (the
3
+ built-in `plan` loader plus whatever the pipeline's `extensions:` register)
4
+ — and runs the pipeline. A loader's flag therefore exists only when its
5
+ extension is listed in that pipeline's YAML."""
6
+
7
+ import argparse
8
+ import importlib.metadata
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .config import load_config
13
+ from .graph import run_pipeline
14
+ from .registry import default_registry, load_extensions
15
+
16
+
17
+ def _version() -> str:
18
+ """The installed distribution version — the single source of truth."""
19
+ try:
20
+ return importlib.metadata.version("agentic-dev-loop")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ print("agentic-dev-loop is not installed — version unavailable; "
23
+ "run `pip install -e .`", file=sys.stderr)
24
+ raise SystemExit(1)
25
+
26
+
27
+ def _add_version_flag(parser: argparse.ArgumentParser) -> None:
28
+ parser.add_argument("--version", action="version",
29
+ version=f"agentic-dev-loop {_version()}")
30
+
31
+
32
+ def _core_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="agentic-dev-loop",
35
+ description="Run an OpenHands agent pipeline defined in YAML. "
36
+ "Document-loader flags contributed by the pipeline's "
37
+ "extensions appear once PIPELINE is known — see "
38
+ "`agentic-dev-loop PIPELINE --help`.")
39
+ parser.add_argument("pipeline", type=Path,
40
+ help="path to the pipeline YAML")
41
+ parser.add_argument("--resume", metavar="THREAD_ID", default=None,
42
+ help="resume a checkpointed run by thread id")
43
+ parser.add_argument("--max-attempts", type=int, default=None,
44
+ help="override the pipeline's max_attempts")
45
+ parser.add_argument("--no-viz", action="store_true",
46
+ help="suppress the live graph view (served by "
47
+ "default)")
48
+ parser.add_argument("--viz-port", type=int, default=8321,
49
+ help="preferred port for the live graph view "
50
+ "(default: 8321; falls back to a free port when "
51
+ "busy)")
52
+ _add_version_flag(parser)
53
+ return parser
54
+
55
+
56
+ def build_parser(cfg_path) -> argparse.ArgumentParser:
57
+ """The full parser for one pipeline: core flags + loader-contributed."""
58
+ cfg = load_config(cfg_path)
59
+ registry = default_registry()
60
+ load_extensions(cfg.extensions, registry)
61
+ parser = _core_parser()
62
+ for loader in registry.document_loaders:
63
+ loader.add_cli_args(parser)
64
+ return parser
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ probe = argparse.ArgumentParser(add_help=False)
69
+ probe.add_argument("pipeline", type=Path, nargs="?")
70
+ _add_version_flag(probe)
71
+ known, _ = probe.parse_known_args(argv)
72
+ if known.pipeline is None:
73
+ # No pipeline → the extension surface is unknowable; the core parser
74
+ # handles --help and the missing-positional error itself (both exit).
75
+ _core_parser().parse_args(argv)
76
+ return 2 # unreachable — parse_args exits first
77
+ args = build_parser(known.pipeline).parse_args(argv)
78
+ success, _attempts = run_pipeline(args.pipeline, args)
79
+ if not success:
80
+ print("agentic-dev-loop: pipeline failed — exiting with code 1",
81
+ file=sys.stderr)
82
+ return 0 if success else 1
@@ -0,0 +1,156 @@
1
+ """Pipeline YAML schema and loader. Strict: unknown keys fail at every level."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ TERMINALS = frozenset({"success", "failure"})
10
+
11
+
12
+ class VerdictSpec(BaseModel):
13
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
14
+
15
+ pass_: str = Field(alias="pass")
16
+ fail: str = Field(alias="fail")
17
+
18
+
19
+ class Route(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+ goto: str
23
+ retry: bool = False # True consumes an attempt; exhaustion routes to failure
24
+
25
+
26
+ RouteTarget = str | Route
27
+
28
+
29
+ def route_target(route: RouteTarget | None) -> str | None:
30
+ if route is None:
31
+ return None
32
+ return route.goto if isinstance(route, Route) else route
33
+
34
+
35
+ class StepConfig(BaseModel):
36
+ """One pipeline step. Its type IS the key it carries: exactly one of
37
+ `agent`, `action`, or `command` — empty values do not count, anything
38
+ else fails to load. See the `kind` property (the single discriminator
39
+ graph-building dispatches on)."""
40
+ model_config = ConfigDict(extra="forbid")
41
+
42
+ name: str
43
+ agent: str | None = None
44
+ action: str | None = None
45
+ command: str | None = None # mechanical step: exit code is the verdict
46
+ params: dict[str, Any] = {}
47
+ produces: list[str] = [] # hygiene-tracked, no verdict parsing
48
+ artifact: str | None = None # hygiene-tracked AND verdict-parsed
49
+ verdicts: VerdictSpec | None = None
50
+ max_iterations: int | None = None
51
+ timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
52
+ on_pass: RouteTarget | None = None
53
+ on_fail: RouteTarget | None = None
54
+
55
+ @property
56
+ def kind(self) -> str:
57
+ """The step's type: 'agent', 'command', or 'action'. Which of the
58
+ three keys is set is the ONLY signal; validation has already
59
+ guaranteed exactly one is set and non-empty by the time any instance
60
+ exists."""
61
+ if self.agent is not None:
62
+ return "agent"
63
+ if self.command is not None:
64
+ return "command"
65
+ return "action"
66
+
67
+ @field_validator("produces", mode="before")
68
+ @classmethod
69
+ def _coerce_produces(cls, v: Any) -> Any:
70
+ return [v] if isinstance(v, str) else v
71
+
72
+ @model_validator(mode="after")
73
+ def _check_shape(self) -> "StepConfig":
74
+ for key in ("agent", "action", "command"):
75
+ value = getattr(self, key)
76
+ if value is not None and not value.strip():
77
+ raise ValueError(
78
+ f"step {self.name!r}: {key!r} must be a non-empty string"
79
+ )
80
+ if sum(k is not None for k in (self.agent, self.action, self.command)) != 1:
81
+ raise ValueError(
82
+ f"step {self.name!r}: exactly one of 'agent', 'action', or "
83
+ "'command' is required"
84
+ )
85
+ if self.command is not None:
86
+ for field, empty in (("artifact", None), ("verdicts", None),
87
+ ("produces", []), ("params", {}),
88
+ ("max_iterations", None)):
89
+ if getattr(self, field) != empty:
90
+ raise ValueError(
91
+ f"step {self.name!r}: 'command' steps cannot set {field!r}"
92
+ )
93
+ if (self.artifact is None) != (self.verdicts is None):
94
+ raise ValueError(
95
+ f"step {self.name!r}: 'artifact' and 'verdicts' must appear together"
96
+ )
97
+ if self.artifact is not None and self.produces:
98
+ raise ValueError(
99
+ f"step {self.name!r}: 'produces' and 'artifact' are mutually exclusive"
100
+ )
101
+ if self.timeout is not None and self.command is None:
102
+ raise ValueError(
103
+ f"step {self.name!r}: 'timeout' requires 'command'"
104
+ )
105
+ return self
106
+
107
+
108
+ class LlmConfig(BaseModel):
109
+ model_config = ConfigDict(extra="forbid")
110
+
111
+ model: str
112
+ base_url: str | None = None
113
+ api_key_env: str
114
+ timeout: float | None = None # omitted → opinionated no-timeout default
115
+
116
+
117
+ class PipelineConfig(BaseModel):
118
+ model_config = ConfigDict(extra="forbid")
119
+
120
+ name: str
121
+ extensions: list[str] = []
122
+ additional_files: list[str] = []
123
+ env_allowlist: list[str] = []
124
+ llm: LlmConfig
125
+ state_dir: str = ".pr"
126
+ max_attempts: int = 3
127
+ agents_dir: str
128
+ steps: list[StepConfig] = Field(min_length=1)
129
+
130
+ @model_validator(mode="after")
131
+ def _check_graph(self) -> "PipelineConfig":
132
+ names = [s.name for s in self.steps]
133
+ dupes = sorted({n for n in names if names.count(n) > 1})
134
+ if dupes:
135
+ raise ValueError(f"duplicate step names: {dupes}")
136
+ for n in names:
137
+ if n in TERMINALS:
138
+ raise ValueError(f"step name {n!r} collides with a reserved terminal")
139
+ valid = set(names) | TERMINALS
140
+ for step in self.steps:
141
+ for route in (step.on_pass, step.on_fail):
142
+ target = route_target(route)
143
+ if target is not None and target not in valid:
144
+ raise ValueError(
145
+ f"step {step.name!r}: unknown route target {target!r}"
146
+ )
147
+ return self
148
+
149
+
150
+ def load_config(path: Path) -> PipelineConfig:
151
+ data = yaml.safe_load(Path(path).read_text())
152
+ if not isinstance(data, dict):
153
+ raise ValueError(
154
+ f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
155
+ )
156
+ return PipelineConfig.model_validate(data)
File without changes
@@ -0,0 +1,152 @@
1
+ """AI-DLC document-loader extension. Loading IS choosing: name
2
+ `agentic_dev_loop.extensions.aidlc:AidlcExtension` in the pipeline's
3
+ `extensions:` list and the pipeline gains `--ai-dlc-unit` (any
4
+ unambiguous unit spelling: U4 / 4 / 004 / U004 / full slug) plus bare-run
5
+ autodetect of the current unit from `aidlc-docs/aidlc-state.md`.
6
+
7
+ Resolvers are verbatim ports of yosk's `resolve_unit_docs` /
8
+ `get_current_unit`, with `docs_dir` parameterised (was the module-level
9
+ AIDLC_DOCS_DIR) and the coding-standards merge dropped (the pipeline's
10
+ `additional_files:` now owns shared files).
11
+ """
12
+
13
+ import argparse
14
+ import re
15
+ from pathlib import Path
16
+
17
+ _DOC_SUFFIXES = (".md", ".yaml", ".yml", ".txt")
18
+ _UNIT_DIR_NUMBER = re.compile(r"unit[-_](\d+)")
19
+
20
+
21
+ def _leading_number(text: str) -> int | None:
22
+ """Leading digits of a unit id (``"004-preset-management"`` -> 4), or
23
+ None for ids that do not start with a number."""
24
+ match = re.match(r"(\d+)", text)
25
+ return int(match.group(1)) if match else None
26
+
27
+
28
+ def _dir_unit_number(dir_name: str) -> int | None:
29
+ """Numeric prefix of a unit dir name (``"unit-004-preset-management"``
30
+ -> 4, ``"unit-14"`` -> 14)."""
31
+ match = _UNIT_DIR_NUMBER.match(dir_name.lower())
32
+ return int(match.group(1)) if match else None
33
+
34
+
35
+ def resolve_unit_docs(docs_dir: Path | str, unit_id: str) -> dict[str, str]:
36
+ docs: dict[str, str] = {}
37
+ docs_dir = Path(docs_dir)
38
+ unit_id = unit_id.strip()
39
+ if unit_id[:1] in ("U", "u"):
40
+ unit_id = unit_id[1:]
41
+
42
+ unit_patterns = [f"unit-{unit_id}", f"unit_{unit_id}"]
43
+ number = _leading_number(unit_id)
44
+ construction_dir = docs_dir / "construction"
45
+ candidates: list[Path] = []
46
+ if construction_dir.is_dir():
47
+ for entry in sorted(construction_dir.iterdir()):
48
+ if not entry.is_dir():
49
+ continue
50
+ name = entry.name.lower()
51
+ if name == unit_patterns[0].lower() or name == unit_patterns[1].lower():
52
+ candidates.append(entry)
53
+ elif number is not None and _dir_unit_number(entry.name) == number:
54
+ candidates.append(entry)
55
+
56
+ candidates = sorted(set(candidates), key=lambda p: p.name.lower())
57
+ if len(candidates) > 1:
58
+ raise RuntimeError(
59
+ f"Ambiguous unit id '{unit_id}': multiple unit directories match "
60
+ f"({', '.join(p.name for p in candidates)}). Pass the full unit "
61
+ "slug to disambiguate.")
62
+
63
+ unit_dir = candidates[0] if candidates else None
64
+ if unit_dir is None:
65
+ look_for = ", ".join(unit_patterns)
66
+ if number is not None:
67
+ look_for += f", or any unit dir numbered {number}"
68
+ raise FileNotFoundError(
69
+ f"No docs directory found for unit {unit_id} under {construction_dir} "
70
+ f"(looked for {look_for})")
71
+
72
+ for file in sorted(unit_dir.rglob("*")):
73
+ if file.is_file() and file.suffix in _DOC_SUFFIXES:
74
+ key = str(file.relative_to(unit_dir))
75
+ docs[key] = file.read_text()
76
+
77
+ # At this point docs holds only unit-dir files — an empty load means the
78
+ # pipeline would build context from inception docs alone (the 2026-07-19
79
+ # U3 silent-degradation bug). Fail fast instead.
80
+ if not any(not k.startswith("inception/") for k in docs):
81
+ raise RuntimeError(
82
+ f"Unit dir {unit_dir} matched but yielded no docs — "
83
+ "refusing to build context from inception docs alone")
84
+
85
+ # Namespaced keys so an inception doc can never silently overwrite a
86
+ # unit doc with the same filename.
87
+ inception = docs_dir / "inception" / "application-design"
88
+ if inception.is_dir():
89
+ for file in sorted(inception.iterdir()):
90
+ if file.is_file() and file.suffix in (".md", ".txt"):
91
+ docs[f"inception/{file.name}"] = file.read_text()
92
+
93
+ # The unit's code-generation plans are the build spec — absent is fine,
94
+ # present is how the spec reaches agents.
95
+ plans_dir = docs_dir / "construction" / "plans"
96
+ if plans_dir.is_dir():
97
+ for file in sorted(plans_dir.glob(f"unit-{unit_id}-*-code-generation-plan.md")):
98
+ docs[f"plans/{file.name}"] = file.read_text()
99
+
100
+ return docs
101
+
102
+
103
+ def get_current_unit(docs_dir: Path | str) -> str:
104
+ """Read the current unit from the aidlc-state.md Current Stage line."""
105
+ state_file = Path(docs_dir) / "aidlc-state.md"
106
+ if not state_file.is_file():
107
+ raise FileNotFoundError(f"aidlc-state file not found: {state_file}")
108
+ # Anchor to the Current Stage line — the file accumulates history, so an
109
+ # unanchored search could silently pick up an earlier "Unit N" mention.
110
+ match = re.search(r"Current Stage[^\n]*Unit (\d+)", state_file.read_text())
111
+ if not match:
112
+ raise RuntimeError(
113
+ f"Could not determine current unit from {state_file} "
114
+ "(no 'Unit N' in the Current Stage line)")
115
+ return f"U{match.group(1)}"
116
+
117
+
118
+ def _normalize_unit(unit: str) -> str:
119
+ unit = unit.strip()
120
+ if unit[:1] in ("U", "u"):
121
+ unit = unit[1:]
122
+ return f"U{unit}"
123
+
124
+
125
+ class AidlcDocumentLoader:
126
+ """`--ai-dlc-unit` (any unambiguous spelling), or bare-run
127
+ autodetect from aidlc-state.md."""
128
+
129
+ name = "ai-dlc"
130
+ defaultable = True
131
+
132
+ def __init__(self, docs_dir: Path | str = "aidlc-docs"):
133
+ self.docs_dir = Path(docs_dir)
134
+
135
+ def add_cli_args(self, parser: argparse.ArgumentParser) -> None:
136
+ parser.add_argument("--ai-dlc-unit", metavar="U<N>", default=None,
137
+ help="AI-DLC unit ID: any unambiguous spelling "
138
+ "(U4, 4, 004, U004) or the full unit slug "
139
+ "(U004-preset-management); defaults to the "
140
+ "current unit in aidlc-docs/aidlc-state.md")
141
+
142
+ def resolve(self, args: argparse.Namespace) -> tuple[dict[str, str], str]:
143
+ unit = (_normalize_unit(args.ai_dlc_unit) if args.ai_dlc_unit
144
+ else get_current_unit(self.docs_dir))
145
+ return resolve_unit_docs(self.docs_dir, unit), f"unit {unit}"
146
+
147
+
148
+ class AidlcExtension:
149
+ name = "ai-dlc"
150
+
151
+ def register(self, registry) -> None:
152
+ registry.add_document_loader(AidlcDocumentLoader())