forge-dev 0.1.1__tar.gz → 0.1.3__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 (35) hide show
  1. forge_dev-0.1.3/.github/workflows/release-please.yml +20 -0
  2. {forge_dev-0.1.1 → forge_dev-0.1.3}/.gitignore +1 -0
  3. forge_dev-0.1.3/.release-please-config.json +28 -0
  4. forge_dev-0.1.3/.release-please-manifest.json +3 -0
  5. forge_dev-0.1.3/CHANGELOG.md +21 -0
  6. {forge_dev-0.1.1 → forge_dev-0.1.3}/PKG-INFO +48 -10
  7. {forge_dev-0.1.1 → forge_dev-0.1.3}/README.md +47 -9
  8. forge_dev-0.1.3/forge_core/__init__.py +8 -0
  9. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/cli.py +216 -18
  10. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/detector.py +84 -9
  11. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/editor_bridge.py +237 -4
  12. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/models.py +33 -15
  13. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/phases/context.py +146 -51
  14. {forge_dev-0.1.1 → forge_dev-0.1.3}/pyproject.toml +1 -1
  15. forge_dev-0.1.1/CLAUDE.md +0 -81
  16. forge_dev-0.1.1/forge_core/__init__.py +0 -3
  17. {forge_dev-0.1.1 → forge_dev-0.1.3}/.github/workflows/publish.yml +0 -0
  18. {forge_dev-0.1.1 → forge_dev-0.1.3}/claude_skill/SKILL.md +0 -0
  19. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/agents/__init__.py +0 -0
  20. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/auditor.py +0 -0
  21. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/phases/__init__.py +0 -0
  22. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/phases/coherence.py +0 -0
  23. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/phases/intake.py +0 -0
  24. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/registry.py +0 -0
  25. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/standards/api-first-design.yaml +0 -0
  26. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/standards/microservice-packaging.yaml +0 -0
  27. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/standards/observability.yaml +0 -0
  28. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/standards/security-baseline.yaml +0 -0
  29. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/standards/type-safety.yaml +0 -0
  30. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/templates/__init__.py +0 -0
  31. {forge_dev-0.1.1 → forge_dev-0.1.3}/forge_core/utils/__init__.py +0 -0
  32. {forge_dev-0.1.1 → forge_dev-0.1.3}/mcp_server/__init__.py +0 -0
  33. {forge_dev-0.1.1 → forge_dev-0.1.3}/mcp_server/server.py +0 -0
  34. {forge_dev-0.1.1 → forge_dev-0.1.3}/references/implementation-phases.md +0 -0
  35. {forge_dev-0.1.1 → forge_dev-0.1.3}/setup.sh +0 -0
@@ -0,0 +1,20 @@
1
+ name: release-please
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ pull-requests: write
11
+
12
+ jobs:
13
+ release-please:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: googleapis/release-please-action@v4
17
+ with:
18
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
19
+ config-file: .release-please-config.json
20
+ manifest-file: .release-please-manifest.json
@@ -12,3 +12,4 @@ build/
12
12
  venv/
13
13
  .env
14
14
  *.log
