forgedock 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +34 -1
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +229 -17
  4. package/commands/autopilot.md +77 -0
  5. package/commands/design-bench.md +176 -0
  6. package/commands/design-render-critique-loop.md +103 -0
  7. package/commands/design.md +259 -0
  8. package/commands/issue.md +7 -3
  9. package/commands/orchestrate.md +121 -0
  10. package/commands/qa-sweep.md +37 -2
  11. package/commands/quality-gate.md +30 -0
  12. package/commands/review-pr-staging.md +158 -2
  13. package/commands/test-gate.md +929 -0
  14. package/commands/work-on/build/implement.md +5 -3
  15. package/commands/work-on.md +53 -3
  16. package/docs/CI.md +177 -0
  17. package/docs/FORGE-PROTOCOL.md +324 -0
  18. package/docs/articles/b-vs-c-baseline.md +210 -0
  19. package/docs/articles/full-abc-benchmark.md +205 -0
  20. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  21. package/docs/design/abc-benchmark.md +195 -0
  22. package/docs/design/design-architect-rationale.md +123 -0
  23. package/docs/design/design-memory.md +61 -0
  24. package/docs/design/design-spec-schema.md +248 -0
  25. package/docs/design/design-system-lint.md +150 -0
  26. package/docs/design/divergent-generation.md +106 -0
  27. package/docs/design/effects-appropriateness.md +133 -0
  28. package/docs/design/fixtures/briefs/README.md +47 -0
  29. package/docs/design/fixtures/briefs/cadence.md +39 -0
  30. package/docs/design/fixtures/briefs/plume.md +39 -0
  31. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  32. package/docs/design/fixtures/briefs/tender.md +39 -0
  33. package/docs/design/fixtures/briefs/voltage.md +39 -0
  34. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  35. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  36. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  37. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  38. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  39. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  40. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  41. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  42. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  43. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  44. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  45. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  46. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  47. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  48. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  49. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  50. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  51. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  52. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  53. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  54. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  55. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  56. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  57. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  58. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  59. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  60. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  61. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  62. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  63. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  64. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  65. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  66. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  67. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  68. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  69. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  70. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  71. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  72. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  73. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  74. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  75. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  76. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  77. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  78. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  79. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  80. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  81. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  82. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  83. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  84. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  85. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  86. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  87. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  88. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  89. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  90. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  91. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  92. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  93. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  94. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  95. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  96. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  97. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  98. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  99. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  100. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  101. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  102. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  103. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  104. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  105. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  106. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  107. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  108. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  109. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  110. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  111. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  112. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  113. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  114. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  115. package/docs/design/foundations/brutalist.css +294 -0
  116. package/docs/design/foundations/editorial.css +300 -0
  117. package/docs/design/foundations/minimal-luxury.css +295 -0
  118. package/docs/design/foundations/technical.css +293 -0
  119. package/docs/design/foundations/warm-photo.css +299 -0
  120. package/docs/design/reference-corpus.md +1726 -0
  121. package/docs/design/render-critique-loop.md +57 -0
  122. package/docs/spec-graph-schema.md +397 -0
  123. package/package.json +1 -1
  124. package/scripts/bench-scorecard.mjs +239 -0
  125. package/scripts/build-spec-graph.mjs +509 -0
  126. package/scripts/classify-lane.sh +0 -7
  127. package/scripts/design-system-lint.mjs +993 -0
  128. package/scripts/graph-query.sh +486 -0
  129. package/scripts/transition-label.sh +10 -2
  130. package/scripts/validate-spec-graph.sh +423 -0
  131. package/templates/workflows/forgedock-review.yml +96 -0
