agentic-dev-loop 0.2.0__tar.gz → 0.3.0__tar.gz

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 (49) hide show
  1. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/PKG-INFO +13 -11
  2. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/README.md +12 -10
  3. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/pyproject.toml +1 -1
  4. agentic_dev_loop-0.3.0/src/agentic_dev_loop/extensions/aidlc.py +124 -0
  5. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/PKG-INFO +13 -11
  6. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  7. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_aidlc.py +100 -108
  8. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_cli.py +2 -2
  9. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_e2e_fixtures.py +1 -1
  10. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_loaders.py +7 -7
  11. agentic_dev_loop-0.3.0/tests/test_source_resolution.py +23 -0
  12. agentic_dev_loop-0.2.0/src/agentic_dev_loop/extensions/aidlc.py +0 -152
  13. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/LICENSE +0 -0
  14. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/setup.cfg +0 -0
  15. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/__init__.py +0 -0
  16. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/actions.py +0 -0
  17. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/agents.py +0 -0
  18. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/cli.py +0 -0
  19. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/config.py +0 -0
  20. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/extensions/__init__.py +0 -0
  21. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/graph.py +0 -0
  22. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/llm.py +0 -0
  23. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/loaders.py +0 -0
  24. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/outputs.py +0 -0
  25. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/proc.py +0 -0
  26. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/py.typed +0 -0
  27. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/registry.py +0 -0
  28. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/results.py +0 -0
  29. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/stages.py +0 -0
  30. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop/viz.py +0 -0
  31. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  32. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  33. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  34. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  35. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_actions.py +0 -0
  36. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_agents.py +0 -0
  37. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_config.py +0 -0
  38. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_graph.py +0 -0
  39. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_llm.py +0 -0
  40. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_outputs.py +0 -0
  41. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_outputs_pbt.py +0 -0
  42. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_proc.py +0 -0
  43. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_publish.py +0 -0
  44. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_registry.py +0 -0
  45. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_results.py +0 -0
  46. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_sdk_compat.py +0 -0
  47. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_stages.py +0 -0
  48. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_verdict.py +0 -0
  49. {agentic_dev_loop-0.2.0 → agentic_dev_loop-0.3.0}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Opinionated LangGraph engine for OpenHands agent pipelines
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -65,7 +65,7 @@ Requires Python ≥ 3.12.
65
65
  ```bash
66
66
  export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
67
67
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
68
- agentic-dev-loop agentic-dev-loop/pipeline.yaml --ai-dlc-unit U2
68
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
69
69
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
70
70
  ./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
71
71
  ```
@@ -84,10 +84,12 @@ expanded and live-tails its console. To eyeball the viz page without
84
84
  running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
85
85
  and replays a scripted run (every node state, verdict badges, retry cycle).
86
86
 
87
- `--ai-dlc-unit` accepts any unambiguous spelling of a unit number — `U2`,
88
- `2`, `U002`, `002` — or the full unit slug (`U002-custom-configuration`).
89
- If multiple unit directories match one id, the run fails loudly naming
90
- every candidate; pass the full slug to disambiguate.
87
+ `--aidlc-unit` takes the unit directory name under
88
+ `aidlc-docs/construction/` verbatim — `unit-2`,
89
+ `unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
90
+ (AI-DLC imposes no naming convention on unit directories). No spelling
91
+ tolerance, no numeric aliases: the slug must name the directory exactly,
92
+ and a wrong slug fails loudly listing the directories that do exist.
91
93
 
92
94
  ## What a run looks like
93
95
 
@@ -258,10 +260,10 @@ Loader-selection flow: a loader's flag in argv selects it; flags from two
258
260
  loaders in one invocation error out; with no flag, the sole `defaultable`
259
261
  loader autoruns (the bundled `ai-dlc` loader autodetects the current unit
260
262
  from `aidlc-docs/aidlc-state.md`), otherwise the run proceeds with no
261
- injected docs and the pipeline `name:` as subject. Unit ids resolve by
262
- exact directory name or by numeric prefix (`unit-004-preset-management`
263
- answers to `4`, `004`, `U4`, `U004`, or its full slug); ambiguous ids fail
264
- loud listing all matches. A selected loader that
263
+ injected docs and the pipeline `name:` as subject. A unit id must name
264
+ its directory exactly (`--aidlc-unit unit-3`); there is no numeric
265
+ fallback or spelling tolerance, and an unmatched slug fails loud listing
266
+ the available directories. A selected loader that
265
267
  yields zero docs **fails fast** — context is never silently degraded.
266
268
  Duplicate CLI option strings across loaders are a load-time error naming both.
267
269
 
@@ -303,7 +305,7 @@ The development lifecycle workflow lives in
303
305
  phase details under `aws-aidlc-rule-details/`). Working docs — plans, user
304
306
  stories, units of work, state — live in `aidlc-docs/`, created as the
305
307
  workflow runs. The bundled `ai-dlc` document loader (see Extension points)
306
- wires the pipeline's `--ai-dlc-unit` flag to those docs.
308
+ wires the pipeline's `--aidlc-unit` flag to those docs.
307
309
 
308
310
  OpenCode commands over the workflow: `/epic-status`, `/ticket-details`,
309
311
  `/unit-status`, `/update-subtask` (ticket boards over `aidlc-docs/`),
@@ -27,7 +27,7 @@ Requires Python ≥ 3.12.
27
27
  ```bash