15
+ CLAUDE.md
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "python",
6
+ "package-name": "forge-dev",
7
+ "changelog-path": "CHANGELOG.md",
8
+ "bump-minor-pre-major": true,
9
+ "bump-patch-for-minor-pre-major": true,
10
+ "draft": false,
11
+ "prerelease": false,
12
+ "include-component-in-tag": false,
13
+ "tag-separator": "",
14
+ "changelog-sections": [
15
+ { "type": "feat", "section": "Features" },
16
+ { "type": "fix", "section": "Bug Fixes" },
17
+ { "type": "perf", "section": "Performance" },
18
+ { "type": "refactor", "section": "Refactoring" },
19
+ { "type": "docs", "section": "Documentation" },
20
+ { "type": "chore", "section": "Chores", "hidden": true },
21
+ { "type": "ci", "section": "CI", "hidden": true },
22
+ { "type": "test", "section": "Tests", "hidden": true },
23
+ { "type": "build", "section": "Build", "hidden": true },
24
+ { "type": "style", "section": "Style", "hidden": true }
25
+ ]
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.3"
3
+ }
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [0.1.3](https://github.com/luiskcr/forge/compare/v0.1.2...v0.1.3) (2026-04-12)
4
+
5
+
6
+ ### Documentation
7
+
8
+ * recommend uv as install method and document macOS Tahoe pyexpat issue ([292518a](https://github.com/luiskcr/forge/commit/292518a01ca7d546fa892a4618cced8033290524))
9
+
10
+ ## [0.1.2](https://github.com/luiskcr/forge/compare/v0.1.1...v0.1.2) (2026-04-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * discovery-driven context resolution with unspecified fields ([c5b2269](https://github.com/luiskcr/forge/commit/c5b22699105939be657692a9eb0bab96efbd279f))
16
+
17
+ ## Changelog
18
+
19
+ All notable changes to this project are documented in this file.
20
+
21
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and uses [Conventional Commits](https://www.conventionalcommits.org/). The changelog below is maintained automatically by [release-please](https://github.com/googleapis/release-please) from commit messages on `main`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forge-dev
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: AI-Native Development Workflow Engine
5
5
  Author: NaiaTech
6
6
  License-Expression: MIT
@@ -48,35 +48,73 @@ Forge encodes a complete development paradigm — from receiving a vague require
48
48
 
49
49
  ## Installation
50
50
 
51
+ Forge is published on PyPI as `forge-dev`. The recommended way to install it is with [`uv`](https://github.com/astral-sh/uv), which manages its own Python and isolates CLI tools automatically:
52
+
53
+ ```bash
54
+ # 1. Install uv (one-time, if you don't have it)
55
+ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
56
+ # Windows PowerShell:
57
+ # powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
58
+
59
+ # 2. Install forge-dev as a global tool
60
+ uv tool install forge-dev
61
+
62
+ # 3. Verify
63
+ forge --version
64
+ ```
65
+
66
+ <details>
67
+ <summary>Alternative: <code>pipx</code></summary>
68
+
69
+ ```bash
70
+ pipx install forge-dev
71
+ forge --version
72
+ ```
73
+
74
+ </details>
75
+
76
+ <details>
77
+ <summary>Alternative: plain <code>pip</code></summary>
78
+
51
79
  ```bash
52
80
  pip install forge-dev
53
81
  ```
54
82
 
55
- Or install from source:
83
+ </details>
84
+
85
+ <details>
86
+ <summary>From source (for contributors)</summary>
56
87
 
57
88
  ```bash
58
89
  git clone https://github.com/luiskcr/forge.git
59
90
  cd forge
60
- pip install -e .
91
+ pip install -e ".[dev]"
61
92
  ```
62
93
 
63
- Requires Python ≥ 3.11.
94
+ </details>
95
+
96
+ Requires Python ≥ 3.11. If you use `uv`, it will download a compatible Python automatically.
64
97
 
65
98
  ### Updating
66
99
 
100
+ Use the same tool you installed with:
101
+
67
102
  ```bash
68
- pip install --upgrade forge-dev # latest stable
69
- pip install --upgrade forge-dev==0.1.2 # pin a specific version
70
- forge --version # verify
103
+ uv tool upgrade forge-dev # if installed via uv (recommended)
104
+ pipx upgrade forge-dev # if installed via pipx
105
+ pip install --upgrade forge-dev # if installed via pip
106
+ forge --version # verify the new version
71
107
  ```
72
108
 
73
- If you installed with `pipx`:
109
+ To pin a specific version:
74
110
 
75
111
  ```bash
76
- pipx upgrade forge-dev
112
+ uv tool install forge-dev==0.1.2 --force
77
113
  ```
78
114
 
79
- > Homebrew is not supported yet — Forge is distributed exclusively via PyPI.
115
+ > **Homebrew is not supported.** Forge is distributed exclusively via PyPI.
116
+
117
+ > **Note for macOS users on Tahoe (macOS 26):** the Homebrew bottles for `python@3.13` and `python@3.14` currently ship a broken `pyexpat` module (a `libexpat` symbol mismatch) that breaks `pip`, `pipx` and `venv`. If you hit `ImportError: Symbol not found: _XML_SetAllocTrackerActivationThreshold`, use `uv` instead — it bundles its own Python and avoids the issue entirely.
80
118
 
81
119
  ## How Forge Works
82
120
 
@@ -19,35 +19,73 @@ Forge encodes a complete development paradigm — from receiving a vague require
19
19
 
20
20
  ## Installation
21
21
 
22
+ Forge is published on PyPI as `forge-dev`. The recommended way to install it is with [`uv`](https://github.com/astral-sh/uv), which manages its own Python and isolates CLI tools automatically:
23
+
24
+ ```bash
25
+ # 1. Install uv (one-time, if you don't have it)
26
+ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
27
+ # Windows PowerShell:
28
+ # powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
29
+
30
+ # 2. Install forge-dev as a global tool
31
+ uv tool install forge-dev
32
+
33
+ # 3. Verify
34
+ forge --version
35
+ ```
36
+
37
+ <details>
38
+ <summary>Alternative: <code>pipx</code></summary>
39
+
40
+ ```bash
41
+ pipx install forge-dev
42
+ forge --version
43
+ ```
44
+
45
+ </details>
46
+
47
+ <details>
48
+ <summary>Alternative: plain <code>pip</code></summary>
49
+
22
50
  ```bash
23
51
  pip install forge-dev
24
52
  ```
25
53
 
26
- Or install from source:
54
+ </details>
55
+
56
+ <details>
57
+ <summary>From source (for contributors)</summary>
27
58
 
28
59
  ```bash
29
60
  git clone https://github.com/luiskcr/forge.git
30
61
  cd forge
31
- pip install -e .
62
+ pip install -e ".[dev]"
32
63
  ```
33
64
 
34
- Requires Python ≥ 3.11.
65
+ </details>
66
+
67
+ Requires Python ≥ 3.11. If you use `uv`, it will download a compatible Python automatically.
35
68
 
36
69
  ### Updating
37
70
 
71
+ Use the same tool you installed with:
72
+
38
73
  ```bash
39
- pip install --upgrade forge-dev # latest stable
40
- pip install --upgrade forge-dev==0.1.2 # pin a specific version
41
- forge --version # verify
74
+ uv tool upgrade forge-dev # if installed via uv (recommended)
75
+ pipx upgrade forge-dev # if installed via pipx
76
+ pip install --upgrade forge-dev # if installed via pip
77
+ forge --version # verify the new version
42
78
  ```
43
79
 
44
- If you installed with `pipx`:
80
+ To pin a specific version:
45
81
 
46
82
  ```bash
47
- pipx upgrade forge-dev
83
+ uv tool install forge-dev==0.1.2 --force
48
84
  ```
49
85
 
50
- > Homebrew is not supported yet — Forge is distributed exclusively via PyPI.
86
+ > **Homebrew is not supported.** Forge is distributed exclusively via PyPI.
87
+
88
+ > **Note for macOS users on Tahoe (macOS 26):** the Homebrew bottles for `python@3.13` and `python@3.14` currently ship a broken `pyexpat` module (a `libexpat` symbol mismatch) that breaks `pip`, `pipx` and `venv`. If you hit `ImportError: Symbol not found: _XML_SetAllocTrackerActivationThreshold`, use `uv` instead — it bundles its own Python and avoids the issue entirely.
51
89
 
52
90
  ## How Forge Works
53
91
 
@@ -0,0 +1,8 @@
1
+ """Forge — AI-Native Development Workflow Engine."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("forge-dev")
7
+ except PackageNotFoundError: # pragma: no cover — running from source without install
8
+ __version__ = "0.0.0+unknown"
@@ -163,6 +163,47 @@ def init(name: str | None, backend: str | None, no_interactive: bool) -> None:
163
163
  "## Entries\n\n"
164
164
  )
165
165
 
166
+ # Create overrides/ with a README so users know what goes there
167
+ overrides_dir = project_path / ".forge" / "overrides"
168
+ overrides_dir.mkdir(parents=True, exist_ok=True)
169
+ overrides_readme = overrides_dir / "README.md"
170
+ if not overrides_readme.exists():
171
+ overrides_readme.write_text(
172
+ "# Project Overrides\n\n"
173
+ "Drop YAML files here to override or extend Forge governance for "
174
+ "this project only:\n\n"
175
+ "- `standards/<name>.yaml` — add or override a standard\n"
176
+ "- `patterns/<name>.yaml` — project-specific approved patterns\n"
177
+ "- `anti-patterns/<name>.yaml` — project-specific anti-patterns\n\n"
178
+ "These are merged on top of global user overrides from "
179
+ "`~/.forge/user/` when generating `CLAUDE.md`. Run "
180
+ "`forge sync` after editing.\n"
181
+ )
182
+
183
+ # Generate editor instructions (CLAUDE.md), Claude Code skill, and —
184
+ # if the project has unresolved fields — the discovery prompt for the
185
+ # AI editor to fill in.
186
+ #
187
+ # CLAUDE.md is NOT overwritten if it already exists — user may have
188
+ # customized it. Skill and discovery files are Forge-owned and always
189
+ # regenerated.
190
+ from forge_core.editor_bridge import collect_unspecified_fields, write_editor_file
191
+
192
+ existing_claude = project_path / "CLAUDE.md"
193
+ if existing_claude.exists():
194
+ claude_md_path = existing_claude
195
+ claude_status = "[yellow]kept (already exists — run `forge sync` to overwrite)[/yellow]"
196
+ else:
197
+ claude_md_path = write_editor_file(project_path, context, "claude")
198
+ claude_status = "[green]generated[/green]"
199
+ skill_path = write_editor_file(project_path, context, "skill")
200
+
201
+ # Discovery prompt — only if there are unspecified fields
202
+ unspecified = collect_unspecified_fields(context)
203
+ discovery_path: Path | None = None
204
+ if unspecified:
205
+ discovery_path = write_editor_file(project_path, context, "discovery")
206
+
166
207
  # Record in global history
167
208
  from forge_core.registry import record_project
168
209
  record_project(
@@ -171,21 +212,45 @@ def init(name: str | None, backend: str | None, no_interactive: bool) -> None:
171
212
  {"type": context.type, "backend": context.backend, "cloud": context.cloud},
172
213
  )
173
214
 
215
+ # Build the summary panel
216
+ summary_lines = [
217
+ f"[green]✓ Forge initialized for [bold]{context.name}[/bold][/green]\n",
218
+ f"Context: {context_path}",
219
+ f"Journal: {journal_path}",
220
+ f"Overrides: {overrides_dir}",
221
+ f"CLAUDE: {claude_md_path} — {claude_status}",
222
+ f"Skill: {skill_path}",
223
+ ]
224
+ if discovery_path:
225
+ summary_lines.append(f"Discovery: {discovery_path} [yellow](fields need resolution)[/yellow]")
226
+ summary_lines += [
227
+ "",
228
+ f"Stack: {context.backend.value} + {context.frontend.value}",
229
+ f"Cloud: {context.cloud.value}",
230
+ f"Auth: {context.auth.value}",
231
+ "",
232
+ ]
233
+ if unspecified:
234
+ fields = ", ".join(f"`{u['key']}`" for u in unspecified)
235
+ summary_lines.append(
236
+ f"[yellow]⚠ {len(unspecified)} field(s) unspecified:[/yellow] {fields}"
237
+ )
238
+ summary_lines.append(
239
+ "Read `.forge/discovery_prompt.md` in your AI editor — it will"
240
+ )
241
+ summary_lines.append(
242
+ "investigate the repo and fill these in automatically."
243
+ )
244
+ else:
245
+ summary_lines += [
246
+ "Next steps:",
247
+ " forge intake <file> — process a requirement document",
248
+ " forge audit — run audit agents",
249
+ " forge sync — regenerate editor files after changes",
250
+ ]
251
+
174
252
  console.print()
175
- console.print(Panel(
176
- f"[green]✓ Forge initialized for [bold]{context.name}[/bold][/green]\n\n"
177
- f"Context: {context_path}\n"
178
- f"Journal: {journal_path}\n\n"
179
- f"Stack: {context.backend.value} + {context.frontend.value}\n"
180
- f"Cloud: {context.cloud.value}\n"
181
- f"Auth: {context.auth.value}\n\n"
182
- "Next steps:\n"
183
- " forge intake <file> — process a requirement document\n"
184
- " forge plan — generate implementation plan\n"
185
- " forge audit — run audit agents",
186
- title="🔥 Ready",
187
- border_style="green",
188
- ))
253
+ console.print(Panel("\n".join(summary_lines), title="🔥 Ready", border_style="green"))
189
254
 
190
255
 
191
256
  @main.command()
@@ -455,14 +520,16 @@ def mcps() -> None:
455
520
  )
456
521
  def sync(format: str) -> None:
457
522
  """Generate editor instruction files from Forge governance.
458
-
523
+
459
524
  Translates ALL Forge knowledge (standards, patterns, journal,
460
525
  context) into a CLAUDE.md, .cursorrules, or equivalent file
461
526
  that the AI editor reads and follows.
462
-
527
+
463
528
  This is the core value of Forge — it bridges governance to the editor.
529
+ Also always refreshes the Claude Code skill and cleans up the discovery
530
+ prompt if every context field is resolved.
464
531
  """
465
- from forge_core.editor_bridge import write_editor_file
532
+ from forge_core.editor_bridge import collect_unspecified_fields, write_editor_file
466
533
  from forge_core.phases.context import load_context
467
534
 
468
535
  project_path = Path.cwd()
@@ -481,8 +548,26 @@ def sync(format: str) -> None:
481
548
  path = write_editor_file(project_path, context, fmt)
482
549
  console.print(f"[green]✓ Generated: {path}[/green]")
483
550
 
551
+ # Always refresh the skill file (Forge-owned)
552
+ skill_path = write_editor_file(project_path, context, "skill")
553
+ console.print(f"[green]✓ Generated: {skill_path}[/green]")
554
+
555
+ # Discovery prompt: regenerate if there are still unspecified fields,
556
+ # delete it if everything is resolved.
557
+ remaining = collect_unspecified_fields(context)
558
+ discovery_file = project_path / ".forge" / "discovery_prompt.md"
559
+ if remaining:
560
+ write_editor_file(project_path, context, "discovery")
561
+ console.print(
562
+ f"[yellow]⚠ {len(remaining)} field(s) still unspecified — "
563
+ "discovery prompt refreshed.[/yellow]"
564
+ )
565
+ elif discovery_file.exists():
566
+ discovery_file.unlink()
567
+ console.print("[dim]Removed .forge/discovery_prompt.md (all fields resolved).[/dim]")
568
+
484
569
  console.print()
485
- console.print("[dim]The editor will read this file automatically.[/dim]")
570
+ console.print("[dim]The editor will read these files automatically.[/dim]")
486
571
  console.print("[dim]Re-run `forge sync` after changing standards or context.[/dim]")
487
572
 
488
573
 
@@ -548,5 +633,118 @@ def audit(files: tuple[str, ...], full: bool) -> None:
548
633
  )
549
634
 
550
635
 
636
+ @main.group()
637
+ def context() -> None:
638
+ """Inspect or update the project context (.forge/context.yaml).
639
+
640
+ Used by AI editors during the discovery flow to fill in fields Forge
641
+ could not auto-detect.
642
+ """
643
+ pass
644
+
645
+
646
+ @context.command("show")
647
+ def context_show() -> None:
648
+ """Print the resolved project context as YAML."""
649
+ import yaml as _yaml
650
+
651
+ from forge_core.phases.context import load_context
652
+
653
+ project_path = Path.cwd()
654
+ ctx = load_context(project_path)
655
+ if not ctx:
656
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
657
+ sys.exit(1)
658
+ console.print(_yaml.dump(ctx.model_dump(mode="json"), sort_keys=False))
659
+
660
+
661
+ @context.command("set")
662
+ @click.argument("assignments", nargs=-1, required=True)
663
+ def context_set(assignments: tuple[str, ...]) -> None:
664
+ """Set one or more context fields: `forge context set cloud=azure iac=bicep`.
665
+
666
+ Supported keys: cloud, backend, frontend, database, auth, type, description,
667
+ cicd.iac, cicd.provider, ai.enabled. Nested keys use dot notation.
668
+ After updates, CLAUDE.md and the skill are auto-regenerated.
669
+ """
670
+ from forge_core.editor_bridge import collect_unspecified_fields, write_editor_file
671
+ from forge_core.phases.context import load_context, save_context
672
+
673
+ project_path = Path.cwd()
674
+ ctx = load_context(project_path)
675
+ if not ctx:
676
+ console.print("[red]No Forge project found. Run `forge init` first.[/red]")
677
+ sys.exit(1)
678
+
679
+ data = ctx.model_dump(mode="json")
680
+ updated: list[str] = []
681
+ errors: list[str] = []
682
+
683
+ for assignment in assignments:
684
+ if "=" not in assignment:
685
+ errors.append(f"'{assignment}' — expected key=value")
686
+ continue
687
+ key, raw_value = assignment.split("=", 1)
688
+ key = key.strip()
689
+ value: object = raw_value.strip()
690
+
691
+ # Booleans
692
+ if value in ("true", "True"):
693
+ value = True
694
+ elif value in ("false", "False"):
695
+ value = False
696
+
697
+ # Nested key (dot notation)
698
+ parts = key.split(".")
699
+ target = data
700
+ try:
701
+ for p in parts[:-1]:
702
+ target = target[p]
703
+ target[parts[-1]] = value
704
+ updated.append(f"{key}={raw_value}")
705
+ except (KeyError, TypeError):
706
+ errors.append(f"'{key}' — unknown field")
707
+
708
+ if errors:
709
+ for e in errors:
710
+ console.print(f"[red]✗ {e}[/red]")
711
+ if not updated:
712
+ sys.exit(1)
713
+
714
+ # Re-validate by round-tripping through the Pydantic model
715
+ from forge_core.models import ProjectContext
716
+ try:
717
+ new_ctx = ProjectContext(**data)
718
+ except Exception as exc:
719
+ console.print(f"[red]✗ Validation failed:[/red] {exc}")
720
+ sys.exit(1)
721
+
722
+ save_context(new_ctx, project_path)
723
+
724
+ # Regenerate editor files that exist and skill (skill is always owned by Forge)
725
+ from forge_core.editor_bridge import sync_editor_file
726
+ sync_editor_file(project_path, new_ctx)
727
+ write_editor_file(project_path, new_ctx, "skill")
728
+
729
+ # Clean up discovery prompt if nothing left unresolved
730
+ discovery_file = project_path / ".forge" / "discovery_prompt.md"
731
+ remaining = collect_unspecified_fields(new_ctx)
732
+ if not remaining and discovery_file.exists():
733
+ discovery_file.unlink()
734
+ console.print("[dim]Removed .forge/discovery_prompt.md (all fields resolved).[/dim]")
735
+ elif remaining:
736
+ # Refresh the discovery prompt with the remaining fields
737
+ write_editor_file(project_path, new_ctx, "discovery")
738
+
739
+ for u in updated:
740
+ console.print(f"[green]✓ {u}[/green]")
741
+
742
+ if remaining:
743
+ fields = ", ".join(r["key"] for r in remaining)
744
+ console.print(f"[yellow]Still unspecified:[/yellow] {fields}")
745
+ else:
746
+ console.print("[green]All context fields resolved.[/green]")
747
+
748
+
551
749
  if __name__ == "__main__":
552
750
  main()
@@ -28,6 +28,10 @@ CONFIG_FILES = {
28
28
  "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml",
29
29
  "go.mod", "pom.xml", "build.gradle", "Gemfile", "composer.json",
30
30
  "Makefile", "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
31
+ # IaC
32
+ "main.bicep", "Pulumi.yaml", "main.tf", "terraform.tf", "azure.yaml",
33
+ # CI/CD
34
+ "azure-pipelines.yml", ".gitlab-ci.yml",
31
35
  }
32
36
 
33
37
  IGNORE_DIRS = {
@@ -157,14 +161,18 @@ def _scan_directory(
157
161
 
158
162
 
159
163
  def _detect_stack(path: Path, config_files: list[Path]) -> dict[str, str]:
160
- """Detect the technology stack from config files."""
164
+ """Detect the technology stack from config files.
165
+
166
+ Only fills fields Forge can identify with high confidence from file
167
+ contents or presence. Anything uncertain is left out — the discovery
168
+ flow (via CLAUDE.md) asks the AI editor to resolve the gaps.
169
+ """
161
170
  stack: dict[str, str] = {}
162
171
  config_names = {f.name for f in config_files}
163
172
 
164
- # Backend detection
173
+ # ── Backend / language ────────────────────────────────────────────────
165
174
  if "pyproject.toml" in config_names or "requirements.txt" in config_names:
166
175
  stack["language"] = "python"
167
- # Try to detect framework
168
176
  for cfg in config_files:
169
177
  if cfg.name in ("pyproject.toml", "requirements.txt"):
170
178
  try:
@@ -190,20 +198,87 @@ def _detect_stack(path: Path, config_files: list[Path]) -> dict[str, str]:
190
198
  stack["backend"] = "node/fastify"
191
199
  elif "nestjs" in content or "@nestjs" in content:
192
200
  stack["backend"] = "node/nestjs"
193
- # Frontend detection
194
- if '"react"' in content or '"react-dom"' in content:
195
- stack["frontend"] = "react"
196
- elif '"next"' in content:
201
+ # Frontend
202
+ if '"next"' in content:
197
203
  stack["frontend"] = "nextjs"
204
+ elif '"react"' in content or '"react-dom"' in content:
205
+ stack["frontend"] = "react"
198
206
  elif '"vue"' in content:
199
207
  stack["frontend"] = "vue"
200
208
  elif '"svelte"' in content:
201
209
  stack["frontend"] = "svelte"
210
+ elif '"@angular/core"' in content:
211
+ stack["frontend"] = "angular"
212
+ # Auth (only strong signals)
213
+ if "@azure/msal" in content:
214
+ stack["auth"] = "azure-ad-b2c"
215
+ elif '"next-auth"' in content or '"@auth/core"' in content:
216
+ stack["auth"] = "custom-jwt"
217
+ elif '"@clerk/' in content:
218
+ stack["auth"] = "clerk"
219
+ elif '"@supabase/supabase-js"' in content:
220
+ stack["auth"] = "supabase-auth"
221
+ elif '"auth0"' in content or '"@auth0/' in content:
222
+ stack["auth"] = "auth0"
223
+ except Exception:
224
+ pass
225
+
226
+ # ── IaC ───────────────────────────────────────────────────────────────
227
+ if any(f.suffix == ".bicep" for f in config_files) or "main.bicep" in config_names:
228
+ stack["iac"] = "bicep"
229
+ stack["cloud"] = "azure"
230
+ elif "Pulumi.yaml" in config_names:
231
+ stack["iac"] = "pulumi"
232
+ elif "main.tf" in config_names or "terraform.tf" in config_names:
233
+ stack["iac"] = "terraform"
234
+ elif "azure.yaml" in config_names:
235
+ stack["iac"] = "azd"
236
+ stack["cloud"] = "azure"
237
+
238
+ # Scan top-level for .bicep/.tf files even if not in CONFIG_FILES set
239
+ try:
240
+ for entry in path.iterdir():
241
+ if entry.is_file():
242
+ if entry.suffix == ".bicep":
243
+ stack.setdefault("iac", "bicep")
244
+ stack.setdefault("cloud", "azure")
245
+ elif entry.suffix == ".tf":
246
+ stack.setdefault("iac", "terraform")
247
+ except Exception:
248
+ pass
249
+
250
+ # ── CI/CD ─────────────────────────────────────────────────────────────
251
+ if (path / ".github" / "workflows").exists():
252
+ stack["cicd"] = "github-actions"
253
+ elif "azure-pipelines.yml" in config_names:
254
+ stack["cicd"] = "azure-devops"
255
+ elif ".gitlab-ci.yml" in config_names:
256
+ stack["cicd"] = "gitlab-ci"
257
+
258
+ # ── Database (from docker-compose services) ──────────────────────────
259
+ compose_name = (
260
+ "docker-compose.yml" if "docker-compose.yml" in config_names
261
+ else "docker-compose.yaml" if "docker-compose.yaml" in config_names
262
+ else None
263
+ )
264
+ if compose_name:
265
+ for cfg in config_files:
266
+ if cfg.name == compose_name:
267
+ try:
268
+ content = cfg.read_text(errors="ignore").lower()
269
+ if "postgres" in content:
270
+ stack["database"] = "postgresql"
271
+ elif "mysql" in content or "mariadb" in content:
272
+ stack["database"] = "mysql"
273
+ elif "mongo" in content:
274
+ stack["database"] = "mongodb"
275
+ elif "mssql" in content or "sql-server" in content:
276
+ stack["database"] = "sqlserver"
202
277
  except Exception:
203
278
  pass
204
279
 
205
- # Docker detection
206
- if "Dockerfile" in config_names or "docker-compose.yml" in config_names:
280
+ # ── Docker ────────────────────────────────────────────────────────────
281
+ if "Dockerfile" in config_names or compose_name:
207
282
  stack["containerized"] = "true"
208
283
 
209
284
  return stack