forgedock 1.0.20 → 1.0.22

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. package/README.md +146 -77
  2. package/bin/demo.mjs +386 -0
  3. package/bin/forgedock.mjs +357 -30
  4. package/bin/init-detect.mjs +95 -0
  5. package/bin/runner.mjs +780 -0
  6. package/commands/ci-audit.md +469 -0
  7. package/commands/cleanup.md +85 -39
  8. package/commands/deploy-info.md +6 -2
  9. package/commands/forge-stats.md +1 -1
  10. package/commands/optimize.md +1 -1
  11. package/commands/orchestrate.md +684 -33
  12. package/commands/quality-gate.md +166 -46
  13. package/commands/review-pr-agents.md +106 -34
  14. package/commands/review-pr.md +33 -2
  15. package/commands/scope.md +11 -3
  16. package/commands/security-audit.md +24 -16
  17. package/commands/sync-ecosystem.md +2 -2
  18. package/commands/work-on/build/architect.md +47 -3
  19. package/commands/work-on/build/context.md +36 -5
  20. package/commands/work-on/build/validate.md +16 -7
  21. package/commands/work-on/close.md +129 -1
  22. package/commands/work-on/investigate.md +24 -1
  23. package/commands/work-on.md +226 -8
  24. package/docs/comparison.md +135 -0
  25. package/docs/design/developer-objections-ai-context-tools.md +244 -0
  26. package/docs/design/messaging-angles-research.md +179 -0
  27. package/docs/design/show-hn-draft.md +86 -0
  28. package/docs/site/annotations-explained.md +55 -0
  29. package/docs/site/command-learning-path.md +104 -0
  30. package/docs/site/command-reference.md +3 -0
  31. package/docs/site/getting-started.md +36 -14
  32. package/docs/site/troubleshooting.md +208 -0
  33. package/docs/spec/forge-protocol-v1.md +768 -0
  34. package/examples/forgedock-demo/README.md +113 -0
  35. package/examples/forgedock-demo/bootstrap.sh +125 -0
  36. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  37. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  38. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  39. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  40. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  41. package/examples/forgedock-demo/labels.json +21 -0
  42. package/examples/forgedock-demo/package.json +16 -0
  43. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  44. package/examples/forgedock-demo/src/auth.js +28 -0
  45. package/examples/forgedock-demo/src/db.js +66 -0
  46. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  47. package/examples/forgedock-demo/src/server.js +79 -0
  48. package/package.json +6 -2
  49. package/templates/forge.yaml.minimal +44 -0