28
28
  export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
29
29
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
30
- agentic-dev-loop agentic-dev-loop/pipeline.yaml --ai-dlc-unit U2
30
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
31
31
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
32
32
  ./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
33
33
  ```
@@ -46,10 +46,12 @@ expanded and live-tails its console. To eyeball the viz page without
46
46
  running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
47
47
  and replays a scripted run (every node state, verdict badges, retry cycle).
48
48
 
49
- `--ai-dlc-unit` accepts any unambiguous spelling of a unit number — `U2`,
50
- `2`, `U002`, `002` — or the full unit slug (`U002-custom-configuration`).
51
- If multiple unit directories match one id, the run fails loudly naming
52
- every candidate; pass the full slug to disambiguate.
49
+ `--aidlc-unit` takes the unit directory name under
50
+ `aidlc-docs/construction/` verbatim — `unit-2`,
51
+ `unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
52
+ (AI-DLC imposes no naming convention on unit directories). No spelling
53
+ tolerance, no numeric aliases: the slug must name the directory exactly,
54
+ and a wrong slug fails loudly listing the directories that do exist.
53
55
 
54
56
  ## What a run looks like
55
57
 
@@ -220,10 +222,10 @@ Loader-selection flow: a loader's flag in argv selects it; flags from two
220
222
  loaders in one invocation error out; with no flag, the sole `defaultable`
221
223
  loader autoruns (the bundled `ai-dlc` loader autodetects the current unit
222
224
  from `aidlc-docs/aidlc-state.md`), otherwise the run proceeds with no
223
- injected docs and the pipeline `name:` as subject. Unit ids resolve by
224
- exact directory name or by numeric prefix (`unit-004-preset-management`
225
- answers to `4`, `004`, `U4`, `U004`, or its full slug); ambiguous ids fail
226
- loud listing all matches. A selected loader that
225
+ injected docs and the pipeline `name:` as subject. A unit id must name
226
+ its directory exactly (`--aidlc-unit unit-3`); there is no numeric
227
+ fallback or spelling tolerance, and an unmatched slug fails loud listing
228
+ the available directories. A selected loader that
227
229
  yields zero docs **fails fast** — context is never silently degraded.
228
230
  Duplicate CLI option strings across loaders are a load-time error naming both.
229
231
 
@@ -265,7 +267,7 @@ The development lifecycle workflow lives in
265
267
  phase details under `aws-aidlc-rule-details/`). Working docs — plans, user
266
268
  stories, units of work, state — live in `aidlc-docs/`, created as the
267
269
  workflow runs. The bundled `ai-dlc` document loader (see Extension points)
268
- wires the pipeline's `--ai-dlc-unit` flag to those docs.
270
+ wires the pipeline's `--aidlc-unit` flag to those docs.
269
271
 
270
272
  OpenCode commands over the workflow: `/epic-status`, `/ticket-details`,
