forgedock 1.26.0 → 1.28.0

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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
package/bin/runner.mjs CHANGED
@@ -359,6 +359,37 @@ export function resolveConfiguredDefaultModel(cwd) {
359
359
  }
360
360
  }
361
361
 
362
+ /**
363
+ * Whether the owner approved the paid API backend as the `auto` fallback (#4257).
364
+ * Needs both forge.yaml `execution.agent_backend: api` in `cwd` (repo owner) and
365
+ * env `FORGEDOCK_ALLOW_API_BACKEND=1` (host owner), so a committed config edit
366
+ * alone cannot start billing an API key. Never throws.
367
+ *
368
+ * @param {{cwd?: string, env?: object}} [opts]
369
+ * @returns {boolean}
370
+ */
371
+ export function isApiBackendApproved({ cwd = process.cwd(), env = process.env } = {}) {
372
+ if (String(env?.FORGEDOCK_ALLOW_API_BACKEND ?? "").trim() !== "1") return false;
373
+ try {
374
+ const forgeYamlPath = join(cwd, "forge.yaml");
375
+ if (!existsSync(forgeYamlPath)) return false;
376
+ return parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.execution?.agent_backend === "api";
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+
382
+ /** #4257: typed error when `auto` finds no usable CLI and the API backend is not approved. */
383
+ function cliUnavailableError(detail = "The claude CLI is not available") {
384
+ const err = new Error(
385
+ `${detail}, and the API backend is never used implicitly. Install and authenticate the \`claude\` CLI, ` +
386
+ "or opt in to the API backend (forge.yaml `execution.agent_backend: api` plus FORGEDOCK_ALLOW_API_BACKEND=1), " +
387
+ "or pass --backend api.",
388
+ );
389
+ err.code = "CLI_BACKEND_UNAVAILABLE";
390
+ return err;
391
+ }
392
+
362
393
  /**
363
394
  * Resolve the CLI backend timeout from forge.yaml's
364
395
  * `pipeline.cli_timeout_minutes` field, if it is a positive integer.
@@ -854,6 +885,7 @@ export function checkExecutionBackend({
854
885
  apiKey = env.ANTHROPIC_API_KEY,
855
886
  resolveCliFn = resolveClaudeCliBinary,
856
887
  spawnImpl = spawnSync,
888
+ apiApproved = isApiBackendApproved({ cwd, env }),
857
889
  sdkAvailableFn = () => {
858
890
  try {
859
891
  createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
@@ -884,7 +916,8 @@ export function checkExecutionBackend({
884
916
  return { ready: true, backend: "cli", reason: "resolved-cli" };
885
917
  }
886
918
  }
887
- if (requested === "cli") {
919
+ // #4257: auto never crosses to the paid API backend without the owner's opt-in.
920
+ if (requested === "cli" || !apiApproved) {
888
921
  return { ready: false, backend: "cli", reason: "cli-unavailable" };
889
922
  }
890
923
  }
@@ -905,7 +938,8 @@ export function checkExecutionBackend({
905
938
  * CLI and prefer it when present — but diverge on (a) which override
906
939
  * values are valid and whether an invalid one throws or is silently
907
940
  * ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
908
- * always falls back to `"api"`; `resolveEnrichBackend` falls back to `"api"`
941
+ * falls back to `"api"` only with the owner's opt-in, else `"cli"` (#4257);
942
+ * `resolveEnrichBackend` falls back to `"api"`
909
943
  * only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
910
944
  * those into one shape, this shared helper implements exactly the common
911
945
  * two steps and defers to a `cliFallback` callback for the diverging tail,
@@ -954,24 +988,27 @@ export function resolveBackendLadder({
954
988
  * 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
955
989
  * CLI backend when a working `claude` install is detected (reuses
956
990
  * whatever credentials the CLI already has — Pro/Max OAuth or a
957
- * CLI-managed key — with no ANTHROPIC_API_KEY required), otherwise
958
- * falls back to the API backend unchanged (existing behavior for every
959
- * caller that never opts in to this feature).
991
+ * CLI-managed key — with no ANTHROPIC_API_KEY required). Without a CLI it
992
+ * falls back to the API backend ONLY when the owner opted in
993
+ * (`isApiBackendApproved()`, #4257); otherwise it returns `"cli"` and
994
+ * `runCommand()` fails with a typed `CLI_BACKEND_UNAVAILABLE`. The API
995
+ * backend is never the implicit default.
960
996
  *
961
997
  * Delegates its shared "override-or-probe-CLI" shape to
962
998
  * `resolveBackendLadder()` (issue #2026) — this function retains its own
963
999
  * `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
964
- * doc comment) and its own no-API-key-check fallback (always `"api"`,
965
- * unconditionally — the API key itself is validated lazily downstream, only
1000
+ * doc comment) and its own opt-in-gated fallback (`"api"` only when
1001
+ * approved — the API key itself is validated lazily downstream, only
966
1002
  * if/when the api backend is actually selected).
967
1003
  *
968
1004
  * @param {object} [opts]
969
1005
  * @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
970
1006
  * @param {string} [opts.cwd] - Working directory for the CLI probe.
971
1007
  * @param {object} [opts.env] - Runtime environment used for capability checks.
1008
+ * @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe (test seam).
972
1009
  * @returns {"cli"|"api"}
973
1010
  */
974
- export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
1011
+ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env, isCliAvailableFn = isClaudeCliAvailable } = {}) {
975
1012
  if (!VALID_BACKENDS.has(requested)) {
976
1013
  throw new Error(
977
1014
  `Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
@@ -982,7 +1019,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env =
982
1019
  override: requested === "auto" ? undefined : requested,
983
1020
  validOverrides: new Set(["cli", "api"]),
984
1021
  cwd,
985
- cliFallback: () => "api",
1022
+ isCliAvailableFn,
1023
+ cliFallback: () => (isApiBackendApproved({ cwd, env }) ? "api" : "cli"),
986
1024
  });
987
1025
  }
988
1026
 
@@ -2789,7 +2827,9 @@ export function renderSummaryCard(ctx) {
2789
2827
  * @param {string} [opts.backend] - "cli" | "api" | "auto" (default
2790
2828
  * "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
2791
2829
  * "auto" prefers the local Claude Code CLI when detected (no
2792
- * ANTHROPIC_API_KEY needed), else falls back to the API backend. This
2830
+ * ANTHROPIC_API_KEY needed); without a CLI it uses the API backend only
2831
+ * with the owner's opt-in (`isApiBackendApproved()`, #4257), else it fails
2832
+ * with a typed CLI_BACKEND_UNAVAILABLE — api is never the default. This
2793
2833
  * precedence is intentional/shipped (issue #2003's accepted acceptance
2794
2834
  * criteria) and is NOT changed by issue #2020 — that issue only adds a
2795
2835
  * discoverability notice (below) for the case where the ladder silently
@@ -2840,6 +2880,7 @@ export async function runCommand(opts = {}) {
2840
2880
 
2841
2881
  if (resolvedBackend === "cli" && backend === "auto") {
2842
2882
  const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
2883
+ // #4257: readiness only reports api when the owner approved it.
2843
2884
  if (readiness.backend === "api") resolvedBackend = "api";
2844
2885
  }
2845
2886
 
@@ -2938,6 +2979,8 @@ export async function runCommand(opts = {}) {
2938
2979
  sessionFlags = buildMinimalSessionFlags(commandName, caps);
2939
2980
  }
2940
2981
  if (!resolvedBin && backend === "auto") {
2982
+ // #4257: never cross to the paid API backend without the owner's opt-in.
2983
+ if (!isApiBackendApproved({ cwd, env: runtimeEnv })) throw cliUnavailableError();
2941
2984
  resolvedBackend = "api";
2942
2985
  } else if (!resolvedBin) {
2943
2986
  const err = new Error("The configured claude CLI path disappeared before invocation.");
@@ -163,7 +163,7 @@ async function* listIssuePages(gh, repo, startSince) {
163
163
  }
164
164
 
165
165
  const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
166
- updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
166
+ updatedAt: it.updated_at ?? null, milestone: it.milestone?.title ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
167
167
 
168
168
  const CHECKPOINT_EVERY = 1000;
169
169
  const RATE_CHECK_EVERY = 50;
@@ -13,6 +13,7 @@ import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
13
13
  import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
14
14
  import { readLocalRuns, runsDir } from "./local-source.mjs";
15
15
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
+ import { edgeQuery, parseEdge, verdictMix } from "./query.mjs";
16
17
 
17
18
  const here = dirname(fileURLToPath(import.meta.url));
18
19
 
@@ -34,10 +35,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
34
35
  --min-n N low-n threshold (default ${DEFAULT_MIN_N})
35
36
  --release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
36
37
  (default: git tags v* of the ForgeDock checkout)
38
+
39
+ Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry, no comment bodies read):
40
+ --edge FROM:TO merged PRs that added the dependency or import edge FROM -> TO (e.g. @s/web:@s/db)
41
+ --milestone M conformance verdict mix and per-day structure series for milestone M
37
42
  `;
38
43
 
39
44
  export function parseArgs(argv) {
40
- const o = { since: null, repo: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
45
+ const o = { since: null, repo: null, edge: null, milestone: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
41
46
  const val = (i, name) => {
42
47
  const v = argv[i + 1];
43
48
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -51,6 +56,8 @@ export function parseArgs(argv) {
51
56
  if (name === "--since") o.since = take();
52
57
  else if (name === "--repo") o.repo = take();
53
58
  else if (name === "--release-times") o.releaseTimes = take();
59
+ else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
60
+ else if (name === "--milestone") o.milestone = take();
54
61
  else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
55
62
  else if (name === "--json") o.json = true;
56
63
  else if (name === "--refresh") o.refresh = true;
@@ -153,6 +160,11 @@ export async function buildReport(opts, deps = {}) {
153
160
  if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
154
161
  const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
155
162
  onProgress: deps.onProgress, sleep: deps.sleep, now: deps.now });
163
+ if (opts.edge || opts.milestone) { // forge#4165: audit query instead of the version report
164
+ const e = opts.edge ? parseEdge(opts.edge) : null;
165
+ const query = { ...(e ? { edge: { ...e, prs: edgeQuery(gh.records, e.from, e.to) } } : {}), ...(opts.milestone ? { mix: verdictMix(gh.records, gh.items, opts.milestone) } : {}) };
166
+ return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
167
+ }
156
168
  const model = toModel(repo, gh);
157
169
  const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
158
170
  let local = [];
@@ -178,7 +190,7 @@ export async function runStats(argv, deps = {}) {
178
190
  }
179
191
  try {
180
192
  const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
181
- process.stdout.write(`${opts.json ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
193
+ process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
182
194
  return 0;
183
195
  } catch (err) {
184
196
  process.stderr.write(`forgedock stats: ${err.message}\n`);
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4165: deterministic audit queries over FORGE:TELEMETRY records, so `/audit` answers "which PRs added A→B?" and
5
+ * "what is the conformance verdict mix for milestone M?" from structured events instead of comment archaeology.
6
+ * Inputs are the records and issue metadata `fetchGithubRecords` already returns; no extra API call, no model.
7
+ * structure_delta (lean, merge-time): deps_added / new_edges / removed_edges as "from:to" comma lists, pr, milestone
8
+ * run_terminal: reason, pr, milestone, conformance_mode, shadow_would
9
+ */
10
+
11
+ const list = (v) => (typeof v === "string" && v !== "none" ? v.split(",").filter(Boolean) : []);
12
+ const day = (ts) => (typeof ts === "string" ? ts.slice(0, 10) : "unknown");
13
+
14
+ /** "from:to" -> {from, to}; package names hold no ":" so the last ":" splits (a scoped name keeps its "/"). */
15
+ export function parseEdge(s) {
16
+ const i = String(s ?? "").lastIndexOf(":");
17
+ if (i <= 0 || i === String(s).length - 1) return null;
18
+ return { from: String(s).slice(0, i), to: String(s).slice(i + 1) };
19
+ }
20
+
21
+ /**
22
+ * Milestone of a record: its issue's milestone from the crawled metadata (the exact title), else the record's own field
23
+ * (the emitter replaces characters outside [A-Za-z0-9 _.:/#@+=,-] with "_", so it may differ from the title).
24
+ */
25
+ function milestoneOf(r, byIssue) {
26
+ return byIssue.get(r.issue)?.milestone ?? (typeof r.milestone === "string" && r.milestone ? r.milestone : null);
27
+ }
28
+
29
+ /**
30
+ * PRs (and issues) whose merged change added the edge from→to: `kind` "package" (a package.json dependency) or
31
+ * "import" (value imports). Oldest first; one row per issue and kind (the latest record wins).
32
+ */
33
+ export function edgeQuery(records, from, to) {
34
+ const key = `${from}:${to}`;
35
+ const rows = new Map();
36
+ for (const r of records) {
37
+ if (r?.event !== "structure_delta") continue;
38
+ for (const [field, kind] of [["deps_added", "package"], ["new_edges", "import"]]) {
39
+ if (!list(r[field]).includes(key)) continue;
40
+ const k = `${r.issue}\u0000${kind}`;
41
+ const prev = rows.get(k);
42
+ if (!prev || String(r.ts ?? "") >= String(prev.ts ?? "")) rows.set(k, { issue: r.issue ?? null, pr: r.pr ?? null, ts: r.ts ?? null, kind, milestone: r.milestone ?? null });
43
+ }
44
+ }
45
+ return [...rows.values()].sort((a, b) => String(a.ts ?? "").localeCompare(String(b.ts ?? "")) || (a.issue ?? 0) - (b.issue ?? 0));
46
+ }
47
+
48
+ /**
49
+ * Conformance verdict mix of finished runs (run_terminal) and the per-day structure series (structure_delta), for one
50
+ * milestone or, with `milestone` null, for the whole repo. `items` is the crawled issue metadata ({number, milestone}).
51
+ */
52
+ export function verdictMix(records, items = [], milestone = null) {
53
+ const byIssue = new Map(items.map((i) => [i.number, i]));
54
+ const sel = (r) => milestone == null || milestoneOf(r, byIssue) === milestone;
55
+ const terms = records.filter((r) => r?.event === "run_terminal" && sel(r));
56
+ const count = (xs, f) => xs.reduce((m, x) => { const k = f(x); if (k != null) m[k] = (m[k] ?? 0) + 1; return m; }, {});
57
+ const series = new Map();
58
+ for (const r of records) {
59
+ if (r?.event !== "structure_delta" || r.phase !== "merge" || !sel(r)) continue;
60
+ const d = day(r.ts);
61
+ const s = series.get(d) ?? { date: d, merged: 0, deps_added: 0, new_edges: 0, removed_edges: 0 };
62
+ s.merged++; s.deps_added += list(r.deps_added).length; s.new_edges += list(r.new_edges).length; s.removed_edges += list(r.removed_edges).length;
63
+ series.set(d, s);
64
+ }
65
+ return {
66
+ milestone, runs: terms.length,
67
+ verdicts: count(terms, (r) => (typeof r.shadow_would === "string" ? r.shadow_would : null)),
68
+ modes: count(terms, (r) => (typeof r.conformance_mode === "string" ? r.conformance_mode : null)),
69
+ reasons: count(terms, (r) => (typeof r.reason === "string" ? r.reason : null)),
70
+ series: [...series.values()].sort((a, b) => a.date.localeCompare(b.date)),
71
+ };
72
+ }
@@ -14,6 +14,7 @@ import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel }
14
14
  import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
15
15
  import { readLocalRuns } from "./local-source.mjs";
16
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
+ import { edgeQuery, parseEdge, verdictMix } from "./query.mjs";
17
18
 
18
19
  const here = dirname(fileURLToPath(import.meta.url));
19
20
  const fx = (f) => join(here, "fixtures", f);
@@ -471,3 +472,56 @@ describe("cli", () => {
471
472
  try { assert.equal(await runStats(["--repo", "bad repo"]), 2); } finally { process.stderr.write = real; }
472
473
  });
473
474
  });
475
+
476
+ describe("audit queries (forge#4165)", () => {
477
+ const rec = (o) => ({ v: 1, repo: "o/r", run: null, forge_version: "1.20.0", ...o });
478
+ const RECS = [
479
+ rec({ eid: "s1", ts: "2026-10-10T10:00:00Z", issue: 11, pr: 21, phase: "merge", event: "structure_delta", deps_added: "@s/web:@s/db", new_edges: "none", removed_edges: "none", decisions: "K1:lint", milestone: "M One" }),
480
+ rec({ eid: "s2", ts: "2026-10-11T10:00:00Z", issue: 12, pr: 22, phase: "merge", event: "structure_delta", deps_added: "none", new_edges: "@s/web:@s/db,@s/web:@s/core", removed_edges: "@s/a:@s/b", milestone: "M One" }),
481
+ rec({ eid: "s3", ts: "2026-10-11T11:00:00Z", issue: 13, pr: 23, phase: "merge", event: "structure_delta", deps_added: "none", new_edges: "@s/x:@s/db" }),
482
+ rec({ eid: "s4", ts: "2026-10-09T10:00:00Z", issue: 14, phase: "implement", event: "structure_delta", edges_added: 1 }),
483
+ rec({ eid: "t1", ts: "2026-10-10T10:01:00Z", issue: 11, pr: 21, event: "run_terminal", reason: "merged", conformance_mode: "shadow", shadow_would: "conforms", milestone: "M One" }),
484
+ rec({ eid: "t2", ts: "2026-10-11T10:01:00Z", issue: 12, pr: 22, event: "run_terminal", reason: "merged", conformance_mode: "shadow", shadow_would: "conformance.scope" }),
485
+ rec({ eid: "t3", ts: "2026-10-11T11:01:00Z", issue: 13, pr: 23, event: "run_terminal", reason: "merged", conformance_mode: "enforce", milestone: "M Two" }),
486
+ ];
487
+ const ITEMS = [{ number: 11, milestone: "M One" }, { number: 12, milestone: "M One" }, { number: 13, milestone: null }];
488
+
489
+ it("parseEdge splits on the last colon and rejects malformed input", () => {
490
+ assert.deepEqual(parseEdge("@s/web:@s/db"), { from: "@s/web", to: "@s/db" });
491
+ for (const bad of ["", "nocolon", ":b", "a:"]) assert.equal(parseEdge(bad), null, bad);
492
+ });
493
+ it("lists the PRs that added an edge, package or import, oldest first; exact element match only", () => {
494
+ assert.deepEqual(edgeQuery(RECS, "@s/web", "@s/db"), [
495
+ { issue: 11, pr: 21, ts: "2026-10-10T10:00:00Z", kind: "package", milestone: "M One" },
496
+ { issue: 12, pr: 22, ts: "2026-10-11T10:00:00Z", kind: "import", milestone: "M One" },
497
+ ]);
498
+ assert.deepEqual(edgeQuery(RECS, "@s/web", "@s/d"), [], "a prefix is not the edge");
499
+ assert.deepEqual(edgeQuery(RECS, "@s/a", "@s/b"), [], "a removed edge is not an added one");
500
+ });
501
+ it("verdict mix and structure series for a milestone (issue metadata first, then the record's own field)", () => {
502
+ const m = verdictMix(RECS, ITEMS, "M One");
503
+ assert.equal(m.runs, 2);
504
+ assert.deepEqual(m.verdicts, { conforms: 1, "conformance.scope": 1 });
505
+ assert.deepEqual(m.modes, { shadow: 2 });
506
+ assert.deepEqual(m.series, [{ date: "2026-10-10", merged: 1, deps_added: 1, new_edges: 0, removed_edges: 0 }, { date: "2026-10-11", merged: 1, deps_added: 0, new_edges: 2, removed_edges: 1 }]);
507
+ assert.equal(verdictMix(RECS, [], "M Two").runs, 1, "falls back to the record's milestone");
508
+ assert.equal(verdictMix(RECS, ITEMS, null).runs, 3, "no milestone: the whole repo");
509
+ });
510
+ it("buildReport --edge/--milestone answers from telemetry records alone; the version report is unchanged without them", async () => {
511
+ const body = `<!-- FORGE:TELEMETRY v1 -->\n\`\`\`jsonl\n${RECS.filter((r) => r.issue === 11 || r.issue === 12).map((r) => JSON.stringify(r)).join("\n")}\n\`\`\``;
512
+ const issues = [11, 12].map((n) => ({ number: n, labels: [], created_at: "2026-10-09T00:00:00Z", closed_at: null, updated_at: "2026-10-11T12:00:00Z", user: { type: "Bot" }, milestone: { title: "M One" } }));
513
+ const gh = (args) => {
514
+ const path = args.find((a) => a.startsWith("repos/")) ?? "";
515
+ if (/issues\/(11|12)\/comments$/.test(path)) return JSON.stringify(path.includes("/11/") ? [{ body, user: { type: "Bot" } }] : []);
516
+ if (/issues\/comments\?/.test(path)) return JSON.stringify(/page=1(&|$)/.test(path) ? [{ body, user: { type: "Bot" }, issue_url: "https://api.github.com/repos/o/r/issues/11" }] : []);
517
+ return JSON.stringify(issues);
518
+ };
519
+ const opts = { repo: "o/r", local: false, cache: false, minN: 10, ...parseArgs(["--edge", "@s/web:@s/db", "--milestone", "M One"]) };
520
+ const report = await buildReport({ ...opts, repo: "o/r", local: false, cache: false }, { gh, env: ENV, releaseTimes: [] });
521
+ assert.deepEqual(report.query.edge.prs.map((p) => [p.pr, p.kind]), [[21, "package"], [22, "import"]]);
522
+ assert.deepEqual(report.query.mix.verdicts, { conforms: 1, "conformance.scope": 1 });
523
+ assert.ok(!("groups" in report));
524
+ assert.throws(() => parseArgs(["--edge", "nocolon"]), /--edge must be FROM:TO/);
525
+ assert.deepEqual([parseArgs([]).edge, parseArgs([]).milestone], [null, null]);
526
+ });
527
+ });
package/commands/adopt.md CHANGED
@@ -421,6 +421,9 @@ Where `{reason}` is a brief signal: "confirmed bug + file refs", "P1 + acceptanc
421
421
 
422
422
  4. To re-run adoption on newly added issues:
423
423
  \`/adopt\` (already-labeled issues are skipped automatically)
424
+
425
+ {If the repo's boundary config contains an allow-all rule (check as in `/forgedock-init` 2G: eslint-plugin-boundaries `from: [X], allow: ['*']`, dependency-cruiser with no `forbidden` rules, Nx `onlyDependOnLibsWithTags: ['*']`), add once:}
426
+ 5. Your boundary config allows every package to import every package ({FILE}: {RULE}). Consider tightening it per package; core work-on only reports import growth along allowed edges.
424
427
  ```
425
428
 
426
429
  ---
package/commands/audit.md CHANGED
@@ -94,6 +94,8 @@ Read `$ARGUMENTS` carefully. The user is describing a failure — extract:
94
94
 
95
95
  ### 1B: Find related GitHub artifacts
96
96
 
97
+ Events first: `forgedock stats --repo {GH_REPO} --edge FROM:TO` (PRs that added an edge) or `--milestone M` (verdict mix, edge series) reads `structure_delta`/`run_terminal` telemetry. Read only the matched issues; parse comments below only when the events are missing.
98
+
97
99
  Start from whatever the user provides (issue number, PR number, or description) and expand outward:
98
100
 
99
101
  ```bash
@@ -261,14 +263,9 @@ Classify where the breakdown occurred. Use these categories:
261
263
  - **Root cause**: The single most impactful failure point. If THIS had worked, the problem wouldn't have reached production.
262
264
  - **Contributing factors**: Other pipeline links that could have caught it as a safety net but didn't.
263
265
 
264
- ### 3D: Cross-reference with Pipeline Hardening Principles
265
-
266
- Before proposing a fix, check which layer it belongs to (from CLAUDE.md):
266
+ ### 3D: Cross-reference with the hardening layers
267
267
 
268
- - **Quality gate** → static analysis, grep-catchable patterns
269
- - **Review agents** → semantic reasoning, library API contracts, cross-service interactions
270
- - **Domain detection** → broad category matching, not per-bug-instance
271
- - **Builder rules** → prevention at write time, simple memorable rules
268
+ Before proposing a fix, pick its layer from `NORTHSTAR.md` § Where a fix belongs: the earliest, cheapest layer that would have stopped it (issue spec/plan rule → builder rule → deterministic check → domain detection → review prompt line → new agent, last resort).
272
269
 
273
270
  **Do NOT propose a quality-gate grep check for a reasoning failure. Do NOT propose a review-agent prompt change for a pattern grep could catch.**
274
271
 
@@ -368,7 +365,7 @@ Files that need changes:
368
365
  - If tightening a review agent: describe the reasoning step to add
369
366
  - If adding context/architect guidance: describe the lookup or trace step}
370
367
 
371
- **Hardening layer justification** (per CLAUDE.md Pipeline Hardening Principles):
368
+ **Hardening layer justification** (per `NORTHSTAR.md` § Where a fix belongs):
372
369
  {Explain why this fix belongs in the proposed layer and not another}
373
370
 
374
371
  ### Related Prior Work
@@ -28,6 +28,8 @@ You are a fully autonomous deploy loop for this project. Your job is to **detect
28
28
 
29
29
  **Issue selection and priority**: Phase 2 (Fast Lane Loop) dispatches all eligible open issues **concurrently** via the durable engine — ordering is irrelevant at dispatch time because all issues start simultaneously. For scenarios requiring sequential, priority-ordered dispatch (P0 before P1 before P2, unlabeled excluded), use `scripts/select-fix-targets.sh` as the entry point. This script will be the substitution point when #1743 (economic scheduling) implements value/cost-based ordering. <!-- Added: forge#1752 -->
30
30
 
31
+ **Maintenance budget** (#4197, only with forge.yaml `maintenance.enabled: true`): the batch runner admits P0/P1 features > maintenance-labelled issues within `maintenance.share` of lane starts (0.2 = 1 in 5) > P2+ features, and `when_idle` drains maintenance when no feature is pending. When `maintenance.scan: weekly` and the newest issue carrying a `FORGE:MAINT` marker (open or closed: `gh issue list --state all --search "FORGE:MAINT in:body" --limit 1 --json createdAt`) is older than 7 days, or none exists, run `scripts/maintenance-scan.sh --print-issue-args` once before Phase 2 and file each printed line through `/issue` programmatic mode, `Skill(skill="{FORGE_SKILL_PREFIX}issue", args=<line>)`; never raw `gh issue create`. The scan is deterministic, already deduplicated by marker, and prints nothing under threshold.
32
+
31
33
  **This command overrides the standard "never merge to main" rule.** `/autopilot` IS the authorized deploy system. It ships staging→main and milestone→staging→main as part of normal operation.
32
34
 
33
35
  **This command resumes from wherever the pipeline is stuck.** It always reads current GitHub state before taking any action.
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: Guided idea-to-backlog setup for a brand-new project — interview, defaults, doubts, spike doc, validated orchestrate-ready backlog
3
+ argument-hint: "<free text | path to docs> [--dry-run] [--spike <path>]"
4
+ ---
5
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
+
8
+ # /bootstrap — idea to orchestrate-ready backlog
9
+
10
+ **Input**: $ARGUMENTS
11
+
12
+ This spec holds only judgment. All GitHub writes run in `scripts/bootstrap-create.mjs`. Templates live in `$(forge_root)/templates/bootstrap/`.
13
+
14
+ ```bash
15
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
16
+ BOOT_TPL="$(forge_root)/templates/bootstrap"; VALIDATE=$(forge_resolve bootstrap-validate.mjs); CREATE=$(forge_resolve bootstrap-create.mjs)
17
+ [ -n "$VALIDATE" ] && [ -n "$CREATE" ] || { echo "ERROR: bootstrap scripts not found in a trusted ForgeDock install" >&2; exit 3; }
18
+ ```
19
+
20
+ 1. **Read the input.** Use the free text, or read each doc at the given path(s).
21
+ 2. **Interview** from `interview.md`. Cover every aspect it lists, briefly and conversationally. Skip what the input already answers.
22
+ 3. **Fill the blanks.** Give every unanswered aspect a sensible default with a one-line rationale tied to what the user said, using the closest `stack-profiles.md` preset. Show them all in ONE editable "Here's what I assumed" list and apply the user's edits.
23
+ 4. **Ask only genuine doubts** (they change architecture or cost: native vs PWA, data residency, self-host vs managed DB, product name, repo owner). Ask with AskUserQuestion, at most 4 multiple-choice questions per batch, each with a recommended option. Everything else stays a default. Headless (no AskUserQuestion, e.g. `npx forgedock bootstrap`): take each recommended option and list it under "Here's what I assumed".
24
+ 5. **Spike doc.** Write `technical-spike.md` to `--spike` (default `docs/engineering/technical-spike.md`); set the plan's `spikeDoc` to that path. Fill every section and give each decision an ADR-style `**Rationale**:` line.
25
+ 6. **Plan JSON** in `$TMPDIR` (shape: header of `bootstrap-validate.mjs` and `bootstrap-create.mjs`):
26
+ - Milestones in build order, foundation first.
27
+ - A `SPIKE` parent issue.
28
+ - Sub-issues from `issue-body.md` with planned paths, `[type:*]` tags, `priority` and `component`.
29
+ - `"ui": true` issues carry the full design spec. `"hero": true` issues route to `/design`.
30
+ - One `needs-human-issue.md` issue per credential, account or external input. Code issues reference it as `#{{KEY}}` but never depend on it, and name the mock that keeps dev and CI green.
31
+ - Sub-issues point at the spike via `**Parent**` only, never `Depends on`.
32
+ - `guardrails` and `domainRules` from the interview.
33
+ 7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Never create from an invalid plan.
34
+ 8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched). The script reuses existing issues by title and parent, so on an error re-run the same command to resume.
35
+ 9. **Hand off.** Print the script's hand-off verbatim: what was created (links), what needs a human (links), and the `/orchestrate` command per milestone, in order.
@@ -12,6 +12,8 @@ install: extras
12
12
 
13
13
  Scan the entire development environment for rot and fix it. This is a maintenance command — run periodically or after large orchestration batches. It covers 6 domains: stale labels, orphaned issues and dead claims boards, worktree/branch pruning, milestone hygiene, and Project board sync.
14
14
 
15
+ Issues this command or a `dead-code` finding produces carry the `maintenance` label, so `forgedock orchestrate` dispatches them within the maintenance budget (forge.yaml `maintenance.share`, #4197), not the feature queue.
16
+
15
17
  **NEVER use plan mode (EnterPlanMode)** — it breaks execution context.
16
18
 
17
19
  ---
@@ -47,7 +49,7 @@ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD
47
49
  |-------|--------|
48
50
  | `labels` or empty | Fix stale/missing workflow labels on closed issues; detect OPEN issues stuck in intermediate states (Phase 1D) |
49
51
  | `orphans` | Close open issues whose PRs are already merged (Phase 2A/2B), and close dead orchestrate claims boards (Phase 2C) |
50
- | `branches` | Prune worktrees and remote branches for merged PRs |
52
+ | `branches` | Reclaim ForgeDock-owned worktrees, tmp, docker and orphans via `forgedock gc` (3A/3B); prune remote branches for merged PRs (3C) |
51
53
  | `milestones` | Report milestones with 0 open issues (advisory — never closes) |
52
54
  | `board` | Sync closed issues to Project board with correct terminal state |
53
55
  | `batch-p3` | Sweep: fold stale unbatched P3 review findings into surface-area-grouped batch issues (same file, then leaf directory) |
@@ -321,32 +323,15 @@ echo "Claims boards closed: $BOARDS_CLOSED"
321
323
 
322
324
  ## Phase 3: Worktree & Branch Pruning
323
325
 
324
- ### 3A: Identify worktrees with merged PRs
326
+ ### 3A/3B: Reclaim owned worktrees, tmp, docker and orphans (the `forgedock gc` reaper, #4183)
325
327
 
326
328
  ```bash
327
329
  cd {REPO_PATH}
328
-
329
- # For each worktree (excluding the main one), check if its branch has a merged PR
330
- REPO_NAME=$(basename "{REPO_PATH}")
331
- git worktree list --porcelain | grep "^worktree " | grep -v "/$REPO_NAME$" | sed 's/^worktree //' | while read wt; do
332
- branch=$(git -C "$wt" branch --show-current 2>/dev/null)
333
- if [ -n "$branch" ]; then
334
- merged_pr=$(gh pr list --head "$branch" --state merged --json number --jq '.[0].number' 2>/dev/null)
335
- if [ -n "$merged_pr" ]; then
336
- echo "STALE_WT|$wt|$branch|PR#$merged_pr"
337
- fi
338
- fi
339
- done
330
+ npx forgedock gc --worktrees --tmp --docker --processes # dry-run report
331
+ npx forgedock gc --worktrees --tmp --docker --processes --apply # reclaim
340
332
  ```
341
333
 
342
- ### 3B: Remove stale worktrees and local branches
343
-
344
- For each worktree with a merged PR:
345
- ```bash
346
- git worktree remove "$WORKTREE_PATH" --force
347
- git branch -D "$BRANCH_NAME" 2>/dev/null || true
348
- echo "Removed: $WORKTREE_PATH ($BRANCH_NAME)"
349
- ```
334
+ It honours `forge.yaml` `hygiene:` TTLs and reclaims only what ForgeDock owns (stale lane ledgers, `lean-*`/`agent-*` worktrees that are clean and pushed, including unregistered dirs, `forgedock.owner=1` containers/volumes, `~/.forge/tmp` scratch, orphaned lane processes). Everything else is reported, never removed. Include its summary table in the report.
350
335
 
351
336
  ### 3C: Prune merged remote branches
352
337
 
@@ -56,7 +56,8 @@ Commands are classified by role. Each role has a max line count — beyond this,
56
56
 
57
57
  `scripts/check-spec-size.sh` (job `spec-size-budget` in `check-commands.yml`, PRs only) enforces a budget that is independent of the line-count `.baselines`; the baseline is refreshed on release via `/forge-stats baseline`. It compares bytes against the PR base:
58
58
 
59
- - `review-pr.md`, `review-pr-staging.md`, `work-on/remediate.md`, `work-on/review.md`, `work-on.md` may not grow by more than 2% or 4 KB. Shrinking always passes.
59
+ - Every `commands/**/*.md` spec may not grow by more than 2% or 4 KB; a new spec may be at most 4 KB. Shrinking always passes.
60
+ - Per-file hard ceilings (`CEILINGS` in the script) cap a spec whatever its growth: `commands/issue.md` 15 KB (its procedure lives in `scripts/issue-create.sh`, #4164).
60
61
  - No `commands/**` file may gain a copy of a canonical block (FORGE_ROOT bootstrap, TRUSTED_SCRIPT/HELPER_SCRIPT resolver, FENCE_BOOTSTRAP anchor); the per-file marker count may not rise. The shared helpers (`scripts/lib/forge-resolve.sh`, `scripts/lib/fence-bootstrap.sh`) are sourced through marker-free stubs, so a conversion reads as a net reduction.
61
62
  - Exemption: the `spec-size-exempt` PR label only (needs triage rights; a PR-body marker is not honoured). The job summary lists the per-file delta and any waiver.
62
63