package/bin/forgedock.mjs CHANGED
@@ -204,7 +204,7 @@ function stripJsonc(raw) {
204
204
  if (ch === "/" && i + 1 < len && raw[i + 1] === "*") {
205
205
  i += 2;
206
206
  while (i + 1 < len && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
207
- i += 2;
207
+ if (i + 1 < len) i += 2; // only advance past */ if terminator was found
208
208
  continue;
209
209
  }
210
210
 
@@ -240,7 +240,7 @@ function stripJsonc(raw) {
240
240
  if (j + 1 < len && raw[j] === "/" && raw[j + 1] === "*") {
241
241
  j += 2;
242
242
  while (j + 1 < len && !(raw[j] === "*" && raw[j + 1] === "/")) j++;
243
- j += 2;
243
+ if (j + 1 < len) j += 2; // only advance past */ if terminator was found
244
244
  advanced = true;
245
245
  }
246
246
  }
@@ -321,6 +321,51 @@ function atomicWriteFile(filePath, content) {
321
321
  }
322
322
  }
323
323
 
324
+ /**
325
+ * Idempotently write a `.symlink-source` sentinel file to TARGET_DIR
326
+ * (~/.claude/commands/) so other installers can detect that ForgeDock
327
+ * owns this namespace.
328
+ *
329
+ * The sentinel is a plain UTF-8 text file (not a symlink) containing
330
+ * the source path, a reinstall hint, and the install timestamp. It is
331
+ * updated on every install so the timestamp stays fresh. Write failures
332
+ * are non-fatal — the function returns 'failed' and the caller logs a
333
+ * warning; install continues regardless.
334
+ *
335
+ * Calling convention matches the other state-write helpers:
336
+ * 'written' — sentinel was newly created or refreshed
337
+ * 'failed' — an error occurred (non-fatal; install continues)
338
+ *
339
+ * <!-- Added: forge#1038 -->
340
+ *
341
+ * @returns {'written' | 'failed'}
342
+ */
343
+ function writeSymlinkSentinel() {
344
+ const sentinelPath = join(TARGET_DIR, ".symlink-source");
345
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
346
+ const content =
347
+ `# ForgeDock command symlinks — DO NOT REPOINT\n` +
348
+ `# Source: ${COMMANDS_DIR}\n` +
349
+ `#\n` +
350
+ `# These symlinks are managed by ForgeDock (https://forgedock.com).\n` +
351
+ `# ForgeDock owns the global ~/.claude/commands/ namespace.\n` +
352
+ `# Project-specific commands should install to the project's\n` +
353
+ `# .claude/commands/ directory instead (Claude Code merges both;\n` +
354
+ `# project-local commands win on name collisions).\n` +
355
+ `#\n` +
356
+ `# Running another installer here will silently repoint these\n` +
357
+ `# symlinks, breaking all ForgeDock commands globally.\n` +
358
+ `#\n` +
359
+ `# To reinstall ForgeDock: npx forgedock install\n` +
360
+ `# Last installed: ${timestamp}\n`;
361
+ try {
362
+ atomicWriteFile(sentinelPath, content);
363
+ return "written";
364
+ } catch {
365
+ return "failed";
366
+ }
367
+ }
368
+
324
369
  /**
325
370
  * Install the ForgeDock SessionStart hook into ~/.claude/settings.json
326
371
  * idempotently. Does not modify any existing hooks unrelated to ForgeDock.
@@ -566,9 +611,18 @@ function injectManagedBlock(filePath) {
566
611
  "[\\s\\S]*?" +
567
612
  escapeRegExp(CLAUDE_BLOCK_END),
568
613
  );
614
+ if (!blockRegex.test(existing)) {
615
+ // Should not happen: both markers were confirmed above via includes() and
616
+ // indexOf() ordering. Guard against silent misclassification — if the regex
617
+ // somehow fails to match, replaced === existing would incorrectly return
618
+ // 'unchanged' instead of surfacing the broken state.
619
+ throw new Error(
620
+ `injectManagedBlock: regex failed to match markers in ${filePath}`,
621
+ );
622
+ }
569
623
  const replaced = existing.replace(blockRegex, managed);
570
624
  if (replaced === existing) {
571
- // Regex matched but content was already identical
625
+ // Content between markers was already identical to the managed block
572
626
  return "unchanged";
573
627
  }
574
628
  atomicWriteFile(filePath, replaced);
@@ -754,8 +808,12 @@ function removeForgeHomeFromProfile(profilePath) {
754
808
  // Step 1: Remove the 2-line ForgeDock block (comment + export), preceded
755
809
  // by an optional leading blank line that install() inserts.
756
810
  // The \r? handles profiles with CRLF line endings.
811
+ // The value pattern (?:[^"\\]|\\.)* matches a double-quoted shell string
812
+ // that may contain backslash-escape sequences (e.g. \" or \\) written by
813
+ // shellEscapeDoubleQuotedPath(). The simpler [^"]* would stop prematurely
814
+ // at the escaped-quote character inside \", failing to match the full line.
757
815
  let cleaned = content.replace(
758
- /\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="[^"]*"\r?\n/g,
816
+ /\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="(?:[^"\\]|\\.)*"\r?\n/g,
759
817
  "\n",
760
818
  );
761
819
 
@@ -820,6 +878,16 @@ async function linkCommands(step) {
820
878
  if (current === file) {
821
879
  skipped++;
822
880
  } else {
881
+ // Symlink points to a different source — warn if it appears to belong
882
+ // to another Forge/ForgeDock installation (i.e. not a broken link that
883
+ // already pointed here under a different absolute path).
884
+ // Only emit the warning once per install to avoid log spam.
885
+ // <!-- Added: forge#1038 -->
886
+ if (!current.startsWith(COMMANDS_DIR)) {
887
+ step.note(
888
+ yellow(`collision: ${rel} was → ${current} — repointing to ForgeDock`),
889
+ );
890
+ }
823
891
  try {
824
892
  await symlink(file, target + ".tmp");
825
893
  const { rename } = await import("fs/promises");
@@ -986,6 +1054,29 @@ async function install() {
986
1054
  {
987
1055
  label: "Checking environment",
988
1056
  async run(step) {
1057
+ // Worktree guard — refuse to install when FORGE_HOME is a git worktree.
1058
+ // In a worktree, FORGE_HOME/.git is a regular file (not a directory).
1059
+ // Installing from a worktree would bake an ephemeral path into the
1060
+ // symlinks and SessionStart hook; the path breaks when the worktree
1061
+ // is cleaned up, taking all Forge commands offline globally.
1062
+ // <!-- Added: forge#1037 -->
1063
+ const gitPath = join(FORGE_HOME, ".git");
1064
+ let gitStat = null;
1065
+ try {
1066
+ gitStat = await lstat(gitPath);
1067
+ } catch (err) {
1068
+ if (err.code !== "ENOENT") throw err;
1069
+ // .git absent — npm install or detached checkout; not a worktree.
1070
+ }
1071
+ if (gitStat !== null && gitStat.isFile()) {
1072
+ throw new Error(
1073
+ `install() is running from a git worktree (${FORGE_HOME}).\n` +
1074
+ ` Installing from a worktree would repoint ~/.claude/commands/ symlinks\n` +
1075
+ ` to an ephemeral path that breaks when the worktree is deleted.\n` +
1076
+ ` Run \`npx forgedock install\` from the main repository clone instead.`,
1077
+ );
1078
+ }
1079
+
989
1080
  await mkdir(TARGET_DIR, { recursive: true });
990
1081
  step.note(cyan(TARGET_DIR));
991
1082
  },
@@ -1000,6 +1091,20 @@ async function install() {
1000
1091
  );
1001
1092
  },
1002
1093
  },
1094
+ {
1095
+ label: "Writing namespace sentinel",
1096
+ async run(step) {
1097
+ // Write .symlink-source to ~/.claude/commands/ so other installers
1098
+ // can detect that ForgeDock owns this namespace. Non-fatal on failure.
1099
+ // <!-- Added: forge#1038 -->
1100
+ const result = writeSymlinkSentinel();
1101
+ if (result === "failed") {
1102
+ step.note("could not write .symlink-source — run npx forgedock install again to retry");
1103
+ } else {
1104
+ step.note(cyan(join(TARGET_DIR, ".symlink-source")));
1105
+ }
1106
+ },
1107
+ },
1003
1108
  {
1004
1109
  label: "Linking scripts",
1005
1110
  async run(step) {
@@ -1036,7 +1141,12 @@ async function install() {
1036
1141
  // Either not present, or set to a stale path — refresh it.
1037
1142
  if (content.includes("FORGE_HOME")) {
1038
1143
  // Strip the old block before appending the updated one.
1039
- removeForgeHomeFromProfile(profile);
1144
+ const removeResult = removeForgeHomeFromProfile(profile);
1145
+ if (removeResult === "failed") {
1146
+ // Removal failed — old export still present. Skip append to
1147
+ // avoid writing a duplicate FORGE_HOME export. (ref: forge#846)
1148
+ continue;
1149
+ }
1040
1150
  }
1041
1151
  appendFileSync(
1042
1152
  profile,
@@ -1158,8 +1268,17 @@ async function uninstall() {
1158
1268
  console.log(` ${RED}Removed${RESET}: ${rel}`);
1159
1269
  removed++;
1160
1270
  }
1271
+ } else if (
1272
+ readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
1273
+ ) {
1274
+ // Regular file installed by ForgeDock in copy mode — content matches.
1275
+ const { unlink } = await import("fs/promises");
1276
+ await unlink(target);
1277
+ console.log(` ${RED}Removed${RESET}: ${rel}`);
1278
+ removed++;
1161
1279
  }
1162
- } catch {
1280
+ } catch (err) {
1281
+ if (err.code !== "ENOENT") throw err;
1163
1282
  // Doesn't exist — nothing to do
1164
1283
  }
1165
1284
  }
@@ -1580,6 +1699,14 @@ async function init(fromInstall = false) {
1580
1699
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1581
1700
  .join(" ");
1582
1701
 
1702
+ // Pre-escape values for embedding in YAML double-quoted strings.
1703
+ // Backslash must be escaped first to avoid double-escaping.
1704
+ const safeOwner = owner.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1705
+ const safeRepo = repo.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1706
+ const safeProjectName = projectName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1707
+ const safeDefaultBranch = defaultBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1708
+ const safeStagingBranch = stagingBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1709
+
1583
1710
  const content = `# forge.yaml — ForgeDock Configuration
1584
1711
  #
1585
1712
  # Auto-generated by: npx forgedock init
@@ -1595,9 +1722,9 @@ async function init(fromInstall = false) {
1595
1722
  # =============================================================================
1596
1723
 
1597
1724
  project:
1598
- name: "${projectName}"
1599
- owner: "${owner}"
1600
- repo: "${repo}"
1725
+ name: "${safeProjectName}"
1726
+ owner: "${safeOwner}"
1727
+ repo: "${safeRepo}"
1601
1728
  description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1602
1729
 
1603
1730
  # =============================================================================
@@ -1613,8 +1740,8 @@ paths:
1613
1740
  # =============================================================================
1614
1741
 
1615
1742
  branches:
1616
- default: "${defaultBranch}"
1617
- staging: "${stagingBranch}"
1743
+ default: "${safeDefaultBranch}"
1744
+ staging: "${safeStagingBranch}"
1618
1745
  feature_pattern: "milestone/{slug}"
1619
1746
 
1620
1747
  # =============================================================================
@@ -1624,22 +1751,22 @@ branches:
1624
1751
 
1625
1752
  # repos:
1626
1753
  # default:
1627
- # repo: "${owner}/${repo}"
1628
- # staging_branch: "${stagingBranch}"
1754
+ # repo: "${safeOwner}/${safeRepo}"
1755
+ # staging_branch: "${safeStagingBranch}"
1629
1756
  # satellites:
1630
1757
  # - prefix: "mcp"
1631
- # repo: "${owner}/your-satellite-repo"
1758
+ # repo: "${safeOwner}/your-satellite-repo"
1632
1759
  # staging_branch: "main"
1633
1760
  # local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1634
1761
 
1635
1762
  # =============================================================================
1636
1763
  # PROJECT BOARD (OPTIONAL)
1637
1764
  # GitHub Projects v2 integration.
1638
- # To find IDs: gh project list --owner ${owner}
1765
+ # To find IDs: gh project list --owner ${safeOwner}
1639
1766
  # =============================================================================
1640
1767
 
1641
1768
  # project_board:
1642
- # owner: "${owner}"
1769
+ # owner: "${safeOwner}"
1643
1770
  # project_number: 1
1644
1771
  # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
1645
1772
  # field_ids:
@@ -1665,9 +1792,37 @@ branches:
1665
1792
  # =============================================================================
1666
1793
 
1667
1794
  # verification:
1668
- # health_endpoint: "https://api.${repo}.io/health"
1795
+ # health_endpoint: "https://api.${safeRepo}.io/health"
1669
1796
  # health_patterns:
1670
1797
  # - '"status": "ok"'
1798
+ #
1799
+ # # Integration test suites run by /test-gate against the provisioned cluster.
1800
+ # # Each entry specifies a logical cluster (matched to test_services), the shell
1801
+ # # command to execute, and the working directory (relative to project root).
1802
+ # # Used by: test-gate (Phase 3 Provision + Phase 4 Fan out)
1803
+ # #
1804
+ # # integration_tests:
1805
+ # # - cluster: "api"
1806
+ # # command: "pytest tests/integration/ -q --tb=short"
1807
+ # # working_dir: "."
1808
+ #
1809
+ # # Maps logical cluster names (from integration_tests) to running container
1810
+ # # names. /test-gate checks these containers are live before running tests.
1811
+ # # Omit entries for clusters that do not use Docker.
1812
+ # # Used by: test-gate (Phase 3 Provision)
1813
+ # #
1814
+ # # test_services:
1815
+ # # api: "${safeRepo}-api-blue"
1816
+ # # worker: "${safeRepo}-worker-blue"
1817
+ #
1818
+ # # Controls /test-gate posture at the staging-to-main deploy boundary.
1819
+ # # posture: blocking (default) or advisory.
1820
+ # # override_phrase: exact comment text an operator posts to bypass a BLOCK.
1821
+ # # Used by: test-gate (Phase 7 Verdict), review-pr-staging (Phase 6.5)
1822
+ # #
1823
+ # # test_gate:
1824
+ # # posture: "blocking"
1825
+ # # override_phrase: "OVERRIDE: shipping with test failures \u2014"
1671
1826
  `;
1672
1827
 
1673
1828
  atomicWriteFile(outputPath, content);
@@ -1866,6 +2021,7 @@ async function status(dir) {
1866
2021
  * 6. CLAUDE.md has the ForgeDock behavioral block (cwd)
1867
2022
  * 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
1868
2023
  * 8. FORGE_HOME environment variable is set
2024
+ * 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
1869
2025
  *
1870
2026
  * Exits with code 0 if all checks pass, code 1 if any fail.
1871
2027
  */
@@ -2229,6 +2385,62 @@ async function doctor() {
2229
2385
  }
2230
2386
  }