271
273
  `/unit-status`, `/update-subtask` (ticket boards over `aidlc-docs/`),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Opinionated LangGraph engine for OpenHands agent pipelines"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,124 @@
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 `--aidlc-unit` — the unit
4
+ directory name under `aidlc-docs/construction/` (e.g. `unit-3`,
5
+ `support-for-windows`), matched verbatim — plus bare-run autodetect of
6
+ the current unit from the Current Stage line of
7
+ `aidlc-docs/aidlc-state.md`.
8
+
9
+ No input mangling. AI-DLC's own ruleset imposes no naming convention on
10
+ unit directories (`{unit-name}` is a free placeholder), so the extension
11
+ imposes none either: the slug is the directory name, exactly. `U3`, `3`,
12
+ `004` and friends are NOT accepted unless a directory is literally named
13
+ that. The Current Stage line names the unit as its directory name
14
+ ("Unit unit-3", "Unit support-for-windows") for autodetect.
15
+ """
16
+
17
+ import argparse
18
+ import re
19
+ from pathlib import Path
20
+
21
+ _DOC_SUFFIXES = (".md", ".yaml", ".yml", ".txt")
22
+
23
+
24
+ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
25
+ docs: dict[str, str] = {}
26
+ docs_dir = Path(docs_dir)
27
+ unit_slug = unit_slug.strip()
28
+
29
+ construction_dir = docs_dir / "construction"
30
+ unit_dir: Path | None = None
31
+ if construction_dir.is_dir():
32
+ for entry in sorted(construction_dir.iterdir()):
33
+ if entry.is_dir() and entry.name == unit_slug:
34
+ unit_dir = entry
35
+ break
36
+
37
+ if unit_dir is None:
38
+ available = ""
39
+ if construction_dir.is_dir():
40
+ names = sorted(p.name for p in construction_dir.iterdir() if p.is_dir())
41
+ available = f" (available: {', '.join(names)})"
42
+ raise FileNotFoundError(
43
+ f"No unit directory named '{unit_slug}' under {construction_dir}"
44
+ f"{available or ' (no unit directories found)'}")
45
+
46
+ for file in sorted(unit_dir.rglob("*")):
47
+ if file.is_file() and file.suffix in _DOC_SUFFIXES:
48
+ key = str(file.relative_to(unit_dir))
49
+ docs[key] = file.read_text()
50
+
51
+ # At this point docs holds only unit-dir files — an empty load means the
52
+ # pipeline would build context from inception docs alone (the 2026-07-19
53
+ # U3 silent-degradation bug). Fail fast instead.
54
+ if not any(not k.startswith("inception/") for k in docs):
55
+ raise RuntimeError(
56
+ f"Unit dir {unit_dir} matched but yielded no docs — "
57
+ "refusing to build context from inception docs alone")
58
+
59
+ # Namespaced keys so an inception doc can never silently overwrite a
60
+ # unit doc with the same filename.
61
+ inception = docs_dir / "inception" / "application-design"
62
+ if inception.is_dir():
63
+ for file in sorted(inception.iterdir()):
64
+ if file.is_file() and file.suffix in (".md", ".txt"):
65
+ docs[f"inception/{file.name}"] = file.read_text()
66
+
67
+ # The unit's code-generation plans are the build spec — absent is fine,
68
+ # present is how the spec reaches agents.
69
+ plans_dir = docs_dir / "construction" / "plans"
70
+ if plans_dir.is_dir():
71
+ for file in sorted(plans_dir.glob(f"{unit_slug}-*-code-generation-plan.md")):
72
+ docs[f"plans/{file.name}"] = file.read_text()
73
+
74
+ return docs
75
+
76
+
77
+ def get_current_unit(docs_dir: Path | str) -> str:
78
+ """Read the current unit slug from the aidlc-state.md Current Stage line.
79
+
80
+ The stage line must name the unit as its directory name ("Unit unit-3",
81
+ "Unit support-for-windows"); the slug is returned verbatim for a direct
82
+ directory match.
83
+ """
84
+ state_file = Path(docs_dir) / "aidlc-state.md"
85
+ if not state_file.is_file():
86
+ raise FileNotFoundError(f"aidlc-state file not found: {state_file}")
87
+ # Anchor to the Current Stage line — the file accumulates history, so an
88
+ # unanchored search could silently pick up an earlier "Unit N" mention.
89
+ match = re.search(r"Current Stage[^\n]*Unit ([\w-]+(?:\.[\w-]+)*)", state_file.read_text())
90
+ if not match:
91
+ raise RuntimeError(
92
+ f"Could not determine current unit from {state_file} "
93
+ "(no 'Unit <slug>' in the Current Stage line)")
94
+ return match.group(1)
95
+
96
+
97
+ class AidlcDocumentLoader:
98
+ """`--aidlc-unit` (a unit directory name, matched verbatim), or bare-run
99
+ autodetect from the Current Stage line of aidlc-state.md."""
100
+
101
+ name = "ai-dlc"
102
+ defaultable = True
103
+
104
+ def __init__(self, docs_dir: Path | str = "aidlc-docs"):
105
+ self.docs_dir = Path(docs_dir)
106
+
107
+ def add_cli_args(self, parser: argparse.ArgumentParser) -> None:
108
+ parser.add_argument("--aidlc-unit", metavar="SLUG", default=None,
109
+ help="AI-DLC unit: the unit directory name under "
110
+ "aidlc-docs/construction/ (e.g. unit-3 or "
111
+ "support-for-windows), matched verbatim; "
112
+ "defaults to the current unit in "
113
+ "aidlc-docs/aidlc-state.md")
114
+
115
+ def resolve(self, args: argparse.Namespace) -> tuple[dict[str, str], str]:
116
+ unit = args.aidlc_unit or get_current_unit(self.docs_dir)
117
+ return resolve_unit_docs(self.docs_dir, unit), f"unit {unit}"
118
+
119
+
120
+ class AidlcExtension:
121
+ name = "ai-dlc"
122
+
123
+ def register(self, registry) -> None:
124
+ registry.add_document_loader(AidlcDocumentLoader())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Opinionated LangGraph engine for OpenHands agent pipelines
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -65,7 +65,7 @@ Requires Python ≥ 3.12.
65
65
  ```bash
66
66
  export OPENAI_API_KEY=... # whatever env var your pipeline's api_key_env names
67
67
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --plan plan.md
68
- agentic-dev-loop agentic-dev-loop/pipeline.yaml --ai-dlc-unit U2
68
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --aidlc-unit unit-2
69
69
  agentic-dev-loop agentic-dev-loop/pipeline.yaml --resume <thread-id> --max-attempts 5
70
70
  ./trigger-agentic-dev-loop.sh --plan plan.md # same thing, from the repo root
71
71
  ```
@@ -84,10 +84,12 @@ expanded and live-tails its console. To eyeball the viz page without
84
84
  running a pipeline, `.venv/bin/python viz_demo.py` serves the real topology
85
85
  and replays a scripted run (every node state, verdict badges, retry cycle).
86
86
 
87
- `--ai-dlc-unit` accepts any unambiguous spelling of a unit number — `U2`,
88
- `2`, `U002`, `002` — or the full unit slug (`U002-custom-configuration`).
89
- If multiple unit directories match one id, the run fails loudly naming
90
- every candidate; pass the full slug to disambiguate.
87
+ `--aidlc-unit` takes the unit directory name under
88
+ `aidlc-docs/construction/` verbatim — `unit-2`,
89
+ `unit-002-custom-configuration`, or any slug AI-DLC's own naming uses
90
+ (AI-DLC imposes no naming convention on unit directories). No spelling
91
+ tolerance, no numeric aliases: the slug must name the directory exactly,
92
+ and a wrong slug fails loudly listing the directories that do exist.
91
93
 
92
94
  ## What a run looks like
93
95
 
@@ -258,10 +260,10 @@ Loader-selection flow: a loader's flag in argv selects it; flags from two
258
260
  loaders in one invocation error out; with no flag, the sole `defaultable`
259
261
  loader autoruns (the bundled `ai-dlc` loader autodetects the current unit
260
262
  from `aidlc-docs/aidlc-state.md`), otherwise the run proceeds with no
261
- injected docs and the pipeline `name:` as subject. Unit ids resolve by
262
- exact directory name or by numeric prefix (`unit-004-preset-management`
263
- answers to `4`, `004`, `U4`, `U004`, or its full slug); ambiguous ids fail
264
- loud listing all matches. A selected loader that
263
+ injected docs and the pipeline `name:` as subject. A unit id must name
264
+ its directory exactly (`--aidlc-unit unit-3`); there is no numeric
265
+ fallback or spelling tolerance, and an unmatched slug fails loud listing
266
+ the available directories. A selected loader that
265
267
  yields zero docs **fails fast** — context is never silently degraded.
266
268
  Duplicate CLI option strings across loaders are a load-time error naming both.
267
269
 
@@ -303,7 +305,7 @@ The development lifecycle workflow lives in
303
305
  phase details under `aws-aidlc-rule-details/`). Working docs — plans, user
304
306
  stories, units of work, state — live in `aidlc-docs/`, created as the
305
307
  workflow runs. The bundled `ai-dlc` document loader (see Extension points)
306
- wires the pipeline's `--ai-dlc-unit` flag to those docs.
308
+ wires the pipeline's `--aidlc-unit` flag to those docs.
307
309
 
308
310
  OpenCode commands over the workflow: `/epic-status`, `/ticket-details`,
309
311
  `/unit-status`, `/update-subtask` (ticket boards over `aidlc-docs/`),
@@ -40,6 +40,7 @@ tests/test_publish.py
40
40
  tests/test_registry.py
41
41
  tests/test_results.py
42
42
  tests/test_sdk_compat.py
43
+ tests/test_source_resolution.py
43
44
  tests/test_stages.py
44
45
  tests/test_verdict.py
45
46
  tests/test_viz.py
@@ -1,7 +1,12 @@
1
- """AI-DLC document loader tests — ported from yosk's TestResolveUnitDocs /
2
- TestGetCurrentUnit (docs_dir parameterised instead of a monkeypatched module
3
- constant; coding-standards cases dropped — the pipeline's `additional_files:`
4
- now owns those)."""
1
+ """AI-DLC document loader tests.
2
+
3
+ The loader contract: `--aidlc-unit` takes the unit DIRECTORY NAME verbatim
4
+ (`unit-3`, `support-for-windows`) and matches it by exact string equality
5
+ under `aidlc-docs/construction/`. No input mangling: `U3`, `3`, `004`,
6
+ `unit_3` and friends are NOT accepted unless a directory is literally
7
+ named that. Bare-run autodetect reads the slug from the Current Stage
8
+ line of `aidlc-docs/aidlc-state.md` (`Unit <slug>`).
9
+ """
5
10
 
6
11
  import argparse
7
12
 
@@ -23,31 +28,40 @@ class TestResolveUnitDocs:
23
28
  other_dir.mkdir(parents=True)
24
29
  (other_dir / "other.md").write_text("# Other")
25
30
 
26
- docs = resolve_unit_docs(tmp_path, "2")
31
+ docs = resolve_unit_docs(tmp_path, "unit-2")
27
32
 
28
33
  assert "design.md" in docs
29
34
  assert "tasks.md" in docs
30
35
  assert "other.md" not in docs
31
36
 
32
- def test_accepts_u_prefixed_unit_id(self, tmp_path):
37
+ def test_u_prefixed_id_is_not_mangled(self, tmp_path):
38
+ """The slug is the directory name — `U2` must NOT resolve `unit-2`."""
33
39
  unit_dir = tmp_path / "construction" / "unit-2"
34
40
  unit_dir.mkdir(parents=True)
35
41
  (unit_dir / "design.md").write_text("# Design")
36
42
 
37
- assert "design.md" in resolve_unit_docs(tmp_path, "U2")
43
+ with pytest.raises(FileNotFoundError, match="unit-2"):
44
+ resolve_unit_docs(tmp_path, "U2")
38
45
 
39
- def test_accepts_lowercase_u_prefixed_unit_id(self, tmp_path):
46
+ def test_lowercase_u_prefixed_id_is_not_mangled(self, tmp_path):
40
47
  unit_dir = tmp_path / "construction" / "unit-3"
41
48
  unit_dir.mkdir(parents=True)
42
49
  (unit_dir / "design.md").write_text("# Design")
43
50
 
44
- assert "design.md" in resolve_unit_docs(tmp_path, "u3")
51
+ with pytest.raises(FileNotFoundError, match="unit-3"):
52
+ resolve_unit_docs(tmp_path, "u3")
45
53
 
46
- def test_raises_for_missing_unit(self, tmp_path):
54
+ def test_raises_for_missing_unit_and_lists_available(self, tmp_path):
47
55
  """Unknown unit must fail fast — silently running the pipeline with
