forgedock 1.0.6 → 1.0.8

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,9 +2,18 @@
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,
16
+ rm,
8
17
  symlink,
9
18
  copyFile,
10
19
  readlink,
@@ -2211,7 +2220,9 @@ async function _buildForgeDockBlock() {
2211
2220
  if (!description) continue;
2212
2221
 
2213
2222
  // Derive display name: relative path from COMMANDS_DIR, remove .md extension
2214
- const rel = relative(COMMANDS_DIR, file).replace(/\\/g, "/").replace(/\.md$/, "");
2223
+ const rel = relative(COMMANDS_DIR, file)
2224
+ .replace(/\\/g, "/")
2225
+ .replace(/\.md$/, "");
2215
2226
  commands.push({ name: rel, description });
2216
2227
  }
2217
2228
 
@@ -2274,28 +2285,51 @@ function _injectManagedBlock(filePath, blockContent, createIfMissing) {
2274
2285
 
2275
2286
  let existing = readFileSync(filePath, "utf-8");
2276
2287
 
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, "\\$&");
2288
+ if (
2289
+ existing.includes(CLAUDE_BLOCK_BEGIN) &&
2290
+ existing.includes(CLAUDE_BLOCK_END)
2291
+ ) {
2292
+ // Both markers present — replace existing block in place.
2293
+ // Guard: if markers are reversed (END before BEGIN) the regex finds no
2294
+ // forward-spanning match; fall through to the repair path instead of
2295
+ // writing the file unchanged and returning a false 'updated'. <!-- Added: forge#291 -->
2296
+ const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(
2297
+ /[.*+?^${}()|[\]\\]/g,
2298
+ "\\$&",
2299
+ );
2280
2300
  const escaped_end = CLAUDE_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2281
2301
  const blockRegex = new RegExp(
2282
2302
  `${escaped_begin}[\\s\\S]*?${escaped_end}`,
2283
2303
  "g",
2284
2304
  );
2285
- existing = existing.replace(blockRegex, managed);
2286
- writeFileSync(filePath, existing, "utf-8");
2287
- return "updated";
2288
- }
2289
-
2290
- if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2305
+ const replaced = existing.replace(blockRegex, managed);
2306
+ if (replaced !== existing) {
2307
+ writeFileSync(filePath, replaced, "utf-8");
2308
+ return "updated";
2309
+ }
2310
+ // Regex found no match (e.g. reversed markers) — strip both orphaned
2311
+ // markers below and fall through to the append path to repair the file.
2312
+ existing = existing
2313
+ .replace(CLAUDE_BLOCK_BEGIN, "")
2314
+ .replace(CLAUDE_BLOCK_END, "")
2315
+ .trimEnd();
2316
+ } else if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
2291
2317
  // BEGIN present but END missing — file is truncated or malformed.
2292
2318
  // Strip the orphaned marker and everything after it, then fall through
2293
2319
  // to the append path below so the complete block is written correctly.
2294
- existing = existing.slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN)).trimEnd();
2320
+ existing = existing
2321
+ .slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN))
2322
+ .trimEnd();
2323
+ } else if (existing.includes(CLAUDE_BLOCK_END)) {
2324
+ // END present but BEGIN missing — orphaned END marker left by a previous
2325
+ // failed write. Strip it and fall through to append the complete block.
2326
+ // <!-- Added: forge#291 -->
2327
+ existing = existing.replace(CLAUDE_BLOCK_END, "").trimEnd();
2295
2328
  }
2296
2329
 
2297
2330
  // Append block to existing content, separated by a blank line
2298
- const separator = existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2331
+ const separator =
2332
+ existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
2299
2333
  writeFileSync(filePath, `${existing}${separator}${managed}\n`, "utf-8");
2300
2334
  return "updated";
2301
2335
  }