2231
2387
 
2388
+ // ── Check 9: Playwright MCP registered ────────────────────────────────────
2389
+ // Playwright MCP is a guaranteed ForgeDock dependency for browser automation
2390
+ // commands (/qa-sweep). This check is advisory (warn, not fail) because MCP
2391
+ // server presence is orthogonal to the core ForgeDock install — but missing
2392
+ // it causes /qa-sweep to silently fail mid-sweep.
2393
+ {
2394
+ const mcpServersPath = join(HOME, ".claude", "mcp_servers.json");
2395
+ let playwrightFound = false;
2396
+ let fileReadable = false;
2397
+ let hasMcpServers = false;
2398
+
2399
+ try {
2400
+ const mcpRaw = readFileSync(mcpServersPath, "utf-8");
2401
+ const mcpConfig = JSON.parse(mcpRaw);
2402
+ // Read + parse succeeded — the file exists and contains valid JSON.
2403
+ fileReadable = true;
2404
+ const servers = mcpConfig?.mcpServers;
2405
+ if (servers && typeof servers === "object") {
2406
+ hasMcpServers = true;
2407
+ // Search for a registered server whose name or command references playwright
2408
+ for (const [name, entry] of Object.entries(servers)) {
2409
+ const nameLower = name.toLowerCase();
2410
+ const cmdStr = [
2411
+ entry?.command ?? "",
2412
+ ...(Array.isArray(entry?.args) ? entry.args : []),
2413
+ ].join(" ").toLowerCase();
2414
+ if (nameLower.includes("playwright") || cmdStr.includes("playwright")) {
2415
+ playwrightFound = true;
2416
+ break;
2417
+ }
2418
+ }
2419
+ }
2420
+ } catch {
2421
+ // File absent or unreadable — treat as no MCP servers configured
2422
+ }
2423
+
2424
+ if (!fileReadable) {
2425
+ warn(
2426
+ "Playwright MCP",
2427
+ "No MCP servers file found (~/.claude/mcp_servers.json). Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
2428
+ );
2429
+ } else if (!hasMcpServers) {
2430
+ warn(
2431
+ "Playwright MCP",
2432
+ "MCP servers file found (~/.claude/mcp_servers.json) but it has no 'mcpServers' key. Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
2433
+ );
2434
+ } else if (playwrightFound) {
2435
+ pass("Playwright MCP", "registered in Claude Code MCP servers");
2436
+ } else {
2437
+ warn(
2438
+ "Playwright MCP",
2439
+ "Not registered — /qa-sweep and browser automation commands will fail. Run: claude mcp add playwright npx @playwright/mcp@latest",
2440
+ );
2441
+ }
2442
+ }
2443
+
2232
2444
  // ── Summary ────────────────────────────────────────────────────────────────