48
- zero design docs is a forbidden silent fallback."""
49
- with pytest.raises(FileNotFoundError, match="unit"):
50
- resolve_unit_docs(tmp_path, "99")
56
+ zero design docs is a forbidden silent fallback. The error names the
57
+ directories that DO exist."""
58
+ (tmp_path / "construction" / "unit-1").mkdir(parents=True)
59
+ (tmp_path / "construction" / "unit-2").mkdir(parents=True)
60
+
61
+ with pytest.raises(FileNotFoundError, match="unit-99") as exc:
62
+ resolve_unit_docs(tmp_path, "unit-99")
63
+ assert "unit-1" in str(exc.value)
64
+ assert "unit-2" in str(exc.value)
51
65
 
52
66
  def test_includes_inception_docs(self, tmp_path):
53
67
  unit_dir = tmp_path / "construction" / "unit-1"
@@ -57,7 +71,7 @@ class TestResolveUnitDocs:
57
71
  inception.mkdir(parents=True)
58
72
  (inception / "architecture.md").write_text("# Arch")
59
73
 
60
- docs = resolve_unit_docs(tmp_path, "1")
74
+ docs = resolve_unit_docs(tmp_path, "unit-1")
61
75
 
62
76
  assert "inception/architecture.md" in docs
63
77
  assert "Arch" in docs["inception/architecture.md"]