@@ -0,0 +1,469 @@
1
+ ---
2
+ description: Stack-aware CI gap detection — audits a project's GitHub Actions workflows against its declared tech stack and files issues for missing config validation checks
3
+ argument-hint: [--repo <prefix>] [--dry-run]
4
+ ---
5
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
+
8
+ # /ci-audit — Stack-Aware CI Gap Detection
9
+
10
+ **Input**: $ARGUMENTS
11
+
12
+ You are the CI hygiene auditor. Given a project's declared tech stack (from `forge.yaml`), determine what config validation tools exist for each stack component and audit whether the project's GitHub Actions workflows include those validation steps. File GitHub issues for any gaps found.
13
+
14
+ This command enforces ForgeDock's separation-of-concerns principle:
15
+
16
+ | Layer | What runs | Tokens | Example |
17
+ |-------|-----------|--------|---------|
18
+ | **CI (automated)** | Deterministic tool validators | Zero | `traefik validate`, `nginx -t`, `kubectl --dry-run` |
19
+ | **ForgeDock (autonomous)** | Reasoning about architecture, logic, security | Yes | "Is this timeout chain coherent?", "Does this wiring match the deploy script?" |
20
+ | **ForgeDock CI hygiene** | Audit that CI has the right checks for the stack | Once | "Project uses Traefik v3 but CI has no `traefik validate` step → file issue" |
21
+
22
+ Config validation runs in CI (zero tokens). ForgeDock's role is to ensure the right CI checks exist — not to run them itself. <!-- Added: forge#1104 -->
23
+
24
+ This command is designed to be run:
25
+ - **Once after project setup** to establish CI hygiene baseline
26
+ - **After stack changes** (adding Traefik, upgrading K8s, adopting Terraform)
27
+ - **As part of `/autopilot`** periodic review cycle
28
+ - **After a production incident** caused by a structurally invalid config
29
+
30
+ This is NOT a PR review — it does not approve or block. It creates issues for each CI gap found.
31
+
32
+ **Agent model policy**: Default `model: "sonnet"`. If Sonnet is rate-limited, fall back to `model: "opus"`.
33
+ **NEVER use plan mode (EnterPlanMode).**
34
+
35
+ ---
36
+
37
+ ## Config Preamble
38
+
39
+ Before executing any phase, read `forge.yaml` to resolve project references:
40
+
41
+ ```bash
42
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' forge.yaml 2>/dev/null)
43
+ GH_FLAG="-R ${GH_REPO}"
44
+ REPO_PATH=$(yq '.paths.root' forge.yaml 2>/dev/null || echo ".")
45
+ TECH_STACK=$(yq '.review.tech_stack // ""' forge.yaml 2>/dev/null || echo "")
46
+ TOOL_VERSIONS=$(yq '.review.tool_versions // {}' forge.yaml 2>/dev/null || echo '{}')
47
+ ```
48
+
49
+ **Optional `forge.yaml` fields used by this command**:
50
+
51
+ ```yaml
52
+ review:
53
+ tech_stack: "Traefik v3, nginx 1.25, Terraform 1.6, Kubernetes 1.28" # Free-form stack description
54
+ tool_versions: # Structured version declarations
55
+ traefik: "3"
56
+ nginx: "1.25"
57
+ terraform: "1.6"
58
+ kubernetes: "1.28"
59
+ docker_compose: "2"
60
+
61
+ # Optional: override which CI workflows to audit (default: all files in .github/workflows/)
62
+ ci_audit:
63
+ workflow_paths:
64
+ - ".github/workflows/ci.yml"
65
+ - ".github/workflows/deploy-production.yml"
66
+ dry_run: false # When true: print findings without creating issues
67
+ ```
68
+
69
+ If `forge.yaml` is missing: stop and tell the user to run `npx forgedock init` to generate it.
70
+
71
+ ---
72
+
73
+ ## Phase 1: Discover Stack Components
74
+
75
+ ### 1A: Parse declared stack
76
+
77
+ Extract stack components from `forge.yaml → review.tech_stack` (free-form) and `forge.yaml → review.tool_versions` (structured).
78
+
79
+ ```bash
80
+ echo "=== Stack from forge.yaml ==="
81
+ echo "TECH_STACK: $TECH_STACK"
82
+ echo "TOOL_VERSIONS: $TOOL_VERSIONS"
83
+ ```
84
+
85
+ **Parse `TECH_STACK` (free-form text) for known tool names**:
86
+
87
+ Scan the `TECH_STACK` string for these tool keywords (case-insensitive):
88
+
89
+ | Keyword pattern | Tool | Validator(s) |
90
+ |-----------------|------|-------------|
91
+ | `traefik` | Traefik | `traefik validate`, `traefik healthcheck` |
92
+ | `nginx` | nginx | `nginx -t`, `nginx -T` |
93
+ | `kubernetes\|k8s\|kubectl` | Kubernetes | `kubectl apply --dry-run=client`, `kubeval`, `kustomize build \| kubectl apply --dry-run=client` |
94
+ | `terraform` | Terraform | `terraform validate`, `terraform plan`, `tflint` |
95
+ | `helm` | Helm | `helm lint`, `helm template \| kubectl apply --dry-run=client` |
96
+ | `docker.compose\|docker-compose` | Docker Compose | `docker compose config` |
97
+ | `pydantic\|fastapi` | Python (Pydantic) | `python -c "from app.main import app"` (startup validation) |
98
+ | `ansible` | Ansible | `ansible-playbook --syntax-check`, `ansible-lint` |
99
+ | `pulumi` | Pulumi | `pulumi preview --non-interactive` |
100
+
101
+ **Parse `TOOL_VERSIONS` (structured)** — for each key present, add to the detected components map with its version.
102
+
103
+ **Version-aware validator selection**:
104
+
105
+ The correct validator depends on the tool version. Version mismatches (e.g., using Traefik v2 flags on a v3 install) produce misleading results.
106
+
107
+ | Tool | Version | Preferred validator | Notes |
108
+ |------|---------|---------------------|-------|
109
+ | Traefik | v2.x | `traefik validate --configFile=traefik.yml` | v2 flag syntax |
110
+ | Traefik | v3.x | `traefik validate` (no `--configFile` flag needed in v3) | v3 changed CLI |
111
+ | nginx | any | `nginx -t -c /etc/nginx/nginx.conf` | Standard across versions |
112
+ | Terraform | ≥0.13 | `terraform validate` (in-directory) | Requires `terraform init` first |
113
+ | Terraform | all | `tflint` (linter, catches schema errors `terraform validate` misses) | Complementary |
114
+ | Kubernetes | any | `kubectl apply --dry-run=client -f k8s/` | Client-side, no cluster needed |
115
+ | Helm | ≥3.x | `helm lint ./chart` | v3 only (v2 deprecated) |
116
+ | Docker Compose | v2 | `docker compose config` | v2 CLI (`docker compose`, not `docker-compose`) |
117
+ | Docker Compose | v1 | `docker-compose config` | v1 CLI (legacy) |
118
+
119
+ If tool version is unknown: use the most permissive/universal validator form and note version ambiguity in the filed issue.
120
+
121
+ ### 1B: Discover config file locations
122
+
123
+ For each detected tool, check whether config files exist in the repo:
124
+
125
+ ```bash
126
+ REPO_PATH="${REPO_PATH:-.}"
127
+
128
+ # Traefik
129
+ TRAEFIK_CONFIGS=$(find "$REPO_PATH/traefik" -name "*.yml" -o -name "*.yaml" -o -name "*.toml" 2>/dev/null | head -10)
130
+ [ -n "$TRAEFIK_CONFIGS" ] && echo "Traefik configs found: $TRAEFIK_CONFIGS"
131
+
132
+ # nginx
133
+ NGINX_CONFIGS=$(find "$REPO_PATH/infra/nginx" "$REPO_PATH/nginx" -name "*.conf" 2>/dev/null | head -10)
134
+ [ -n "$NGINX_CONFIGS" ] && echo "nginx configs found: $NGINX_CONFIGS"
135
+
136
+ # Kubernetes
137
+ K8S_CONFIGS=$(find "$REPO_PATH/k8s" "$REPO_PATH/kubernetes" "$REPO_PATH/manifests" -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -10)
138
+ [ -n "$K8S_CONFIGS" ] && echo "Kubernetes manifests found: $K8S_CONFIGS"
139
+
140
+ # Terraform
141
+ TF_CONFIGS=$(find "$REPO_PATH/terraform" "$REPO_PATH/infra/terraform" -name "*.tf" 2>/dev/null | head -10)
142
+ [ -n "$TF_CONFIGS" ] && echo "Terraform configs found: $TF_CONFIGS"
143
+
144
+ # Helm charts
145
+ HELM_CHARTS=$(find "$REPO_PATH" -name "Chart.yaml" 2>/dev/null | head -10)
146
+ [ -n "$HELM_CHARTS" ] && echo "Helm charts found: $HELM_CHARTS"
147
+
148
+ # Docker Compose
149
+ COMPOSE_FILES=$(find "$REPO_PATH" -maxdepth 2 -name "docker-compose*.yml" 2>/dev/null | head -10)
150
+ [ -n "$COMPOSE_FILES" ] && echo "Docker Compose files found: $COMPOSE_FILES"
151
+ ```
152
+
153
+ **Skip any tool whose config files are NOT found in the repo** — a declared stack component with no config files has no CI validation gap to audit.
154
+
155
+ ---
156
+
157
+ ## Phase 2: Audit GitHub Actions Workflows
158
+
159
+ ### 2A: Read all workflow files
160
+
161
+ ```bash
162
+ WORKFLOW_DIR="$REPO_PATH/.github/workflows"
163
+ WORKFLOW_FILES=$(ls "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml 2>/dev/null | head -30)
164
+
165
+ if [ -z "$WORKFLOW_FILES" ]; then
166
+ echo "AUDIT GAP: No GitHub Actions workflows found in $WORKFLOW_DIR"
167
+ echo "A project without CI has no automated validation at all — see Phase 3 for issue creation."
168
+ fi
169
+
170
+ echo "=== Workflows to audit ==="
171
+ echo "$WORKFLOW_FILES"
172
+ ```
173
+
174
+ **Override with `forge.yaml → ci_audit.workflow_paths`** if that section is present:
175
+ ```bash
176
+ CUSTOM_PATHS=$(yq '.ci_audit.workflow_paths // []' forge.yaml 2>/dev/null | yq '.[]' 2>/dev/null || echo "")
177
+ [ -n "$CUSTOM_PATHS" ] && WORKFLOW_FILES="$CUSTOM_PATHS"
178
+ ```
179
+
180
+ ### 2B: Check for each tool's validator
181
+
182
+ For each tool detected in Phase 1 with config files present, search all workflow files for a validation step:
183
+
184
+ ```bash
185
+ # Build a combined search corpus from all workflow files
186
+ WORKFLOW_CORPUS=$(cat $WORKFLOW_FILES 2>/dev/null)
187
+
188
+ declare -A TOOL_VALIDATOR_FOUND
189
+ declare -A TOOL_VALIDATOR_PATTERN
190
+
191
+ # Traefik
192
+ TOOL_VALIDATOR_PATTERN[traefik]="traefik.*validate|traefik.*healthcheck|traefik.*check"
193
+
194
+ # nginx
195
+ TOOL_VALIDATOR_PATTERN[nginx]="nginx.*-t|nginx.*-T|nginx.*configtest|nginx.*test"
196
+
197
+ # Kubernetes
198
+ TOOL_VALIDATOR_PATTERN[kubernetes]="kubectl.*--dry-run|kubeval|kustomize.*\|.*kubectl|kubectl.*validate"
199
+
200
+ # Terraform
201
+ TOOL_VALIDATOR_PATTERN[terraform]="terraform.*validate|terraform.*plan|tflint"
202
+
203
+ # Helm
204
+ TOOL_VALIDATOR_PATTERN[helm]="helm.*lint|helm.*template"
205
+
206
+ # Docker Compose
207
+ TOOL_VALIDATOR_PATTERN[docker_compose]="docker.*compose.*config|docker-compose.*config"
208
+
209
+ for tool in "${!TOOL_VALIDATOR_PATTERN[@]}"; do
210
+ PATTERN="${TOOL_VALIDATOR_PATTERN[$tool]}"
211
+ if echo "$WORKFLOW_CORPUS" | grep -qiE "$PATTERN"; then
212
+ TOOL_VALIDATOR_FOUND[$tool]="YES"
213
+ FOUND_IN=$(grep -liE "$PATTERN" $WORKFLOW_FILES 2>/dev/null | head -3 | tr '\n' ', ')
214
+ echo "OK: $tool validator found in: $FOUND_IN"
215
+ else
216
+ TOOL_VALIDATOR_FOUND[$tool]="NO"
217
+ echo "GAP: No $tool validation step found in any CI workflow"
218
+ fi
219
+ done
220
+ ```
221
+
222
+ ### 2C: Check validator placement
223
+
224
+ A validation step that runs only in a deployment workflow (not in the CI workflow that runs on every PR) provides partial coverage — config errors reach `main` undetected if the deploy-only validator only runs at deploy time.
225
+
226
+ ```bash
227
+ CI_WORKFLOW=$(ls "$WORKFLOW_DIR/ci.yml" "$WORKFLOW_DIR/test.yml" "$WORKFLOW_DIR/build.yml" 2>/dev/null | head -1)
228
+
229
+ if [ -n "$CI_WORKFLOW" ]; then
230
+ echo "=== Checking validator placement (CI workflow: $CI_WORKFLOW) ==="
231
+ for tool in "${!TOOL_VALIDATOR_FOUND[@]}"; do
232
+ if [ "${TOOL_VALIDATOR_FOUND[$tool]}" = "YES" ]; then
233
+ PATTERN="${TOOL_VALIDATOR_PATTERN[$tool]}"
234
+ if grep -qiE "$PATTERN" "$CI_WORKFLOW" 2>/dev/null; then
235
+ echo "OK: $tool validator is in the CI workflow (runs on every PR)"
236
+ else
237
+ echo "PLACEMENT GAP: $tool validator exists but is NOT in $CI_WORKFLOW — it may only run at deploy time, missing PR-time validation"
238
+ fi
239
+ fi
240
+ done
241
+ fi
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Phase 3: File Issues for Gaps
247
+
248
+ For each gap detected (tool present in repo but no CI validation step found), file a GitHub issue.
249
+
250
+ **Skip issue creation if `--dry-run` flag is passed** (or `forge.yaml → ci_audit.dry_run: true`):
251
+ ```bash
252
+ DRY_RUN=$(yq '.ci_audit.dry_run // "false"' forge.yaml 2>/dev/null || echo "false")
253
+ [ "$1" = "--dry-run" ] || [ "$2" = "--dry-run" ] && DRY_RUN="true"
254
+ ```
255
+
256
+ ### 3A: Determine validator recommendation
257
+
258
+ For each gap, compose a concrete, version-aware recommendation:
259
+
260
+ ```bash
261
+ get_validator_recommendation() {
262
+ local tool="$1"
263
+ local version="$2" # may be empty if unknown
264
+
265
+ case "$tool" in
266
+ traefik)
267
+ if [ "${version%%.*}" = "3" ] || echo "$version" | grep -q "^v3"; then
268
+ echo "Add a CI step that runs: \`docker run --rm -v \$(pwd)/traefik:/etc/traefik traefik:v${version:-3} validate\`"
269
+ echo "Traefik v3 schema differs from v2 — the validator must use the same version as production."
270
+ else
271
+ echo "Add a CI step that runs: \`docker run --rm -v \$(pwd)/traefik:/etc/traefik traefik:v${version:-2} validate --configFile=/etc/traefik/traefik.yml\`"
272
+ fi
273
+ ;;
274
+ nginx)
275
+ echo "Add a CI step that runs: \`docker run --rm -v \$(pwd)/infra/nginx:/etc/nginx nginx:${version:-alpine} nginx -t\`"
276
+ ;;
277
+ kubernetes)
278
+ echo "Add a CI step that runs: \`kubectl apply --dry-run=client -f k8s/\` (requires \`kubectl\` in CI environment, no cluster needed for client-side validation)"
279
+ ;;
280
+ terraform)
281
+ echo "Add CI steps that run: (1) \`terraform init -backend=false\` then (2) \`terraform validate\`. Optionally add \`tflint\` for schema linting beyond what terraform validate catches."
282
+ ;;
283
+ helm)
284
+ echo "Add a CI step that runs: \`helm lint ./chart\` (or the path to your chart directory)"
285
+ ;;
286
+ docker_compose)
287
+ if [ "${version%%.*}" = "2" ]; then
288
+ echo "Add a CI step that runs: \`docker compose config\` (validates compose file structure and interpolation)"
289
+ else
290
+ echo "Add a CI step that runs: \`docker-compose config\` (validates compose file structure and interpolation)"
291
+ fi
292
+ ;;
293
+ esac
294
+ }
295
+ ```
296
+
297
+ ### 3B: Create issues for gaps
298
+
299
+ For each tool with `TOOL_VALIDATOR_FOUND[$tool] = "NO"`:
300
+
301
+ ```bash
302
+ for tool in "${!TOOL_VALIDATOR_FOUND[@]}"; do
303
+ if [ "${TOOL_VALIDATOR_FOUND[$tool]}" = "NO" ]; then
304
+ TOOL_VERSION=$(yq ".review.tool_versions.${tool} // \"\"" forge.yaml 2>/dev/null || echo "")
305
+ RECOMMENDATION=$(get_validator_recommendation "$tool" "$TOOL_VERSION")
306
+ VERSION_NOTE=""
307
+ [ -n "$TOOL_VERSION" ] && VERSION_NOTE="**Declared version**: \`$tool $TOOL_VERSION\`" || VERSION_NOTE="**Version**: Unknown — declare in \`forge.yaml → review.tool_versions.$tool\` for version-aware validation"
308
+
309
+ ISSUE_BODY="## Problem
310
+
311
+ The project uses \`$tool\` (config files present in repo) but the CI pipeline has no structural validation step for \`$tool\` config files. Structural config errors (wrong nesting, missing required keys, version-incompatible syntax) are therefore not caught before merge or deploy.
312
+
313
+ **Upstream incident**: This CI gap was identified via \`/ci-audit\` following incident analysis showing that review agents verify logical correctness (coherent values, naming consistency, cross-file wiring) but cannot substitute for deterministic structural validators. <!-- Added: forge#1104 -->
314
+
315
+ ## Root Cause
316
+
317
+ **Failure pattern**: The \`$tool\` validator has not been added to CI. Config changes are reviewed for logical correctness by the INFRA review agent but structural correctness (schema conformance) is not deterministically checked before merge.
318
+
319
+ **Separation of concerns**:
320
+ - ForgeDock review agents: verify logical correctness (architecture, wiring, security)
321
+ - CI validators: verify structural correctness (schema conformance, tool version compatibility)
322
+
323
+ Both layers are required. This issue tracks the CI layer gap.
324
+
325
+ ## Affected Files
326
+
327
+ Files to be identified during investigation:
328
+ 1. \`.github/workflows/ci.yml\` (or equivalent) — add validation step
329
+ 2. \`$tool/\` config directory — files to be validated
330
+
331
+ ## Expected Behavior
332
+
333
+ Every PR that changes \`$tool\` config files triggers a CI step that runs the \`$tool\` validator. Structural errors (wrong YAML nesting, missing required keys, version-incompatible syntax) are caught before merge — not after deploy.
334
+
335
+ ## Acceptance Criteria
336
+
337
+ - [ ] A CI step runs \`$tool\` structural validation on every PR that changes \`$tool\` config files
338
+ - [ ] The CI step uses the same \`$tool\` version as production (version-pinned)
339
+ - [ ] The CI step runs in the PR-time workflow (not only at deploy time)
340
+ - [ ] Validation failure blocks merge
341
+ - [ ] No regression in existing CI workflows
342
+
343
+ ## Recommendation
344
+
345
+ ${RECOMMENDATION}
346
+
347
+ ${VERSION_NOTE}
348
+
349
+ **Where to add**: Add the validation step to the CI workflow that runs on every PR (\`ci.yml\` or equivalent), not only the deploy workflow. A validator that only runs at deploy time allows structural errors to merge to \`staging\` undetected.
350
+
351
+ **Implementation**: File this issue → \`/work-on\` pipeline → builder adds the CI step → review verifies version pinning and trigger conditions → merge.
352
+
353
+ ## Severity
354
+
355
+ **P2** — Structural config errors are silently ignored by the tool at runtime. A project without a \`$tool\` validator in CI will ship broken configs whenever a config refactor changes YAML nesting. The failure mode is silent and only surfaces at deploy time (or not at all, if the tool silently ignores the misconfiguration)."
356
+
357
+ if [ "$DRY_RUN" = "true" ]; then
358
+ echo "=== DRY RUN: Would create issue for $tool CI gap ==="
359
+ echo "Title: ci($tool): add structural config validation step to CI pipeline"
360
+ echo "Body:"
361
+ echo "$ISSUE_BODY"
362
+ echo "Labels: bug,P2,audit-finding"
363
+ else
364
+ CREATED_ISSUE=$(gh issue create $GH_FLAG \
365
+ --title "ci($tool): add structural config validation step to CI pipeline" \
366
+ --body "$ISSUE_BODY" \
367
+ --label "bug,P2,audit-finding" 2>&1)
368
+ echo "Created issue for $tool gap: $CREATED_ISSUE"
369
+ fi
370
+ fi
371
+ done
372
+ ```
373
+
374
+ ### 3C: Handle placement gaps
375
+
376
+ For each tool whose validator exists but is only in the deploy workflow (not in the CI workflow):
377
+
378
+ ```bash
379
+ # (Populated in Phase 2C)
380
+ for tool in "${!PLACEMENT_GAPS[@]}"; do
381
+ TOOL_VERSION=$(yq ".review.tool_versions.${tool} // \"\"" forge.yaml 2>/dev/null || echo "")
382
+
383
+ ISSUE_BODY="## Problem
384
+
385
+ The project has a \`$tool\` validation step in CI, but it only runs in the deployment workflow — not in the PR-time CI workflow. This means structural config errors in \`$tool\` configs can merge to \`staging\` undetected and only surface during deployment.
386
+
387
+ ## Root Cause
388
+
389
+ **Failure pattern**: Validator placement is deploy-time only. PRs that introduce structural config errors pass all checks and merge normally. The error is only caught when the deploy pipeline runs.
390
+
391
+ ## Acceptance Criteria
392
+
393
+ - [ ] The \`$tool\` validation step runs in the PR-time CI workflow (\`ci.yml\` or equivalent)
394
+ - [ ] Validation failure blocks merge (not just alerts)
395
+ - [ ] No regression in the existing deploy-time validation
396
+
397
+ ## Severity
398
+
399
+ **P3** — Partial coverage. Structural errors are caught at deploy time but not at PR time, creating a window where broken configs exist on \`staging\` until a deploy is attempted."
400
+
401
+ if [ "$DRY_RUN" = "true" ]; then
402
+ echo "=== DRY RUN: Would create placement issue for $tool ==="
403
+ else
404
+ gh issue create $GH_FLAG \
405
+ --title "ci($tool): move config validation to PR-time CI workflow" \
406
+ --body "$ISSUE_BODY" \
407
+ --label "bug,P3,audit-finding" 2>&1
408
+ fi
409
+ done
410
+ ```
411
+
412
+ ---
413
+
414
+ ## Phase 4: Post Audit Summary
415
+
416
+ ```bash
417
+ gh issue comment $(gh issue list $GH_FLAG --search "ci-audit" --limit 1 --json number --jq '.[0].number // ""' 2>/dev/null || echo "") $GH_FLAG --body "## CI Audit Summary
418
+
419
+ **Audit date**: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
420
+ **Stack audited**: ${TECH_STACK:-"(no tech_stack declared in forge.yaml)"}
421
+
422
+ ### Results
423
+
424
+ | Tool | Config Files Found | CI Validator Found | Placement | Issues Filed |
425
+ |------|--------------------|-------------------|-----------|-------------|
426
+ $(for tool in "${!TOOL_VALIDATOR_FOUND[@]}"; do
427
+ CONFIG_FOUND=$([ -n "${TOOL_CONFIG_FOUND[$tool]}" ] && echo "Yes" || echo "No")
428
+ VALIDATOR="${TOOL_VALIDATOR_FOUND[$tool]:-NO}"
429
+ PLACEMENT="${TOOL_PLACEMENT[$tool]:-"N/A"}"
430
+ echo "| $tool | $CONFIG_FOUND | $VALIDATOR | $PLACEMENT | (see above) |"
431
+ done)
432
+
433
+ ### Actions Required
434
+
435
+ $([ -n "$(for t in "${!TOOL_VALIDATOR_FOUND[@]}"; do [ "${TOOL_VALIDATOR_FOUND[$t]}" = "NO" ] && echo "$t"; done)" ] && \
436
+ echo "Issues filed for: $(for t in "${!TOOL_VALIDATOR_FOUND[@]}"; do [ "${TOOL_VALIDATOR_FOUND[$t]}" = "NO" ] && echo "$t"; done | tr '\n' ' ')" || \
437
+ echo "No CI gaps found — all detected stack components have CI validators.")
438
+
439
+ ### Next Steps
440
+
441
+ 1. Run \`/work-on\` on each filed issue to implement the CI validation step
442
+ 2. Re-run \`/ci-audit\` after implementation to verify gaps are closed
443
+ 3. Add \`forge.yaml → review.tool_versions\` entries for any tools whose version was marked Unknown
444
+ " 2>/dev/null || echo "Audit complete. Summary printed above (no issue to comment on in dry-run mode)."
445
+ ```
446
+
447
+ Print the summary to stdout regardless:
448
+
449
+ ```bash
450
+ echo ""
451
+ echo "=== /ci-audit Complete ==="
452
+ echo "Stack: ${TECH_STACK:-"(no tech_stack declared)"}"
453
+ echo "Workflows audited: $(echo $WORKFLOW_FILES | tr ' ' '\n' | wc -l | tr -d ' ')"
454
+ echo "Tools with config files: $(echo "${!TOOL_CONFIG_FOUND[@]}" | wc -w | tr -d ' ')"
455
+ echo "CI gaps found: $(for t in "${!TOOL_VALIDATOR_FOUND[@]}"; do [ "${TOOL_VALIDATOR_FOUND[$t]}" = "NO" ] && echo "$t"; done | wc -w | tr -d ' ')"
456
+ echo ""
457
+ echo "Issues filed:"
458
+ echo "${ISSUES_FILED:-" (none — dry-run or no gaps)"}"
459
+ ```
460
+
461
+ ---
462
+
463
+ ## Error Handling
464
+
465
+ - **`forge.yaml` missing**: Stop and tell the user to run `npx forgedock init`
466
+ - **No stack declared** (`TECH_STACK` empty and `TOOL_VERSIONS` empty): Print advisory — "No tech stack declared in forge.yaml. Add `review.tech_stack` or `review.tool_versions` to enable stack-aware CI auditing." Do not file issues.
467
+ - **No config files found for a declared tool**: Skip that tool's CI audit — presence in `TECH_STACK` without actual config files may mean the project is in the process of adopting the tool, not that CI is missing a validator.
468
+ - **No GitHub Actions workflows found**: File a single high-severity issue: "Project has no CI — all stack components lack automated validation."
469
+ - **`gh` auth failure**: Print `gh auth status` output and stop.
@@ -47,7 +47,7 @@ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD
47
47
  | `labels` or empty | Fix stale/missing workflow labels on closed issues |
