forgedock 1.1.10 → 1.1.11

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
@@ -28,6 +28,7 @@ import {
28
28
  parseInstallTier,
29
29
  writeForgeYaml,
30
30
  backupExisting,
31
+ backfillForgeYaml,
31
32
  manualLowConfidenceKeys,
32
33
  isEphemeralCachePath,
33
34
  writeInstallReceipt,
@@ -1874,6 +1875,16 @@ async function doctor(fix = false) {
1874
1875
  * Reuses forge() (bin/journey.mjs) verbatim — the same repair path already
1875
1876
  * used by `npx forgedock update` (see relinkAndHint()) — rather than
1876
1877
  * duplicating symlink/copy-mode or hook-registration logic here.
1878
+ *
1879
+ * NOTE: `installRepairRan` only memoizes whether the repair primitive
1880
+ * itself has executed during this invocation — it is a cross-check flag
1881
+ * by necessity (any of the three checks below can trigger it, and it must
1882
+ * not re-run for the other two). It must NOT be read by an individual
1883
+ * check to decide whether THAT check was fixed — a check that was already
1884
+ * healthy before a sibling check triggered repair would incorrectly
1885
+ * report "repaired" too. Each check below tracks its own pre-repair
1886
+ * broken state in a local `*WasBroken` variable for that decision instead.
1887
+ * (forge#1975)
1877
1888
  */
1878
1889
  let installRepairRan = false;
1879
1890
  async function runInstallRepairOnce() {
@@ -1954,7 +1965,9 @@ async function doctor(fix = false) {
1954
1965
  }
1955
1966
 
1956
1967
  let cmdFiles = await detectCommandFiles();
1968
+ let cmdFilesWasBroken = false;
1957
1969
  if (!cmdFiles.error && !cmdFiles.ok && fix) {
1970
+ cmdFilesWasBroken = true;
1958
1971
  await runInstallRepairOnce();
1959
1972
  cmdFiles = await detectCommandFiles();
1960
1973
  }
@@ -1962,7 +1975,7 @@ async function doctor(fix = false) {
1962
1975
  if (cmdFiles.error) {
1963
1976
  fail("Command files", `Could not read commands directory: ${cmdFiles.error}`);
1964
1977
  } else if (cmdFiles.ok) {
1965
- if (fix && installRepairRan) {
1978
+ if (fix && cmdFilesWasBroken) {
1966
1979
  fixed("Command files", `${cmdFiles.checked} files installed (repaired)`);
1967
1980
  } else {
1968
1981
  pass("Command files", `${cmdFiles.checked} files installed`);
@@ -2153,7 +2166,9 @@ async function doctor(fix = false) {
2153
2166
  }
2154
2167
 
2155
2168
  let hookReg = detectSessionStartHookRegistered();
2169
+ let hookRegWasBroken = false;
2156
2170
  if (!hookReg.error && !hookReg.ok && fix) {
2171
+ hookRegWasBroken = true;
2157
2172
  await runInstallRepairOnce();
2158
2173
  hookReg = detectSessionStartHookRegistered();
2159
2174
  }
@@ -2161,7 +2176,7 @@ async function doctor(fix = false) {
2161
2176
  if (hookReg.error) {
2162
2177
  fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
2163
2178
  } else if (hookReg.ok) {
2164
- if (fix && installRepairRan) {
2179
+ if (fix && hookRegWasBroken) {
2165
2180
  fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
2166
2181
  } else {
2167
2182
  pass("SessionStart hook", "registered in ~/.claude/settings.json");
@@ -2221,7 +2236,9 @@ async function doctor(fix = false) {
2221
2236
  // ever surfaced. This check catches that silent-failure case. (forge#1895)
2222
2237
  {
2223
2238
  let hookPathResult = detectSessionStartHookPath();
2239
+ let hookPathWasBroken = false;
2224
2240
  if (hookPathResult.state === "dangling" && fix) {
2241
+ hookPathWasBroken = true;
2225
2242
  await runInstallRepairOnce();
2226
2243
  hookPathResult = detectSessionStartHookPath();
2227
2244
  }
@@ -2237,7 +2254,7 @@ async function doctor(fix = false) {
2237
2254
  `Could not parse a script path out of the registered hook command ("${hookPathResult.command}"). Run: npx forgedock install`,
2238
2255
  );
2239
2256
  } else if (hookPathResult.state === "ok") {
2240
- if (fix && installRepairRan) {
2257
+ if (fix && hookPathWasBroken) {
2241
2258
  fixed("SessionStart hook script path", `refreshed to a valid path (${hookPathResult.path})`);
2242
2259
  } else {
2243
2260
  pass("SessionStart hook script path", `exists on disk (${hookPathResult.path})`);
@@ -2709,12 +2726,14 @@ function help() {
2709
2726
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
2710
2727
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
2711
2728
  ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
2729
+ ["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
2712
2730
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
2713
2731
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
2714
2732
  ["npx forgedock doctor", "Check installation health"],
2715
2733
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
2716
2734
  ["npx forgedock update", "Pull latest & reinstall"],
2717
2735
  ["npx forgedock uninstall", "Remove commands"],
2736
+ ["npx forgedock version", "Print the installed version and check for updates"],
2718
2737
  ["npx forgedock help", "Show this help"],
2719
2738
  ];
2720
2739
  const flagRows = [
@@ -2723,6 +2742,7 @@ function help() {
2723
2742
  ["--manual", "Plain text prompts instead of the review screen (init)"],
2724
2743
  ["--verbose", "Show detection sources for every field (init)"],
2725
2744
  ["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
2745
+ ["--version, -v", "Print the installed version and check for updates"],
2726
2746
  ];
2727
2747
 
2728
2748
  process.stdout.write(
@@ -3051,7 +3071,8 @@ async function watch() {
3051
3071
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3052
3072
  const KNOWN_COMMANDS = new Set([
3053
3073
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3054
- "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
3074
+ "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3075
+ "version", "--version", "-v",
3055
3076
  ]);
3056
3077
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
3057
3078
 
@@ -3200,6 +3221,60 @@ switch (command) {
3200
3221
  }
3201
3222
  break;
3202
3223
  }
3224
+ case "config": {
3225
+ const subcommand = positional[1];
3226
+ if (subcommand === "migrate") {
3227
+ // Directory override mirrors `status [dir]` — defaults to cwd.
3228
+ const dir = positional[2] ? resolve(positional[2]) : process.cwd();
3229
+ const result = backfillForgeYaml(dir);
3230
+ if (!result.present) {
3231
+ console.log(
3232
+ `${RED}No forge.yaml found in ${dir}.${RESET}\n` +
3233
+ ` Run ${cyan("npx forgedock init")} to generate one.`,
3234
+ );
3235
+ exitCode = 1;
3236
+ } else if (result.added.length === 0) {
3237
+ console.log(
3238
+ `${GREEN}forge.yaml already has all ${result.alreadyPresent.length} optional sections.${RESET} Nothing to migrate.`,
3239
+ );
3240
+ } else {
3241
+ console.log(`${GREEN}Backfilled ${result.added.length} missing optional section(s) into forge.yaml:${RESET}`);
3242
+ for (const key of result.added) console.log(` ${GREEN}+${RESET} ${key}`);
3243
+ console.log(`\n Sections already present (unchanged): ${result.alreadyPresent.join(", ") || "none"}`);
3244
+ console.log(`\n All added sections are commented out — edit forge.yaml to enable the ones you need.`);
3245
+ }
3246
+ } else {
3247
+ console.log(`${RED}Unknown config subcommand: ${subcommand ?? "(none)"}${RESET}`);
3248
+ console.log(
3249
+ `Usage: ${CYAN}npx forgedock config migrate [dir]${RESET}\n` +
3250
+ ` Backfills any of the 16 optional forge.yaml sections missing from an existing config (idempotent).`,
3251
+ );
3252
+ exitCode = 1;
3253
+ }
3254
+ break;
3255
+ }
3256
+ case "version":
3257
+ case "--version":
3258
+ case "-v": {
3259
+ // Print the local version immediately — must work offline/instantly,
3260
+ // no network dependency for the primary output (getVersion() reads
3261
+ // package.json off disk only). The latest-version check below is
3262
+ // best-effort: fetchLatestVersion() has its own 5s timeout and never
3263
+ // rejects, so a slow/offline network never blocks or fails this
3264
+ // command. Mirrors the version-check message pattern already used by
3265
+ // update() (see the npm/npx branch above).
3266
+ const localVersion = getVersion();
3267
+ console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
3268
+
3269
+ const latestVersion = await fetchLatestVersion();
3270
+ if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
3271
+ console.log(
3272
+ ` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}). ` +
3273
+ `Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
3274
+ );
3275
+ }
3276
+ break;
3277
+ }
3203
3278
  case "help":
3204
3279
  case "--help":
3205
3280
  case "-h":
@@ -283,7 +283,7 @@ export function resolveGitRoot(cwd) {
283
283
  if (toplevel) {
284
284
  const resolved = resolve(toplevel) !== resolve(cwd);
285
285
  return {
286
- root: resolved ? toplevel : cwd,
286
+ root: resolved ? resolve(toplevel) : cwd,
287
287
  resolved,
288
288
  why: `'${cwd}' is nested inside a git repository rooted at '${toplevel}'`,
289
289
  };
package/bin/journey.mjs CHANGED
@@ -23,6 +23,242 @@ function todo(key, low) {
23
23
  return low.includes(key) ? ` # TODO(forgedock:${key}) — verify this value` : "";
24
24
  }
25
25
 
26
+ /**
27
+ * Ordered list of the 16 optional forge.yaml sections. Each entry is a
28
+ * self-contained, fully-commented stub block (banner + example content).
29
+ * This is the single source of truth for optional-section text, shared by
30
+ * writeForgeYaml() (full-file generation for `npx forgedock init`) and
31
+ * backfillForgeYaml() (append-only migration for existing files, #1982) —
32
+ * keeping the two paths from drifting apart the same way full-generation
33
+ * itself drifted from forge.yaml.example before #1983.
34
+ *
35
+ * `key` is the top-level YAML key the section introduces (used by
36
+ * backfillForgeYaml() to detect whether a section is already present).
37
+ * `block(v)` renders the stub text; blocks never end with a trailing
38
+ * newline of their own — callers join them with `"\n\n"` to reproduce the
39
+ * blank-line separation between sections.
40
+ * @type {Array<{ key: string, block: (v: object) => string }>}
41
+ */
42
+ const OPTIONAL_SECTIONS = [
43
+ {
44
+ key: "agents",
45
+ block: () => `# =============================================================================
46
+ # AGENTS (OPTIONAL) — model overrides for pipeline agents.
47
+ # Commands: all commands with an "Agent model policy" line
48
+ # =============================================================================
49
+
50
+ # agents:
51
+ # default_model: "sonnet" # main orchestrator/agent model: "sonnet" | "opus" | "haiku"
52
+ # subagent_model: "sonnet" # model for child sub-agents (orchestrate, review-pr, ...)`,
53
+ },
54
+ {
55
+ key: "repos",
56
+ block: (v) => `# =============================================================================
57
+ # REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
58
+ # =============================================================================
59
+
60
+ # repos:
61
+ # default:
62
+ # repo: "${esc(v.owner)}/${esc(v.repo)}"
63
+ # staging_branch: "${esc(v.stagingBranch)}"
64
+ # satellites:
65
+ # - prefix: "mcp"
66
+ # repo: "${esc(v.owner)}/your-satellite-repo"
67
+ # staging_branch: "main"`,
68
+ },
69
+ {
70
+ key: "project_board",
71
+ block: (v) => `# =============================================================================
72
+ # PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
73
+ # To find IDs: gh project list --owner ${esc(v.owner)}
74
+ # =============================================================================
75
+
76
+ # project_board:
77
+ # owner: "${esc(v.owner)}"
78
+ # project_number: 1
79
+ # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
80
+ # field_ids:
81
+ # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"`,
82
+ },
83
+ {
84
+ key: "pipeline",
85
+ block: () => `# =============================================================================
86
+ # PIPELINE (OPTIONAL) — tuning knobs for the /orchestrate batch engine.
87
+ # Commands: orchestrate
88
+ # =============================================================================
89
+
90
+ # pipeline:
91
+ # stall_timeout_minutes: 15 # minutes an agent may sit idle before auto-resume
92
+ # token_budget_per_batch: 900000 # per-batch token ceiling for the review-finding cascade
93
+ # token_estimate_per_finding: 150000
94
+ # narration: "terse" # "terse" | "verbose"`,
95
+ },
96
+ {
97
+ key: "services",
98
+ block: () => `# =============================================================================
99
+ # SERVICES (OPTIONAL) — external service URLs for analytics/monitoring/GEO audit.
100
+ # Commands: analytics, geo-audit, autopilot (analytics snapshot)
101
+ # =============================================================================
102
+
103
+ # services:
104
+ # domain: "acme.io"
105
+ # gsc_property: "https://acme.io"
106
+ # app_url: "https://acme.io"
107
+ # api_url: "https://api.acme.io"
108
+ # analytics:
109
+ # umami:
110
+ # url: "https://umami.acme.io"
111
+ # website_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
112
+ # clarity:
113
+ # project_id: "xxxxxxxxxx"
114
+ # ga4:
115
+ # property_id: "000000000"`,
116
+ },
117
+ {
118
+ key: "review",
119
+ block: () => `# =============================================================================
120
+ # REVIEW (OPTIONAL) — context injected into review agent prompts.
121
+ # =============================================================================
122
+
123
+ # review:
124
+ # tech_stack: "Node.js, TypeScript, PostgreSQL"
125
+ # context: |
126
+ # Describe your repo structure and any unusual conventions here.`,
127
+ },
128
+ {
129
+ key: "verification",
130
+ block: () => `# =============================================================================
131
+ # VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
132
+ # =============================================================================
133
+
134
+ # verification:
135
+ # health_endpoint: "https://api.example.com/health"
136
+ # health_patterns:
137
+ # - '"status": "ok"'`,
138
+ },
139
+ {
140
+ key: "deploy",
141
+ block: () => `# =============================================================================
142
+ # DEPLOY (OPTIONAL) — deployment model configuration.
143
+ # Commands: deploy-info, incident-response, rollback, work-on (Phase 3J)
144
+ # =============================================================================
145
+
146
+ # deploy:
147
+ # workflow: "deploy.yml" # GitHub Actions workflow filename used to trigger deploys
148
+ # workflow_inputs:
149
+ # services: "services"
150
+ # reason: "reason"
151
+ # secrets_backend: "sops" # sops | aws-sm | vault | ci-env | none`,
152
+ },
153
+ {
154
+ key: "autopilot",
155
+ block: () => `# =============================================================================
156
+ # AUTOPILOT (OPTIONAL) — configuration for the /autopilot autonomous deploy loop.
157
+ # Commands: autopilot
158
+ # =============================================================================
159
+
160
+ # autopilot:
161
+ # ops_issue_label: "autopilot-ops"
162
+ # headless: false # opt-in to unattended fixing; default: false
163
+ # approve:
164
+ # p0: needs-human
165
+ # p1: needs-human
166
+ # p2: auto
167
+ # p3: auto
168
+ # budget:
169
+ # per_cycle_fixes: 3
170
+ # per_cycle_tokens: null`,
171
+ },
172
+ {
173
+ key: "billing",
174
+ block: () => `# =============================================================================
175
+ # BILLING (OPTIONAL) — financial integrity checks in /security-audit.
176
+ # Commands: security-audit (Phase 4 — Financial Integrity)
177
+ # =============================================================================
178
+
179
+ # billing:
180
+ # enabled: false`,
181
+ },
182
+ {
183
+ key: "devdocs",
184
+ block: () => `# =============================================================================
185
+ # DEVDOCS (OPTIONAL) — path to the devdocs knowledge tree.
186
+ # Commands: docs init
187
+ # =============================================================================
188
+
189
+ # devdocs:
190
+ # path: "devdocs"`,
191
+ },
192
+ {
193
+ key: "adaptive_scripts",
194
+ block: () => `# =============================================================================
195
+ # ADAPTIVE_SCRIPTS (OPTIONAL) — per-repo scripts ForgeDock learns and updates.
196
+ # Commands: work-on (script discovery), optimize (script generation)
197
+ # =============================================================================
198
+
199
+ # adaptive_scripts:
200
+ # enabled: true
201
+ # directory: ".forgedock/scripts"
202
+ # commit: false`,
203
+ },
204
+ {
205
+ key: "learned",
206
+ block: () => `# =============================================================================
207
+ # LEARNED (OPTIONAL) — agent-writable patterns captured across sessions.
208
+ # Commands: work-on (Phase 0B reads; Phase 1D writes)
209
+ # =============================================================================
210
+
211
+ # learned:
212
+ # branch_targets:
213
+ # staging: "develop"
214
+ # test_commands:
215
+ # - "pnpm typecheck"
216
+ # label_map:
217
+ # "workflow:investigating": "needs-triage"
218
+ # commit_style: "conventional-with-scope"`,
219
+ },
220
+ {
221
+ key: "index",
222
+ block: () => `# =============================================================================
223
+ # INDEX (OPTIONAL) — per-commit code index for scripts/code-index.sh.
224
+ # Commands: work-on:investigate, work-on/build/architect, review-pr
225
+ # =============================================================================
226
+
227
+ # index:
228
+ # languages: "Python,JavaScript,TypeScript,Go"
229
+ # cache_dir: ".forge/index"
230
+ # enabled: true`,
231
+ },
232
+ {
233
+ key: "attribution",
234
+ block: () => `# =============================================================================
235
+ # ATTRIBUTION (OPTIONAL) — opt-in growth features (PR/annotation footer links).
236
+ # Commands: work-on/review, review-pr
237
+ # =============================================================================
238
+
239
+ # attribution:
240
+ # pr_footer: true
241
+ # annotation_link: true`,
242
+ },
243
+ {
244
+ key: "pattern_feeds",
245
+ block: () => `# =============================================================================
246
+ # PATTERN_FEEDS (OPTIONAL) — subscribe to external pattern card repositories.
247
+ # Commands: scripts/build-knowledge-index.mjs, quality-gate, optimize
248
+ # =============================================================================
249
+
250
+ # pattern_feeds:
251
+ # feeds:
252
+ # - slug: "forge-core"
253
+ # repo: "RapierCraftStudios/forge-patterns"
254
+ # ref: "abc1234def5678..." # REQUIRED: pinned commit SHA — never a branch name
255
+ # path: "cards"
256
+ # stacks: ["node", "bash", "gha"]
257
+ # priority: "LOW"
258
+ # enabled: true`,
259
+ },
260
+ ];
261
+
26
262
  /**
27
263
  * Write forge.yaml from reviewed values.
28
264
  * @param {{owner:string,repo:string,name:string,description:string,root:string,
@@ -34,13 +270,15 @@ function todo(key, low) {
34
270
  */
35
271
  export function writeForgeYaml(v, lowConfidenceKeys, outputPath) {
36
272
  const low = lowConfidenceKeys;
37
- const content = `# forge.yaml — ForgeDock Configuration
273
+ const header = `# forge.yaml — ForgeDock Configuration
38
274
  #
39
275
  # Auto-generated by: npx forgedock
40
276
  # Fields marked with TODO comments below were guessed — verify them.
41
277
  #
42
278
  # Required sections: project, paths, branches
43
- # Optional sections: repos, project_board, services, review, verification
279
+ # Optional sections: agents, repos, project_board, pipeline, services, review,
280
+ # verification, deploy, autopilot, billing, devdocs, adaptive_scripts,
281
+ # learned, index, attribution, pattern_feeds
44
282
  #
45
283
  # See docs/CONFIG.md for full reference.
46
284
 
@@ -69,51 +307,9 @@ paths:
69
307
  branches:
70
308
  default: "${esc(v.defaultBranch)}"${todo("defaultBranch", low)}
71
309
  staging: "${esc(v.stagingBranch)}"${todo("stagingBranch", low)}
72
- feature_pattern: "milestone/{slug}"
73
-
74
- # =============================================================================
75
- # REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
76
- # =============================================================================
77
-
78
- # repos:
79
- # default:
80
- # repo: "${esc(v.owner)}/${esc(v.repo)}"
81
- # staging_branch: "${esc(v.stagingBranch)}"
82
- # satellites:
83
- # - prefix: "mcp"
84
- # repo: "${esc(v.owner)}/your-satellite-repo"
85
- # staging_branch: "main"
86
-
87
- # =============================================================================
88
- # PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
89
- # To find IDs: gh project list --owner ${esc(v.owner)}
90
- # =============================================================================
310
+ feature_pattern: "milestone/{slug}"`;
91
311
 
92
- # project_board:
93
- # owner: "${esc(v.owner)}"
94
- # project_number: 1
95
- # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
96
- # field_ids:
97
- # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
98
-
99
- # =============================================================================
100
- # REVIEW (OPTIONAL) — context injected into review agent prompts.
101
- # =============================================================================
102
-
103
- # review:
104
- # tech_stack: "Node.js, TypeScript, PostgreSQL"
105
- # context: |
106
- # Describe your repo structure and any unusual conventions here.
107
-
108
- # =============================================================================
109
- # VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
110
- # =============================================================================
111
-
112
- # verification:
113
- # health_endpoint: "https://api.example.com/health"
114
- # health_patterns:
115
- # - '"status": "ok"'
116
- `;
312
+ const content = [header, ...OPTIONAL_SECTIONS.map((s) => s.block(v))].join("\n\n") + "\n";
117
313
  // Atomic write: write to a temp file first, then rename into place.
118
314
  // If the write fails (e.g. ENOSPC), the original file is untouched and
119
315
  // any partial .tmp is cleaned up — no corrupt or missing forge.yaml.
@@ -129,6 +325,79 @@ branches:
129
325
  return { todoCount };
130
326
  }
131
327
 
328
+ /**
329
+ * Backfill any of the 16 optional forge.yaml sections missing from an
330
+ * existing config file (#1982). Additive only — never edits, reorders, or
331
+ * removes existing content. Required sections, already-present optional
332
+ * sections, and any hand-edited content are left byte-for-byte untouched;
333
+ * only trailing whitespace at the very end of the file is trimmed before
334
+ * appending. Follows the same atomic temp+rename write pattern as
335
+ * writeForgeYaml() (ref: #1396 — a non-atomic write here could corrupt the
336
+ * user's forge.yaml on disk-full).
337
+ *
338
+ * A section counts as "present" if its top-level key appears either active
339
+ * (`key:` at column 0 — the user enabled it) or as the standard commented
340
+ * stub (`# key:`) anywhere in the file. Detection is intentionally strict
341
+ * about indentation (no arbitrary leading whitespace) so a same-named key
342
+ * nested under an unrelated section can never produce a false "present".
343
+ *
344
+ * @param {string} cwd - directory containing forge.yaml
345
+ * @returns {{ present: boolean, added: string[], alreadyPresent: string[] }}
346
+ * `present: false` means no forge.yaml exists at cwd — added/alreadyPresent
347
+ * are both empty; the caller should direct the user to `npx forgedock init`
348
+ * instead of migrate.
349
+ */
350
+ export function backfillForgeYaml(cwd) {
351
+ const outputPath = join(cwd, "forge.yaml");
352
+ if (!existsSync(outputPath)) {
353
+ return { present: false, added: [], alreadyPresent: [] };
354
+ }
355
+ const raw = readFileSync(outputPath, "utf-8");
356
+
357
+ // Best-effort values for the blocks that interpolate project identity
358
+ // (repos/project_board examples) — read from the existing file's REQUIRED
359
+ // sections so backfilled examples reference this project rather than a
360
+ // generic placeholder. Falls back to placeholders if unset/unparseable;
361
+ // these only ever land inside commented-out example text.
362
+ const v = {
363
+ owner: raw.match(/^\s*owner:\s*"([^"]*)"/m)?.[1] || "your-org",
364
+ repo: raw.match(/^\s*repo:\s*"([^"]*)"/m)?.[1] || "your-repo",
365
+ stagingBranch: raw.match(/^\s*staging:\s*"([^"]*)"/m)?.[1] || "staging",
366
+ };
367
+
368
+ const added = [];
369
+ const alreadyPresent = [];
370
+ const newBlocks = [];
371
+ for (const section of OPTIONAL_SECTIONS) {
372
+ const isActive = new RegExp(`^${section.key}:`, "m").test(raw);
373
+ const isStub = new RegExp(`^#\\s?${section.key}:`, "m").test(raw);
374
+ if (isActive || isStub) {
375
+ alreadyPresent.push(section.key);
376
+ } else {
377
+ added.push(section.key);
378
+ newBlocks.push(section.block(v));
379
+ }
380
+ }
381
+
382
+ if (added.length === 0) {
383
+ return { present: true, added, alreadyPresent };
384
+ }
385
+
386
+ const trimmedRaw = raw.replace(/\s+$/, "");
387
+ const content = `${trimmedRaw}\n\n${newBlocks.join("\n\n")}\n`;
388
+
389
+ const tmpPath = outputPath + ".tmp";
390
+ try {
391
+ writeFileSync(tmpPath, content, "utf-8");
392
+ renameSync(tmpPath, outputPath);
393
+ } catch (err) {
394
+ try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
395
+ throw err;
396
+ }
397
+
398
+ return { present: true, added, alreadyPresent };
399
+ }
400
+
132
401
  /** Maps manual-flow value keys to their location in a ConfigDraft. */
133
402
  const DRAFT_KEY_PATHS = {
134
403
  owner: ["project", "owner"],
@@ -147,10 +147,18 @@ INPUT_ISSUE=$(echo "$ARGUMENTS" | grep -oE '#?([0-9]+)' | grep -oE '[0-9]+' | he
147
147
  GH_FLAG=$(yq -r '"-R " + .project.owner + "/" + .project.repo' forge.yaml 2>/dev/null || echo "")
148
148
 
149
149
  if [ -n "$INPUT_ISSUE" ] && [ -n "$GH_FLAG" ]; then
150
- ISSUE_LABELS=$(gh issue view "$INPUT_ISSUE" "$GH_FLAG" --json labels \
151
- --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
152
-
153
- if echo "$ISSUE_LABELS" | grep -q "needs-validation"; then
150
+ # forge#1997: a bare `2>/dev/null || echo ""` here collapsed "fetch
151
+ # failed" and "fetch succeeded, issue has no labels" into the same empty
152
+ # string, so a failed fetch fell through to the "no needs-validation —
153
+ # no label transition needed" branch below, silently misreporting a
154
+ # transient API failure as a legitimate no-op. Use the same
155
+ # `if VAR=$(cmd); then ... else ... fi` idiom already applied to
156
+ # scripts/transition-label.sh (forge#1991) to keep the two outcomes
157
+ # distinguishable.
158
+ if ! ISSUE_LABELS=$(gh issue view "$INPUT_ISSUE" "$GH_FLAG" --json labels \
159
+ --jq '[.labels[].name] | join(",")' 2>/dev/null); then
160
+ echo "WARNING: needs-validation label fetch failed (transient network error / rate limit?) for issue #$INPUT_ISSUE — cannot determine finding-lifecycle state. Skipping label transition." >&2
161
+ elif echo "$ISSUE_LABELS" | grep -q "needs-validation"; then
154
162
  echo "Issue #$INPUT_ISSUE has needs-validation — applying verdict label transition..."
155
163
 
156
164
  # Map /validate verdict to transition-label.sh verdict format
@@ -240,13 +240,21 @@ POST_REVIEW_LABELS=$(gh issue view {ISSUE_NUMBER} {GH_FLAG} --json labels --jq '
240
240
  **If `workflow:awaiting-merge` is present** (clean re-review case — the only branch where `review-pr.md`'s guard has already safely parked this PR): compute the bar.
241
241
 
242
242
  ```bash
243
+ # Trust filter: only reviews/comments from repo collaborators (OWNER/MEMBER/COLLABORATOR
244
+ # authorAssociation) can contribute to the auto-land bar. Unlike work-on.md Phase 7A's
245
+ # informational-only APPROVED: count (a summary-card/decision-record annotation, not a
246
+ # merge gate), this count directly drives `gh pr merge` below — so it must not trust
247
+ # unauthenticated signal. Any GitHub user can comment "APPROVED: ..." on a public PR;
248
+ # authorAssociation is GitHub's own repo-permission classification and cannot be spoofed
249
+ # by comment text. (Ref: forge#1976)
243
250
  REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
244
- --jq '[.reviews[].body // ""] + [.comments[].body // ""] | .[]')
251
+ --jq '[.reviews[] | select(.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR") | .body // ""] +
252
+ [.comments[] | select(.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR") | .body // ""] | .[]')
245
253
  APPROVED_COUNT=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED_COUNT=${APPROVED_COUNT:-0}
246
254
  ```
247
255
 
248
256
  **Auto-land bar** (per #1809 Q1) — BOTH conditions required:
249
- 1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments (same counting convention as `work-on.md` Phase 7A).
257
+ 1. `APPROVED_COUNT >= 2` — at least two distinct adversarial `APPROVED:` review comments from repo collaborators (`OWNER`/`MEMBER`/`COLLABORATOR` authorAssociation only — see trust filter above; same counting convention as `work-on.md` Phase 7A).
250
258
  2. `GATE_PASSED = true` from this remediation's own Phase M3 quality-gate loop.
251
259
 
252
260
  **If the bar is met**:
@@ -382,7 +382,16 @@ if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
382
382
  echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
383
383
  ADAPTIVE_ENABLED=false
384
384
  fi
385
- UNIVERSAL_DIR="${FORGEDOCK_HOME:-$(dirname "$(which classify-lane.sh 2>/dev/null || echo 'scripts')")}/scripts"
385
+ UNIVERSAL_DIR="${FORGEDOCK_HOME:-$REPO_PATH}/scripts"
386
+ # NOTE: never resolve this via `which` or `find` — universal scripts are
387
+ # repo-relative, not installed on $PATH, so a PATH lookup always misses.
388
+ # REPO_PATH is already resolved from forge.yaml → paths.root earlier in
389
+ # Phase 0, so it is the deterministic fallback when FORGEDOCK_HOME is unset.
390
+ # Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
391
+ # locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
392
+ # does not exist, resolve_script() falls through to Tier 4 (prose) below,
393
+ # which is always safe and available. A missing script is never a reason
394
+ # to search the filesystem. <!-- Added: forge#1984 -->
386
395
 
387
396
  resolve_script() {
388
397
  local operation="$1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.1.10",
3
+ "version": "1.1.11",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -349,7 +349,9 @@ function collectCommits(repoPath, sinceDate) {
349
349
  const reason = result.error
350
350
  ? result.error.message
351
351
  : `exit code ${result.status}${result.signal ? ` (signal ${result.signal})` : ''}`;
352
- log(`WARNING: git log failed — ${reason}`);
352
+ const stderrDetail =
353
+ result.stderr && result.stderr.trim() ? ` — ${result.stderr.trim()}` : '';
354
+ log(`WARNING: git log failed — ${reason}${stderrDetail}`);
353
355
  return [];
354
356
  }
355
357
 
@@ -83,8 +83,25 @@ if [ "${1:-}" = "--validate" ]; then
83
83
  fi
84
84
 
85
85
  # Check if issue carries needs-validation (idempotent gate)
86
- CURRENT_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
87
- --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
86
+ #
87
+ # forge#1991: a bare `2>/dev/null || echo ""` here collapsed "fetch
88
+ # failed" and "fetch succeeded, issue has no labels" into the same empty
89
+ # string, so a failed fetch fell through to the "no needs-validation —
90
+ # no action taken (idempotent)" branch below and exited 0, silently
91
+ # misreporting a transient API failure as successful idempotent no-op.
92
+ # Use the same `if VAR=$(cmd); then ... else ... fi` idiom already
93
+ # applied to the workflow-mode label fetches (forge#1977/#1988/#1990) to
94
+ # keep the two outcomes distinguishable. This block has no
95
+ # TRANSITION_EXIT_CODE deferred-exit convention (that only exists in the
96
+ # workflow-mode block below), so failure exits 1 directly here, matching
97
+ # this script's documented exit codes (0 = success, 1 = error).
98
+ if CURRENT_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
99
+ --jq '[.labels[].name] | join(",")' 2>/dev/null); then
100
+ : # fetch succeeded; CURRENT_LABELS may legitimately be an empty string
101
+ else
102
+ echo "ERROR: needs-validation label fetch failed (transient network error / rate limit?) for issue #$ISSUE_NUMBER — cannot determine finding-lifecycle state. Aborting without action." >&2
103
+ exit 1
104
+ fi
88
105
 
89
106
  if ! echo "$CURRENT_LABELS" | grep -q "needs-validation"; then
90
107
  echo "OK: Issue #$ISSUE_NUMBER does not have needs-validation — no action taken (idempotent)"
@@ -282,9 +299,27 @@ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --add-label "$EFFECTIVE_LABEL"
282
299
  # have doesn't need removing anyway — so intersecting against the issue's
283
300
  # current labels sidesteps the all-or-nothing failure mode entirely instead
284
301
  # of relying on `|| true` to mask it.
302
+ #
303
+ # forge#1990: this fetch itself can fail (transient network error, rate
304
+ # limit) independently of whether the issue actually has any stale labels.
305
+ # A bare `2>/dev/null || echo ""` collapsed "fetch failed" and "fetch
306
+ # succeeded, issue has no labels" into the same empty string — so a failed
307
+ # fetch here silently skipped the ENTIRE removal/verification block below
308
+ # (not just its post-hoc confirmation) while the script still reported `OK`
309
+ # on exit 0. Use the same `if VAR=$(cmd); then ... else ... fi` idiom
310
+ # already applied to the post-removal verification fetch below (forge#1977/
311
+ # #1988) to keep the two outcomes distinguishable, and defer a non-zero
312
+ # exit via TRANSITION_EXIT_CODE (forge#1929 convention) instead of masking
313
+ # the failure as success.
285
314
  # ---------------------------------------------------------------------------
286
- CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
287
- --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
315
+ if CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
316
+ --jq '[.labels[].name] | join(",")' 2>/dev/null); then
317
+ : # fetch succeeded; CURRENT_ISSUE_LABELS may legitimately be an empty string
318
+ else
319
+ echo "ERROR: pre-removal label fetch failed (transient network error / rate limit?) for issue #$ISSUE_NUMBER — cannot determine which stale workflow:* labels to remove. Skipping removal to avoid acting on incomplete data." >&2
320
+ CURRENT_ISSUE_LABELS=""
321
+ TRANSITION_EXIT_CODE=1
322
+ fi
288
323
 
289
324
  TO_REMOVE=""
290
325
  IFS=',' read -ra REMOVE_CANDIDATES <<< "$REMOVE_LABELS"
@@ -327,35 +362,52 @@ if [ -n "$TO_REMOVE" ]; then
327
362
  # step in the one case (persistent removal failure) it's most important
328
363
  # for the caller to see a consistent, fully-applied label state.
329
364
  # -------------------------------------------------------------------------
330
- POST_REMOVE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
331
- --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
332
-
333
- STILL_PRESENT=""
334
- IFS=',' read -ra TO_REMOVE_CHECK <<< "$TO_REMOVE"
335
- for candidate in "${TO_REMOVE_CHECK[@]}"; do
336
- case ",$POST_REMOVE_LABELS," in
337
- *",$candidate,"*)
338
- STILL_PRESENT="${STILL_PRESENT:+$STILL_PRESENT,}$candidate"
339
- ;;
340
- esac
341
- done
365
+ # forge#1977: the fetch itself can fail (transient network error, rate
366
+ # limit) independently of whether the label was actually removed. A bare
367
+ # `2>/dev/null || echo ""` collapses "fetch failed" and "fetch succeeded,
368
+ # label absent" into the same empty string, so a failed verification call
369
+ # was previously indistinguishable from a confirmed-clean result — masking
370
+ # the exact failure mode this block exists to catch. Use `if VAR=$(cmd);
371
+ # then ... else ... fi` (set -e-safe: command substitution failure inside
372
+ # an `if` condition does not trigger `set -e`) to keep the two outcomes
373
+ # distinguishable, and treat a failed fetch as "still present" so it flows
374
+ # into the existing retry / fail-loud path below instead of silently
375
+ # reporting success.
376
+ if POST_REMOVE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
377
+ --jq '[.labels[].name] | join(",")' 2>/dev/null); then
378
+ STILL_PRESENT=""
379
+ IFS=',' read -ra TO_REMOVE_CHECK <<< "$TO_REMOVE"
380
+ for candidate in "${TO_REMOVE_CHECK[@]}"; do
381
+ case ",$POST_REMOVE_LABELS," in
382
+ *",$candidate,"*)
383
+ STILL_PRESENT="${STILL_PRESENT:+$STILL_PRESENT,}$candidate"
384
+ ;;
385
+ esac
386
+ done
387
+ else
388
+ echo "WARNING: post-removal verification API call failed (transient network error / rate limit?) — cannot confirm removal, treating ($TO_REMOVE) as unverified..." >&2
389
+ STILL_PRESENT="$TO_REMOVE"
390
+ fi
342
391
 
343
392
  if [ -n "$STILL_PRESENT" ]; then
344
393
  echo "WARNING: label removal did not take effect for ($STILL_PRESENT) — retrying once..." >&2
345
394
  gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$STILL_PRESENT" 2>/dev/null || true
346
395
 
347
- POST_RETRY_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
348
- --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
349
-
350
- STILL_PRESENT_AFTER_RETRY=""
351
- IFS=',' read -ra RETRY_CHECK <<< "$STILL_PRESENT"
352
- for candidate in "${RETRY_CHECK[@]}"; do
353
- case ",$POST_RETRY_LABELS," in
354
- *",$candidate,"*)
355
- STILL_PRESENT_AFTER_RETRY="${STILL_PRESENT_AFTER_RETRY:+$STILL_PRESENT_AFTER_RETRY,}$candidate"
356
- ;;
357
- esac
358
- done
396
+ if POST_RETRY_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
397
+ --jq '[.labels[].name] | join(",")' 2>/dev/null); then
398
+ STILL_PRESENT_AFTER_RETRY=""
399
+ IFS=',' read -ra RETRY_CHECK <<< "$STILL_PRESENT"
400
+ for candidate in "${RETRY_CHECK[@]}"; do
401
+ case ",$POST_RETRY_LABELS," in
402
+ *",$candidate,"*)
403
+ STILL_PRESENT_AFTER_RETRY="${STILL_PRESENT_AFTER_RETRY:+$STILL_PRESENT_AFTER_RETRY,}$candidate"
404
+ ;;
405
+ esac
406
+ done
407
+ else
408
+ echo "WARNING: post-retry verification API call also failed (transient network error / rate limit?) — cannot confirm removal after retry, treating ($STILL_PRESENT) as unverified..." >&2
409
+ STILL_PRESENT_AFTER_RETRY="$STILL_PRESENT"
410
+ fi
359
411
 
360
412
  if [ -n "$STILL_PRESENT_AFTER_RETRY" ]; then
361
413
  echo "ERROR: failed to remove stale label(s) ($STILL_PRESENT_AFTER_RETRY) from issue #$ISSUE_NUMBER after retry — label state machine now inconsistent (issue carries both '$EFFECTIVE_LABEL' and the stale label(s) above)." >&2