@nathapp/nax 0.33.0 → 0.34.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.
package/dist/nax.js CHANGED
@@ -20135,8 +20135,8 @@ function isStalled(prd) {
20135
20135
  const remaining = prd.userStories.filter((s) => s.status !== "passed" && s.status !== "skipped");
20136
20136
  if (remaining.length === 0)
20137
20137
  return false;
20138
- const blockedIds = new Set(prd.userStories.filter((s) => s.status === "blocked" || s.status === "failed" || s.status === "paused").map((s) => s.id));
20139
- return remaining.every((s) => s.status === "blocked" || s.status === "failed" || s.status === "paused" || s.dependencies.some((dep) => blockedIds.has(dep)));
20138
+ const blockedIds = new Set(prd.userStories.filter((s) => s.status === "blocked" || s.status === "failed" || s.status === "paused" || s.status === "regression-failed").map((s) => s.id));
20139
+ return remaining.every((s) => s.status === "blocked" || s.status === "failed" || s.status === "paused" || s.status === "regression-failed" || s.dependencies.some((dep) => blockedIds.has(dep)));
20140
20140
  }
20141
20141
 
20142
20142
  // src/prd/index.ts
@@ -20194,8 +20194,8 @@ function countStories(prd) {
20194
20194
  return {
20195
20195
  total: prd.userStories.length,
20196
20196
  passed: prd.userStories.filter((s) => s.passes || s.status === "passed").length,
20197
- failed: prd.userStories.filter((s) => s.status === "failed").length,
20198
- pending: prd.userStories.filter((s) => !s.passes && s.status !== "passed" && s.status !== "failed" && s.status !== "skipped" && s.status !== "blocked" && s.status !== "paused" && s.status !== "decomposed").length,
20197
+ failed: prd.userStories.filter((s) => s.status === "failed" || s.status === "regression-failed").length,
20198
+ pending: prd.userStories.filter((s) => !s.passes && s.status !== "passed" && s.status !== "failed" && s.status !== "skipped" && s.status !== "blocked" && s.status !== "paused" && s.status !== "regression-failed" && s.status !== "decomposed").length,
20199
20199
  skipped: prd.userStories.filter((s) => s.status === "skipped").length,
20200
20200
  blocked: prd.userStories.filter((s) => s.status === "blocked").length,
20201
20201
  paused: prd.userStories.filter((s) => s.status === "paused").length,
@@ -20242,7 +20242,7 @@ var package_default;
20242
20242
  var init_package = __esm(() => {
20243
20243
  package_default = {
20244
20244
  name: "@nathapp/nax",
20245
- version: "0.33.0",
20245
+ version: "0.34.0",
20246
20246
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
20247
20247
  type: "module",
20248
20248
  bin: {
@@ -20304,8 +20304,8 @@ var init_version = __esm(() => {
20304
20304
  NAX_VERSION = package_default.version;
20305
20305
  NAX_COMMIT = (() => {
20306
20306
  try {
20307
- if (/^[0-9a-f]{6,10}$/.test("f154976"))
20308
- return "f154976";
20307
+ if (/^[0-9a-f]{6,10}$/.test("a679961"))
20308
+ return "a679961";
20309
20309
  } catch {}
20310
20310
  try {
20311
20311
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -20384,6 +20384,8 @@ function collectStoryMetrics(ctx, storyStartTime) {
20384
20384
  const modelDef = modelEntry ? resolveModel(modelEntry) : null;
20385
20385
  const modelUsed = modelDef?.model || routing.modelTier;
20386
20386
  const initialComplexity = story.routing?.initialComplexity ?? routing.complexity;
20387
+ const isTddStrategy = routing.testStrategy === "three-session-tdd" || routing.testStrategy === "three-session-tdd-lite";
20388
+ const fullSuiteGatePassed = isTddStrategy ? ctx.fullSuiteGatePassed ?? false : false;
20387
20389
  return {
20388
20390
  storyId: story.id,
20389
20391
  complexity: routing.complexity,
@@ -20397,7 +20399,8 @@ function collectStoryMetrics(ctx, storyStartTime) {
20397
20399
  durationMs: agentResult?.durationMs || 0,
20398
20400
  firstPassSuccess,
20399
20401
  startedAt: storyStartTime,
20400
- completedAt: new Date().toISOString()
20402
+ completedAt: new Date().toISOString(),
20403
+ fullSuiteGatePassed
20401
20404
  };
20402
20405
  }
20403
20406
  function collectBatchMetrics(ctx, storyStartTime) {
@@ -20426,7 +20429,8 @@ function collectBatchMetrics(ctx, storyStartTime) {
20426
20429
  durationMs: durationPerStory,
20427
20430
  firstPassSuccess: true,
20428
20431
  startedAt: storyStartTime,
20429
- completedAt: new Date().toISOString()
20432
+ completedAt: new Date().toISOString(),
20433
+ fullSuiteGatePassed: false
20430
20434
  };
20431
20435
  });
20432
20436
  }
@@ -28022,6 +28026,181 @@ var init_precheck = __esm(() => {
28022
28026
  };
28023
28027
  });
28024
28028
 
28029
+ // src/hooks/runner.ts
28030
+ import { existsSync as existsSync25 } from "fs";
28031
+ import { join as join30 } from "path";
28032
+ async function loadHooksConfig(projectDir, globalDir) {
28033
+ let globalHooks = { hooks: {} };
28034
+ let projectHooks = { hooks: {} };
28035
+ let skipGlobal = false;
28036
+ const projectPath = join30(projectDir, "hooks.json");
28037
+ if (existsSync25(projectPath)) {
28038
+ try {
28039
+ const projectData = await Bun.file(projectPath).json();
28040
+ projectHooks = projectData;
28041
+ skipGlobal = projectData.skipGlobal ?? false;
28042
+ } catch (err) {
28043
+ const logger = getLogger();
28044
+ logger.warn("hooks", "Failed to parse project hooks.json", { path: projectPath, error: String(err) });
28045
+ }
28046
+ }
28047
+ if (!skipGlobal && globalDir) {
28048
+ const globalPath = join30(globalDir, "hooks.json");
28049
+ if (existsSync25(globalPath)) {
28050
+ try {
28051
+ const globalData = await Bun.file(globalPath).json();
28052
+ globalHooks = globalData;
28053
+ } catch (err) {
28054
+ const logger = getLogger();
28055
+ logger.warn("hooks", "Failed to parse global hooks.json", { path: globalPath, error: String(err) });
28056
+ }
28057
+ }
28058
+ }
28059
+ return {
28060
+ ...projectHooks,
28061
+ _global: skipGlobal ? undefined : globalHooks,
28062
+ _skipGlobal: skipGlobal
28063
+ };
28064
+ }
28065
+ function escapeEnvValue(value) {
28066
+ return value.replace(/\0/g, "").replace(/\n/g, " ").replace(/\r/g, "");
28067
+ }
28068
+ function buildEnv(ctx) {
28069
+ const env2 = {
28070
+ NAX_EVENT: escapeEnvValue(ctx.event),
28071
+ NAX_FEATURE: escapeEnvValue(ctx.feature)
28072
+ };
28073
+ if (ctx.storyId)
28074
+ env2.NAX_STORY_ID = escapeEnvValue(ctx.storyId);
28075
+ if (ctx.status)
28076
+ env2.NAX_STATUS = escapeEnvValue(ctx.status);
28077
+ if (ctx.reason)
28078
+ env2.NAX_REASON = escapeEnvValue(ctx.reason);
28079
+ if (ctx.cost !== undefined)
28080
+ env2.NAX_COST = ctx.cost.toFixed(4);
28081
+ if (ctx.model)
28082
+ env2.NAX_MODEL = escapeEnvValue(ctx.model);
28083
+ if (ctx.agent)
28084
+ env2.NAX_AGENT = escapeEnvValue(ctx.agent);
28085
+ if (ctx.iteration !== undefined)
28086
+ env2.NAX_ITERATION = String(ctx.iteration);
28087
+ return env2;
28088
+ }
28089
+ function hasShellOperators(command) {
28090
+ const shellOperators = /[|&;$`<>(){}]/;
28091
+ return shellOperators.test(command);
28092
+ }
28093
+ function validateHookCommand(command) {
28094
+ const dangerousPatterns = [
28095
+ /\$\(.*\)/,
28096
+ /`.*`/,
28097
+ /\|\s*bash/,
28098
+ /\|\s*sh/,
28099
+ /;\s*rm\s+-rf/,
28100
+ /&&\s*rm\s+-rf/,
28101
+ /\beval\s+/,
28102
+ /curl\s+[^|]*\|\s*/,
28103
+ /python\s+-c/
28104
+ ];
28105
+ for (const pattern of dangerousPatterns) {
28106
+ if (pattern.test(command)) {
28107
+ throw new Error(`Hook command contains dangerous pattern: ${pattern.source}`);
28108
+ }
28109
+ }
28110
+ }
28111
+ function parseCommandToArgv(command) {
28112
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
28113
+ return command.trim().split(/\s+/).map((token) => token.startsWith("~/") ? home + token.slice(1) : token);
28114
+ }
28115
+ async function executeHook(hookDef, ctx, workdir) {
28116
+ if (hookDef.enabled === false) {
28117
+ return { success: true, output: "(disabled)" };
28118
+ }
28119
+ try {
28120
+ validateHookCommand(hookDef.command);
28121
+ } catch (err) {
28122
+ return {
28123
+ success: false,
28124
+ output: `Security validation failed: ${err}`
28125
+ };
28126
+ }
28127
+ const logger = getLogger();
28128
+ if (hasShellOperators(hookDef.command)) {
28129
+ logger.warn("hooks", "[SECURITY] Hook command contains shell operators", {
28130
+ command: hookDef.command,
28131
+ warning: "Shell operators may enable injection attacks. Consider using simple commands only."
28132
+ });
28133
+ }
28134
+ const timeout = hookDef.timeout ?? DEFAULT_TIMEOUT;
28135
+ const env2 = buildEnv(ctx);
28136
+ const contextJson = JSON.stringify(ctx);
28137
+ const argv = parseCommandToArgv(hookDef.command);
28138
+ if (argv.length === 0) {
28139
+ return { success: false, output: "Empty command" };
28140
+ }
28141
+ const proc = Bun.spawn(argv, {
28142
+ cwd: workdir,
28143
+ stdin: new Response(contextJson),
28144
+ stdout: "pipe",
28145
+ stderr: "pipe",
28146
+ env: { ...process.env, ...env2 }
28147
+ });
28148
+ const timeoutId = setTimeout(() => {
28149
+ proc.kill("SIGTERM");
28150
+ }, timeout);
28151
+ const exitCode = await proc.exited;
28152
+ clearTimeout(timeoutId);
28153
+ const stdout = await new Response(proc.stdout).text();
28154
+ const stderr = await new Response(proc.stderr).text();
28155
+ const output = (stdout + stderr).trim();
28156
+ if (exitCode !== 0 && output === "") {
28157
+ return {
28158
+ success: false,
28159
+ output: `Hook timed out after ${timeout}ms`
28160
+ };
28161
+ }
28162
+ return {
28163
+ success: exitCode === 0,
28164
+ output
28165
+ };
28166
+ }
28167
+ async function fireHook(config2, event, ctx, workdir) {
28168
+ const logger = getLogger();
28169
+ if (config2._global && !config2._skipGlobal) {
28170
+ const globalHookDef = config2._global.hooks[event];
28171
+ if (globalHookDef && globalHookDef.enabled !== false) {
28172
+ try {
28173
+ const result = await executeHook(globalHookDef, { ...ctx, event }, workdir);
28174
+ if (!result.success) {
28175
+ logger.warn("hooks", `Global hook ${event} failed`, { event, output: result.output });
28176
+ }
28177
+ } catch (err) {
28178
+ logger.warn("hooks", `Global hook ${event} error`, { event, error: String(err) });
28179
+ }
28180
+ }
28181
+ }
28182
+ const projectHookDef = config2.hooks[event];
28183
+ if (projectHookDef && projectHookDef.enabled !== false) {
28184
+ try {
28185
+ const result = await executeHook(projectHookDef, { ...ctx, event }, workdir);
28186
+ if (!result.success) {
28187
+ logger.warn("hooks", `Project hook ${event} failed`, { event, output: result.output });
28188
+ }
28189
+ } catch (err) {
28190
+ logger.warn("hooks", `Project hook ${event} error`, { event, error: String(err) });
28191
+ }
28192
+ }
28193
+ }
28194
+ var DEFAULT_TIMEOUT = 5000;
28195
+ var init_runner3 = __esm(() => {
28196
+ init_logger2();
28197
+ });
28198
+
28199
+ // src/hooks/index.ts
28200
+ var init_hooks = __esm(() => {
28201
+ init_runner3();
28202
+ });
28203
+
28025
28204
  // src/execution/crash-recovery.ts
28026
28205
  import { appendFileSync as appendFileSync2 } from "fs";
28027
28206
  async function writeFatalLog(jsonlFilePath, signal, error48) {
@@ -28267,181 +28446,6 @@ function calculateMaxIterations(tierOrder) {
28267
28446
  return tierOrder.reduce((sum, t) => sum + t.attempts, 0);
28268
28447
  }
28269
28448
 
28270
- // src/hooks/runner.ts
28271
- import { existsSync as existsSync25 } from "fs";
28272
- import { join as join30 } from "path";
28273
- async function loadHooksConfig(projectDir, globalDir) {
28274
- let globalHooks = { hooks: {} };
28275
- let projectHooks = { hooks: {} };
28276
- let skipGlobal = false;
28277
- const projectPath = join30(projectDir, "hooks.json");
28278
- if (existsSync25(projectPath)) {
28279
- try {
28280
- const projectData = await Bun.file(projectPath).json();
28281
- projectHooks = projectData;
28282
- skipGlobal = projectData.skipGlobal ?? false;
28283
- } catch (err) {
28284
- const logger = getLogger();
28285
- logger.warn("hooks", "Failed to parse project hooks.json", { path: projectPath, error: String(err) });
28286
- }
28287
- }
28288
- if (!skipGlobal && globalDir) {
28289
- const globalPath = join30(globalDir, "hooks.json");
28290
- if (existsSync25(globalPath)) {
28291
- try {
28292
- const globalData = await Bun.file(globalPath).json();
28293
- globalHooks = globalData;
28294
- } catch (err) {
28295
- const logger = getLogger();
28296
- logger.warn("hooks", "Failed to parse global hooks.json", { path: globalPath, error: String(err) });
28297
- }
28298
- }
28299
- }
28300
- return {
28301
- ...projectHooks,
28302
- _global: skipGlobal ? undefined : globalHooks,
28303
- _skipGlobal: skipGlobal
28304
- };
28305
- }
28306
- function escapeEnvValue(value) {
28307
- return value.replace(/\0/g, "").replace(/\n/g, " ").replace(/\r/g, "");
28308
- }
28309
- function buildEnv(ctx) {
28310
- const env2 = {
28311
- NAX_EVENT: escapeEnvValue(ctx.event),
28312
- NAX_FEATURE: escapeEnvValue(ctx.feature)
28313
- };
28314
- if (ctx.storyId)
28315
- env2.NAX_STORY_ID = escapeEnvValue(ctx.storyId);
28316
- if (ctx.status)
28317
- env2.NAX_STATUS = escapeEnvValue(ctx.status);
28318
- if (ctx.reason)
28319
- env2.NAX_REASON = escapeEnvValue(ctx.reason);
28320
- if (ctx.cost !== undefined)
28321
- env2.NAX_COST = ctx.cost.toFixed(4);
28322
- if (ctx.model)
28323
- env2.NAX_MODEL = escapeEnvValue(ctx.model);
28324
- if (ctx.agent)
28325
- env2.NAX_AGENT = escapeEnvValue(ctx.agent);
28326
- if (ctx.iteration !== undefined)
28327
- env2.NAX_ITERATION = String(ctx.iteration);
28328
- return env2;
28329
- }
28330
- function hasShellOperators(command) {
28331
- const shellOperators = /[|&;$`<>(){}]/;
28332
- return shellOperators.test(command);
28333
- }
28334
- function validateHookCommand(command) {
28335
- const dangerousPatterns = [
28336
- /\$\(.*\)/,
28337
- /`.*`/,
28338
- /\|\s*bash/,
28339
- /\|\s*sh/,
28340
- /;\s*rm\s+-rf/,
28341
- /&&\s*rm\s+-rf/,
28342
- /\beval\s+/,
28343
- /curl\s+[^|]*\|\s*/,
28344
- /python\s+-c/
28345
- ];
28346
- for (const pattern of dangerousPatterns) {
28347
- if (pattern.test(command)) {
28348
- throw new Error(`Hook command contains dangerous pattern: ${pattern.source}`);
28349
- }
28350
- }
28351
- }
28352
- function parseCommandToArgv(command) {
28353
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
28354
- return command.trim().split(/\s+/).map((token) => token.startsWith("~/") ? home + token.slice(1) : token);
28355
- }
28356
- async function executeHook(hookDef, ctx, workdir) {
28357
- if (hookDef.enabled === false) {
28358
- return { success: true, output: "(disabled)" };
28359
- }
28360
- try {
28361
- validateHookCommand(hookDef.command);
28362
- } catch (err) {
28363
- return {
28364
- success: false,
28365
- output: `Security validation failed: ${err}`
28366
- };
28367
- }
28368
- const logger = getLogger();
28369
- if (hasShellOperators(hookDef.command)) {
28370
- logger.warn("hooks", "[SECURITY] Hook command contains shell operators", {
28371
- command: hookDef.command,
28372
- warning: "Shell operators may enable injection attacks. Consider using simple commands only."
28373
- });
28374
- }
28375
- const timeout = hookDef.timeout ?? DEFAULT_TIMEOUT;
28376
- const env2 = buildEnv(ctx);
28377
- const contextJson = JSON.stringify(ctx);
28378
- const argv = parseCommandToArgv(hookDef.command);
28379
- if (argv.length === 0) {
28380
- return { success: false, output: "Empty command" };
28381
- }
28382
- const proc = Bun.spawn(argv, {
28383
- cwd: workdir,
28384
- stdin: new Response(contextJson),
28385
- stdout: "pipe",
28386
- stderr: "pipe",
28387
- env: { ...process.env, ...env2 }
28388
- });
28389
- const timeoutId = setTimeout(() => {
28390
- proc.kill("SIGTERM");
28391
- }, timeout);
28392
- const exitCode = await proc.exited;
28393
- clearTimeout(timeoutId);
28394
- const stdout = await new Response(proc.stdout).text();
28395
- const stderr = await new Response(proc.stderr).text();
28396
- const output = (stdout + stderr).trim();
28397
- if (exitCode !== 0 && output === "") {
28398
- return {
28399
- success: false,
28400
- output: `Hook timed out after ${timeout}ms`
28401
- };
28402
- }
28403
- return {
28404
- success: exitCode === 0,
28405
- output
28406
- };
28407
- }
28408
- async function fireHook(config2, event, ctx, workdir) {
28409
- const logger = getLogger();
28410
- if (config2._global && !config2._skipGlobal) {
28411
- const globalHookDef = config2._global.hooks[event];
28412
- if (globalHookDef && globalHookDef.enabled !== false) {
28413
- try {
28414
- const result = await executeHook(globalHookDef, { ...ctx, event }, workdir);
28415
- if (!result.success) {
28416
- logger.warn("hooks", `Global hook ${event} failed`, { event, output: result.output });
28417
- }
28418
- } catch (err) {
28419
- logger.warn("hooks", `Global hook ${event} error`, { event, error: String(err) });
28420
- }
28421
- }
28422
- }
28423
- const projectHookDef = config2.hooks[event];
28424
- if (projectHookDef && projectHookDef.enabled !== false) {
28425
- try {
28426
- const result = await executeHook(projectHookDef, { ...ctx, event }, workdir);
28427
- if (!result.success) {
28428
- logger.warn("hooks", `Project hook ${event} failed`, { event, output: result.output });
28429
- }
28430
- } catch (err) {
28431
- logger.warn("hooks", `Project hook ${event} error`, { event, error: String(err) });
28432
- }
28433
- }
28434
- }
28435
- var DEFAULT_TIMEOUT = 5000;
28436
- var init_runner3 = __esm(() => {
28437
- init_logger2();
28438
- });
28439
-
28440
- // src/hooks/index.ts
28441
- var init_hooks = __esm(() => {
28442
- init_runner3();
28443
- });
28444
-
28445
28449
  // src/execution/escalation/tier-outcome.ts
28446
28450
  async function handleNoTierAvailable(ctx, failureCategory) {
28447
28451
  const logger = getSafeLogger();
@@ -29924,7 +29928,8 @@ var init_parallel_lifecycle = __esm(() => {
29924
29928
  // src/execution/parallel-executor.ts
29925
29929
  var exports_parallel_executor = {};
29926
29930
  __export(exports_parallel_executor, {
29927
- runParallelExecution: () => runParallelExecution
29931
+ runParallelExecution: () => runParallelExecution,
29932
+ _parallelExecutorDeps: () => _parallelExecutorDeps
29928
29933
  });
29929
29934
  import * as os5 from "os";
29930
29935
  import path15 from "path";
@@ -30001,7 +30006,8 @@ async function runParallelExecution(options, initialPrd) {
30001
30006
  feature,
30002
30007
  totalCost
30003
30008
  });
30004
- await fireHook(hooks, "on-complete", hookCtx(feature, { status: "complete", cost: totalCost }), workdir);
30009
+ await _parallelExecutorDeps.fireHook(hooks, "on-all-stories-complete", hookCtx(feature, { status: "passed", cost: totalCost }), workdir);
30010
+ await _parallelExecutorDeps.fireHook(hooks, "on-complete", hookCtx(feature, { status: "complete", cost: totalCost }), workdir);
30005
30011
  const durationMs = Date.now() - startTime;
30006
30012
  const runCompletedAt = new Date().toISOString();
30007
30013
  const { handleParallelCompletion: handleParallelCompletion2 } = await Promise.resolve().then(() => (init_parallel_lifecycle(), exports_parallel_lifecycle));
@@ -30049,12 +30055,16 @@ async function runParallelExecution(options, initialPrd) {
30049
30055
  }
30050
30056
  return { prd, totalCost, storiesCompleted, completed: false };
30051
30057
  }
30058
+ var _parallelExecutorDeps;
30052
30059
  var init_parallel_executor = __esm(() => {
30053
30060
  init_hooks();
30054
30061
  init_logger2();
30055
30062
  init_prd();
30056
30063
  init_helpers();
30057
30064
  init_parallel();
30065
+ _parallelExecutorDeps = {
30066
+ fireHook
30067
+ };
30058
30068
  });
30059
30069
 
30060
30070
  // src/pipeline/subscribers/events-writer.ts
@@ -30444,11 +30454,13 @@ async function handlePipelineSuccess(ctx, pipelineResult) {
30444
30454
  }
30445
30455
  const storiesCompletedDelta = ctx.storiesToExecute.length;
30446
30456
  for (const completedStory of ctx.storiesToExecute) {
30457
+ const now = Date.now();
30447
30458
  logger?.info("story.complete", "Story completed successfully", {
30448
30459
  storyId: completedStory.id,
30449
30460
  storyTitle: completedStory.title,
30450
30461
  totalCost: ctx.totalCost + costDelta,
30451
- durationMs: Date.now() - ctx.startTime
30462
+ durationMs: now - ctx.startTime,
30463
+ storyDurationMs: ctx.storyStartTime ? now - ctx.storyStartTime : undefined
30452
30464
  });
30453
30465
  pipelineEventBus.emit({
30454
30466
  type: "story:completed",
@@ -30469,7 +30481,8 @@ async function handlePipelineSuccess(ctx, pipelineResult) {
30469
30481
  pendingStories: updatedCounts.pending,
30470
30482
  totalCost: ctx.totalCost + costDelta,
30471
30483
  costLimit: ctx.config.execution.costLimit,
30472
- elapsedMs: Date.now() - ctx.startTime
30484
+ elapsedMs: Date.now() - ctx.startTime,
30485
+ storyDurationMs: ctx.storyStartTime ? Date.now() - ctx.storyStartTime : undefined
30473
30486
  });
30474
30487
  return { storiesCompletedDelta, costDelta, prd, prdDirty: true };
30475
30488
  }
@@ -30575,6 +30588,7 @@ async function runIteration(ctx, prd, selection, iterations, totalCost, allStory
30575
30588
  prdDirty: dryRunResult.prdDirty
30576
30589
  };
30577
30590
  }
30591
+ const storyStartTime = Date.now();
30578
30592
  const storyGitRef = await captureGitRef(ctx.workdir);
30579
30593
  const pipelineContext = {
30580
30594
  config: ctx.config,
@@ -30622,7 +30636,8 @@ async function runIteration(ctx, prd, selection, iterations, totalCost, allStory
30622
30636
  isBatchExecution,
30623
30637
  allStoryMetrics,
30624
30638
  storyGitRef,
30625
- interactionChain: ctx.interactionChain
30639
+ interactionChain: ctx.interactionChain,
30640
+ storyStartTime
30626
30641
  };
30627
30642
  if (pipelineResult.success) {
30628
30643
  const r2 = await handlePipelineSuccess(handlerCtx, pipelineResult);
@@ -30751,14 +30766,6 @@ async function executeSequential(ctx, initialPrd) {
30751
30766
  return buildResult("pre-merge-aborted");
30752
30767
  }
30753
30768
  }
30754
- pipelineEventBus.emit({
30755
- type: "run:completed",
30756
- totalStories: 0,
30757
- passedStories: 0,
30758
- failedStories: 0,
30759
- durationMs: Date.now() - ctx.startTime,
30760
- totalCost
30761
- });
30762
30769
  return buildResult("completed");
30763
30770
  }
30764
30771
  const selected = selectNextStories(prd, ctx.config, ctx.batchPlan, currentBatchIndex, lastStoryId, ctx.useBatch);
@@ -31242,6 +31249,16 @@ __export(exports_run_completion, {
31242
31249
  handleRunCompletion: () => handleRunCompletion,
31243
31250
  _runCompletionDeps: () => _runCompletionDeps
31244
31251
  });
31252
+ function shouldSkipDeferredRegression(allStoryMetrics, isSequential) {
31253
+ const effectiveSequential = isSequential !== false;
31254
+ if (!effectiveSequential) {
31255
+ return false;
31256
+ }
31257
+ if (allStoryMetrics.length === 0) {
31258
+ return false;
31259
+ }
31260
+ return allStoryMetrics.every((m) => m.fullSuiteGatePassed === true);
31261
+ }
31245
31262
  async function handleRunCompletion(options) {
31246
31263
  const logger = getSafeLogger();
31247
31264
  const {
@@ -31256,23 +31273,56 @@ async function handleRunCompletion(options) {
31256
31273
  startTime,
31257
31274
  workdir,
31258
31275
  statusWriter,
31259
- config: config2
31276
+ config: config2,
31277
+ hooksConfig,
31278
+ isSequential
31260
31279
  } = options;
31261
31280
  const regressionMode = config2.execution.regressionGate?.mode;
31262
31281
  if (regressionMode === "deferred" && config2.quality.commands.test) {
31263
- const regressionResult = await _runCompletionDeps.runDeferredRegression({
31264
- config: config2,
31265
- prd,
31266
- workdir
31267
- });
31268
- logger?.info("regression", "Deferred regression gate completed", {
31269
- success: regressionResult.success,
31270
- failedTests: regressionResult.failedTests,
31271
- affectedStories: regressionResult.affectedStories
31272
- });
31282
+ if (shouldSkipDeferredRegression(allStoryMetrics, isSequential)) {
31283
+ logger?.info("regression", "Smart-skip: skipping deferred regression (all stories passed full-suite gate in sequential mode)");
31284
+ } else {
31285
+ const regressionResult = await _runCompletionDeps.runDeferredRegression({
31286
+ config: config2,
31287
+ prd,
31288
+ workdir
31289
+ });
31290
+ logger?.info("regression", "Deferred regression gate completed", {
31291
+ success: regressionResult.success,
31292
+ failedTests: regressionResult.failedTests,
31293
+ affectedStories: regressionResult.affectedStories
31294
+ });
31295
+ if (!regressionResult.success) {
31296
+ for (const storyId of regressionResult.affectedStories) {
31297
+ const story = prd.userStories.find((s) => s.id === storyId);
31298
+ if (story) {
31299
+ story.status = "regression-failed";
31300
+ }
31301
+ }
31302
+ statusWriter.setRunStatus("failed");
31303
+ if (hooksConfig) {
31304
+ await _runCompletionDeps.fireHook(hooksConfig, "on-final-regression-fail", {
31305
+ event: "on-final-regression-fail",
31306
+ feature,
31307
+ status: "failed",
31308
+ failedTests: regressionResult.failedTests,
31309
+ affectedStories: regressionResult.affectedStories
31310
+ }, workdir);
31311
+ }
31312
+ }
31313
+ }
31273
31314
  }
31274
31315
  const durationMs = Date.now() - startTime;
31275
31316
  const runCompletedAt = new Date().toISOString();
31317
+ const finalCounts = countStories(prd);
31318
+ pipelineEventBus.emit({
31319
+ type: "run:completed",
31320
+ totalStories: finalCounts.total,
31321
+ passedStories: finalCounts.passed,
31322
+ failedStories: finalCounts.failed,
31323
+ durationMs,
31324
+ totalCost
31325
+ });
31276
31326
  const runMetrics = {
31277
31327
  runId,
31278
31328
  feature,
@@ -31281,12 +31331,15 @@ async function handleRunCompletion(options) {
31281
31331
  totalCost,
31282
31332
  totalStories: allStoryMetrics.length,
31283
31333
  storiesCompleted,
31284
- storiesFailed: countStories(prd).failed,
31334
+ storiesFailed: finalCounts.failed,
31285
31335
  totalDurationMs: durationMs,
31286
31336
  stories: allStoryMetrics
31287
31337
  };
31288
- await saveRunMetrics(workdir, runMetrics);
31289
- const finalCounts = countStories(prd);
31338
+ try {
31339
+ await saveRunMetrics(workdir, runMetrics);
31340
+ } catch (err) {
31341
+ logger?.warn("run.complete", "Failed to save run metrics", { error: String(err) });
31342
+ }
31290
31343
  const storyMetricsSummary = allStoryMetrics.map((sm) => ({
31291
31344
  storyId: sm.storyId,
31292
31345
  complexity: sm.complexity,
@@ -31330,12 +31383,15 @@ async function handleRunCompletion(options) {
31330
31383
  }
31331
31384
  var _runCompletionDeps;
31332
31385
  var init_run_completion = __esm(() => {
31386
+ init_runner3();
31333
31387
  init_logger2();
31334
31388
  init_metrics();
31389
+ init_event_bus();
31335
31390
  init_prd();
31336
31391
  init_run_regression();
31337
31392
  _runCompletionDeps = {
31338
- runDeferredRegression
31393
+ runDeferredRegression,
31394
+ fireHook
31339
31395
  };
31340
31396
  });
31341
31397
 
@@ -65486,6 +65542,7 @@ async function unlockCommand(options) {
65486
65542
  init_config();
65487
65543
 
65488
65544
  // src/execution/runner.ts
65545
+ init_hooks();
65489
65546
  init_logger2();
65490
65547
  init_prd();
65491
65548
  init_batch_route();
@@ -65530,8 +65587,12 @@ function precomputeBatchPlan(stories, maxBatchSize = DEFAULT_MAX_BATCH_SIZE) {
65530
65587
  // src/execution/runner.ts
65531
65588
  init_crash_recovery();
65532
65589
  init_helpers();
65590
+ init_story_context();
65533
65591
  init_escalation();
65534
65592
  init_escalation();
65593
+ var _runnerDeps = {
65594
+ fireHook
65595
+ };
65535
65596
  async function run(options) {
65536
65597
  const {
65537
65598
  prdPath,
@@ -65713,6 +65774,9 @@ async function run(options) {
65713
65774
  iterations = acceptanceResult.iterations;
65714
65775
  storiesCompleted = acceptanceResult.storiesCompleted;
65715
65776
  }
65777
+ if (isComplete(prd)) {
65778
+ await _runnerDeps.fireHook(hooks, "on-all-stories-complete", hookCtx(feature, { status: "passed", cost: totalCost }), workdir);
65779
+ }
65716
65780
  const { handleRunCompletion: handleRunCompletion2 } = await Promise.resolve().then(() => (init_run_completion(), exports_run_completion));
65717
65781
  const completionResult = await handleRunCompletion2({
65718
65782
  runId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -409,3 +409,11 @@ export function resetCrashHandlers(): void {
409
409
  handlersInstalled = false;
410
410
  stopHeartbeat();
411
411
  }
412
+
413
+ /**
414
+ * Returns true if heartbeat timer is currently active.
415
+ * @internal - test use only.
416
+ */
417
+ export function _isHeartbeatActive(): boolean {
418
+ return heartbeatTimer !== null;
419
+ }