48
48
  | `orphans` | Close open issues whose PRs are already merged |
49
49
  | `branches` | Prune worktrees and remote branches for merged PRs |
50
- | `milestones` | Close milestones with 0 open issues |
50
+ | `milestones` | Report milestones with 0 open issues (advisory — never closes) |
51
51
  | `board` | Sync closed issues to Project board with correct terminal state |
52
52
  | `all` | All of the above, in order |
53
53
 
@@ -172,58 +172,109 @@ echo "Removed: $WORKTREE_PATH ($BRANCH_NAME)"
172
172
 
173
173
  ### 3C: Prune merged remote branches
174
174
 
175
- Delete remote `fix/` and `feat/` branches that are fully merged into `{STAGING_BRANCH}`:
175
+ Delete remote `fix/` and `feat/` branches whose PR has merged — using **GitHub PR state as the source of truth**, not local git ancestry.
176
+
177
+ **Why not `git branch -r --merged {STAGING_BRANCH}`**: an ancestry check only catches branches whose tip commit is reachable from `{STAGING_BRANCH}`. This misses two common cases:
178
+ - **Feature-lane branches merged into a milestone branch.** Per `work-on.md` Phase 3E, milestone issues branch from and PR into `origin/milestone/{slug}`, not `{STAGING_BRANCH}`. Once such a branch's own PR merges into the milestone branch, it's fully absorbed and safe to delete — but it won't show up as "merged into staging" until the milestone itself ships (which may be days later, or never, if abandoned). This is the dominant cause of `feat/*` branches accumulating for a milestone/feature cluster.
179
+ - **Squash merges.** If a branch was merged via squash (manual UI merge, or org/branch-protection settings that force squash-only), the resulting commit is a brand-new SHA that is never an ancestor of the original branch — so ancestry-based detection never matches it, even though the PR is clearly `MERGED` on GitHub.
180
+
181
+ Both gaps disappear when merged-PR head-refs (regardless of base branch or merge strategy) are used directly as the deletion set:
182
+
183
+ **Why also check `headRefOid`**: branch names are freely reusable in git. A name-only match cannot distinguish "this branch's current tip is the commit that merged" from "a branch with this name merged at some point in the past and has since been reused for new, unmerged work" (e.g. a second issue happens to slugify to the same `fix/*`/`feat/*` name). Comparing the branch's live remote tip SHA against the merged PR's `headRefOid` closes that gap — deletion only proceeds when the (name, SHA) pair matches a merged PR exactly, regardless of base branch or merge strategy.
184
+
185
+ **Why `--force-with-lease` on the delete**: the `CURRENT_SHA` snapshot above is read from the local `origin/$branch` ref as of the `git fetch --prune` at the top of this phase. For large batches this loop can run for a while (one network call per branch), so a new commit can land on the remote branch between the fetch and that branch's turn in the loop — a TOCTOU window. A plain `git push origin --delete "$branch"` is unconditional: it deletes whatever the remote ref currently points to, even if that's no longer `$CURRENT_SHA`. Using `--force-with-lease=refs/heads/$branch:$CURRENT_SHA` makes the deletion a server-verified compare-and-swap — the remote rejects the update if `refs/heads/$branch` has moved since the snapshot, instead of silently deleting new work.
176
186
 