2233
2445
  console.log("");
2234
2446
  if (failures === 0 && warnings === 0) {
@@ -312,6 +312,7 @@ Print a structured report:
312
312
  - Issues commented: {count}
313
313
  - Orphans closed: {count}
314
314
  - Stale labels fixed: {count}
315
+ - Spec-edit impact analyses logged: {count} (FORGE:AUTOPILOT_IMPACT — blast radius surfaced before spec fixes)
315
316
 
316
317
  ### Recommended Next Steps
317
318
  {Prioritized list of what the user should look at or deploy}
@@ -372,6 +373,81 @@ Nothing merges to `main` — you deploy when ready.
372
373
 
373
374
  Wait for user response. If they adjust, re-select. If they skip, end the cycle.
374
375
 
376
+ ### 4B.5: Spec-Edit Impact Analysis (MANDATORY before any spec-touching fix) <!-- Added: forge#870 -->
377
+
378
+ **Goal**: Before autopilot dispatches a fix that edits ForgeDock's own command specs (`commands/*.md`), surface the blast radius of the change so the edit doesn't silently break downstream consumers (every spec that reads a changed FORGE annotation, every command in a changed sub-phase chain, every script/label a touched node feeds).
379
+
380
+ This step **consumes the read-only query interface** shipped by the Spec Knowledge Graph (`scripts/graph-query.sh`, subcommand `impact`). It does **not** reimplement graph traversal — the blast radius is computed once, in `graph-query.sh`, and autopilot only reads its output.
381
+
382
+ **When it runs**: for each approved fix target whose affected files (from the issue body's `## Affected Files` section, or the recon finding that created it) include any path matching `commands/*.md`. Non-spec fixes skip this step entirely.
383
+
384
+ **Graceful degradation**: `graph-query.sh` ships only with the Spec Knowledge Graph. If it is absent (fast-lane repos, or a checkout predating the milestone), **skip with a noted reason — never block the cycle**.
385
+
386
+ ```bash
387
+ GRAPH_QUERY="$(git rev-parse --show-toplevel)/scripts/graph-query.sh"
388
+
389
+ # For each approved fix target that edits commands/*.md:
390
+ for issue in $SPEC_FIX_TARGETS; do
391
+ if [ ! -x "$GRAPH_QUERY" ]; then
392
+ echo "skip impact analysis for #$issue — graph-query.sh not present (Spec Knowledge Graph not installed)"
393
+ continue
394
+ fi
395
+
396
+ # Derive the spec node(s) the fix changes. Prefer the FORGE annotation or
397
+ # command/sub-phase named in the issue's Affected Files / recon finding.
398
+ # graph-query.sh normalizes all of these forms:
399
+ # FORGE:CONTRACT == CONTRACT == ann:FORGE:CONTRACT
400
+ # work-on:build:implement == cmd:work-on:build:implement
401
+ # classify-lane.sh == script:classify-lane.sh
402
+ # workflow:merged == label:workflow:merged
403
+ for node in $CHANGED_SPEC_NODES; do
404
+ echo "### Impact of changing $node (fix #$issue)"
405
+ bash "$GRAPH_QUERY" impact "$node" --human
406
+ done
407
+ done
408
+ ```
409
+
410
+ **Log the result in a FORGE annotation.** Post one `FORGE:AUTOPILOT_IMPACT` comment per spec-touching fix target on its issue, BEFORE invoking `/work-on` in 4C:
411
+
412
+ ```bash
413
+ gh issue comment $issue --body "<!-- FORGE:AUTOPILOT_IMPACT -->
414
+ ## Spec-Edit Impact Analysis
415
+
416
+ **Changed spec node(s)**: \`$CHANGED_SPEC_NODES\`
417
+ **Query**: \`graph-query.sh impact <node> --human\` (Spec Knowledge Graph, read-only)
418
+
419
+ ### Blast Radius (downstream consumers of this change)
420
+ \`\`\`
421
+ $IMPACT_OUTPUT
422
+ \`\`\`
423
+
424
+ Every command/spec listed above reads the changed annotation or sits in the changed sub-phase chain. The \`/work-on\` build for this fix MUST keep these consumers working — verify the changed field/marker is still produced in the shape they expect.
425
+
426
+ <!-- FORGE:AUTOPILOT_IMPACT:COMPLETE -->"
427
+ ```
428
+
429
+ If `graph-query.sh` was absent, post the same annotation with the blast-radius block set to `(skipped — Spec Knowledge Graph not installed)` so the skip is auditable.
430
+
431
+ **Worked example** — autopilot picks up a fix that renames a field inside the `FORGE:CONTRACT` annotation. Before dispatching `/work-on`, it runs:
432
+
433
+ ```bash
434
+ $ graph-query.sh impact FORGE:CONTRACT --human
435
+ Impact (blast radius) of changing ann:FORGE:CONTRACT:
436
+ cmd:milestone
437
+ cmd:orchestrate
438
+ cmd:resume
439
+ cmd:review-pr
440
+ cmd:work-on
441
+ cmd:work-on-monolithic
442
+ cmd:work-on:build
443
+ cmd:work-on:build:implement
444
+ cmd:work-on:review
445
+ ```
446
+
447
+ The summary makes the nine downstream consumers explicit before the edit lands, so the contract-field rename is reviewed against every reader — not discovered as a broken consumer later.
448
+
449
+ **`FORGE:AUTOPILOT_IMPACT`** is a leaf annotation: autopilot is its only writer and no downstream pipeline phase consumes it. It exists to record the blast radius on the issue thread for human and reviewer visibility.
450
+
375
451
  ### 4C: Execute fixes
376
452
 
377
453
  For each approved fix target, invoke `/work-on` via the Skill tool:
@@ -403,6 +479,7 @@ FIX_RESULTS.push({
403
479
 
404
480
  **Merged to staging**: {count}
405
481
  **Needs attention**: {list}
482
+ **Spec-edit impact analyses**: {count} blast-radius summaries logged (FORGE:AUTOPILOT_IMPACT) before spec fixes
406
483
 
407
484
  When ready to deploy, merge `staging` → `main` via GitHub.
408
485
  ```
@@ -0,0 +1,176 @@
1
+ ---
2
+ description: Run the ABC benchmark — reference URL → design-blind brief → render arms A/B/C → blind judge → scorecard with win-rates over n runs
3
+ argument-hint: [<brief-name>|all] [--n <runs>] [--corpus-version <ver>]
4
+ ---
5
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
+
8
+ # /design-bench — The ABC Benchmark Rig
9
+
10
+ **Input**: $ARGUMENTS — a seed brief name (`cadence` | `tender` | `slipstream` | `voltage` | `plume`) or `all`, with optional `--n <runs>` (default 3, min 3) and `--corpus-version <ver>`.
11
+
12
+ This is the **fitness function** for the UI Taste Harness (milestone #13, issue #878). It is built *before*
13
+ the harness and is the only thing that tells us whether each lever the harness adds actually moves design
14
+ quality. Running this command answers one question: **did arm A (the harness) score higher against the real
15
+ reference (arm C) than it did last time?**
16
+
17
+ Read [`docs/design/abc-benchmark.md`](../docs/design/abc-benchmark.md) (methodology) before running. The three
18
+ arms, the same-model rule, the C-as-calibration check, the three-layer judging protocol, and the n>=3 rule all
19
+ live there — this command is the executable workflow over that methodology.
20
+
21
+ **No application runtime.** ForgeDock is a set of command specs. This command is a step-by-step procedure an
22
+ agent executes; the only executable helper is the deterministic aggregator `scripts/bench-scorecard.mjs`.
23
+
24
+ ---
25
+
26
+ ## The three arms (one variable)
27
+
28
+ | Arm | What it is | How this command produces it |
29
+ |-----|------------|------------------------------|
30
+ | **A** | ForgeDock harness output | Run the brief through `/design` (#888). **Pluggable** — see [Arm A is pluggable](#arm-a-is-pluggable). |
31
+ | **B** | Raw frontier model, one shot | Hand the brief to the model once, no tools, no iteration. |
32
+ | **C** | The real reference page | Capture the brief's real reference URL through the same render pipeline. |
33
+
34
+ **Hard rule — same generation model for A and B.** The harness is the ONLY difference between A and B; that is
35
+ what makes the A-vs-B delta attributable to the harness. Different models benchmark models, not the harness.
36
+
37
+ **Validated generation model: `claude-opus-4-6`.** Benchmark results (#878) show a tier-level quality difference: 90% A-vs-B pairwise win-rate with Opus vs 54% with Sonnet, rubric mean 4.11 vs 3.40, slop 1.2 vs 1.8. The canonical baseline going forward is the Opus run. Raw data: `docs/design/fixtures/runs/full-abc-opus/`. Use Opus for both arm A and arm B unless explicitly testing a different model.
38
+
39
+ ---
40
+
41
+ ## Phase 0: Resolve inputs
42
+
43
+ 1. Parse `$ARGUMENTS`: the brief name(s) and `--n` (default 3; **reject n<3** — n=1 is a lie). `all` = every
44
+ brief under `docs/design/fixtures/briefs/` (excluding `README.md`).
45
+ 2. For each brief, load `docs/design/fixtures/briefs/<name>.md`. This is the design-blind input handed to arms
46
+ A and B.
47
+ 3. Load the brief→reference(C) mapping from `docs/design/fixtures/briefs/README.md`. **The mapping is for the
48
+ judge/calibration record only — it is NEVER shown to the generating arms.** A real brand name leaking into a
49
+ brief measures recall, not taste, and inflates arm B.
50
+
51
+ ---
52
+
53
+ ## Phase 1: Brief (reference URL → design-blind brief)
54
+
55
+ The seed briefs are pre-stripped fixtures, so for the seed set this phase is a load, not a derivation.
56
+
57
+ **To add a new reference URL** (outside the seed set): fetch the page, strip every visual cue (color, type,
58
+ layout, "make it like X"), keep only what the product *does* / who it's for / what the page must communicate,
59
+ and assign an **anonymized** product name. Save as a new fixture and add its mapping row. Follow
60
+ `docs/design/fixtures/briefs/README.md`.
61
+
62
+ ---
63
+
64
+ ## Phase 2: Render arms (kill confounds)
65
+
66
+ Render **all three arms through the identical pipeline** so the judge compares design, not rendering artifacts.
67
+
68
+ For each of the `--n` runs per brief:
69
+
70
+ 1. **Arm A** — run the brief through `/design` (#888) → self-contained HTML.
71
+ 2. **Arm B** — one-shot the SAME model with the brief → self-contained HTML. No tools, no iteration.
72
+ 3. **Arm C** — the real reference URL.
73
+
74
+ Render every arm with **Playwright** (milestone render dependency) at:
75
+
76
+ - **1440px** desktop and **390px** mobile,
77
+ - `networkidle` + fonts loaded before capture,
78
+ - **full-page** screenshot.
79
+
80
+ For arm C, trim cookie banners / hero video and **record any trim applied** in the run record (reproducibility).
81
+
82
+ > **Arm A is pluggable.** Until `/design` (#888) lands, arm A is produced by a clearly-labeled manual hook:
83
+ > generate arm A by hand using the harness method-of-the-day and drop its HTML into the run record marked
84
+ > `arm_a_source: manual`. The rig — brief loading, render protocol, judging, scorecard — is fully operational
85
+ > without #888. When #888 lands, replace the manual hook with the `/design` invocation; nothing else changes.
86
+ > **Do NOT block the benchmark on #888.**
87
+
88
+ ---
89
+
90
+ ## Phase 3: Judge (three layers)
91
+
92
+ The judge is a **named, independent stage**. See [Anti-Goodhart](#anti-goodhart) — it MUST NOT be the harness's
93
+ own critique loop (#882).
94
+
95
+ For each run, produce judge output in the [runs-file schema](../docs/design/abc-benchmark.md#scorecard-schema):
96
+
97
+ ### Layer 1 — Blind randomized pairwise (primary)
98
+ Show the judge two **anonymized** screenshots in **shuffled** order and ask: *"Which reads more like a
99
+ professionally designed, well-funded SaaS landing page?"* Run **A-vs-C, B-vs-C, A-vs-B**. Record the winner
100
+ (`"A" | "B" | "C" | "tie"`) per comparison. Screenshots carry no arm labels.
101
+
102
+ ### Layer 2 — Rubric (1–5 per arm)
103
+ Score each arm on: hierarchy, typography, color discipline, whitespace rhythm, originality-vs-slop, mobile.
104
+
105
+ ### Layer 3 — Slop detector (binary)
106
+ Run the anti-slop negatives checklist (corpus #880) over each arm; record the count of negatives present
107
+ (lower is better).
108
+
109
+ Append each run to the brief's `runs[]` array in the runs file.
110
+
111
+ ---
112
+
113
+ ## Phase 4: Scorecard
114
+
115
+ Aggregate deterministically:
116
+
117
+ ```bash
118
+ node scripts/bench-scorecard.mjs runs.json > scorecard.json
119
+ ```
120
+
121
+ The aggregator (see `scripts/bench-scorecard.mjs`):
122
+ - **rejects any product with n<3 runs** (exit 1) — enforces the no-point-estimate rule,
123
+ - emits **win-rate vs C** per arm (overall + per-product), the **A-vs-B harness delta**, **rubric mean+stdev**
124
+ per arm/dimension, **mean slop** per arm,
125
+ - runs the **judge-calibration check**: C must beat A and B in the pairwise; any run where C loses is listed in
126
+ `judge_calibration.miscalibrated_runs` and the command exits 2 with a warning. A real reference losing to a
127
+ generated arm is a judge defect, not a harness win — treat those runs as suspect.
128
+
129
+ Post the result as a `FORGE:BENCH_SCORECARD` annotation (registered in
130
+ [`docs/FORGE-PROTOCOL.md`](../docs/FORGE-PROTOCOL.md)) on the tracking issue:
131
+
132
+ ```bash
133
+ gh issue comment <ISSUE> --body "<!-- FORGE:BENCH_SCORECARD -->
134
+ ## ABC Benchmark Scorecard
135
+
136
+ **Corpus version**: <ver> · **Generation model (A & B)**: <model> (canonical: claude-opus-4-6) · **Judge model**: <independent judge>
137
+ **Products**: <n_products> · **Runs/product**: <n>
138
+
139
+ | Arm | Win-rate vs C | A-vs-B | Mean slop |
140
+ |-----|---------------|--------|-----------|
141
+ | A | <wr> | <a_vs_b> | <slop_A> |
142
+ | B | <wr> | — | <slop_B> |
143
+
144
+ **Judge calibration**: <ok | MISCALIBRATED — N runs where C lost (suspect)>
145
+
146
+ \`\`\`json
147
+ <scorecard.json>
148
+ \`\`\`
149
+
150
+ <!-- FORGE:BENCH_SCORECARD:COMPLETE -->"
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Two non-negotiable rules
156
+
157
+ ### n=1 is a lie
158
+ Taste output is high-variance. Run **3–5 generations per arm across 3–5 products** and report **distributions**.
159
+ The aggregator hard-fails on n<3.
160
+
161
+ ### Anti-Goodhart
162
+ The benchmark judge **MUST be independent from the harness's own critique loop (#882)**. Optimizing against the
163
+ same judge that scores the benchmark is training to the test — the number rises while real quality does not. The
164
+ benchmark judge uses its own prompt and is a distinct stage; it does not reuse the #882 critique judge.
165
+
166
+ ---
167
+
168
+ ## Acceptance (issue #878)
169
+
170
+ - [x] Reproducible command/workflow: reference URL → brief → render arms → judge → scorecard.
171
+ - [x] Arms A/B/C defined; same generation model for A and B; C = gold standard + judge calibration.
172
+ - [x] Three judging layers: blind randomized pairwise, 1–5 rubric, binary slop detector.
173
+ - [x] n=3–5 across 3–5 products; distributions reported; n=1 rejected by the aggregator.
174
+ - [x] Benchmark judge independent from the #882 critique loop (anti-Goodhart).
175
+ - [x] Five seed briefs as fixtures (Cadence/Tender/Slipstream/Voltage/Plume), anonymized + mapped to C.
176
+ - [x] Arm A pluggable — rig runs now, plugs into `/design` (#888) when it lands.