forgedock 1.0.6 → 1.0.7

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.
package/bin/forgedock.mjs CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  import os from "os";
4
4
  import { fileURLToPath } from "url";
5
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
5
+ import {
6
+ basename,
7
+ dirname,
8
+ isAbsolute,
9
+ join,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from "path";
6
14
  import {
7
15
  mkdir,
8
16
  symlink,
@@ -2211,7 +2219,9 @@ async function _buildForgeDockBlock() {
2211
2219
  if (!description) continue;
2212
2220
 
2213
2221
  // Derive display name: relative path from COMMANDS_DIR, remove .md extension
2214
- const rel = relative(COMMANDS_DIR, file).replace(/\\/g, "/").replace(/\.md$/, "");
2222
+ const rel = relative(COMMANDS_DIR, file)
2223
+ .replace(/\\/g, "/")
2224
+ .replace(/\.md$/, "");
2215
2225
  commands.push({ name: rel, description });
2216
2226
  }
2217
2227
 
@@ -2274,28 +2284,51 @@ function _injectManagedBlock(filePath, blockContent, createIfMissing) {
2274
2284
 
2275
2285
  let existing = readFileSync(filePath, "utf-8");
2276
2286
 
2277
- if (existing.includes(CLAUDE_BLOCK_BEGIN) && existing.includes(CLAUDE_BLOCK_END)) {
2278
- // Both markers present — replace existing block in place
2279
- const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2287
+ if (
2288
+ existing.includes(CLAUDE_BLOCK_BEGIN) &&
2289
+ existing.includes(CLAUDE_BLOCK_END)
2290
+ ) {
2291
+ // Both markers present — replace existing block in place.
2292
+ // Guard: if markers are reversed (END before BEGIN) the regex finds no
2293
+ // forward-spanning match; fall through to the repair path instead of
2294
+ // writing the file unchanged and returning a false 'updated'. <!-- Added: forge#291 -->
2295
+ const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(
2296
+ /[.*+?^${}()|[\]\\]/g,
2297
+ "\\$&",
2298
+ );
2280
2299
  const escaped_end = CLAUDE_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2281
2300
  const blockRegex = new RegExp(
2282
2301
  `${escaped_begin}[\\s\\S]*?${escaped_end}`,
2283
2302
  "g",
2284
2303
  );
2285
- existing = existing.replace(blockRegex, managed);
2286
- writeFileSync(filePath, existing, "utf-8");
2287
- return "updated";
2288
- }
2289
-
2290
- if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2304
+ const replaced = existing.replace(blockRegex, managed);
2305
+ if (replaced !== existing) {
2306
+ writeFileSync(filePath, replaced, "utf-8");
2307
+ return "updated";
2308
+ }
2309
+ // Regex found no match (e.g. reversed markers) — strip both orphaned
2310
+ // markers below and fall through to the append path to repair the file.
2311
+ existing = existing
2312
+ .replace(CLAUDE_BLOCK_BEGIN, "")
2313
+ .replace(CLAUDE_BLOCK_END, "")
2314
+ .trimEnd();
2315
+ } else if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2291
2316
  // BEGIN present but END missing — file is truncated or malformed.
2292
2317
  // Strip the orphaned marker and everything after it, then fall through
2293
2318
  // to the append path below so the complete block is written correctly.
2294
- existing = existing.slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN)).trimEnd();
2319
+ existing = existing
2320
+ .slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN))
2321
+ .trimEnd();
2322
+ } else if (existing.includes(CLAUDE_BLOCK_END)) {
2323
+ // END present but BEGIN missing — orphaned END marker left by a previous
2324
+ // failed write. Strip it and fall through to append the complete block.
2325
+ // <!-- Added: forge#291 -->
2326
+ existing = existing.replace(CLAUDE_BLOCK_END, "").trimEnd();
2295
2327
  }
2296
2328
 
2297
2329
  // Append block to existing content, separated by a blank line
2298
- const separator = existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2330
+ const separator =
2331
+ existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2299
2332
  writeFileSync(filePath, `${existing}${separator}${managed}\n`, "utf-8");
2300
2333
  return "updated";
2301
2334
  }