177
187
  ```bash
178
188
  cd {REPO_PATH}
179
189
  git fetch --prune origin
180
190
 
181
- # Count first
182
- MERGED_COUNT=$(git branch -r --merged origin/{STAGING_BRANCH} | grep -E "origin/(fix|feat)/" | wc -l)
183
- echo "Found $MERGED_COUNT merged remote branches to delete"
191
+ # All remote fix/* and feat/* branches currently on origin
192
+ REMOTE_BRANCHES=$(git branch -r | grep -E "origin/(fix|feat)/" | sed 's|origin/||' | tr -d ' ')
193
+
194
+ # Head-ref name + head-ref SHA of every merged PR, regardless of base branch (staging,
195
+ # milestone/*, main) or merge strategy (merge-commit vs. squash) — this is GitHub's own
196
+ # merge bookkeeping, not local ref topology, so it's immune to both gaps above.
197
+ # headRefOid is captured alongside headRefName so deletion can be gated on the branch's
198
+ # CURRENT tip matching the commit that actually merged, not just a name match.
199
+ # --limit is set high to avoid silent truncation; repos with more historical merged PRs
200
+ # than this should re-run `/cleanup branches` incrementally to catch the remainder.
201
+ MERGED_HEADS=$(gh pr list {GH_FLAG} --state merged --limit 1000 --json headRefName,headRefOid --jq '.[] | "\(.headRefName)\t\(.headRefOid)"')
202
+
203
+ DELETE_COUNT=0
204
+ SKIP_COUNT=0
205
+ RACE_SKIP_COUNT=0
206
+ for branch in $REMOTE_BRANCHES; do
207
+ CURRENT_SHA=$(git rev-parse "origin/$branch" 2>/dev/null)
208
+ if printf '%s\n' "$MERGED_HEADS" | grep -qxF "$(printf '%s\t%s' "$branch" "$CURRENT_SHA")"; then
209
+ # Name AND tip SHA match a merged PR's head-ref — safe to delete.
210
+ # Use --force-with-lease as a server-side compare-and-swap: the remote re-verifies
211
+ # refs/heads/$branch is still at $CURRENT_SHA at push time, closing the TOCTOU gap
212
+ # between this snapshot and the actual delete (see note above).
213
+ if git push origin --force-with-lease="refs/heads/$branch:$CURRENT_SHA" ":refs/heads/$branch" 2>&1; then
214
+ DELETE_COUNT=$((DELETE_COUNT + 1))
215
+ # Log the pre-deletion SHA so it's visible in run output/logs for recovery —
216
+ # see "Recovery: restoring an accidentally pruned branch" below.
217
+ echo "Deleted origin/$branch (was $CURRENT_SHA) — restorable via: git push origin $CURRENT_SHA:refs/heads/$branch"
218
+ else
219
+ # Lease rejected — the branch's remote tip moved since the snapshot (a new push
220
+ # landed mid-loop). Do NOT retry/force past this: skip and let the next cleanup
221
+ # run re-evaluate it against fresh state.
222
+ RACE_SKIP_COUNT=$((RACE_SKIP_COUNT + 1))
223
+ echo "RACE: origin/$branch tip changed since snapshot ($CURRENT_SHA) — lease rejected, not deleting. Will re-evaluate on next /cleanup run."
224
+ fi
225
+ elif printf '%s\n' "$MERGED_HEADS" | cut -f1 | grep -qxF "$branch"; then
226
+ # Name matches a merged PR's head-ref, but the branch's current tip does not match
227
+ # any merged commit recorded under that name — likely a reused branch name holding
228
+ # new, unmerged work. Skip deletion rather than risk destroying live commits.
229
+ SKIP_COUNT=$((SKIP_COUNT + 1))
230
+ echo "SKIP: origin/$branch name matches a merged PR head-ref but current tip ($CURRENT_SHA) does not match the merged commit — branch name likely reused for new work. Not deleting."
231
+ fi
232
+ done
233
+ echo "Deleted $DELETE_COUNT merged remote branches, skipped $SKIP_COUNT name-matched/SHA-mismatched branches, skipped $RACE_SKIP_COUNT raced branches (tip changed between snapshot and delete) (source of truth: gh pr list --state merged, verified against headRefOid, deletion gated by --force-with-lease)"
234
+ ```
184
235
 
