@nathapp/nax 0.75.3 → 0.75.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/nax.js CHANGED
@@ -17317,7 +17317,11 @@ var init_schemas_reporters = __esm(() => {
17317
17317
  maxBatchSize: exports_external.number().int().positive().default(64),
17318
17318
  flushIntervalMs: exports_external.number().int().positive().default(5000),
17319
17319
  maxQueueSize: exports_external.number().int().positive().default(2048),
17320
- phases: exports_external.array(exports_external.string()).optional()
17320
+ phases: exports_external.array(exports_external.string()).optional(),
17321
+ logs: exports_external.object({
17322
+ enabled: exports_external.boolean().default(false),
17323
+ level: exports_external.enum(["silent", "error", "warn", "info", "debug"]).default("info")
17324
+ }).default({ enabled: false, level: "info" })
17321
17325
  }).default({
17322
17326
  enabled: false,
17323
17327
  headers: {},
@@ -17327,7 +17331,8 @@ var init_schemas_reporters = __esm(() => {
17327
17331
  heartbeatIntervalMs: 1e4,
17328
17332
  maxBatchSize: 64,
17329
17333
  flushIntervalMs: 5000,
17330
- maxQueueSize: 2048
17334
+ maxQueueSize: 2048,
17335
+ logs: { enabled: false, level: "info" }
17331
17336
  });
17332
17337
  ReportersConfigSchema = exports_external.object({
17333
17338
  webhook: WebhookReporterConfigSchema,
@@ -17347,7 +17352,8 @@ var init_schemas_reporters = __esm(() => {
17347
17352
  heartbeatIntervalMs: 1e4,
17348
17353
  maxBatchSize: 64,
17349
17354
  flushIntervalMs: 5000,
17350
- maxQueueSize: 2048
17355
+ maxQueueSize: 2048,
17356
+ logs: { enabled: false, level: "info" }
17351
17357
  }
17352
17358
  });
17353
17359
  });
@@ -18388,6 +18394,30 @@ var init_redact = __esm(() => {
18388
18394
  ];
18389
18395
  });
18390
18396
 
18397
+ // src/logger/sink-registry.ts
18398
+ class SinkRegistry {
18399
+ sinks = [];
18400
+ add(sink) {
18401
+ this.sinks.push(sink);
18402
+ return () => {
18403
+ const idx = this.sinks.indexOf(sink);
18404
+ if (idx !== -1) {
18405
+ this.sinks.splice(idx, 1);
18406
+ }
18407
+ };
18408
+ }
18409
+ dispatch(entry) {
18410
+ for (const sink of this.sinks) {
18411
+ try {
18412
+ sink({ ...entry });
18413
+ } catch (error48) {
18414
+ process.stderr.write(`[logger] Sink threw: ${error48}
18415
+ `);
18416
+ }
18417
+ }
18418
+ }
18419
+ }
18420
+
18391
18421
  // src/logger/logger.ts
18392
18422
  import { mkdirSync } from "fs";
18393
18423
  import { appendFile } from "fs/promises";