@@ -2324,9 +2357,13 @@ async function injectClaudeMd(cwd) {
2324
2357
 
2325
2358
  const claudeResult = _injectManagedBlock(claudePath, blockContent, true);
2326
2359
  if (claudeResult === "created") {
2327
- console.log(` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`);
2360
+ console.log(
2361
+ ` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`,
2362
+ );
2328
2363
  } else {
2329
- console.log(` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`);
2364
+ console.log(
2365
+ ` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`,
2366
+ );
2330
2367
  }
2331
2368
 
2332
2369
  if (existsSync(agentsPath)) {
@@ -3421,9 +3458,7 @@ async function labelsSetup(repo) {
3421
3458
  const manifestPath = join(__dirname, "labels.json");
3422
3459
 
3423
3460
  if (!existsSync(manifestPath)) {
3424
- console.log(
3425
- `${RED}Label manifest not found: ${manifestPath}${RESET}`,
3426
- );
3461
+ console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
3427
3462
  process.exit(1);
3428
3463
  }
3429
3464
 
@@ -3543,21 +3578,54 @@ async function docsInit() {
3543
3578
 
3544
3579
  // Security: assert targetDir is confined to the project directory.
3545
3580
  // Prevents path traversal via ../.. sequences, absolute paths, and symlinks in devdocs.path.
3546
- // Use realpath() to dereference symlinks — resolve() does not follow them, so a symlink
3547
- // pre-placed inside the project (e.g. devdocs -> /etc) would bypass a resolve()-only check.
3548
- // Fall back to resolve() when targetDir does not yet exist (no symlink to dereference).
3581
+ //
3582
+ // Two hardening steps beyond the initial check:
3583
+ //
3584
+ // 1. realpath(cwd) — on Windows, process.cwd() may return a directory-junction path while
3585
+ // realpath() returns the canonical target. Comparing a canonical resolvedTarget against
3586
+ // a non-canonical cwd would false-reject valid paths (BUG-3).
3587
+ //
3588
+ // 2. Walk-up-to-existing-ancestor resolution on ENOENT — when the target path does not yet
3589
+ // exist, falling back to lexical resolve() fails to follow intermediate symlinks. A
3590
+ // symlink planted at "sub/" pointing outside the project would not be dereferenced, so
3591
+ // the lexical result would appear inside the project and the containment check would
3592
+ // pass, allowing mkdir() to write through the symlink (SEC-1). Calling realpath() only
3593
+ // one level up ("parent-first") still fails when the parent also does not exist (e.g.,
3594
+ // devdocs.path = "sub/mid/leaf" where "sub" is a symlink but "sub/mid" is absent) —
3595
+ // realpath("sub/mid") follows sub then ENOENT-s on mid, and the lexical fallback does
3596
+ // not dereference sub (SEC-2). Instead, walk up the directory hierarchy until finding
3597
+ // an ancestor that exists, realpath() it to dereference any symlinks at that level, then
3598
+ // reattach all collected suffix segments.
3599
+ const realCwd = await realpath(cwd).catch(() => resolve(cwd));
3549
3600
  let resolvedTarget;
3550
3601
  try {
3551
3602
  resolvedTarget = await realpath(targetDir);
3552
3603
  } catch {
3604
+ // targetDir does not exist — walk up the hierarchy to the nearest existing ancestor,
3605
+ // dereference it via realpath() to follow any intermediate symlinks, then reattach the
3606
+ // collected suffix segments. Initialise resolvedTarget to the lexical path as a
3607
+ // last-resort sentinel (overwritten by the loop on the first successful realpath call).
3608
+ const trailSegments = [basename(targetDir)];
3609
+ let ancestor = dirname(targetDir);
3553
3610
  resolvedTarget = resolve(targetDir);
3611
+ while (true) {
3612
+ const parent = dirname(ancestor);
3613
+ if (parent === ancestor) break; // reached filesystem root — keep lexical sentinel
3614
+ try {
3615
+ resolvedTarget = join(await realpath(ancestor), ...trailSegments);
3616
+ break;
3617
+ } catch {
3618
+ trailSegments.unshift(basename(ancestor));
3619
+ ancestor = parent;
3620
+ }
3621
+ }
3554
3622
  }
3555
- if (resolvedTarget !== cwd && !resolvedTarget.startsWith(cwd + sep)) {
3623
+ if (resolvedTarget !== realCwd && !resolvedTarget.startsWith(realCwd + sep)) {
3556
3624
  console.log(
3557
3625
  `${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
3558
3626
  );
3559
3627
  console.log(` Resolved: ${resolvedTarget}`);
3560
- console.log(` Project: ${cwd}`);
3628
+ console.log(` Project: ${realCwd}`);
3561
3629
  console.log(
3562
3630
  ` Fix: set ${CYAN}devdocs.path${RESET} to a relative path inside your project (e.g. "devdocs" or "docs/knowledge").`,
3563
3631
  );
@@ -3568,12 +3636,8 @@ async function docsInit() {
3568
3636
  const templatesDir = join(FORGE_HOME, "templates", "devdocs");
3569
3637
 
3570
3638
  if (!existsSync(templatesDir)) {
3571
- console.log(
3572
- `${RED}DevDocs templates not found: ${templatesDir}${RESET}`,
3573
- );
3574
- console.log(
3575
- ` This usually means ForgeDock is not installed correctly.`,
3576
- );
3639
+ console.log(`${RED}DevDocs templates not found: ${templatesDir}${RESET}`);
3640
+ console.log(` This usually means ForgeDock is not installed correctly.`);
3577
3641
  console.log(
3578
3642
  ` Try: ${CYAN}npm install -g forgedock${RESET} or ${CYAN}npx forgedock@latest docs init${RESET}`,
3579
3643
  );
@@ -4083,7 +4147,11 @@ if (!command) {
4083
4147
  break;
4084
4148
  case "labels": {
4085
4149
  const subcommand = args[1];
4086
- if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
4150
+ if (
4151
+ !subcommand ||
4152
+ subcommand === "setup" ||
4153
+ subcommand.startsWith("--")
4154
+ ) {
4087
4155
  const subArgs = args.slice(1);
4088
4156
  const repo = resolveLabelsRepo(subArgs);
4089
4157
  if (!repo) {
@@ -4112,9 +4180,7 @@ if (!command) {
4112
4180
  await docsInit();
4113
4181
  } else {
4114
4182
  console.log(`${RED}Unknown docs subcommand: ${docsSubcommand}${RESET}`);
4115
- console.log(
4116
- `Usage: ${CYAN}npx forgedock docs init${RESET}`,
4117
- );
4183
+ console.log(`Usage: ${CYAN}npx forgedock docs init${RESET}`);
4118
4184
  process.exit(1);
4119
4185
  }
4120
4186
  break;
@@ -11,6 +11,7 @@ argument-hint: [--fix | --recon-only | --fix --limit 5 | --dry-run]
11
11
  - `{CREDENTIALS_FILE}` ← `paths.credentials.file` (optional) — path to credentials YAML for analytics APIs
12
12
  - `{SERVER_SSH}` ← `services.server_ssh` (optional) — SSH target for production server health checks (e.g., `ubuntu@1.2.3.4`)
13
13
  - `{EMEMO_PATH}` ← `services.ememo_path` (optional) — path on production server to open eMemo files
14
+ - `{BILLING_ENABLED}` ← `billing.enabled` (optional, default `false`) — set to `true` to enable Stripe data collection in the Analytics Snapshot agent
14
15
 
15
16
  You are an autonomous improvement engine for this project. Your job is to **find what's wrong, create trackable issues, and optionally fix the highest-impact ones** — all in a single cycle. Every cycle leaves the platform measurably better than before.
16
17
 
@@ -51,9 +52,15 @@ DRY_RUN = true if --dry-run present
51
52
  ```bash
52
53
  echo "=== Autopilot Cycle: $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
53
54
 
55
+ # Portable ISO-8601 date arithmetic using python3 (works on Windows, macOS, Linux).
56
+ # Replaces GNU-only `date -u -d 'N days ago'` which fails on Windows and macOS.
57
+ DATE_3D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=3)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
58
+ DATE_7D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=7)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
59
+ DATE_1D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
60
+
54
61
  # Recent closed issues (last 3 days) — what was fixed recently?
55
62
  gh issue list --state closed --json number,title,labels,closedAt \
56
- --jq '[.[] | select(.closedAt > "'$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ)'")] | length' 2>/dev/null || echo "0"
63
+ --jq '[.[] | select(.closedAt > "'"$DATE_3D_AGO"'")] | length' 2>/dev/null || echo "0"
57
64
 
58
65
  # Open issue count by priority
59
66
  gh issue list --state open --label "priority:P0" --json number --jq 'length'
@@ -64,12 +71,12 @@ gh issue list --state open --label "priority:P2" --json number --jq 'length'
64
71
  gh issue list --state open --limit 200 --json number,title,labels,createdAt \
65
72
  --jq '[.[] | select(
66
73
  (.labels | map(.name) | any(startswith("workflow:")) | not) and
67
- (.createdAt < "'$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)'")
74
+ (.createdAt < "'"$DATE_7D_AGO"'")
68
75
  )] | length'
69
76
 
70
77
  # Failed CI runs in last 24h
71
78
  gh run list --limit 30 --json conclusion,createdAt \
72
- --jq '[.[] | select(.conclusion == "failure" and .createdAt > "'$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)'")] | length'
79
+ --jq '[.[] | select(.conclusion == "failure" and .createdAt > "'"$DATE_1D_AGO"'")] | length'
73
80
  ```
74
81
 
75
82
  Store these as `BASELINE` metrics for the cycle report.
@@ -140,8 +147,9 @@ Analyze GitHub issue backlog:
140
147
  Quick analytics pulse — just the key metrics, not a full audit:
141
148
  1. Read credentials from {CREDENTIALS_FILE} (set via paths.credentials.file in forge.yaml)
142
149
  2. GSC: mcp__gsc__search_analytics for last 7 days — total clicks, impressions, avg position
143
- 3. Stripe: mcp__stripe__retrieve_balance — current balance
144
- 4. Return: clicks trend (up/down), any revenue, notable changes
150
+ 3. Stripe (only if {BILLING_ENABLED} is true): mcp__stripe__retrieve_balance — current balance.
151
+ Skip this step entirely if {BILLING_ENABLED} is false — do NOT call any Stripe MCP tools.
152
+ 4. Return: clicks trend (up/down), revenue (or "N/A — billing.enabled: false" if skipped), notable changes
145
153
  ```
146
154
 
147
155
  ### 1B: Collect results
@@ -291,7 +299,7 @@ Print a structured report:
291
299
 
292
300
  ### Analytics Pulse
293
301
  - Clicks (7d): {N} ({trend})
294
- - Revenue: ${N}
302
+ - Revenue: ${N} (or "N/A — billing.enabled: false" if Stripe step was skipped)
295
303
 
296
304
  ### Actions Taken
297
305
  - Issues created: {count} ({list with numbers})
@@ -25,11 +25,16 @@ GH_FLAG="-R $GH_REPO"
25
25
  REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE")
26
26
  STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
27
27
  PROJECT_BOARD_OWNER=$(yq '.project_board.owner // .project.owner' "$CONFIG_FILE")
28
- PROJECT_NUMBER=$(yq '.project_board.project_number // "1"' "$CONFIG_FILE")
29
- PROJECT_ID=$(yq '.project_board.project_id' "$CONFIG_FILE")
28
+ PROJECT_NUMBER=$(yq '.project_board.project_number // empty' "$CONFIG_FILE" 2>/dev/null || echo "")
29
+ PROJECT_ID=$(yq '.project_board.project_id // empty' "$CONFIG_FILE" 2>/dev/null || echo "")
30
+ # Project board field and option IDs — empty string when project_board section is absent
31
+ STATUS_FIELD_ID=$(yq '.project_board.field_ids.status // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
32
+ WORKFLOW_FIELD_ID=$(yq '.project_board.field_ids.workflow // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
33
+ STATUS_DONE_OPTION_ID=$(yq '.project_board.option_ids.status.done // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
34
+ WORKFLOW_MERGED_OPTION_ID=$(yq '.project_board.option_ids.workflow.merged // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
30
35
  ```
31
36
 
32
- All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD_OWNER}`, `{PROJECT_NUMBER}`, and `{PROJECT_ID}` references below are populated from `forge.yaml`.
37
+ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD_OWNER}`, `{PROJECT_NUMBER}`, `{PROJECT_ID}`, `{STATUS_FIELD_ID}`, `{WORKFLOW_FIELD_ID}`, `{STATUS_DONE_OPTION_ID}`, and `{WORKFLOW_MERGED_OPTION_ID}` references below are populated from `forge.yaml`.
33
38
 
34
39
  ---
35
40
 
@@ -222,19 +227,29 @@ fi
222
227
 
223
228
  ## Phase 5: Sync Project Board (if `board` or `all`)
224
229
 
225
- For closed issues that are on the Project board but have stale fields:
230
+ **Skip if `project_board` is not configured** — check resolved vars before proceeding:
226
231
 
227
232
  ```bash
228
- gh project item-list {PROJECT_NUMBER} --owner {PROJECT_BOARD_OWNER} --format json --limit 200
229
- ```
233
+ if [ -z "$PROJECT_BOARD_OWNER" ] || [ -z "$PROJECT_ID" ] || [ -z "$PROJECT_NUMBER" ]; then
234
+ echo "INFO: project_board not configured in forge.yaml — skipping board sync"
235
+ else
236
+ # Board sync: mark closed issues as Done/Merged on the project board
230
237
 
231
- For each item where `content.state == "CLOSED"` but `status != "Done"` or `workflow` is not a terminal state:
238
+ # List all board items
239
+ ITEMS=$(gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_BOARD_OWNER" --format json --limit 200)
232
240
 
233
- ```bash
234
- gh project item-edit --project-id {PROJECT_ID} --id "$ITEM_ID" \
235
- --field-id {PROJECT_BOARD_STATUS_FIELD_ID} --single-select-option-id {PROJECT_BOARD_STATUS_DONE_ID} 2>/dev/null || true # Status=Done
236
- gh project item-edit --project-id {PROJECT_ID} --id "$ITEM_ID" \
237
- --field-id {PROJECT_BOARD_WORKFLOW_FIELD_ID} --single-select-option-id {PROJECT_BOARD_WORKFLOW_MERGED_ID} 2>/dev/null || true # Workflow=Merged
241
+ # For each item where content.state == "CLOSED" but status != "Done" or workflow is not terminal:
242
+ echo "$ITEMS" | jq -r '.items[] | select(.content.state == "CLOSED") | .id' | while read -r ITEM_ID; do
243
+ if [ -n "$STATUS_FIELD_ID" ] && [ -n "$STATUS_DONE_OPTION_ID" ]; then
244
+ gh project item-edit --project-id "$PROJECT_ID" --id "$ITEM_ID" \
245
+ --field-id "$STATUS_FIELD_ID" --single-select-option-id "$STATUS_DONE_OPTION_ID" 2>/dev/null || true # Status=Done
246
+ fi
247
+ if [ -n "$WORKFLOW_FIELD_ID" ] && [ -n "$WORKFLOW_MERGED_OPTION_ID" ]; then
248
+ gh project item-edit --project-id "$PROJECT_ID" --id "$ITEM_ID" \
249
+ --field-id "$WORKFLOW_FIELD_ID" --single-select-option-id "$WORKFLOW_MERGED_OPTION_ID" 2>/dev/null || true # Workflow=Merged
250
+ fi
251
+ done
252
+ fi
238
253
  ```
239
254
 
240
255
  ---
@@ -10,6 +10,8 @@ argument-hint: [staging | milestone/{slug} | compare {branch}]
10
10
  **Config variables used by this command** (set in `forge.yaml`):
11
11
  - `{REPO_PATH}` ← `paths.root` — project repository root
12
12
  - `{HEALTH_ENDPOINT}` ← `services.health_endpoint` (optional) — URL used in post-deploy health check
13
+ - `{DEPLOY_WORKFLOW}` ← `deploy.workflow` (optional) — GitHub Actions workflow filename to trigger a deploy (e.g., `hotfix-deploy.yml`). When absent or empty, workflow-trigger steps are omitted.
14
+ - `{DEPLOY_SECRETS_BACKEND}` ← `deploy.secrets_backend` (optional) — secrets delivery method (`sops`, `aws-sm`, `vault`, `ci-env`, `none`). Controls whether secret-chain checks run.
13
15
 
14
16
  You are the pipeline's deploy awareness layer. Before the user merges staging → main (triggering CI/CD deployment), this command shows exactly what's going out: which PRs, which issues, what changed, and what risks exist.
15
17
 
@@ -153,13 +155,20 @@ done
153
155
  # Full file diff summary
154
156
  git diff --stat origin/$TARGET..origin/$SOURCE
155
157
 
158
+ # Read layout paths from forge.yaml, fall back to AlterLab defaults
159
+ LAYOUT_API=$(yq '.review.layout.api // "services/api"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "services/api")
160
+ LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "services/worker")
161
+ LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "web/src/app")
162
+ # Derive web root from pages root (strip trailing /src/app or similar suffix if present)
163
+ LAYOUT_WEB=$(echo "$LAYOUT_PAGES" | sed 's|/src/app$||; s|/src$||')
164
+
156
165
  # Categorize by service
157
166
  echo "=== By Service ==="
158
167
  git diff --name-only origin/$TARGET..origin/$SOURCE | sort | while read f; do
159
168
  case "$f" in
160
- services/api/*) echo "API: $f" ;;
161
- services/worker/*) echo "WORKER: $f" ;;
162
- web/*) echo "WEB: $f" ;;
169
+ ${LAYOUT_API}/*) echo "API: $f" ;;
170
+ ${LAYOUT_WORKER}/*) echo "WORKER: $f" ;;
171
+ ${LAYOUT_WEB}/*) echo "WEB: $f" ;;
163
172
  shared/*) echo "SHARED: $f" ;;
164
173
  infra/*) echo "INFRA: $f" ;;
165
174
  *) echo "OTHER: $f" ;;
@@ -170,8 +179,11 @@ done | sort
170
179
  ### Step 2E: Migration check
171
180
 
172
181
  ```bash
182
+ # Read migrations path from forge.yaml, fall back to infra/migrations
183
+ LAYOUT_MIGRATIONS=$(yq '.review.layout.migrations // "infra/migrations"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "infra/migrations")
184
+
173
185
  # Are there new migrations?
174
- MIGRATIONS=$(git diff --name-only origin/$TARGET..origin/$SOURCE | grep "^infra/migrations/" | sort)
186
+ MIGRATIONS=$(git diff --name-only origin/$TARGET..origin/$SOURCE | grep "^${LAYOUT_MIGRATIONS}/" | sort)
175
187
  if [ -n "$MIGRATIONS" ]; then
176
188
  echo "⚠️ DATABASE MIGRATIONS INCLUDED:"
177
189
  echo "$MIGRATIONS"
@@ -195,7 +207,7 @@ Analyze the deploy payload and flag risks:
195
207
  |--------|-----------|--------|
196
208
  | Database migrations present | MEDIUM | Verify migrations are reversible |
197
209
  | `shared/` changes | LOW | Restart-only deploy possible (no rebuild) |
198
- | `.env.example` changes | HIGH | Verify SOPS + decrypt-secrets has new vars |
210
+ | `.env.example` changes | HIGH | Verify secrets backend has new vars (see `deploy.secrets_backend`) |
199
211
  | `docker-compose.prod.yml` changes | HIGH | Review infrastructure changes carefully |
200
212
  | `infra/traefik/` changes | HIGH | Routing changes — test before full deploy |
201
213
  | 10+ PRs in one deploy | MEDIUM | Consider deploying in smaller batches |
@@ -205,17 +217,22 @@ Analyze the deploy payload and flag risks:
205
217
  ### Automated Checks
206
218
 
207
219
  ```bash
208
- # Check for new env vars that might not be in SOPS
220
+ # Check for new env vars that might not be in the configured secrets backend
209
221
  NEW_ENV=$(git diff origin/$TARGET..origin/$SOURCE -- '.env.example' | grep "^+" | grep -v "^+++" | grep -v "^#")
210
222
  if [ -n "$NEW_ENV" ]; then
211
- echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in SOPS:"
223
+ echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in your secrets backend ({deploy.secrets_backend}):"
212
224
  echo "$NEW_ENV"
213
- # Cross-check with decrypt-secrets.sh
214
- for var in $(echo "$NEW_ENV" | grep -oP '^\+\K[A-Z_]+'); do
215
- if ! grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null; then
216
- echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
217
- fi
218
- done
225
+ # Cross-check with SOPS chain (only when deploy.secrets_backend == "sops")
226
+ # If your project uses a different backend, skip this block and verify manually.
227
+ if [ "{deploy.secrets_backend}" = "sops" ]; then
228
+ for var in $(echo "$NEW_ENV" | grep -oP '^\+\K[A-Z_]+'); do
229
+ if ! grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null; then
230
+ echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
231
+ fi
232
+ done
233
+ else
234
+ echo " ℹ️ SOPS chain check skipped — deploy.secrets_backend is '{deploy.secrets_backend}'. Verify new vars in your configured backend."
235
+ fi
219
236
  fi
220
237
  ```
221
238
 
@@ -232,7 +249,7 @@ Generate a checklist based on what's in the deploy:
232
249
  - [ ] All PRs in this deploy have been reviewed
233
250
  - [ ] No open `needs-human` issues blocking deploy
234
251
  - [ ] {If migrations} Database backup verified
235
- - [ ] {If new env vars} SOPS secrets updated and decrypt-secrets.sh has mapping
252
+ - [ ] {If new env vars} Secrets backend ({deploy.secrets_backend}) updated with new vars; verify via your configured secrets chain
236
253
  - [ ] {If traefik changes} Routing tested locally
237
254
 
238
255
  ### Deploy Command
@@ -240,8 +257,9 @@ Generate a checklist based on what's in the deploy:
240
257
  # Standard deploy (merge staging → main via GitHub web UI)
241
258
  # CI/CD auto-triggers on push to main
242
259
 
243
- # Or manual trigger for specific services:
244
- gh workflow run hotfix-deploy.yml --ref main -f services={affected_services} -f reason="Deploy: {summary}"
260
+ # Or manual trigger for specific services (requires deploy.workflow set in forge.yaml):
261
+ gh workflow run {deploy.workflow} --ref main -f {deploy.workflow_inputs.services}={affected_services} -f {deploy.workflow_inputs.reason}="Deploy: {summary}"
262
+ # If deploy.workflow is not configured, deploy via the GitHub web UI or your CI/CD provider.
245
263
  ```
246
264
 
247
265
  ### Post-Deploy
@@ -292,5 +310,6 @@ gh workflow run hotfix-deploy.yml --ref main -f services={affected_services} -f
292
310
  ---
293
311
 
294
312
  **Ready to deploy?** Merge staging → main via GitHub web UI, or run:
295
- `gh workflow run hotfix-deploy.yml --ref staging -f services={services} -f reason="Batch deploy"`
313
+ `gh workflow run {deploy.workflow} --ref staging -f {deploy.workflow_inputs.services}={services} -f {deploy.workflow_inputs.reason}="Batch deploy"`
314
+ *(requires `deploy.workflow` set in `forge.yaml`; omit this line if not configured)*
296
315
  ```
@@ -9,6 +9,7 @@ argument-hint: [issue number | "active" | "postmortem {issue}"]
9
9
 
10
10
  **Config variables used by this command** (set in `forge.yaml`):
11
11
  - `{REPO_PATH}` ← `paths.root` — project repository root
12
+ - `{DEPLOY_WORKFLOW}` ← `deploy.workflow` (optional) — GitHub Actions workflow filename for hotfix deploys (e.g., `hotfix-deploy.yml`). When absent or empty, workflow-trigger and workflow-monitor steps are omitted.
12
13
 
13
14
  You are the pipeline's incident response coordinator. When production goes down or a critical bug surfaces, this command orchestrates the response: validates the hotfix before deploy, reconstructs the incident timeline, and produces a post-incident analysis.
14
15
 
@@ -55,8 +56,9 @@ Parse the issue to understand:
55
56
  gh pr list --state merged --base main --json number,title,mergedAt \
56
57
  --jq '[.[] | select(.mergedAt > (now - 86400 | todate))] | .[] | "\(.number) | \(.title) | \(.mergedAt)"'
57
58
 
58
- # Check recent hotfix deploys
59
- gh run list --workflow=hotfix-deploy.yml --limit 5 --json databaseId,status,conclusion,createdAt,displayTitle
59
+ # Check recent hotfix deploys (requires deploy.workflow set in forge.yaml)
60
+ # Skip this check if deploy.workflow is not configured.
61
+ gh run list --workflow={deploy.workflow} --limit 5 --json databaseId,status,conclusion,createdAt,displayTitle
60
62
  ```
61
63
 
62
64
  #### Step 1C: Post incident acknowledgment
@@ -119,9 +121,10 @@ Your job:
119
121
  - Does it remove functionality other code depends on?
120
122
  - Are there syntax errors?
121
123
  - Does it change DB schema without a migration?
122
- 4. Run compile checks:
123
- - Python: python -m py_compile on changed .py files
124
- - TypeScript: npx tsc --noEmit (if web/ files changed)
124
+ 4. Run compile checks (read forge.yaml → verification.commands for tool commands):
125
+ - Python: always run python -m py_compile on changed .py files (no config needed)
126
+ - Python format/lint: run verification.commands.python.format and .lint if configured; log "SKIPPED — not configured in verification.commands" if absent
127
+ - TypeScript: run verification.commands.typescript.typecheck if configured; log "SKIPPED — not configured in verification.commands" if absent
125
128
  5. Check if the fix is scoped tightly (minimal changes for the incident)
126
129
 
127
130
  Output:
@@ -143,10 +146,11 @@ gh issue comment {NUMBER} --body "$(cat <<'EOF'
143
146
  **Confidence**: {level}
144
147
 
145
148
  {If safe}:
146
- **Ready for deploy.** Run:
149
+ **Ready for deploy.** Run (requires `deploy.workflow` set in `forge.yaml`):
147
150
  ```
148
- gh workflow run hotfix-deploy.yml --ref {FIX_BRANCH} -f services={affected_services} -f reason="P0: {TITLE}"
151
+ gh workflow run {deploy.workflow} --ref {FIX_BRANCH} -f {deploy.workflow_inputs.services}={affected_services} -f {deploy.workflow_inputs.reason}="P0: {TITLE}"
149
152
  ```
153
+ *(If `deploy.workflow` is not configured, merge the fix branch via the GitHub web UI or your CI/CD provider.)*
150
154
 
151
155
  {If not safe}:
152
156
  **Concerns found:**
@@ -164,8 +168,9 @@ EOF
164
168
  #### Step 3A: Watch the deploy workflow
165
169
 
166
170
  ```bash
167
- # Find the most recent hotfix-deploy run
168
- RUN_ID=$(gh run list --workflow=hotfix-deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')
171
+ # Find the most recent deploy run (requires deploy.workflow set in forge.yaml)
172
+ # Skip if deploy.workflow is not configured.
173
+ RUN_ID=$(gh run list --workflow={deploy.workflow} --limit 1 --json databaseId --jq '.[0].databaseId')
169
174
 
170
175
  # Check status
171
176
  gh run view $RUN_ID --json status,conclusion,jobs
package/commands/issue.md CHANGED
@@ -28,7 +28,7 @@ The user's input (`$ARGUMENTS`) can be:
28
28
  |-----------|---------|
29
29
  | Free-text description | "the billing page crashes when credits hit zero" |
30
30
  | URL to error/log | "https://sentry.io/..." or a pasted stack trace |
31
- | Reference to code | "the scraper queue leaks memory in unified_consumer.py" |
31
+ | Reference to code | "the background worker leaks memory in task_processor.py" |
32
32
  | Feature request | "add a dark mode toggle to the dashboard settings page" |
33
33
  | Multi-repo prefix | "mcp: add list_schemas tool" or "n8n: fix credential refresh" |
34
34
  | Audit/investigation | "investigate why deploy times doubled this week" |
@@ -161,8 +161,8 @@ Format: `{prefix}: {concise description}`
161
161
  Rules:
162
162
  - **Max 80 characters**
163
163
  - Use conventional commit prefixes: `fix:`, `feat:`, `refactor:`, `investigate:`, `docs:`
164
- - Be specific — "fix: billing page crash" is bad, "fix: credit_service division by zero when user credits reach 0" is good
165
- - Include the component/domain if it helps: `fix(scraper): queue leak in unified_consumer heartbeat loop`
164
+ - Be specific — "fix: billing page crash" is bad, "fix: payment_processor.charge() raises ZeroDivisionError when amount == 0" is good
165
+ - Include the component/domain if it helps: `fix(worker): queue leak in task_processor heartbeat loop`
166
166
  - Never use vague words: "improve", "update", "change", "handle" without specifics
167
167
 
168
168
  ### 3B: Compose the body
@@ -258,6 +258,8 @@ Files/areas to examine:
258
258
 
259
259
  ### 3D: Pipeline Issue Template (machine-callable reference)
260
260
 
261
+ > **Canonical Standard**: This template is the single source of truth for all automated issue creation in the ForgeDock pipeline. `milestone.md`, `orchestrate.md`, and `work-on/decompose.md` MUST use this template structure when creating issues programmatically. A shared convention is only useful if all create-paths enforce it — divergent inline templates in each command file cause acceptance-criteria drift and coverage gaps. See `work-on/decompose.md` Phase D0 for the investigation-gate pattern that enforces this standard at decomposition time. <!-- Added: forge#293 -->
262
+
261
263
  When pipeline agents create issues programmatically (not via user input), use this canonical template. All automated `gh issue create` calls across Forge commands MUST include these mandatory sections. Domain-specific sections are additive — preserve them, but wrap them with this structure.
262
264
 
263
265
  ```markdown
@@ -488,10 +490,10 @@ Create all {N} issues? (yes / adjust #N / cancel)
488
490
  | Bad Issue | Why It Fails | Good Version |
489
491
  |-----------|-------------|-------------|
490
492
  | "Fix docker mount" | Which compose file? Which mount? | "fix: `.gemini` volume mount in docker-compose.prod.yml missing rw flag" |
491
- | "Update billing" | What about billing? What's broken? | "fix: credit_service.deduct() raises DivisionByZero when user.credits == 0" |
492
- | "Investigate performance" | Investigate what? Where? | "investigate: API p95 latency doubled since last deploy — trace hot paths in scraping routers" |
493
+ | "Update billing" | What about billing? What's broken? | "fix: payment_processor.charge() raises ZeroDivisionError when amount == 0" |
494
+ | "Investigate performance" | Investigate what? Where? | "investigate: API p95 latency doubled since last deploy — trace hot paths in job processing routers" |
493
495
  | No affected files listed | Investigator guesses wrong files | List every file with what needs to change |
494
- | "Handle edge case" | Which edge case? In what function? | "fix: unified_consumer.process_job() silently drops jobs when queue.length > MAX_BATCH" |
496
+ | "Handle edge case" | Which edge case? In what function? | "fix: task_processor.process_job() silently drops jobs when queue.length > MAX_BATCH" |
495
497
  | Missing override files | Fix is incomplete, deploy fails | Include docker-compose.prod.yml, .env.example, etc. |
496
498
  | P0 for a typo fix | Wastes priority signaling | Use P3 for cosmetic issues |
497
499