@@ -72,7 +86,7 @@ class TestResolveUnitDocs:
72
86
  inception.mkdir(parents=True)
73
87
  (inception / "architecture.md").write_text("inception version")
74
88
 
75
- docs = resolve_unit_docs(tmp_path, "1")
89
+ docs = resolve_unit_docs(tmp_path, "unit-1")
76
90
 
77
91
  assert docs["architecture.md"] == "unit version"
78
92
  assert docs["inception/architecture.md"] == "inception version"
@@ -85,7 +99,7 @@ class TestResolveUnitDocs:
85
99
  (unit_dir / "functional-design").mkdir(parents=True)
86
100
  (unit_dir / "functional-design" / "business-rules.md").write_text("# Rules")
87
101
 
88
- docs = resolve_unit_docs(tmp_path, "U3")
102
+ docs = resolve_unit_docs(tmp_path, "unit-3")
89
103
 
90
104
  assert docs["functional-design/business-rules.md"] == "# Rules"
91
105
 
@@ -95,7 +109,7 @@ class TestResolveUnitDocs:
95
109
  (tmp_path / "construction" / "unit-3" / "functional-design").mkdir(parents=True)
96
110
 
97
111
  with pytest.raises(RuntimeError, match="no.*docs|zero"):
98
- resolve_unit_docs(tmp_path, "U3")
112
+ resolve_unit_docs(tmp_path, "unit-3")
99
113
 
100
114
  def test_includes_code_generation_plans(self, tmp_path):
101
115
  """The unit's *-code-generation-plan.md files are the build spec."""
@@ -109,7 +123,7 @@ class TestResolveUnitDocs:
109
123
  (plans / "unit-3-scan-nfr-design-plan.md").write_text("# NOT a code-gen plan")
110
124
  (plans / "unit-2-onboarding-code-generation-plan.md").write_text("# Wrong unit")
111
125
 
112
- docs = resolve_unit_docs(tmp_path, "U3")
126
+ docs = resolve_unit_docs(tmp_path, "unit-3")
113
127
 
114
128
  assert docs["plans/unit-3-scan-core-types-code-generation-plan.md"] == "# Core types"
115
129
  assert docs["plans/unit-3-scan-frontend-code-generation-plan.md"] == "# Frontend"
@@ -121,7 +135,44 @@ class TestResolveUnitDocs:
121
135
  unit_dir.mkdir(parents=True)
122
136
  (unit_dir / "design.md").write_text("# Design")
123
137
 
124
- assert resolve_unit_docs(tmp_path, "U2")["design.md"] == "# Design"
138
+ assert resolve_unit_docs(tmp_path, "unit-2")["design.md"] == "# Design"
139
+
140
+
141
+ class TestDirectSlugMatching:
142
+ """The slug IS the directory name — no spelling tolerance, no numeric
143
+ fallback, no prefix mangling."""
144
+
145
+ def _unit(self, tmp_path, name):
146
+ unit_dir = tmp_path / "construction" / name
147
+ unit_dir.mkdir(parents=True)
148
+ (unit_dir / "design.md").write_text("# Design")
149
+ return unit_dir
150
+
151
+ def test_slug_matches_directory_exactly(self, tmp_path):
152
+ self._unit(tmp_path, "unit-004-preset-management")
153
+ assert resolve_unit_docs(tmp_path, "unit-004-preset-management")["design.md"] == "# Design"
154
+
155
+ def test_bare_slug_directory(self, tmp_path):
156
+ self._unit(tmp_path, "support-for-windows")
157
+ assert resolve_unit_docs(tmp_path, "support-for-windows")["design.md"] == "# Design"
158
+
159
+ def test_number_alone_does_not_match_slugged_dir(self, tmp_path):
160
+ """The numeric fallback is gone: `004` must not resolve
161
+ `unit-004-preset-management`."""
162
+ self._unit(tmp_path, "unit-004-preset-management")
163
+ with pytest.raises(FileNotFoundError, match="004"):
164
+ resolve_unit_docs(tmp_path, "004")
165
+
166
+ def test_underscore_spelling_does_not_match(self, tmp_path):
167
+ """The `unit_<id>` variant is gone: the slug must spell the
168
+ directory exactly."""
169
+ self._unit(tmp_path, "unit-3")
170
+ with pytest.raises(FileNotFoundError, match="unit-3"):
171
+ resolve_unit_docs(tmp_path, "unit_3")
172
+
173
+ def test_non_numeric_slug(self, tmp_path):
174
+ self._unit(tmp_path, "unit-scan")
175
+ assert resolve_unit_docs(tmp_path, "unit-scan")["design.md"] == "# Design"
125
176
 