@@ -18400,6 +18430,7 @@ class Logger {
18400
18430
  suppressConsole;
18401
18431
  writeQueueTail = Promise.resolve();
18402
18432
  pendingLines = [];
18433
+ sinkRegistry = new SinkRegistry;
18403
18434
  constructor(options) {
18404
18435
  this.level = options.level;
18405
18436
  this.filePath = options.filePath;
@@ -18443,10 +18474,11 @@ class Logger {
18443
18474
  ...sessionRole && { sessionRole },
18444
18475
  ...strippedData && { data: strippedData }
18445
18476
  };
18477
+ const entry = redactEntry(rawEntry);
18478
+ this.sinkRegistry.dispatch(entry);
18446
18479
  const consoleEnabled = this.shouldLog(level) && !this.suppressConsole;
18447
18480
  if (!consoleEnabled && !this.filePath)
18448
18481
  return;
18449
- const entry = redactEntry(rawEntry);
18450
18482
  if (consoleEnabled) {
18451
18483
  let consoleOutput = null;
18452
18484
  if (this.formatterMode) {
@@ -18530,8 +18562,19 @@ ${JSON.stringify(entry.data, null, 2)}`;
18530
18562
  debug: (stage, message, data) => this.log("debug", stage, message, data, storyId)
18531
18563
  };
18532
18564
  }
18565
+ addSink(sink) {
18566
+ return this.sinkRegistry.add(sink);
18567
+ }
18533
18568
  close() {}
18534
18569
  }
18570
+ function addSink(sink) {
18571
+ if (!instance) {
18572
+ throw new NaxError("Logger not initialized. Call initLogger() before addSink().", "LOGGER_NOT_INITIALIZED", {
18573
+ stage: "logger"
18574
+ });
18575
+ }
18576
+ return instance.addSink(sink);
18577
+ }
18535
18578
  function initLogger(options = { level: "silent" }) {
18536
18579
  if (instance) {
18537
18580
  throw new Error("Logger already initialized. Call getLogger() to access existing instance.");
@@ -18560,6 +18603,7 @@ function resetLogger() {
18560
18603
  }
18561
18604
  var LOG_LEVEL_PRIORITY, MAX_BATCH_BYTES, instance = null, noopLogger;
18562
18605
  var init_logger = __esm(() => {
18606
+ init_errors();
18563
18607
  init_log_format();
18564
18608
  init_formatters();
18565
18609
  init_redact();
@@ -42579,7 +42623,7 @@ var package_default;
42579
42623
  var init_package = __esm(() => {
42580
42624
  package_default = {
42581
42625
  name: "@nathapp/nax",
42582
- version: "0.75.3",
42626
+ version: "0.75.4",
42583
42627
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
42584
42628
  type: "module",
42585
42629
  bin: {
@@ -42683,8 +42727,8 @@ var init_version = __esm(() => {
42683
42727
  NAX_VERSION = package_default.version;
42684
42728
  NAX_COMMIT = (() => {
42685
42729
  try {
42686
- if (/^[0-9a-f]{6,10}$/.test("ae716fd4"))
42687
- return "ae716fd4";
42730
+ if (/^[0-9a-f]{6,10}$/.test("5aee16bf"))
42731
+ return "5aee16bf";
42688
42732
  } catch {}
42689
42733
  try {
42690
42734
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -58554,15 +58598,7 @@ async function runNonBlockingFix(args, overrides = {}) {
58554
58598
  if (!exhausted) {
58555
58599
  const gateVerdict = args.keptTreeRegressed?.();
58556
58600
  if (gateVerdict?.regressed) {
58557
- logger?.info("non-blocking-fix", "kept tree regressed the full-suite gate \u2014 restoring (ADR-024 \xA73)", {
58558
- storyId: args.storyId,
58559
- regressedKeys: gateVerdict.regressedKeys.slice(0, MAX_LOGGED_REGRESSED_KEYS),
58560
- regressedKeyCount: gateVerdict.regressedKeys.length,
58561
- baselineKeySize: gateVerdict.baselineKeySize,
58562
- keyless: gateVerdict.keyless,
58563
- memoExcludedKeyCount: gateVerdict.memoExcludedKeys.length,
58564
- flakeTriageRan: false
58565
- });
58601
+ logGateRegression(logger, args.storyId, "kept tree regressed the full-suite gate \u2014 restoring (ADR-024 \xA73)", gateVerdict);
58566
58602
  return restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger);
58567
58603
  }
58568
58604
  const cap = args.cfg.sourceDiffCap;
@@ -58590,8 +58626,23 @@ async function runNonBlockingFix(args, overrides = {}) {
58590
58626
  logger?.info("non-blocking-fix", "best-effort fix kept", { storyId: args.storyId });
58591
58627
  return { ran: true, kept: true, restored: false };
58592
58628
  }
58629
+ const exhaustedGateVerdict = args.keptTreeRegressed?.();
58630
+ if (exhaustedGateVerdict?.regressed) {
58631
+ logGateRegression(logger, args.storyId, "best-effort fix exhausted with the full-suite gate red", exhaustedGateVerdict);
58632
+ }
58593
58633
  return restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger);
58594
58634
  }
58635
+ function logGateRegression(logger, storyId, message, verdict) {
58636
+ logger?.info("non-blocking-fix", message, {
58637
+ storyId,
58638
+ regressedKeys: verdict.regressedKeys.slice(0, MAX_LOGGED_REGRESSED_KEYS),
58639
+ regressedKeyCount: verdict.regressedKeys.length,
58640
+ baselineKeySize: verdict.baselineKeySize,
58641
+ keyless: verdict.keyless,
58642
+ memoExcludedKeyCount: verdict.memoExcludedKeys.length,
58643
+ flakeTriageRan: false
58644
+ });
58645
+ }
58595
58646
  async function restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger) {
58596
58647
  await _deps.rollbackToRef(args.workdir, restoreRef);
58597
58648
  for (const key of Object.keys(args.phaseOutputs))
@@ -58744,10 +58795,18 @@ function gateFailureKeys(gateOutput) {
58744
58795
  continue;
58745
58796
  if (f.category === "flaky-test")
58746
58797
  continue;
58747
- keys.add(`${f.file ?? ""}::${f.rule ?? ""}`);
58798
+ keys.add(gateFindingKey(f));
58748
58799
  }
58749
58800
  return keys;
58750
58801
  }
58802
+ function gateFindingKey(finding) {
58803
+ return `${finding.file ?? ""}::${finding.rule ?? ""}`;
58804
+ }
58805
+ function isQuarantinedFlake(finding, quarantineMemo) {
58806
+ if (finding.source !== "test-runner")
58807
+ return false;
58808
+ return quarantineMemo?.has(gateFindingKey(finding)) === true;
58809
+ }
58751
58810
  function describeGateRegression(input) {
58752
58811
  const { gateOutput, baselineKeys, gateName, storyId, quarantineMemo } = input;
58753
58812
  const notRegressed = {
@@ -59447,9 +59506,12 @@ var init_run_phase = __esm(() => {
59447
59506
  });
59448
59507
 
59449
59508
  // src/execution/story-orchestrator/rectification.ts
59450
- function shouldSkipPhaseForRectification(phase, state, phaseOutputs) {
59509
+ function shouldSkipPhaseForRectification(input) {
59510
+ const { phase, state, phaseOutputs, nbfPath } = input;
59451
59511
  if (phase.kind !== "full-suite-gate")
59452
59512
  return false;
59513
+ if (nbfPath)
59514
+ return false;
59453
59515
  const verifierName = state.verifier?.slot.op.name;
59454
59516
  if (!verifierName)
59455
59517
  return false;
@@ -59458,7 +59520,7 @@ function shouldSkipPhaseForRectification(phase, state, phaseOutputs) {
59458
59520
  function gatherRectificationFindings(phaseOutputs, phases, state) {
59459
59521
  const findings = [];
59460
59522
  for (const phase of phases) {
59461
- if (shouldSkipPhaseForRectification(phase, state, phaseOutputs))
59523
+ if (shouldSkipPhaseForRectification({ phase, state, phaseOutputs }))
59462
59524
  continue;
59463
59525
  for (const f of extractPhaseFindings(phaseOutputs[phase.slot.op.name])) {
59464
59526
  if (f.category === "flaky-test")
@@ -59539,7 +59601,9 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
59539
59601
  return {};
59540
59602
  }
59541
59603
  let initialFindings;
59604
+ let nbfPath = false;
59542
59605
  if (overrides?.initialFindings) {
59606
+ nbfPath = true;
59543
59607
  initialFindings = [...overrides.initialFindings];
59544
59608
  } else {
59545
59609
  const gateName = state.fullSuiteGate?.slot.op.name;
@@ -59589,10 +59653,11 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
59589
59653
  let shortCircuited = false;
59590
59654
  for (const phase of phases) {
59591
59655
  await runPhase(ctx, phase.slot, phaseCosts, phaseOutputs);
59592
- if (shouldSkipPhaseForRectification(phase, state, phaseOutputs))
59656
+ if (shouldSkipPhaseForRectification({ phase, state, phaseOutputs, nbfPath }))
59593
59657
  continue;
59594
59658
  const output = phaseOutputs[phase.slot.op.name];
59595
- findings.push(...extractPhaseFindings(output));
59659
+ const phaseFindings = extractPhaseFindings(output);
59660
+ findings.push(...nbfPath ? phaseFindings.filter((f) => !isQuarantinedFlake(f, ctx.runtime.quarantineMemo)) : phaseFindings);
59596
59661
  if (!phasePassed(phase.slot.op.name, output, ctx.storyId)) {
59597
59662
  getSafeLogger()?.warn("story-orchestrator", "Short-circuiting revalidation on phase failure", {
59598
59663
  storyId: ctx.storyId,
@@ -64807,15 +64872,36 @@ var init_config2 = __esm(() => {
64807
64872
  });
64808
64873
 
64809
64874
  // src/plugins/builtin/nax-finish/telegram.ts
64875
+ function buildEscalationMessage(feature, reason, findings) {
64876
+ const head = `nax-finish escalated ${feature}: ${reason}`;
64877
+ if (findings.length === 0)
64878
+ return head;
64879
+ const footerReserve = `
64880
+ \u2026and ${findings.length} more`.length;
64881
+ const lines = [];
64882
+ let used = head.length;
64883
+ for (const f of findings) {
64884
+ const title = f.title.length > MAX_FINDING_TITLE_CHARS ? `${f.title.slice(0, MAX_FINDING_TITLE_CHARS)}\u2026` : f.title;
64885
+ const line = `
64886
+ - [${f.severity}] ${title}`;
64887
+ if (used + line.length + footerReserve > TELEGRAM_MAX_MESSAGE_CHARS)
64888
+ break;
64889
+ lines.push(line);
64890
+ used += line.length;
64891
+ }
64892
+ const omitted = findings.length - lines.length;
64893
+ return `${head}${lines.join("")}${omitted > 0 ? `
64894
+ \u2026and ${omitted} more` : ""}`;
64895
+ }
64810
64896
  async function sendTelegramNotify(cfg, text) {
64811
64897
  const res = await _telegramDeps.fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
64812
64898
  method: "POST",
64813
64899
  headers: { "content-type": "application/json" },
64814
- body: JSON.stringify({ chat_id: cfg.chatId, text, parse_mode: "Markdown" })
64900
+ body: JSON.stringify({ chat_id: cfg.chatId, text })
64815
64901
  });
64816
64902
  return res.ok;
64817
64903
  }
64818
- var _telegramDeps;
64904
+ var TELEGRAM_MAX_MESSAGE_CHARS = 4096, MAX_FINDING_TITLE_CHARS = 120, _telegramDeps;
64819
64905
  var init_telegram2 = __esm(() => {
64820
64906
  _telegramDeps = { fetch: (...a) => fetch(...a) };
64821
64907
  });
@@ -64970,8 +65056,32 @@ var init_nax_finish = __esm(() => {
64970
65056
  message: `nax-finish flow exited ${res.exitCode} (no result file)${tail ? `: ${tail}` : ""}`
64971
65057
  };
64972
65058
  }
64973
- if (result.status === "escalated" && escalateTelegram && creds) {
64974
- await _naxFinishDeps.notify(creds, `nax-finish escalated *${result.feature}*: ${result.escalationReason ?? ""}`);
65059
+ if (result.status === "escalated") {
65060
+ const problems = [];
65061
+ let delivered = !escalateTelegram && !result.deliveryError;
65062
+ if (escalateTelegram && creds) {
65063
+ const sent = await _naxFinishDeps.notify(creds, buildEscalationMessage(result.feature, result.escalationReason ?? "", result.findings ?? []));
65064
+ if (sent)
65065
+ delivered = true;
65066
+ else
65067
+ problems.push("Telegram rejected the message");
65068
+ }
65069
+ if (!delivered) {
65070
+ if (result.deliveryError)
65071
+ problems.push(`the flow could not post it: ${result.deliveryError}`);
65072
+ if (problems.length === 0)
65073
+ problems.push("no escalation channel was reachable");
65074
+ ctx.logger.warn("nax-finish escalation was not delivered", {
65075
+ feature: result.feature,
65076
+ reasons: problems,
65077
+ escalationReason: result.escalationReason
65078
+ });
65079
+ return {
65080
+ success: false,
65081
+ message: `nax-finish: escalated but undelivered \u2014 ${problems.join("; ")}`,
65082
+ url: result.url
65083
+ };
65084
+ }
64975
65085
  }
64976
65086
  return { success: true, message: `nax-finish: ${result.status}`, url: result.url };
64977
65087
  } catch (err) {
@@ -65114,6 +65224,7 @@ var init_batch_queue = __esm(() => {
65114
65224
  });
65115
65225
 
65116
65226
  // src/plugins/builtin/otel-reporter/otlp.ts
65227
+ import { hostname as hostname3 } from "os";
65117
65228
  function attr(key, value) {
65118
65229
  return typeof value === "number" ? { key, value: { doubleValue: value } } : { key, value: { stringValue: value } };
65119
65230
  }
@@ -65133,8 +65244,25 @@ function buildHistogramPoint(values, bounds, attributes, timeUnixNano) {
65133
65244
  function buildCounterPoint(count, attributes, timeUnixNano) {
65134
65245
  return { attributes, timeUnixNano, asInt: String(count) };
65135
65246
  }
65136
- function buildResourceAttributes(serviceName, runId) {
65137
- return [attr("service.name", serviceName), attr("nax.run_id", runId)];
65247
+ function buildResourceAttributes(input) {
65248
+ const attrs = [
65249
+ attr("service.name", input.serviceName),
65250
+ attr("nax.run_id", input.runId),
65251
+ attr("nax.version", NAX_VERSION),
65252
+ attr("process.pid", process.pid)
65253
+ ];
65254
+ try {
65255
+ attrs.push(attr("host.name", hostname3()));
65256
+ } catch {}
65257
+ if (input.feature !== undefined)
65258
+ attrs.push(attr("nax.feature", input.feature));
65259
+ if (input.project !== undefined)
65260
+ attrs.push(attr("nax.project", input.project));
65261
+ if (input.git?.branch !== undefined)
65262
+ attrs.push(attr("nax.git.branch", input.git.branch));
65263
+ if (input.git?.sha !== undefined)
65264
+ attrs.push(attr("nax.git.sha", input.git.sha));
65265
+ return attrs;
65138
65266
  }
65139
65267
  function buildTracesPayload(p) {
65140
65268
  const span = {
@@ -65160,7 +65288,18 @@ function buildTracesPayload(p) {
65160
65288
  return {
65161
65289
  resourceSpans: [
65162
65290
  {
65163
- resource: { attributes: [attr("service.name", p.serviceName)] },
65291
+ resource: {
65292
+ attributes: buildResourceAttributes({
65293
+ serviceName: p.serviceName,
65294
+ runId: p.runId,
65295
+ feature: p.feature,
65296
+ project: p.project,
65297
+ git: {
65298
+ branch: p.gitBranch,
65299
+ sha: p.gitSha
65300
+ }
65301
+ })
65302
+ },
65164
65303
  scopeSpans: [{ scope: { name: "nax" }, spans: [span, ...p.extraSpans ?? []] }]
65165
65304
  }
65166
65305
  ]
@@ -65187,7 +65326,18 @@ function buildMetricsPayload(p) {
65187
65326
  return {
65188
65327
  resourceMetrics: [
65189
65328
  {
65190
- resource: { attributes: [attr("service.name", p.serviceName)] },
65329
+ resource: {
65330
+ attributes: buildResourceAttributes({
65331
+ serviceName: p.serviceName,
65332
+ runId: p.runId,
65333
+ feature: p.feature,
65334
+ project: p.project,
65335
+ git: {
65336
+ branch: p.gitBranch,
65337
+ sha: p.gitSha
65338
+ }
65339
+ })
65340
+ },
65191
65341
  scopeMetrics: [
65192
65342
  {
65193
65343
  scope: { name: "nax" },
@@ -65198,6 +65348,9 @@ function buildMetricsPayload(p) {
65198
65348
  ]
65199
65349
  };
65200
65350
  }
65351
+ var init_otlp = __esm(() => {
65352
+ init_version();
65353
+ });
65201
65354
 
65202
65355
  // src/plugins/builtin/otel-reporter/heartbeat.ts
65203
65356
  function startHeartbeat(opts) {
@@ -65250,7 +65403,12 @@ function buildHeartbeatMetricsPayload(p) {
65250
65403
  return {
65251
65404
  resourceMetrics: [
65252
65405
  {
65253
- resource: { attributes: [attr("service.name", p.serviceName)] },
65406
+ resource: {
65407
+ attributes: buildResourceAttributes({
65408
+ serviceName: p.serviceName,
65409
+ runId: p.snapshot.attributes.runId
65410
+ })
65411
+ },
65254
65412
  scopeMetrics: [
65255
65413
  {
65256
65414
  scope: { name: "nax" },
@@ -65268,6 +65426,7 @@ function buildHeartbeatMetricsPayload(p) {
65268
65426
  var STAGE2 = "otel-reporter-heartbeat";
65269
65427
  var init_heartbeat = __esm(() => {
65270
65428
  init_logger2();
65429
+ init_otlp();
65271
65430
  });
65272
65431
 
65273
65432
  // src/plugins/builtin/otel-reporter/ids.ts
@@ -65281,6 +65440,78 @@ function randomHex(bytes) {
65281
65440
  }
65282
65441
  var newTraceId = () => randomHex(16), newSpanId = () => randomHex(8);
65283
65442
 
65443
+ // src/plugins/builtin/otel-reporter/logs.ts
65444
+ function entryTimestampMs(entry) {
65445
+ return new Date(entry.timestamp).getTime();
65446
+ }
65447
+ function toLogRecord(entry) {
65448
+ const timeUnixNano = msToUnixNano(entryTimestampMs(entry));
65449
+ const { number: severityNumber, text: severityText } = SEVERITY[entry.level];
65450
+ const attributes = [attr("nax.stage", entry.stage)];
65451
+ if (entry.storyId !== undefined)
65452
+ attributes.push(attr("nax.story_id", entry.storyId));
65453
+ if (entry.sessionRole !== undefined)
65454
+ attributes.push(attr("nax.session_role", entry.sessionRole));
65455
+ const data = entry.data ?? {};
65456
+ const nonScalars = {};
65457
+ for (const [key, value] of Object.entries(data)) {
65458
+ if (typeof value === "string") {
65459
+ attributes.push(attr(`nax.data.${key}`, truncate3(value)));
65460
+ } else if (typeof value === "number") {
65461
+ if (Number.isFinite(value)) {
65462
+ attributes.push(attr(`nax.data.${key}`, value));
65463
+ } else {
65464
+ nonScalars[key] = value;
65465
+ }
65466
+ } else if (typeof value === "boolean") {
65467
+ attributes.push(attr(`nax.data.${key}`, String(value)));
65468
+ } else {
65469
+ nonScalars[key] = value;
65470
+ }
65471
+ }
65472
+ if (Object.keys(nonScalars).length > 0) {
65473
+ attributes.push(attr("nax.data_json", truncate3(JSON.stringify(nonScalars))));
65474
+ }
65475
+ return {
65476
+ body: { stringValue: entry.message },
65477
+ timeUnixNano,
65478
+ severityNumber,
65479
+ severityText,
65480
+ attributes
65481
+ };
65482
+ }
65483
+ function buildLogsPayload(entries, resource) {
65484
+ const logRecords = entries.map(toLogRecord);
65485
+ return {
65486
+ resourceLogs: [
65487
+ {
65488
+ resource: {
65489
+ attributes: buildResourceAttributes(resource)
65490
+ },
65491
+ scopeLogs: [{ scope: { name: "nax" }, logRecords }]
65492
+ }
65493
+ ]
65494
+ };
65495
+ }
65496
+ function truncate3(value) {
65497
+ if (value.length <= DATA_JSON_MAX)
65498
+ return value;
65499
+ const marker = TRUNCATION_MARKER;
65500
+ const keep = DATA_JSON_MAX - marker.length;
65501
+ return `${value.slice(0, keep)}${marker}`;
65502
+ }
65503
+ var SEVERITY, DATA_JSON_MAX = 2048, TRUNCATION_MARKER = "...[truncated]";
65504
+ var init_logs = __esm(() => {
65505
+ init_otlp();
65506
+ SEVERITY = {
65507
+ silent: { number: 0, text: "SILENT" },
65508
+ error: { number: 17, text: "ERROR" },
65509
+ warn: { number: 13, text: "WARN" },
65510
+ info: { number: 9, text: "INFO" },
65511
+ debug: { number: 5, text: "DEBUG" }
65512
+ };
65513
+ });
65514
+
65284
65515
  // src/plugins/builtin/otel-reporter/span-tree.ts
65285
65516
  function createSpanTree(traceId, runSpanId) {
65286
65517
  const storySpanIds = new Map;
@@ -65364,7 +65595,8 @@ function createPhaseMetricsAggregator() {
65364
65595
  function recordEscalation(toTier, count) {
65365
65596
  bumpCounter(escalations, [attr("to_tier", toTier)], count);
65366
65597
  }
65367
- function buildMetricsPayload2(serviceName, runId, timeUnixNano) {
65598
+ function buildMetricsPayload2(input) {
65599
+ const { serviceName, runId, timeUnixNano, feature, project, gitBranch, gitSha } = input;
65368
65600
  const groups = [...phaseGroups.values()];
65369
65601
  const counterMetric = (name, source) => ({
65370
65602
  name,
@@ -65400,7 +65632,15 @@ function createPhaseMetricsAggregator() {
65400
65632
  return {
65401
65633
  resourceMetrics: [
65402
65634
  {
65403
- resource: { attributes: buildResourceAttributes(serviceName, runId) },
65635
+ resource: {
65636
+ attributes: buildResourceAttributes({
65637
+ serviceName,
65638
+ runId,
65639
+ feature,
65640
+ project,
65641
+ git: { branch: gitBranch, sha: gitSha }
65642
+ })
65643
+ },
65404
65644
  scopeMetrics: [{ scope: { name: "nax" }, metrics }]
65405
65645
  }
65406
65646
  ]
@@ -65410,6 +65650,7 @@ function createPhaseMetricsAggregator() {
65410
65650
  }
65411
65651
  var PHASE_DURATION_BOUNDS, PHASE_COST_BOUNDS;
65412
65652
  var init_span_tree = __esm(() => {
65653
+ init_otlp();
65413
65654
  PHASE_DURATION_BOUNDS = [100, 500, 1000, 5000, 15000, 60000, 300000, 900000];
65414
65655
  PHASE_COST_BOUNDS = [0.001, 0.01, 0.05, 0.1, 0.5, 1, 5];
65415
65656
  });
@@ -65484,11 +65725,11 @@ function reviewSpanEvents(details, timeUnixNano, verbose) {
65484
65725
  return { timeUnixNano, name: "review.finding", attributes };
65485
65726
  });
65486
65727
  }
65487
- function createOtelReporterPlugin(cfg, deps) {
65728
+ function createOtelReporterPlugin(cfg, deps, workdir) {
65488
65729
  const states = new Map;
65489
65730
  const base = cfg.endpoint?.replace(/\/$/, "");
65490
65731
  let tornDown = false;
65491
- const sendSpanBatch = async (batch) => {
65732
+ const makeSendSpanBatch = (resourceAttrs) => async (batch) => {
65492
65733
  if (!base || batch.length === 0)
65493
65734
  return true;
65494
65735
  const { resolved, missing } = interpolateHeaders(cfg.headers);
@@ -65499,21 +65740,55 @@ function createOtelReporterPlugin(cfg, deps) {
65499
65740
  const payload = {
65500
65741
  resourceSpans: [
65501
65742
  {
65502
- resource: { attributes: [attr("service.name", cfg.serviceName)] },
65743
+ resource: { attributes: resourceAttrs },
65503
65744
  scopeSpans: [{ scope: { name: "nax" }, spans: batch }]
65504
65745
  }
65505
65746
  ]
65506
65747
  };
65507
- return postJson(`${base}/v1/traces`, payload, { headers: resolved, timeoutMs: cfg.timeoutMs, stage: STAGE3, deps });
65748
+ return postJson(`${base}/v1/traces`, payload, {
65749
+ headers: resolved,
65750
+ timeoutMs: cfg.timeoutMs,
65751
+ stage: STAGE3,
65752
+ deps
65753
+ });
65754
+ };
65755
+ const makeSpanQueue = (resourceAttrs) => createBatchQueue({
65756
+ maxBatchSize: cfg.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,
65757
+ flushIntervalMs: cfg.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
65758
+ maxQueueSize: cfg.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
65759
+ send: makeSendSpanBatch(resourceAttrs)
65760
+ });
65761
+ const makeSendLogsBatch = (resource) => async (batch) => {
65762
+ if (!base || batch.length === 0)
65763
+ return true;
65764
+ const { resolved, missing } = interpolateHeaders(cfg.headers);
65765
+ if (missing.length > 0) {
65766
+ getSafeLogger()?.warn(STAGE3, "Skipping OTLP export \u2014 unresolved env vars", { missing });
65767
+ return true;
65768
+ }
65769
+ const payload = buildLogsPayload(batch, {
65770
+ serviceName: resource.serviceName,
65771
+ runId: resource.runId,
65772
+ feature: resource.feature,
65773
+ project: resource.project,
65774
+ git: { branch: resource.gitBranch, sha: resource.gitSha }
65775
+ });
65776
+ return postJson(`${base}/v1/logs`, payload, {
65777
+ headers: resolved,
65778
+ timeoutMs: cfg.timeoutMs,
65779
+ stage: STAGE3,
65780
+ deps
65781
+ });
65508
65782
  };
65509
- const makeSpanQueue = () => createBatchQueue({
65783
+ const makeLogsQueue = (resource) => createBatchQueue({
65510
65784
  maxBatchSize: cfg.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,
65511
65785
  flushIntervalMs: cfg.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
65512
65786
  maxQueueSize: cfg.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
65513
- send: sendSpanBatch
65787
+ send: makeSendLogsBatch(resource)
65514
65788
  });
65515
65789
  const buildOrphanState = (startMs) => {
65516
65790
  const identity = rootSpanIdentity();
65791
+ const orphanAttrs = buildResourceAttributes({ serviceName: cfg.serviceName, runId: "orphan" });
65517
65792
  return {
65518
65793
  ...identity,
65519
65794
  startMs,
@@ -65521,7 +65796,7 @@ function createOtelReporterPlugin(cfg, deps) {
65521
65796
  project: "",
65522
65797
  events: [],
65523
65798
  spanTree: createSpanTree(identity.traceId, identity.spanId),
65524
- spanQueue: makeSpanQueue(),
65799
+ spanQueue: makeSpanQueue(orphanAttrs),
65525
65800
  metrics: createPhaseMetricsAggregator(),
65526
65801
  storyBounds: new Map,
65527
65802
  costUsd: 0,
@@ -65561,6 +65836,9 @@ function createOtelReporterPlugin(cfg, deps) {
65561
65836
  startUnixNano,
65562
65837
  endUnixNano,
65563
65838
  feature: st.feature,
65839
+ project: st.project,
65840
+ gitBranch: st.gitBranch,
65841
+ gitSha: st.gitSha,
65564
65842
  runId: e.runId,
65565
65843
  storySummary: e.storySummary,
65566
65844
  totalCost: e.totalCost,
@@ -65570,11 +65848,23 @@ function createOtelReporterPlugin(cfg, deps) {
65570
65848
  serviceName: cfg.serviceName,
65571
65849
  runId: e.runId,
65572
65850
  timeUnixNano: endUnixNano,
65851
+ feature: st.feature,
65852
+ project: st.project,
65853
+ gitBranch: st.gitBranch,
65854
+ gitSha: st.gitSha,
65573
65855
  storySummary: e.storySummary,
65574
65856
  totalCost: e.totalCost,
65575
65857
  totalDurationMs: e.totalDurationMs
65576
65858
  });
65577
- const aggMetrics = st.metrics.buildMetricsPayload(cfg.serviceName, e.runId, endUnixNano);
65859
+ const aggMetrics = st.metrics.buildMetricsPayload({
65860
+ serviceName: cfg.serviceName,
65861
+ runId: e.runId,
65862
+ timeUnixNano: endUnixNano,
65863
+ feature: st.feature,
65864
+ project: st.project,
65865
+ gitBranch: st.gitBranch,
65866
+ gitSha: st.gitSha
65867
+ });
65578
65868
  metrics.resourceMetrics[0].scopeMetrics[0].metrics.push(...aggMetrics.resourceMetrics[0].scopeMetrics[0].metrics);
65579
65869
  const opts = { headers: resolved, timeoutMs: cfg.timeoutMs, stage: STAGE3, deps };
65580
65870
  await postJson(`${base}/v1/traces`, traces, opts);
@@ -65585,14 +65875,41 @@ function createOtelReporterPlugin(cfg, deps) {
65585
65875
  async onRunStart(event) {
65586
65876
  const identity = rootSpanIdentity();
65587
65877
  const runId = event.runId;
65878
+ let gitBranch;
65879
+ let gitSha;
65880
+ if (base && workdir) {
65881
+ const [branchResult, shaResult] = await Promise.all([
65882
+ gitWithTimeout(["rev-parse", "--abbrev-ref", "HEAD"], workdir).catch(() => null),
65883
+ gitWithTimeout(["rev-parse", "HEAD"], workdir).catch(() => null)
65884
+ ]);
65885
+ if (branchResult?.exitCode === 0) {
65886
+ const branch = branchResult.stdout.trim();
65887
+ if (branch && branch !== "HEAD")
65888
+ gitBranch = branch;
65889
+ }
65890
+ if (shaResult?.exitCode === 0) {
65891
+ const sha = shaResult.stdout.trim();
65892
+ if (sha)
65893
+ gitSha = sha;
65894
+ }
65895
+ }
65896
+ const resourceAttrs = buildResourceAttributes({
65897
+ serviceName: cfg.serviceName,
65898
+ runId,
65899
+ feature: event.feature,
65900
+ project: event.project,
65901
+ git: { branch: gitBranch, sha: gitSha }
65902
+ });
65588
65903
  const state = {
65589
65904
  ...identity,
65590
65905
  startMs: Date.parse(event.startTime),
65591
65906
  feature: event.feature,
65592
65907
  project: event.project ?? "",
65908
+ gitBranch,
65909
+ gitSha,
65593
65910
  events: [],
65594
65911
  spanTree: createSpanTree(identity.traceId, identity.spanId),
65595
- spanQueue: makeSpanQueue(),
65912
+ spanQueue: makeSpanQueue(resourceAttrs),
65596
65913
  metrics: createPhaseMetricsAggregator(),
65597
65914
  storyBounds: new Map,
65598
65915
  costUsd: 0,
@@ -65603,6 +65920,27 @@ function createOtelReporterPlugin(cfg, deps) {
65603
65920
  })
65604
65921
  };
65605
65922
  states.set(runId, state);
65923
+ if (cfg.logs?.enabled) {
65924
+ const logsQueue = makeLogsQueue({
65925
+ serviceName: cfg.serviceName,
65926
+ runId,
65927
+ feature: event.feature,
65928
+ project: event.project ?? "",
65929
+ gitBranch,
65930
+ gitSha
65931
+ });
65932
+ const floorKey = cfg.logs.level;
65933
+ const sank = (entry) => {
65934
+ if (REENTRY_STAGES.has(entry.stage))
65935
+ return;
65936
+ if (LOG_PRIORITY[entry.level] > LOG_PRIORITY[floorKey])
65937
+ return;
65938
+ logsQueue.enqueue(entry);
65939
+ };
65940
+ const addSinkFn = deps?.addSink ?? addSink;
65941
+ state.logsQueue = logsQueue;
65942
+ state.logUnsubscribe = addSinkFn(sank);
65943
+ }
65606
65944
  },
65607
65945
  async onStoryComplete(event) {
65608
65946
  const st = states.get(event.runId);
@@ -65674,6 +66012,11 @@ function createOtelReporterPlugin(cfg, deps) {
65674
66012
  states.delete(event.runId);
65675
66013
  await st.spanQueue.flushNow();
65676
66014
  st.spanQueue.teardown();
66015
+ if (st.logsQueue) {
66016
+ await st.logsQueue.flushNow();
66017
+ st.logsQueue.teardown();
66018
+ st.logUnsubscribe?.();
66019
+ }
65677
66020
  await flush(st, startMs + event.totalDurationMs, event);
65678
66021
  }
65679
66022
  };
@@ -65691,6 +66034,11 @@ function createOtelReporterPlugin(cfg, deps) {
65691
66034
  st.heartbeat.stop();
65692
66035
  await st.spanQueue.flushNow();
65693
66036
  st.spanQueue.teardown();
66037
+ if (st.logsQueue) {
66038
+ await st.logsQueue.flushNow();
66039
+ st.logsQueue.teardown();
66040
+ st.logUnsubscribe?.();
66041
+ }
65694
66042
  const endMs = Date.now();
65695
66043
  await flush(st, endMs, {
65696
66044
  runId,
@@ -65703,14 +66051,25 @@ function createOtelReporterPlugin(cfg, deps) {
65703
66051
  extensions: { reporter }
65704
66052
  };
65705
66053
  }
65706
- var STAGE3 = "otel-reporter", DEFAULT_MAX_BATCH_SIZE = 64, DEFAULT_FLUSH_INTERVAL_MS = 5000, DEFAULT_MAX_QUEUE_SIZE = 2048;
66054
+ var STAGE3 = "otel-reporter", REENTRY_STAGE = "otel-batch-queue", REENTRY_STAGES, DEFAULT_MAX_BATCH_SIZE = 64, DEFAULT_FLUSH_INTERVAL_MS = 5000, DEFAULT_MAX_QUEUE_SIZE = 2048, LOG_PRIORITY;
65707
66055
  var init_otel_reporter = __esm(() => {
65708
66056
  init_logger2();
66057
+ init_git();
65709
66058
  init_reporter_shared();
65710
66059
  init_batch_queue();
65711
66060
  init_heartbeat();
66061
+ init_logs();
66062
+ init_otlp();
65712
66063
  init_span_tree();
65713
66064
  init_traceparent();
66065
+ REENTRY_STAGES = new Set([STAGE3, REENTRY_STAGE]);
66066
+ LOG_PRIORITY = {
66067
+ silent: -1,
66068
+ error: 0,
66069
+ warn: 1,
66070
+ info: 2,
66071
+ debug: 3
66072
+ };
65714
66073
  });
65715
66074
 
65716
66075
  // src/plugins/builtin/webhook-reporter/index.ts
@@ -66166,7 +66525,7 @@ async function loadPlugins(globalDir, projectDir, configPlugins, projectRoot, di
66166
66525
  {
66167
66526
  name: "otel-reporter",
66168
66527
  enabled: reporters.otel.enabled,
66169
- make: () => createOtelReporterPlugin(reporters.otel)
66528
+ make: () => createOtelReporterPlugin(reporters.otel, undefined, effectiveProjectRoot)
66170
66529
  }
66171
66530
  ] : [];
66172
66531
  for (const { name, enabled: reporterEnabled, make } of reporterFactories) {
@@ -17,11 +17,20 @@
17
17
  * - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
18
18
  * resolve` once, and its output feeds both the review prompts (specPath) and
19
19
  * the acceptance gate (groups), so nothing resolves the feature twice.
20
- * - Review fixes loop: `review_* → route_* → fix_* → (re-run acceptance |
21
- * re-review) → review_*` until the reviewer comes back clean or the fix cap
22
- * trips. A single-shot fix left the fixed diff unverified.
20
+ * - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
21
+ * acceptance | re-review) → review_*` until the reviewer comes back clean or
22
+ * the fix cap trips. A single-shot fix left the fixed diff unverified.
23
+ * - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
24
+ * `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
25
+ * re-review: the loop re-reported findings that were already fixed and always
26
+ * escalated at the cap (issue #1397).
23
27
  * - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
24
28
  * enforced deterministically rather than trusting the model's own route.
29
+ * - `quality_gates` re-runs the feature's acceptance tests before the repo's
30
+ * own commands. The quality-review and gate fix loops both edit code after
31
+ * the `acceptance` node last passed, and the repo-root `test` command does
32
+ * not cover per-feature acceptance tests — so without this a fix could break
33
+ * the contract the first gate proved and still ship.
25
34
  */
26
35
  import { defineFlow, extractJsonObject } from "acpx/flows";
27
36
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
@@ -29,6 +38,7 @@ import {
29
38
  _contextDeps,
30
39
  buildEscalationComment,
31
40
  commitAndPush,
41
+ commitFixes,
32
42
  detectBaseBranch,
33
43
  loadQualityCommands,
34
44
  openOrPromotePr,
@@ -39,7 +49,7 @@ import {
39
49
  runQualityGates,
40
50
  writeResult,
41
51
  } from "./steps";
42
- import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
52
+ import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
43
53
 
44
54
  const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
45
55
 
@@ -55,6 +65,8 @@ interface LoadCtxOutput {
55
65
  base?: string;
56
66
  specPath?: string;
57
67
  groups?: AcceptanceGroup[];
68
+ /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
69
+ acceptanceStatus?: string;
58
70
  route?: string;
59
71
  }
60
72
 
@@ -66,16 +78,48 @@ function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
66
78
  return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
67
79
  }
68
80
 
69
- /** Re-run the acceptance gate, routing on the shared fix-cap rules. */
81
+ /**
82
+ * Re-run the acceptance gate, routing on the shared fix-cap rules.
83
+ *
84
+ * "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
85
+ * unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
86
+ * `no-prd` and `disabled`, and reports `exists: false` for a group whose test
87
+ * was expected at its canonical path but never generated. Treating all of those
88
+ * as green let the flow open a ready PR having verified nothing about the
89
+ * feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
90
+ * — skips cleanly.
91
+ */
70
92
  async function acceptanceGateNode(ctx: {
71
93
  input: unknown;
72
94
  outputs: unknown;
73
95
  state: { steps: { nodeId: string }[] };
74
96
  }): Promise<{ route: string; reason?: string; output: string }> {
75
97
  const i = inputOf(ctx);
76
- const groups = loadCtxOf(ctx).groups ?? [];
98
+ const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
99
+ if (acceptanceStatus === "disabled") {
100
+ return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
101
+ }
102
+ if (acceptanceStatus === "no-prd") {
103
+ return {
104
+ route: "escalate",
105
+ reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
106
+ output: "[acceptance] no prd.json resolved — acceptance targets unknown",
107
+ };
108
+ }
109
+
77
110
  const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
78
- if (r.passed) return { route: "proceed", output: r.output };
111
+ if (r.passed) {
112
+ // A real failure below routes to the fix loop, which is more actionable;
113
+ // the coverage hole is only reported once the runnable groups are green.
114
+ if (r.missing.length > 0) {
115
+ return {
116
+ route: "escalate",
117
+ reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
118
+ output: r.output,
119
+ };
120
+ }
121
+ return { route: "proceed", output: r.output };
122
+ }
79
123
  const attempts = fixAttemptCount(ctx, "fix_acceptance");
80
124
  if (attempts >= MAX_FIX_ATTEMPTS) {
81
125
  return {
@@ -119,6 +163,26 @@ function routeReview(
119
163
  return { route: "fix", findings };
120
164
  }
121
165
 
166
+ /**
167
+ * Build the `commit_<phase>` node that follows `fix_<phase>`.
168
+ *
169
+ * One node per phase rather than a single shared one because each returns to a
170
+ * different successor, and acpx routes on the node id — a shared node would
171
+ * need a switch reconstructing which fix ran from the step history.
172
+ */
173
+ function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
174
+ return {
175
+ nodeType: "action" as const,
176
+ async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
177
+ const i = inputOf(ctx);
178
+ // skipHooks: an intermediate checkpoint must not be rejected by a repo's
179
+ // pre-commit hook — quality_gates runs the repo's real gates before any
180
+ // PR opens, and a hook failure here would kill the flow mid-loop.
181
+ return commitFixes(i.workdir, `fix(${i.feature}): nax-finish ${phase} fixes`, { skipHooks: true });
182
+ },
183
+ };
184
+ }
185
+
122
186
  /** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
123
187
  function parseVerdict(text: string): ReviewVerdict {
124
188
  const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
@@ -162,6 +226,7 @@ export default defineFlow({
162
226
  prompt: (ctx) => fixPrompt("acceptance", ctx),
163
227
  parse: parseVerdict,
164
228
  },
229
+ commit_acceptance: commitFixNode("acceptance"),
165
230
  review_spec: {
166
231
  nodeType: "acp",
167
232
  session: { isolated: true },
@@ -181,6 +246,7 @@ export default defineFlow({
181
246
  prompt: (ctx) => fixPrompt("spec", ctx),
182
247
  parse: parseVerdict,
183
248
  },
249
+ commit_spec: commitFixNode("spec"),
184
250
  review_quality: {
185
251
  nodeType: "acp",
186
252
  session: { isolated: true },
@@ -200,15 +266,57 @@ export default defineFlow({
200
266
  prompt: (ctx) => fixPrompt("quality", ctx),
201
267
  parse: parseVerdict,
202
268
  },
269
+ commit_quality: commitFixNode("quality"),
203
270
  fix_gate: {
204
271
  nodeType: "acp",
205
272
  prompt: (ctx) => fixPrompt("gate", ctx),
206
273
  parse: parseVerdict,
207
274
  },
275
+ commit_gate: commitFixNode("gate"),
208
276
  quality_gates: {
209
277
  nodeType: "action",
210
278
  async run(ctx) {
211
279
  const i = inputOf(ctx);
280
+
281
+ // Acceptance is gate zero here, not just at the `acceptance` node.
282
+ // Both fix loops that run after it — quality review and this gate —
283
+ // edit code, and the repo-root `test` command does not cover the
284
+ // feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
285
+ // and usually need their own runner config. Re-running them here is what
286
+ // makes "nothing reaches open_pr without the feature's own contract
287
+ // passing against the tree as it will ship" true on every path (#1398).
288
+ //
289
+ // Unconditional, though the common green path re-runs a gate that
290
+ // already passed: acceptance is the cheapest gate in the pipeline, and a
291
+ // conditional skip derived from step history would be a check that can
292
+ // be *wrong* — a silent false green, the failure mode this exists to
293
+ // prevent.
294
+ //
295
+ // `missing` is deliberately ignored: groups are resolved once at
296
+ // load_ctx, so a coverage hole was already escalated by the acceptance
297
+ // node and cannot appear here.
298
+ const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
299
+ timeoutMs: i.timeouts?.acceptanceMs,
300
+ });
301
+ if (!acc.passed) {
302
+ // Short-circuit: the repo gates are re-run next round anyway, and
303
+ // skipping them keeps this out of the "nothing configured" branch
304
+ // below, which would otherwise misreport configured-but-skipped
305
+ // commands as absent.
306
+ const accAttempts = fixAttemptCount(ctx, "fix_gate");
307
+ const failing = ["acceptance"];
308
+ if (accAttempts >= MAX_FIX_ATTEMPTS) {
309
+ return {
310
+ route: "escalate",
311
+ reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
312
+ ran: [],
313
+ failing,
314
+ output: acc.output,
315
+ };
316
+ }
317
+ return { route: "fix", ran: [], failing, output: acc.output };
318
+ }
319
+
212
320
  const cmds = await loadQualityCommands(i.workdir);
213
321
  const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
214
322
  if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
@@ -289,12 +397,36 @@ export default defineFlow({
289
397
  syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
290
398
  }
291
399
 
400
+ // Write the result BEFORE attempting delivery. Delivery touches the
401
+ // network and the forge — a rate limit, an expired token, a locked PR
402
+ // or an unrecognised remote used to throw here, killing the node before
403
+ // any result existed. The plugin then had nothing to report and, on the
404
+ // Telegram channel, nothing to notify from: the one path whose job is
405
+ // to say "a human is needed" was the one path with no fallback (#1399).
406
+ const result: FinishResult = {
407
+ feature: i.feature,
408
+ status: "escalated",
409
+ escalationReason: reason,
410
+ findings: verdict?.findings ?? [],
411
+ };
412
+ await writeResult(i.workdir, result);
413
+
292
414
  const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
293
- const { url, channel } = await postEscalation(i.workdir, i.branch, comment, {
294
- preferTelegram: i.escalateTelegram,
295
- });
296
- await writeResult(i.workdir, { feature: i.feature, status: "escalated", url, escalationReason: reason });
297
- return { route: "done", url, channel, escalationReason: reason };
415
+ let url: string | undefined;
416
+ let channel: string | undefined;
417
+ let deliveryError: string | undefined;
418
+ try {
419
+ const posted = await postEscalation(i.workdir, i.branch, comment, {
420
+ preferTelegram: i.escalateTelegram,
421
+ });
422
+ url = posted.url;
423
+ channel = posted.channel;
424
+ } catch (err) {
425
+ deliveryError = String(err);
426
+ }
427
+ await writeResult(i.workdir, { ...result, url, deliveryError });
428
+
429
+ return { route: "done", url, channel, deliveryError, escalationReason: reason };
298
430
  },
299
431
  },
300
432
  },
@@ -304,7 +436,11 @@ export default defineFlow({
304
436
  from: "acceptance",
305
437
  switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
306
438
  },
307
- { from: "fix_acceptance", to: "acceptance" },
439
+ // Each fix commits before anything re-reads the diff: the reviewers see
440
+ // `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
441
+ // verbatim until the cap escalated it (#1397).
442
+ { from: "fix_acceptance", to: "commit_acceptance" },
443
+ { from: "commit_acceptance", to: "acceptance" },
308
444
  { from: "review_spec", to: "route_spec" },
309
445
  {
310
446
  from: "route_spec",
@@ -312,7 +448,8 @@ export default defineFlow({
312
448
  },
313
449
  // Spec fixes re-run the acceptance gate first (they can break it), and the
314
450
  // acceptance node's `proceed` edge leads back into review_spec for re-review.
315
- { from: "fix_spec", to: "acceptance" },
451
+ { from: "fix_spec", to: "commit_spec" },
452
+ { from: "commit_spec", to: "acceptance" },
316
453
  { from: "review_quality", to: "route_quality" },
317
454
  {
318
455
  from: "route_quality",
@@ -320,11 +457,13 @@ export default defineFlow({
320
457
  },
321
458
  // Quality fixes are re-reviewed by the same lens; the repo-root gates that
322
459
  // follow catch anything the fix broke mechanically.
323
- { from: "fix_quality", to: "review_quality" },
460
+ { from: "fix_quality", to: "commit_quality" },
461
+ { from: "commit_quality", to: "review_quality" },
324
462
  {
325
463
  from: "quality_gates",
326
464
  switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
327
465
  },
328
- { from: "fix_gate", to: "quality_gates" },
466
+ { from: "fix_gate", to: "commit_gate" },
467
+ { from: "commit_gate", to: "quality_gates" },
329
468
  ],
330
469
  });
@@ -21,24 +21,47 @@ export function buildAcceptanceCommand(repoRoot: string, group: AcceptanceGroup)
21
21
  return template.replace(/\{\{FILE\}\}|\{\{file\}\}|\{\{files\}\}/g, absFile);
22
22
  }
23
23
 
24
+ export interface AcceptanceGateOutcome {
25
+ /** Every group that ran exited 0. Says nothing about groups that could not run. */
26
+ passed: boolean;
27
+ ran: number;
28
+ /**
29
+ * Package names whose acceptance test the resolver expected at its canonical
30
+ * path but which is absent on disk — never generated, or generation failed.
31
+ * The caller must treat a non-empty list as a coverage hole rather than a
32
+ * pass: skipping these silently is how a feature reached a "ready" PR with
33
+ * nothing verified.
34
+ */
35
+ missing: string[];
36
+ output: string;
37
+ }
38
+
24
39
  export async function runAcceptanceGate(
25
40
  repoRoot: string,
26
41
  groups: AcceptanceGroup[],
27
42
  opts: { timeoutMs?: number } = {},
28
- ): Promise<{ passed: boolean; ran: number; output: string }> {
43
+ ): Promise<AcceptanceGateOutcome> {
29
44
  const chunks: string[] = [];
30
45
  const timeoutMs = opts.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS;
46
+ const missing: string[] = [];
31
47
  let ran = 0;
32
48
  for (const g of groups) {
33
- if (!g.exists) continue;
49
+ const name = g.packageDir || "root";
50
+ if (!g.exists) {
51
+ missing.push(name);
52
+ continue;
53
+ }
34
54
  const cwd = g.packageDir ? `${repoRoot}/${g.packageDir}` : repoRoot;
35
55
  ran += 1;
36
56
  const res = await _acceptanceDeps.runShell(buildAcceptanceCommand(repoRoot, g), { cwd, timeoutMs });
37
- chunks.push(`[${g.packageDir || "root"}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
38
- if (res.exitCode !== 0) return { passed: false, ran, output: chunks.join("\n\n") };
57
+ chunks.push(`[${name}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
58
+ if (res.exitCode !== 0) return { passed: false, ran, missing, output: chunks.join("\n\n") };
59
+ }
60
+ if (missing.length > 0) {
61
+ chunks.push(`[acceptance] no acceptance test file on disk for: ${missing.join(", ")}`);
39
62
  }
40
63
  if (ran === 0) chunks.push("[acceptance] no acceptance test files present — nothing to run");
41
- return { passed: true, ran, output: chunks.join("\n\n") };
64
+ return { passed: true, ran, missing, output: chunks.join("\n\n") };
42
65
  }
43
66
 
44
67
  function languageRunner(language: string): string {
@@ -12,15 +12,64 @@ const URL_REGEX = /https?:\/\/\S+/;
12
12
 
13
13
  export type Forge = "github" | "gitlab";
14
14
 
15
+ /**
16
+ * Host of a git remote, for both URL forms git accepts:
17
+ * `git@host:path` (scp-like) and `scheme://[user@]host[:port]/path`.
18
+ */
19
+ export function remoteHost(remoteUrl: string): string {
20
+ const scp = remoteUrl.match(/^[^/]*@([^:/]+):/);
21
+ if (scp?.[1]) return scp[1].toLowerCase();
22
+ const url = remoteUrl.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/]+)/i);
23
+ return url?.[1]?.toLowerCase() ?? "";
24
+ }
25
+
26
+ /**
27
+ * Classify by host name.
28
+ *
29
+ * Matching the host (not a substring of the whole URL) is what makes
30
+ * self-hosted instances work: `"gitlab.mycorp.com".includes("gitlab.com")` is
31
+ * false, so the previous check rejected every self-hosted forge. GitHub is
32
+ * tested first purely for determinism on an absurd host naming both.
33
+ */
34
+ function forgeFromHost(host: string): Forge | null {
35
+ if (host.includes("github")) return "github";
36
+ if (host.includes("gitlab")) return "gitlab";
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Last resort for an enterprise host that names neither forge (`git.corp.com`):
42
+ * ask which CLI is installed. Only decisive when exactly one is — with both or
43
+ * neither present a guess would send `gh` at a GitLab remote.
44
+ */
45
+ async function forgeFromCli(run: RunFn, repoRoot: string): Promise<Forge | null> {
46
+ const [gh, glab] = await Promise.all([
47
+ run(["gh", "--version"], { cwd: repoRoot }),
48
+ run(["glab", "--version"], { cwd: repoRoot }),
49
+ ]);
50
+ const hasGh = gh.exitCode === 0;
51
+ const hasGlab = glab.exitCode === 0;
52
+ if (hasGh && !hasGlab) return "github";
53
+ if (hasGlab && !hasGh) return "gitlab";
54
+ return null;
55
+ }
56
+
15
57
  export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
16
58
  const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
17
59
  const remoteUrl = remote.stdout.trim();
18
- if (remoteUrl.includes("github.com")) return "github";
19
- if (remoteUrl.includes("gitlab.com")) return "gitlab";
20
- throw new FinishError(`Unable to determine forge from remote URL "${remoteUrl}"`, "FINISH_UNKNOWN_FORGE", {
21
- stage,
22
- remoteUrl,
23
- });
60
+ const host = remoteHost(remoteUrl);
61
+
62
+ const byHost = forgeFromHost(host);
63
+ if (byHost) return byHost;
64
+
65
+ const byCli = await forgeFromCli(run, repoRoot);
66
+ if (byCli) return byCli;
67
+
68
+ throw new FinishError(
69
+ `Unable to determine forge for remote host "${host || remoteUrl}" — its name matches neither github nor gitlab, and the gh/glab probe was inconclusive`,
70
+ "FINISH_UNKNOWN_FORGE",
71
+ { stage, remoteUrl, host },
72
+ );
24
73
  }
25
74
 
26
75
  /** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * Branch synchronisation for the nax-finish flow.
3
3
  *
4
- * Every fix node edits the working tree in place. Without this step those edits
5
- * stay local and uncommitted: `gh pr create --head <branch>` then opens a PR
6
- * from the *remote* branch, which is missing every fix the flow just made (and
7
- * an escalation comment would describe state nobody else can see). Both
8
- * terminal nodes call `commitAndPush` before touching the forge.
4
+ * Every fix node edits the working tree in place, which two different consumers
5
+ * would otherwise miss:
6
+ *
7
+ * - The **reviewers** read `git diff <base>...HEAD` committed history only.
8
+ * With the fixes uncommitted, every re-review re-read the pre-fix code and
9
+ * re-reported findings the fix node had already resolved, so the loop could
10
+ * never converge and always escalated at the fix cap (issue #1397). Each
11
+ * `commit_*` node calls `commitFixes` for this reason.
12
+ * - The **forge**: `gh pr create --head <branch>` opens a PR from the *remote*
13
+ * branch, and an escalation comment would describe state nobody else can see.
14
+ * Both terminal nodes call `commitAndPush` before touching the forge.
9
15
  */
10
16
  import { FinishError } from "../errors";
11
17
  import { runArgv } from "../exec";
@@ -32,34 +38,62 @@ async function isDirty(repoRoot: string): Promise<boolean> {
32
38
  return status.stdout.trim().length > 0;
33
39
  }
34
40
 
41
+ /**
42
+ * Commit the working tree, if it has anything in it, without pushing.
43
+ *
44
+ * Called by the `commit_*` nodes after every fix node so the next reviewer's
45
+ * `git diff <base>...HEAD` contains the fix. `git add -A` (not `-u`) because a
46
+ * fix routinely adds a *new* test file, and an untracked file is invisible to
47
+ * that diff — which is also why committing beats widening the reviewer's diff
48
+ * to include the working tree.
49
+ *
50
+ * `skipHooks` (used by every mid-loop `commit_*` node) adds `--no-verify`. Those
51
+ * commits are internal checkpoints, not shipped history: a repo whose
52
+ * pre-commit hook runs lint or typecheck would otherwise reject an intermediate
53
+ * state — a lint error the gate loop was about to fix — and take the whole flow
54
+ * down with it, with no result file. Nothing is lost by skipping them, because
55
+ * `quality_gates` runs the repo's own build/typecheck/lint/test and no PR opens
56
+ * unless they are green. The terminal `commitAndPush` leaves hooks enabled.
57
+ *
58
+ * A failing commit still throws: the fix is then unreviewable, and continuing
59
+ * would silently reproduce the stale-diff bug this exists to fix.
60
+ */
61
+ export async function commitFixes(
62
+ repoRoot: string,
63
+ message: string,
64
+ opts: { skipHooks?: boolean } = {},
65
+ ): Promise<{ committed: boolean }> {
66
+ if (!(await isDirty(repoRoot))) return { committed: false };
67
+
68
+ const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
69
+ if (add.exitCode !== 0) {
70
+ throw new FinishError(
71
+ `git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
72
+ "FINISH_GIT_ADD_FAILED",
73
+ { stage: "finish-git", repoRoot },
74
+ );
75
+ }
76
+ const commitArgv = ["git", "commit", "-m", message, ...(opts.skipHooks ? ["--no-verify"] : [])];
77
+ const commit = await _gitDeps.run(commitArgv, { cwd: repoRoot });
78
+ if (commit.exitCode !== 0) {
79
+ throw new FinishError(
80
+ `git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
81
+ "FINISH_GIT_COMMIT_FAILED",
82
+ { stage: "finish-git", repoRoot },
83
+ );
84
+ }
85
+ return { committed: true };
86
+ }
87
+
35
88
  /**
36
89
  * Commit any outstanding fixes and push the branch to `origin`.
37
90
  *
38
91
  * The push is unconditional — even with nothing new to commit the local branch
39
- * may be ahead of its remote (nax's own run commits, or a previous partial
40
- * finish), and the PR must reflect HEAD.
92
+ * may be ahead of its remote (nax's own run commits, or the `commit_*` nodes'
93
+ * per-round commits), and the PR must reflect HEAD.
41
94
  */
42
95
  export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
43
- let committed = false;
44
- if (await isDirty(repoRoot)) {
45
- const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
46
- if (add.exitCode !== 0) {
47
- throw new FinishError(
48
- `git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
49
- "FINISH_GIT_ADD_FAILED",
50
- { stage: "finish-git", repoRoot },
51
- );
52
- }
53
- const commit = await _gitDeps.run(["git", "commit", "-m", message], { cwd: repoRoot });
54
- if (commit.exitCode !== 0) {
55
- throw new FinishError(
56
- `git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
57
- "FINISH_GIT_COMMIT_FAILED",
58
- { stage: "finish-git", repoRoot, branch },
59
- );
60
- }
61
- committed = true;
62
- }
96
+ const { committed } = await commitFixes(repoRoot, message);
63
97
 
64
98
  const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
65
99
  if (push.exitCode !== 0) {
@@ -48,6 +48,20 @@ export interface FinishResult {
48
48
  status: "opened" | "promoted" | "already-ready" | "escalated" | "nothing-to-finish";
49
49
  url?: string;
50
50
  escalationReason?: string;
51
+ /**
52
+ * The findings behind an escalation. Persisted because the reason alone is a
53
+ * bare count ("3 finding(s) after 3 fix attempts"), and on the Telegram
54
+ * channel the composed PR comment — the only other thing carrying them — is
55
+ * never posted. Without this the findings survived only in acpx's run bundle.
56
+ */
57
+ findings?: Finding[];
58
+ /**
59
+ * Set when the escalation could not be delivered to its channel (forge
60
+ * comment failed, remote unrecognised). The result file is written before
61
+ * delivery is attempted, so an undelivered escalation is still reported
62
+ * rather than lost.
63
+ */
64
+ deliveryError?: string;
51
65
  }
52
66
  export interface RunResult {
53
67
  exitCode: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.75.3",
3
+ "version": "0.75.4",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {