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/README.md CHANGED
@@ -268,7 +268,9 @@ Then just open Claude Code and run `/work-on <issue>`.
268
268
 
269
269
  Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock init` to generate `forge.yaml`.
270
270
 
271
- **Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. Otherwise it falls back to the Anthropic API directly (`ANTHROPIC_API_KEY` required). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
271
+ **Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. It never falls back to the paid Anthropic API implicitly: without a CLI it fails with `CLI_BACKEND_UNAVAILABLE` unless the owner opted in (forge.yaml `execution.agent_backend: api` plus `FORGEDOCK_ALLOW_API_BACKEND=1`; see [CONFIG.md](docs/CONFIG.md#execution-optional)). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
272
+
273
+ **Brand-new project?** `/bootstrap "<idea>"` (or `npx forgedock bootstrap "<idea>" --dry-run` to preview) turns an idea into a validated, milestone-ordered GitHub backlog that `/orchestrate` can build. See [Start a New Project with ForgeDock](docs/site/start-a-new-project.md).
272
274
 
273
275
  **Explicit install command:**
274
276
 
@@ -363,6 +365,7 @@ Using ForgeDock in your pipeline? Add the badge — each one is a backlink and a
363
365
 
364
366
  - [GitHub Is Already Your Agents' Memory](docs/site/github-is-the-memory.md) — the canonical argument: why GitHub is the right place for agent memory, how FORGE annotations make it machine-readable, and how to adopt the protocol without ForgeDock
365
367
  - [Getting Started in 5 Minutes](docs/site/getting-started.md)
368
+ - [Start a New Project with ForgeDock](docs/site/start-a-new-project.md) — idea → `/bootstrap` → backlog → `/orchestrate`, with a worked example
366
369
  - [How the Knowledge Graph Works](docs/site/how-it-works.md)
367
370
  - [What Are Those FORGE Comments?](docs/site/annotations-explained.md) — 2-minute explainer for annotations you meet in the wild
368
371
  - [FORGE Annotation Protocol](docs/spec/forge-protocol-v1.md) — the open standard for AI context passing (CC-BY-4.0)
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * `forgedock bootstrap` (#4177, part of #4155): the headless `/bootstrap`.
5
+ * - Plan mode, `--plan <plan.json> [--dry-run] [--repo] [--config] [--base] [--json] [--cost-per-issue <usd>]`:
6
+ * scripts/bootstrap-create.mjs validates and previews (dry run, no GitHub access) or creates the backlog. No LLM
7
+ * call. Re-running the same command resumes from GitHub state: existing spike, milestones, branches, issues and
8
+ * marker comments are reused, so no local state is needed.
9
+ * - Idea mode, `<free text | docs path> [--dry-run] [--spike <path>] [--model <id>] [--backend <cli|api|auto>]`:
10
+ * runs commands/bootstrap.md headlessly through runner.mjs runCommand. `--dry-run` and `--spike` are spec
11
+ * arguments; `--dry-run` is never the runner's own prompt-preview dryRun.
12
+ * Exit: 0 ok; 1 failed run (or invalid plan); 2 usage; 3 GitHub error (plan mode, re-run to resume).
13
+ */
14
+
15
+ const USAGE = "usage: forgedock bootstrap <idea | docs path> [--dry-run] [--spike <path>] [--model <id>] [--backend <cli|api|auto>]\n" +
16
+ " forgedock bootstrap --plan <plan.json> [--dry-run] [--repo OWNER/NAME] [--config forge.yaml] [--base <branch>] [--json] [--cost-per-issue <usd>]\n";
17
+
18
+ /**
19
+ * @param {string[]} argv arguments after `bootstrap`
20
+ * @param {{runCommand?: Function, createMain?: Function, commandsDir?: string, cwd?: string, out?: {write}, err?: {write}}} deps
21
+ * @returns {Promise<number>} exit code
22
+ */
23
+ export async function runBootstrap(argv = [], deps = {}) {
24
+ const { commandsDir, cwd = process.cwd(), out = process.stdout, err = process.stderr } = deps;
25
+ const needValue = (i) => i + 1 < argv.length && !argv[i + 1].startsWith("--");
26
+ const planIdx = argv.indexOf("--plan");
27
+ if (planIdx !== -1) {
28
+ if (!needValue(planIdx)) { err.write(`forgedock bootstrap: --plan requires a value\n${USAGE}`); return 2; }
29
+ const createMain = deps.createMain ?? (await import("../scripts/bootstrap-create.mjs")).main;
30
+ const rest = [...argv.slice(0, planIdx), ...argv.slice(planIdx + 2)];
31
+ return createMain([argv[planIdx + 1], ...rest], { out, err });
32
+ }
33
+
34
+ const args = [];
35
+ let model, backend;
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === "--dry-run") args.push(a);
39
+ else if (a === "--spike" || a === "--model" || a === "--backend") {
40
+ if (!needValue(i)) { err.write(`forgedock bootstrap: ${a} requires a value\n${USAGE}`); return 2; }
41
+ const v = argv[++i];
42
+ if (a === "--spike") args.push(a, v);
43
+ else if (a === "--model") model = v;
44
+ else backend = v;
45
+ } else if (a.startsWith("--")) { err.write(`forgedock bootstrap: unknown argument ${a}\n${USAGE}`); return 2; }
46
+ else args.push(a);
47
+ }
48
+ if (!args.some((a, i) => !a.startsWith("--") && args[i - 1] !== "--spike")) { err.write(USAGE); return 2; }
49
+ const runCommand = deps.runCommand ?? (await import("./runner.mjs")).runCommand;
50
+ const result = await runCommand({
51
+ commandsDir, commandName: "bootstrap", args, cwd, dryRun: false,
52
+ ...(model ? { model } : {}), ...(backend ? { backend } : {}),
53
+ });
54
+ return result && (result.status === "max-iterations" || result.status === "incomplete") ? 1 : 0;
55
+ }
@@ -36,15 +36,20 @@ export const OUTCOME_CODES = Object.freeze({
36
36
  BASE_CONFLICT: "base.conflict",
37
37
  RATE_LIMITED: "rate_limited",
38
38
  HOST_BUSY: "host.busy",
39
+ GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
39
40
  MERGE_TRANSIENT: "merge.transient",
40
41
  PUSH_TRANSIENT: "push.transient",
41
42
  CLI_IDLE: "cli.idle",
43
+ RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
42
44
  PATH_SWITCH_CONFLICT: "path.switch_conflict",
43
45
  ENGINE_ERROR: "engine.error",
44
46
  INFRA_DISK_QUOTA: "infra.disk_quota",
45
47
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
46
48
  CLI_TIMEOUT: "cli.timeout",
47
49
  CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
50
+ ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
51
+ SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
52
+ SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
48
53
  LEASE_HELD: "lease.held",
49
54
  DEPENDENCY_OPEN: "dependency.open",
50
55
  DEPENDENCY_UNREADABLE: "dependency.unreadable",
@@ -74,8 +79,15 @@ export const OUTCOME_CODES = Object.freeze({
74
79
  CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
75
80
  CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
76
81
  CONFORMANCE_REGRESSION: "conformance.regression",
82
+ STRUCTURE_UNDECLARED_EDGE: "structure.undeclared_edge", // forge#4159: a workspace package.json edge the contract does not name
83
+ DOCS_STALE: "docs.stale", // the target repo's devdocs freshness rule still fails after one builder resume
84
+ MAINTENANCE_HOTSPOT_GROWTH: "maintenance.hotspot_growth", // forge#4195: an over-threshold file grew past growth_lines without hotspot-exempt
77
85
  FOLLOWUP_UNAVAILABLE: "followup.unavailable",
78
86
  MERGE_HEAD_MISMATCH: "merge.head_mismatch",
87
+ // refactor:split lane (forge#4211): deterministic refusals and the move proof; every one leaves no edit behind.
88
+ SPLIT_UNSUPPORTED: "split.unsupported", // no codemod or index tool for the target language, or libcst or ts-morph missing; refused before any edit
89
+ SPLIT_PLAN_INVALID: "split.plan_invalid", // missing target/test command, unparseable grouping reply, or a plan symbol-move refuses
90
+ SPLIT_VERIFY_FAILED: "split.verify_failed", // refactor-verify found a logic diff, missing symbol, changed counts or new cycle; branch reset
79
91
  // Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
80
92
  COMPLETED: "completed",
81
93
  AWAITING_MERGE: "awaiting_merge",
@@ -130,6 +142,7 @@ export function codeForRunnerError(err) {
130
142
  return OUTCOME_CODES.CLI_SPAWN_FAILED;
131
143
  case "NO_API_KEY":
132
144
  case "NO_SDK":
145
+ case "CLI_BACKEND_UNAVAILABLE":
133
146
  case "CLI_BACKEND_FAILED":
134
147
  case "PHASE_ARGS_INVALID":
135
148
  case "CLI_MAX_TURNS":
@@ -349,12 +349,48 @@ export async function openDependency(issue, io) {
349
349
  return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
350
350
  }
351
351
  const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
352
- if (st === "open") open.push(dep);
352
+ if (st === "open") {
353
+ // #4153: an open decomposed parent is a tracker; the edge resolves to its sub-issues (satisfied when all close).
354
+ const subs = isDecomposedRecord(d) ? await decomposedSubissues(dep, io) : null;
355
+ if (!subs) { open.push(dep); continue; }
356
+ for (const s of subs) {
357
+ let sr;
358
+ try { sr = await readIssueRecord(s, io); } catch (e) {
359
+ return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} could not be read: ${e?.message || e}` };
360
+ }
361
+ const ss = typeof sr?.state === "string" ? sr.state.toLowerCase() : "";
362
+ if (ss === "open") open.push(s);
363
+ else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
364
+ }
365
+ }
353
366
  else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
354
367
  }
355
- return open.length ? { open } : null;
368
+ return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
356
369
  }
357
370
 
371
+ /**
372
+ * #4153: sub-issues of a decomposed parent, from the `- #N` lines of its newest trusted `<!-- FORGE:DECOMPOSED -->`
373
+ * comment (lean `- #N title` and decompose.md `- #N: title`). `null` when there is none, it lists no issue, it was
374
+ * revoked, or the trail is unreadable: the caller then keeps the parent as the dependency (fail closed).
375
+ */
376
+ export async function decomposedSubissues(issue, io) {
377
+ let comments;
378
+ try { comments = (await issueMarkers(issue, io)).comments; } catch { return null; }
379
+ const head = /^\s*<!--\s*FORGE:DECOMPOSED\s*-->/;
380
+ const revoked = /^\s*<!--\s*FORGE:DECOMPOSED:REVOKED(?![A-Za-z0-9_])/;
381
+ let rec = null;
382
+ for (let i = comments.length - 1; i >= 0; i--) {
383
+ if (revoked.test(comments[i])) return null;
384
+ if (head.test(comments[i])) { rec = comments[i]; break; }
385
+ }
386
+ if (!rec) return null;
387
+ const subs = [...new Set([...rec.matchAll(/^[ \t]*[-*][ \t]+#(\d+)\b/gm)].map((m) => Number(m[1])))].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
388
+ return subs.length ? subs : null;
389
+ }
390
+
391
+ /** True when a REST/`gh --json` issue record carries the `workflow:decomposed` label. */
392
+ export const isDecomposedRecord = (rec) => (rec?.labels ?? []).some((l) => (l?.name ?? l) === "workflow:decomposed");
393
+
358
394
  /**
359
395
  * Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
360
396
  * short reason string when the issue must NOT be routed to decompose, else null:
@@ -3,8 +3,13 @@
3
3
  /**
4
4
  * forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
5
5
  * seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
6
- * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, turns, duration_ms, cost_usd, *_tokens)
7
- * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
6
+ * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, *_tokens)
7
+ * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would and the pr)
8
+ * SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
9
+ * STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
10
+ * lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
11
+ * new_edges / removed_edges as "from:to" comma-joined, top_growth "file+N", decisions "K1:lint",
12
+ * plus decisions_doc/lint/precedent/spec counts. Values are capped at 200 chars by the emitter.
8
13
  * Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
9
14
  * are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
10
15
  */
@@ -23,19 +28,48 @@ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v ===
23
28
  const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
24
29
  const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
25
30
 
26
- /** Pure mapping, exported for tests: returns { event, payload, phase } or null when the record is not mapped. */
31
+ const SOURCES = ["doc", "lint", "precedent", "spec"];
32
+ const prOf = (rec) => (Number.isSafeInteger(rec.pr) && rec.pr > 0 ? { pr: rec.pr } : {});
33
+ /** "a:b,c:d" from [{from,to}] (forge#4165); undefined for a non-array so compact() drops it. An empty list is "". */
34
+ const pairs = (v) => (Array.isArray(v) ? v.map((e) => `${e?.from}:${e?.to}`).join(",") : undefined);
35
+ const nonEmpty = (v) => (v === "" ? "none" : v);
36
+ /** Lean's forge#4165 extras of a STRUCTURE_DELTA record; {} for the implement-time counts-only shape. */
37
+ function structureLists(rec) {
38
+ if (!Array.isArray(rec.decisions) && !Array.isArray(rec.new_edges) && !Array.isArray(rec.deps_added)) return {};
39
+ const dec = Array.isArray(rec.decisions) ? rec.decisions : [];
40
+ const out = {
41
+ milestone: rec.milestone, deps_added: nonEmpty(pairs(rec.deps_added)), new_edges: nonEmpty(pairs(rec.new_edges)), removed_edges: nonEmpty(pairs(rec.removed_edges)),
42
+ top_growth: Array.isArray(rec.top_growth) ? nonEmpty(rec.top_growth.map((g) => `${g?.file}+${g?.lines}`).join(",")) : undefined,
43
+ decisions: nonEmpty(dec.map((k) => `${k?.id}:${k?.source ?? "none"}`).join(",")),
44
+ };
45
+ for (const src of SOURCES) out[`decisions_${src}`] = dec.filter((k) => k?.source === src).length;
46
+ return out;
47
+ }
48
+
49
+ /** Pure mapping, exported for tests: returns { event, payload, phase, pr? } or null when the record is not mapped. */
27
50
  export function mapRecord(rec, wallMs) {
28
51
  if (!rec || typeof rec !== "object") return null;
29
52
  if (rec.event === "PHASE_ATTEMPT_USAGE") {
30
53
  return { event: "phase_attempt", phase: rec.phase, payload: compact({
31
- attempt: rec.attempt, model: rec.model, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
54
+ attempt: rec.attempt, model: rec.model, backend: rec.backend ?? undefined, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
32
55
  cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
33
56
  input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
34
57
  cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
35
58
  }) };
36
59
  }
37
60
  if (rec.event === "RUN_TERMINAL") {
38
- return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
61
+ return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
62
+ milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would }) };
63
+ }
64
+ if (rec.event === "SANDBOX_USAGE") {
65
+ return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,
66
+ cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined, batch: rec.batch }) };
67
+ }
68
+ if (rec.event === "STRUCTURE_DELTA") {
69
+ const bit = (v) => (typeof v === "boolean" ? Number(v) : v);
70
+ return { event: "structure_delta", phase: rec.phase ?? "implement", ...prOf(rec), payload: compact({ edges_added: rec.edges_added, edges_removed: rec.edges_removed,
71
+ import_files_added: rec.import_files_added, import_files_removed: rec.import_files_removed, boundary_changed: bit(rec.boundary_changed), boundary_loosened: bit(rec.boundary_loosened),
72
+ ...structureLists(rec) }) };
39
73
  }
40
74
  if (rec.event === "CI_PENDING_WAIT") {
41
75
  return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
@@ -59,7 +93,8 @@ export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now,
59
93
  if (!tried) { tried = true; resolved = resolveRepo() || null; }
60
94
  if (!resolved) return; // no destination: the Node emitter would drop it anyway
61
95
  // ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
62
- emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined });
96
+ emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined,
97
+ ...(Number.isSafeInteger(mapped.pr) ? { pr: mapped.pr } : {}) });
63
98
  } catch { /* telemetry never fails a run */ }
64
99
  };
65
100
  }
@@ -14,6 +14,12 @@ function harness(over = {}) {
14
14
  }
15
15
 
16
16
  describe("engine telemetry seam", () => {
17
+ it("forge#4257: phase_attempt carries the backend, omitted when unreported", () => {
18
+ assert.equal(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: "api" }, 0).payload.backend, "api");
19
+ assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: null }, 0).payload, false);
20
+ assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1 }, 0).payload, false);
21
+ });
22
+
17
23
  it("flattens usage, omits nulls and passes repo/issue/run/phase as ctx (not payload)", async () => {
18
24
  const { calls, emit } = harness();
19
25
  await emit(attempt);
@@ -37,6 +43,35 @@ describe("engine telemetry seam", () => {
37
43
  assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
38
44
  assert.equal(calls[0][2].phase, undefined);
39
45
  });
46
+ it("maps STRUCTURE_DELTA (forge#4159) to structure_delta with scalar counts and 0/1 flags", () => {
47
+ const m = mapRecord({ event: "STRUCTURE_DELTA", issue: 7, edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: true, boundary_loosened: false }, 10);
48
+ assert.deepEqual(m, { event: "structure_delta", phase: "implement", payload: { edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: 1, boundary_loosened: 0 } });
49
+ });
50
+ it("forge#4165: the merge-time STRUCTURE_DELTA and RUN_TERMINAL carry scalar lists, milestone and the pr in ctx", async () => {
51
+ const { calls, emit } = harness();
52
+ await emit({ event: "STRUCTURE_DELTA", issue: 7, pr: 41, phase: "merge", milestone: "M", edges_added: 1, edges_removed: 0, import_files_added: 2, import_files_removed: 0,
53
+ boundary_changed: false, boundary_loosened: false, deps_added: [{ from: "@s/a", to: "@s/b" }], deps_removed: [], new_edges: [{ from: "@s/a", to: "@s/c", file: "x.ts" }, { from: "@s/d", to: "@s/c" }],
54
+ removed_edges: [], top_growth: [{ file: "x.ts", lines: 9 }], decisions: [{ id: "K1", source: "lint" }, { id: "K2", source: null }] });
55
+ await emit({ event: "RUN_TERMINAL", issue: 7, pr: 41, reason: "merged", code: null, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
56
+ assert.equal(calls.length, 2);
57
+ const [[e1, p1, c1], [e2, p2, c2]] = calls;
58
+ assert.deepEqual([e1, c1.pr, c1.phase, e2, c2.pr], ["structure_delta", 41, "merge", "run_terminal", 41]);
59
+ assert.deepEqual([p1.deps_added, p1.new_edges, p1.removed_edges, p1.top_growth, p1.decisions, p1.decisions_lint, p1.decisions_doc, p1.milestone],
60
+ ["@s/a:@s/b", "@s/a:@s/c,@s/d:@s/c", "none", "x.ts+9", "K1:lint,K2:none", 1, 0, "M"]);
61
+ for (const v of [...Object.values(p1), ...Object.values(p2)]) assert.notEqual(typeof v, "object");
62
+ assert.deepEqual(p2, { reason: "merged", wall_ms: 0, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
63
+ assert.equal(mapRecord({ event: "RUN_TERMINAL", reason: "merged", pr: 0 }, 1).pr, undefined, "no pr 0 in ctx");
64
+ });
65
+ it("forge#4255: SANDBOX_USAGE maps to sandbox_usage with provider, minutes and cost", async () => {
66
+ const { calls, emit } = harness();
67
+ await emit({ event: "SANDBOX_USAGE", issue: 7, provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.1234567891, batch: "b1" });
68
+ const [event, payload, ctx] = calls[0];
69
+ assert.equal(event, "sandbox_usage");
70
+ assert.deepEqual(payload, { provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.123457, batch: "b1" });
71
+ assert.equal(ctx.issue, 7);
72
+ assert.equal(ctx.phase, undefined);
73
+ });
74
+
40
75
  it("null usage yields a record with no token fields", () => {
41
76
  assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
42
77
  });