126
177
 
127
178
  class TestGetCurrentUnit:
@@ -130,16 +181,22 @@ class TestGetCurrentUnit:
130
181
  state.write_text(f"# AI-DLC State Tracking\n\n{stage_line}\n")
131
182
  return state
132
183
 
133
- def test_parses_unit_from_current_stage(self, tmp_path):
184
+ def test_parses_slug_from_current_stage(self, tmp_path):
185
+ self._write_state(
186
+ tmp_path,
187
+ "- **Current Stage**: CONSTRUCTION — Unit unit-3 (SCAN) — Code Generation in progress.")
188
+ assert get_current_unit(tmp_path) == "unit-3"
189
+
190
+ def test_parses_bare_slug_from_current_stage(self, tmp_path):
134
191
  self._write_state(
135
192
  tmp_path,
136
- "- **Current Stage**: CONSTRUCTION — Unit 3 (SCAN) — Code Generation in progress.")
137
- assert get_current_unit(tmp_path) == "U3"
193
+ "- **Current Stage**: CONSTRUCTION — Unit support-for-windows.")
194
+ assert get_current_unit(tmp_path) == "support-for-windows"
138
195
 
139
- def test_parses_different_unit_number(self, tmp_path):
196
+ def test_parses_different_slug(self, tmp_path):
140
197
  self._write_state(
141
- tmp_path, "- **Current Stage**: CONSTRUCTION — Unit 12 (REPORT).")
142
- assert get_current_unit(tmp_path) == "U12"
198
+ tmp_path, "- **Current Stage**: CONSTRUCTION — Unit unit-12 (REPORT).")
199
+ assert get_current_unit(tmp_path) == "unit-12"
143
200
 
144
201
  def test_ignores_unit_mentions_before_the_stage_line(self, tmp_path):
145
202
  """The state file accumulates history; only the Current Stage line
@@ -148,8 +205,8 @@ class TestGetCurrentUnit:
148
205
  state.write_text(
149
206
  "# AI-DLC State Tracking\n\n"
150
207
  "- Finished Unit 9 (REPORT) last week.\n"
151
- "- **Current Stage**: CONSTRUCTION — Unit 3 (SCAN) — Code Generation.\n")
152
- assert get_current_unit(tmp_path) == "U3"
208
+ "- **Current Stage**: CONSTRUCTION — Unit unit-3 (SCAN) — Code Generation.\n")
209
+ assert get_current_unit(tmp_path) == "unit-3"
153
210
 
154
211
  def test_stage_line_without_unit_raises_despite_earlier_mention(self, tmp_path):
155
212
  """A 'Unit N' somewhere else in the file is not a Current Stage —
@@ -182,108 +239,43 @@ class TestAidlcDocumentLoader:
182
239
  def test_is_defaultable(self):
183
240
  assert AidlcDocumentLoader.defaultable is True
184
241
 
185
- def test_resolve_with_explicit_unit_normalises_subject(self, tmp_path):
242
+ def test_resolve_with_explicit_slug(self, tmp_path):
186
243
  self._unit_docs(tmp_path)
187
244
  loader = AidlcDocumentLoader(docs_dir=tmp_path)
188
245
 
189
- docs, subject = loader.resolve(argparse.Namespace(ai_dlc_unit="u3"))
246
+ docs, subject = loader.resolve(argparse.Namespace(aidlc_unit="unit-3"))
190
247
 
191
248
  assert docs["design.md"] == "# Design"
192
- assert subject == "unit U3"
249
+ assert subject == "unit unit-3"
193
250
 
194
251
  def test_resolve_without_unit_autodetects_current(self, tmp_path):
195
252
  self._unit_docs(tmp_path)
196
253
  (tmp_path / "aidlc-state.md").write_text(
197
- "# State\n\n- **Current Stage**: CONSTRUCTION — Unit 3 (SCAN).\n")
254
+ "# State\n\n- **Current Stage**: CONSTRUCTION — Unit unit-3 (SCAN).\n")
198
255
  loader = AidlcDocumentLoader(docs_dir=tmp_path)
199
256
 
200
- docs, subject = loader.resolve(argparse.Namespace(ai_dlc_unit=None))
257
+ docs, subject = loader.resolve(argparse.Namespace(aidlc_unit=None))
201
258
 