185
- # Delete them
186
- if [ "$MERGED_COUNT" -gt 0 ]; then
187
- git branch -r --merged origin/{STAGING_BRANCH} | grep -E "origin/(fix|feat)/" | sed 's|origin/||' | xargs -I{} git push origin --delete {} 2>&1
188
- fi
236
+ **Note**: This can take a while for large batches (1 network call per deleted branch, plus one batched `gh pr list` call). Run in background if > 20 branches.
237
+
238
+ **One-time backfill for existing stale branches**: repos that adopted this fix after already accumulating stale `feat/*`/`fix/*` branches (e.g. from milestones that shipped long ago) should run `/cleanup branches` once manually — the PR-state query above will catch the full backlog in a single pass since it isn't scoped to "this batch" or "this session," it queries all merged PRs on the repo.
239
+
240
+ **Recovery: restoring an accidentally pruned branch**
241
+
242
+ Phase 3C only ever deletes a branch whose (name, SHA) pair matched a merged PR's `headRefOid`, so the exact deleted commit is always recoverable: its SHA is echoed in the run log above (`Deleted origin/$branch (was $CURRENT_SHA) ...`), and the commit object survives in the remote's object store until garbage collection (for merge-commit merges it also remains reachable from the PR's merge commit; for squash merges the original tip becomes a dangling commit — not reachable by ancestry, but still restorable by SHA for as long as it hasn't been GC'd). If a branch is pruned in error (or needs to be recreated for any reason), restore it with:
243
+
244
+ ```bash
245
+ # Using the SHA logged at deletion time (or from `gh pr view {PR_NUMBER} --json headRefOid`
246
+ # if the run log is no longer available):
247
+ git push origin <sha>:refs/heads/<branch>
189
248
  ```
190
249
 
191
- **Note**: This can take a while for large batches (1 network call per branch). Run in background if > 20 branches.
250
+ Alternatively, GitHub itself offers a one-click **"Restore branch"** button on the merged PR's page (shown on the "<branch> was deleted" banner) for a limited window after deletion — typically available for as long as the underlying ref data hasn't been garbage-collected, often a few weeks. This is the fastest option when working from the GitHub UI rather than a local clone.
251
+
252
+ Both options only apply to branches deleted by this phase (or by GitHub's own merge/delete UI) — a SHA is not captured for branches removed by other means (e.g. manual `git push origin --delete` run outside of `/cleanup`), so recovery there depends on `git reflog` on a clone that still has the ref, or GitHub's audit log.
192
253
 
193
254
  ---
194
255
 
195
- ## Phase 4: Milestone Hygiene
256
+ ## Phase 4: Milestone Hygiene (Advisory Only)
257
+
258
+ **NEVER close milestones in this phase.** Milestones are only closed by `/milestone ship` (human-gated) or `/milestone close` (explicit abandonment). Incorrect closure destroys milestone state that cannot be trivially recovered. <!-- fix: forge#1160 -->
196
259
 
197
- ### 4A: Find completed milestones
260
+ ### 4A: Find milestones with 0 open issues
198
261
 
199
262
  ```bash
200
263
  # List open milestones with 0 remaining issues
201
264
  gh api repos/:owner/:repo/milestones --jq '.[] | select(.state == "open" and .open_issues == 0) | "\(.number) | \(.title) | open:\(.open_issues) closed:\(.closed_issues)"'
202
265
  ```
203
266
 
204
- ### 4B: Close completed milestones (shipping-verified only)
267
+ ### 4B: Report findings (DO NOT close)
205
268
 
206
- For each milestone with 0 open issues, verify the milestone branch has been merged to staging or main before closing:
269
+ For each milestone with 0 open issues, report it as a candidate for shipping or closing — but take no action:
207
270
 
208
271
  ```bash
209
- # Derive slug from milestone title (lowercase, spaces→hyphens, strip non-alphanumeric except hyphens)
210
- SLUG=$(echo "$MILESTONE_TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
211
-
212
- # Check if milestone branch was merged to staging or main
213
- SHIPPED_STAGING=$(gh pr list --base staging --head "milestone/$SLUG" --state merged --json number --jq '.[0].number' 2>/dev/null)
214
- SHIPPED_MAIN=$(gh pr list --base main --head "milestone/$SLUG" --state merged --json number --jq '.[0].number' 2>/dev/null)
215
-
216
- if [ -n "$SHIPPED_STAGING" ] || [ -n "$SHIPPED_MAIN" ]; then
217
- # Safe to close — code has been shipped to staging or main
218
- gh api repos/:owner/:repo/milestones/$MILESTONE_NUMBER -X PATCH -f state=closed
219
- echo "CLOSED milestone: $MILESTONE_TITLE (shipped via PR #${SHIPPED_STAGING:-$SHIPPED_MAIN})"
220
- else
221
- # DO NOT close — code is only on the milestone branch, not yet shipped
222
- echo "SKIPPED: $MILESTONE_TITLE — issues done but milestone branch not merged to staging or main"
223
- fi
272
+ echo "ADVISORY: $MILESTONE_TITLE — 0 open issues, $CLOSED_ISSUES closed"
273
+ echo " → To ship: /milestone ship $SLUG"
274
+ echo " → To abandon: /milestone close $SLUG"
224
275
  ```
225
276
 
226
- **Exception**: Don't close milestones that are intentionally kept open for future work (check if the milestone description says "ongoing" or "rolling"). If unsure, **SKIP it** — milestones are only closed by `/milestone ship` after code reaches staging. Incorrect closure destroys milestone state that cannot be trivially recovered.
277
+ Do NOT call `gh api ... -X PATCH -f state=closed` on any milestone. This phase is informational only.
227
278
 
228
279
  ---
229
280
 
@@ -281,15 +332,10 @@ fi
281
332
  | Local branches deleted | {N} |
282
333
  | Remote branches deleted | {N} |
283
334
 
284
- ### Milestones Closed
285
- | Milestone | Issues (closed) | Shipped via |
286
- |-----------|-----------------|-------------|
287
- | {title} | {N} | PR #{M} → staging/main |
288
-
289
- ### Milestones Skipped (unshipped)
290
- | Milestone | Issues (closed) | Reason |
291
- |-----------|-----------------|--------|
292
- | {title} | {N} | No merged PR from milestone/{slug} → staging or main |
335
+ ### Milestones Ready to Ship (advisory — no action taken)
336
+ | Milestone | Issues (closed) | Recommended Action |
337
+ |-----------|-----------------|-------------------|
338
+ | {title} | {N} | `/milestone ship {slug}` or `/milestone close {slug}` |
293
339
 
294
340
  ### Board Synced
295
341
  | Action | Count |
@@ -189,10 +189,14 @@ if [ -n "$MIGRATIONS" ]; then
189
189
  echo "⚠️ DATABASE MIGRATIONS INCLUDED:"
190
190
  echo "$MIGRATIONS"
191
191
  # Show migration content for review
192
- for m in $MIGRATIONS; do
192
+ # $MIGRATIONS is one path per line (`git diff --name-only`) — herestring,
193
+ # not a piped `| while read`, for consistency with the newline-safe pattern
194
+ # used elsewhere in this sweep.
195
+ while IFS= read -r m; do
196
+ [ -z "$m" ] && continue
193
197
  echo "--- $m ---"
194
198
  git show origin/$SOURCE:$m 2>/dev/null | head -30
195
- done
199
+ done <<< "$MIGRATIONS"
196
200
  fi
197
201
  ```
198
202