@@ -2324,9 +2358,13 @@ async function injectClaudeMd(cwd) {
2324
2358
 
2325
2359
  const claudeResult = _injectManagedBlock(claudePath, blockContent, true);
2326
2360
  if (claudeResult === "created") {
2327
- console.log(` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`);
2361
+ console.log(
2362
+ ` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`,
2363
+ );
2328
2364
  } else {
2329
- console.log(` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`);
2365
+ console.log(
2366
+ ` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`,
2367
+ );
2330
2368
  }
2331
2369
 
2332
2370
  if (existsSync(agentsPath)) {
@@ -3421,9 +3459,7 @@ async function labelsSetup(repo) {
3421
3459
  const manifestPath = join(__dirname, "labels.json");
3422
3460
 
3423
3461
  if (!existsSync(manifestPath)) {
3424
- console.log(
3425
- `${RED}Label manifest not found: ${manifestPath}${RESET}`,
3426
- );
3462
+ console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
3427
3463
  process.exit(1);
3428
3464
  }
3429
3465
 
@@ -3543,21 +3579,54 @@ async function docsInit() {
3543
3579
 
3544
3580
  // Security: assert targetDir is confined to the project directory.
3545
3581
  // 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).
3582
+ //
3583
+ // Two hardening steps beyond the initial check:
3584
+ //
3585
+ // 1. realpath(cwd) — on Windows, process.cwd() may return a directory-junction path while
3586
+ // realpath() returns the canonical target. Comparing a canonical resolvedTarget against
3587
+ // a non-canonical cwd would false-reject valid paths (BUG-3).
3588
+ //
3589
+ // 2. Walk-up-to-existing-ancestor resolution on ENOENT — when the target path does not yet
3590
+ // exist, falling back to lexical resolve() fails to follow intermediate symlinks. A
3591
+ // symlink planted at "sub/" pointing outside the project would not be dereferenced, so
3592
+ // the lexical result would appear inside the project and the containment check would
3593
+ // pass, allowing mkdir() to write through the symlink (SEC-1). Calling realpath() only
3594
+ // one level up ("parent-first") still fails when the parent also does not exist (e.g.,
3595
+ // devdocs.path = "sub/mid/leaf" where "sub" is a symlink but "sub/mid" is absent) —
3596
+ // realpath("sub/mid") follows sub then ENOENT-s on mid, and the lexical fallback does
3597
+ // not dereference sub (SEC-2). Instead, walk up the directory hierarchy until finding
3598
+ // an ancestor that exists, realpath() it to dereference any symlinks at that level, then
3599
+ // reattach all collected suffix segments.
3600
+ const realCwd = await realpath(cwd).catch(() => resolve(cwd));
3549
3601
  let resolvedTarget;
3550
3602
  try {
3551
3603
  resolvedTarget = await realpath(targetDir);
3552
3604
  } catch {
3605
+ // targetDir does not exist — walk up the hierarchy to the nearest existing ancestor,
3606
+ // dereference it via realpath() to follow any intermediate symlinks, then reattach the
3607
+ // collected suffix segments. Initialise resolvedTarget to the lexical path as a
3608
+ // last-resort sentinel (overwritten by the loop on the first successful realpath call).
3609
+ const trailSegments = [basename(targetDir)];
3610
+ let ancestor = dirname(targetDir);
3553
3611
  resolvedTarget = resolve(targetDir);
3612
+ while (true) {
3613
+ const parent = dirname(ancestor);
3614
+ if (parent === ancestor) break; // reached filesystem root — keep lexical sentinel
3615
+ try {
3616
+ resolvedTarget = join(await realpath(ancestor), ...trailSegments);
3617
+ break;
3618
+ } catch {
3619
+ trailSegments.unshift(basename(ancestor));
3620
+ ancestor = parent;
3621
+ }
3622
+ }
3554
3623
  }
3555
- if (resolvedTarget !== cwd && !resolvedTarget.startsWith(cwd + sep)) {
3624
+ if (resolvedTarget !== realCwd && !resolvedTarget.startsWith(realCwd + sep)) {
3556
3625
  console.log(
3557
3626
  `${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
3558
3627
  );
3559
3628
  console.log(` Resolved: ${resolvedTarget}`);
3560
- console.log(` Project: ${cwd}`);
3629
+ console.log(` Project: ${realCwd}`);
3561
3630
  console.log(
3562
3631
  ` Fix: set ${CYAN}devdocs.path${RESET} to a relative path inside your project (e.g. "devdocs" or "docs/knowledge").`,
3563
3632
  );
@@ -3568,12 +3637,8 @@ async function docsInit() {
3568
3637
  const templatesDir = join(FORGE_HOME, "templates", "devdocs");
3569
3638
 
3570
3639
  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
- );
3640
+ console.log(`${RED}DevDocs templates not found: ${templatesDir}${RESET}`);
3641
+ console.log(` This usually means ForgeDock is not installed correctly.`);
3577
3642
  console.log(
3578
3643
  ` Try: ${CYAN}npm install -g forgedock${RESET} or ${CYAN}npx forgedock@latest docs init${RESET}`,
3579
3644
  );
@@ -3589,14 +3654,51 @@ async function docsInit() {
3589
3654
  console.log(` Files: ${templateFiles.length} seed files`);
3590
3655
  console.log("");
3591
3656
 
3592
- await mkdir(targetDir, { recursive: true });
3657
+ // Use resolvedTarget (canonical path) rather than the raw targetDir string. The
3658
+ // containment check above already resolved symlinks and validated the path; using the
3659
+ // canonical form here closes the TOCTOU window to the narrowest possible interval
3660
+ // and ensures mkdir does not follow a symlink that was NOT present when we checked.
3661
+ //
3662
+ // TOCTOU residual: a narrow window remains between the realpath check and this mkdir
3663
+ // on POSIX systems — a symlink planted at an intermediate path component during that
3664
+ // window could cause mkdir to create directories outside the project boundary.
3665
+ // Full elimination requires O_NOFOLLOW semantics (kernel-level, not available from
3666
+ // Node.js user-space). We mitigate with a post-create re-validation below (SEC-5).
3667
+ await mkdir(resolvedTarget, { recursive: true });
3668
+
3669
+ // SEC-5: Post-create re-validation — detect symlink planted during the TOCTOU window.
3670
+ // After mkdir succeeds, call realpath() on the now-existing directory to get its
3671
+ // canonical on-disk location. If that location lies outside the project root a
3672
+ // symlink was planted between the containment check and the mkdir call. In that case
3673
+ // we remove the directory tree that was created (wherever it landed) and abort.
3674
+ try {
3675
+ const postMkdirReal = await realpath(resolvedTarget);
3676
+ if (postMkdirReal !== realCwd && !postMkdirReal.startsWith(realCwd + sep)) {
3677
+ await rm(postMkdirReal, { recursive: true, force: true }).catch(() => {});
3678
+ console.log(
3679
+ `${RED}Error: devdocs directory escaped the project boundary after creation (TOCTOU).${RESET}`,
3680
+ );
3681
+ console.log(` Created at: ${postMkdirReal}`);
3682
+ console.log(` Project: ${realCwd}`);
3683
+ console.log(
3684
+ ` A symlink was likely planted at an intermediate path component between the`,
3685
+ );
3686
+ console.log(
3687
+ ` containment check and the mkdir call. The created directory has been removed.`,
3688
+ );
3689
+ process.exit(1);
3690
+ }
3691
+ } catch {
3692
+ // realpath() failure after a successful mkdir is unexpected; proceed — the directory
3693
+ // was created by us and the normal containment check already passed.
3694
+ }
3593
3695
 
3594
3696
  let copied = 0;
3595
3697
  let skipped = 0;
3596
3698
 
3597
3699
  for (const srcFile of templateFiles) {
3598
3700
  const rel = relative(templatesDir, srcFile);
3599
- const destFile = join(targetDir, rel);
3701
+ const destFile = join(resolvedTarget, rel);
3600
3702
  const destDir = dirname(destFile);
3601
3703
 
3602
3704
  await mkdir(destDir, { recursive: true });
@@ -4083,7 +4185,11 @@ if (!command) {
4083
4185
  break;
4084
4186
  case "labels": {
4085
4187
  const subcommand = args[1];
4086
- if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
4188
+ if (
4189
+ !subcommand ||
4190
+ subcommand === "setup" ||
4191
+ subcommand.startsWith("--")
4192
+ ) {
4087
4193
  const subArgs = args.slice(1);
4088
4194
  const repo = resolveLabelsRepo(subArgs);
4089
4195
  if (!repo) {
@@ -4112,9 +4218,7 @@ if (!command) {
4112
4218
  await docsInit();
4113
4219
  } else {
4114
4220
  console.log(`${RED}Unknown docs subcommand: ${docsSubcommand}${RESET}`);
4115
- console.log(
4116
- `Usage: ${CYAN}npx forgedock docs init${RESET}`,
4117
- );
4221
+ console.log(`Usage: ${CYAN}npx forgedock docs init${RESET}`);
4118
4222
  process.exit(1);
4119
4223
  }
4120
4224
  break;
@@ -11,6 +11,13 @@ 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
15
+
16
+ Resolve `{BILLING_ENABLED}` from `forge.yaml` before executing any phase:
17
+
18
+ ```bash
19
+ BILLING_ENABLED=$(yq '.billing.enabled // false' "$(git rev-parse --show-toplevel)/forge.yaml" 2>/dev/null || echo 'false')
20
+ ```
14
21
 
15
22
  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
23
 
@@ -51,9 +58,15 @@ DRY_RUN = true if --dry-run present
51
58
  ```bash
52
59
  echo "=== Autopilot Cycle: $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
53
60
 
61
+ # Portable ISO-8601 date arithmetic using python3 (works on Windows, macOS, Linux).
62
+ # Replaces GNU-only `date -u -d 'N days ago'` which fails on Windows and macOS.
63
+ 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'))")
64
+ 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'))")
65
+ 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'))")
66
+
54
67
  # Recent closed issues (last 3 days) — what was fixed recently?
55
68
  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"
69
+ --jq '[.[] | select(.closedAt > "'"$DATE_3D_AGO"'")] | length' 2>/dev/null || echo "0"
57
70
 
58
71
  # Open issue count by priority
59
72
  gh issue list --state open --label "priority:P0" --json number --jq 'length'
@@ -64,12 +77,12 @@ gh issue list --state open --label "priority:P2" --json number --jq 'length'
64
77
  gh issue list --state open --limit 200 --json number,title,labels,createdAt \
65
78
  --jq '[.[] | select(
66
79
  (.labels | map(.name) | any(startswith("workflow:")) | not) and
67
- (.createdAt < "'$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)'")
80
+ (.createdAt < "'"$DATE_7D_AGO"'")
68
81
  )] | length'
69
82
 
70
83
  # Failed CI runs in last 24h
71
84
  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'
85
+ --jq '[.[] | select(.conclusion == "failure" and .createdAt > "'"$DATE_1D_AGO"'")] | length'
73
86
  ```
74
87
 
75
88
  Store these as `BASELINE` metrics for the cycle report.
@@ -140,8 +153,9 @@ Analyze GitHub issue backlog:
140
153
  Quick analytics pulse — just the key metrics, not a full audit:
141
154
  1. Read credentials from {CREDENTIALS_FILE} (set via paths.credentials.file in forge.yaml)
142
155
  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
156
+ 3. Stripe (only if {BILLING_ENABLED} is true): mcp__stripe__retrieve_balance — current balance.
157
+ Skip this step entirely if {BILLING_ENABLED} is false — do NOT call any Stripe MCP tools.
158
+ 4. Return: clicks trend (up/down), revenue (or "N/A — billing.enabled: false" if skipped), notable changes
145
159
  ```
146
160
 
147
161
  ### 1B: Collect results
@@ -291,7 +305,7 @@ Print a structured report:
291
305
 
292
306
  ### Analytics Pulse
293
307
  - Clicks (7d): {N} ({trend})
294
- - Revenue: ${N}
308
+ - Revenue: ${N} (or "N/A — billing.enabled: false" if Stripe step was skipped)
295
309
 
296
310
  ### Actions Taken
297
311
  - 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"' forge.yaml 2>/dev/null || echo "services/api")
160
+ LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' forge.yaml 2>/dev/null || echo "services/worker")
161
+ LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' 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"' 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