202
259
  assert docs["design.md"] == "# Design"
203
- assert subject == "unit U3"
204
-
205
- def test_extension_registers_loader_and_flag(self):
206
- reg = Registry()
207
- AidlcExtension().register(reg)
208
-
209
- assert [l.name for l in reg.document_loaders] == ["ai-dlc"]
210
- assert reg.cli_option_owners["--ai-dlc-unit"] == "ai-dlc"
211
- assert reg.cli_dests["ai_dlc_unit"] == "ai-dlc"
212
-
260
+ assert subject == "unit unit-3"
213
261
 
214
- class TestResolveUnitDocsFlexibleUnitIds:
215
- """Numeric tolerance: U004 / 004 / 4 / U4 must all resolve the same unit.
216
-
217
- Real-world unit dirs are named unit-NNN-slug (e.g.
218
- unit-004-preset-management); the loader must accept any unambiguous
219
- spelling of the unit number and fail loud on ambiguity."""
220
-
221
- def _unit(self, tmp_path, name):
222
- unit_dir = tmp_path / "construction" / name
223
- unit_dir.mkdir(parents=True)
224
- (unit_dir / "design.md").write_text("# Design")
225
- return unit_dir
226
-
227
- @pytest.mark.parametrize("unit_id", [
228
- "4", "U4", "u4", "004", "U004",
229
- "004-preset-management", "U004-preset-management",
230
- ])
231
- def test_accepts_all_spellings_for_slugged_dir(self, tmp_path, unit_id):
232
- self._unit(tmp_path, "unit-004-preset-management")
233
- assert resolve_unit_docs(tmp_path, unit_id)["design.md"] == "# Design"
234
-
235
- def test_plain_number_matches_underscore_dir_form(self, tmp_path):
236
- self._unit(tmp_path, "unit_004_presets")
237
- assert resolve_unit_docs(tmp_path, "4")["design.md"] == "# Design"
238
-
239
- def test_higher_number_does_not_match(self, tmp_path):
240
- self._unit(tmp_path, "unit-40")
241
- with pytest.raises(FileNotFoundError, match="unit"):
242
- resolve_unit_docs(tmp_path, "4")
243
-
244
- def test_zero_padded_plain_dir(self, tmp_path):
245
- self._unit(tmp_path, "unit-004")
246
- assert resolve_unit_docs(tmp_path, "U4")["design.md"] == "# Design"
247
-
248
- def test_ambiguous_number_raises_loud(self, tmp_path):
249
- self._unit(tmp_path, "unit-4")
250
- self._unit(tmp_path, "unit-004-preset-management")
251
- with pytest.raises(RuntimeError, match="[Aa]mbiguous") as exc:
252
- resolve_unit_docs(tmp_path, "4")
253
- assert "unit-4" in str(exc.value)
254
- assert "unit-004-preset-management" in str(exc.value)
255
-
256
- def test_ambiguous_u_prefixed_number_raises_loud(self, tmp_path):
257
- self._unit(tmp_path, "unit-004")
258
- self._unit(tmp_path, "unit-004-preset-management")
259
- with pytest.raises(RuntimeError, match="[Aa]mbiguous"):
260
- resolve_unit_docs(tmp_path, "U004")
261
-
262
- def test_non_numeric_id_still_uses_exact_match(self, tmp_path):
263
- self._unit(tmp_path, "unit-scan")
264
- assert resolve_unit_docs(tmp_path, "scan")["design.md"] == "# Design"
265
-
266
-
267
- class TestFlexibleIdsViaLoader:
268
- def test_autodetect_resolves_slugged_zero_padded_dir(self, tmp_path):
262
+ def test_autodetect_resolves_slugged_dir(self, tmp_path):
269
263
  unit_dir = tmp_path / "construction" / "unit-004-preset-management"
270
264
  unit_dir.mkdir(parents=True)
271
265
  (unit_dir / "design.md").write_text("# Design")
272
266
  (tmp_path / "aidlc-state.md").write_text(
273
- "# State\n\n- **Current Stage**: CONSTRUCTION — Unit 004 ADL-01.\n")
267
+ "# State\n\n- **Current Stage**: CONSTRUCTION — Unit unit-004-preset-management ADL-01.\n")
274
268
  loader = AidlcDocumentLoader(docs_dir=tmp_path)
275
269
 
276
- docs, subject = loader.resolve(argparse.Namespace(ai_dlc_unit=None))
270
+ docs, subject = loader.resolve(argparse.Namespace(aidlc_unit=None))
277
271
 
278
272
  assert docs["design.md"] == "# Design"
279
- assert subject == "unit U004"
280
-
281
- def test_plain_number_subject(self, tmp_path):
282
- unit_dir = tmp_path / "construction" / "unit-4"
283
- unit_dir.mkdir(parents=True)
284
- (unit_dir / "design.md").write_text("# Design")
285
- loader = AidlcDocumentLoader(docs_dir=tmp_path)
273
+ assert subject == "unit unit-004-preset-management"
286
274
 
287
- docs, subject = loader.resolve(argparse.Namespace(ai_dlc_unit="4"))
275
+ def test_extension_registers_loader_and_flag(self):
276
+ reg = Registry()
277
+ AidlcExtension().register(reg)
288
278
 
289
- assert subject == "unit U4"
279
+ assert [l.name for l in reg.document_loaders] == ["ai-dlc"]
280
+ assert reg.cli_option_owners["--aidlc-unit"] == "ai-dlc"
281
+ assert reg.cli_dests["aidlc_unit"] == "ai-dlc"
@@ -120,11 +120,11 @@ class TestHelp:
120
120
  with pytest.raises(SystemExit) as e:
121
121
  main(["aidlc.yaml", "--help"])
122
122
  assert e.value.code == 0
123
- assert "--ai-dlc-unit" in capsys.readouterr().out
123
+ assert "--aidlc-unit" in capsys.readouterr().out
124
124
 
125
125
  with pytest.raises(SystemExit):
126
126
  main(["pipeline.yaml", "--help"])
127
- assert "--ai-dlc-unit" not in capsys.readouterr().out
127
+ assert "--aidlc-unit" not in capsys.readouterr().out
128
128
 
129
129
  def test_no_pipeline_shows_core_surface(self, repo, capsys):
130
130
  with pytest.raises(SystemExit) as e:
@@ -184,7 +184,7 @@ class TestHistoricalFixtures:
184
184
  / "unit-99").mkdir(parents=True)
185
185
  with pytest.raises(RuntimeError, match="yielded no docs"):
186
186
  main([str(consumer_repo / "agentic-dev-loop" / "pipeline.yaml"),
187
- "--ai-dlc-unit", "U99", "--no-viz"])
187
+ "--aidlc-unit", "unit-99", "--no-viz"])
188
188
  assert fake_agents.calls == []
189
189
 
190
190
 
@@ -81,28 +81,28 @@ class TestSelectLoader:
81
81
 
82
82
  def test_flag_selects_its_loader(self):
83
83
  plan = _Loader("plan", "--plan", "plan")
84
- aidlc = _Loader("ai-dlc", "--ai-dlc-unit", "ai_dlc_unit", defaultable=True)
84
+ aidlc = _Loader("ai-dlc", "--aidlc-unit", "aidlc_unit", defaultable=True)
85
85
  reg = self._registry_with(plan, aidlc)
86
86
 
87
- assert select_loader(reg, _args(plan=Path("p.md"), ai_dlc_unit=None)) is plan
88
- assert select_loader(reg, _args(plan=None, ai_dlc_unit="U3")) is aidlc
87
+ assert select_loader(reg, _args(plan=Path("p.md"), aidlc_unit=None)) is plan
88
+ assert select_loader(reg, _args(plan=None, aidlc_unit="U3")) is aidlc
89
89
 
90
90
  def test_flags_from_two_loaders_error_naming_both(self):
91
91
  reg = self._registry_with(
92
92
  _Loader("plan", "--plan", "plan"),
93
- _Loader("ai-dlc", "--ai-dlc-unit", "ai_dlc_unit", defaultable=True))
93
+ _Loader("ai-dlc", "--aidlc-unit", "aidlc_unit", defaultable=True))
94
94
 
95
95
  with pytest.raises(ValueError) as excinfo:
96
- select_loader(reg, _args(plan=Path("p.md"), ai_dlc_unit="U3"))
96
+ select_loader(reg, _args(plan=Path("p.md"), aidlc_unit="U3"))
97
97
 
98
98
  message = str(excinfo.value)
99
99
  assert "plan" in message and "ai-dlc" in message
100
100
 
101
101
  def test_sole_defaultable_autoruns_with_no_flag(self):
102
- aidlc = _Loader("ai-dlc", "--ai-dlc-unit", "ai_dlc_unit", defaultable=True)
102
+ aidlc = _Loader("ai-dlc", "--aidlc-unit", "aidlc_unit", defaultable=True)
103
103
  reg = self._registry_with(_Loader("plan", "--plan", "plan"), aidlc)
104
104
 
105
- assert select_loader(reg, _args(plan=None, ai_dlc_unit=None)) is aidlc
105
+ assert select_loader(reg, _args(plan=None, aidlc_unit=None)) is aidlc
106
106
 
107
107
  def test_none_defaultable_returns_none_with_no_flag(self):
108
108
  reg = self._registry_with(_Loader("plan", "--plan", "plan"))
@@ -0,0 +1,23 @@
1
+ """The suite must exercise the working tree, not an installed wheel.
2
+
3
+ This repo dogfoods its own published wheel in .venv (deliberate). Plain
4
+ pytest would otherwise import the published code and silently test stale
5
+ behaviour. This test pins the import to src/ — it fails the moment the
6
+ conftest sys.path insertion stops working.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import agentic_dev_loop
12
+
13
+ REPO_SRC = Path(__file__).resolve().parents[1] / "src"
14
+
15
+
16
+ def test_import_resolves_to_working_tree():
17
+ resolved = Path(agentic_dev_loop.__file__).resolve()
18
+ assert str(resolved).startswith(
19
+ str(REPO_SRC) + "/"
20
+ ), (
21
+ f"agentic_dev_loop imported from {resolved}, not the working tree "
22
+ f"({REPO_SRC}) — conftest's src/ sys.path insertion is not effective"
23
+ )
@@ -1,152 +0,0 @@
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())