@mestreyoda/fabrica 0.2.19 → 0.2.21

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 (2) hide show
  1. package/dist/index.js +423 -33
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -113905,8 +113905,8 @@ import fsSync from "node:fs";
113905
113905
  import path5 from "node:path";
113906
113906
  import { fileURLToPath as fileURLToPath3 } from "node:url";
113907
113907
  function getCurrentVersion() {
113908
- if ("0.2.19") {
113909
- return "0.2.19";
113908
+ if ("0.2.21") {
113909
+ return "0.2.21";
113910
113910
  }
113911
113911
  try {
113912
113912
  const pkgPath = path5.join(THIS_DIR, "..", "..", "package.json");
@@ -114883,7 +114883,7 @@ function resolve2(config2, sourceLayers, trace2) {
114883
114883
  dispatchMs: config2.timeouts?.dispatchMs ?? 6e5,
114884
114884
  staleWorkerHours: config2.timeouts?.staleWorkerHours ?? 2,
114885
114885
  sessionContextBudget: config2.timeouts?.sessionContextBudget ?? 0.6,
114886
- stallTimeoutMinutes: config2.timeouts?.stallTimeoutMinutes ?? 5,
114886
+ stallTimeoutMinutes: config2.timeouts?.stallTimeoutMinutes ?? 25,
114887
114887
  sessionConfirmAttempts: config2.timeouts?.sessionConfirmAttempts ?? 5,
114888
114888
  sessionConfirmDelayMs: config2.timeouts?.sessionConfirmDelayMs ?? 250,
114889
114889
  sessionLabelMaxLength: config2.timeouts?.sessionLabelMaxLength ?? 64,
@@ -118561,6 +118561,34 @@ var conduct_interview_exports = {};
118561
118561
  __export(conduct_interview_exports, {
118562
118562
  conductInterviewStep: () => conductInterviewStep
118563
118563
  });
118564
+ function deriveFeatureScopeFromRawIdea(rawIdea) {
118565
+ const text = rawIdea.toLowerCase();
118566
+ const scope = [];
118567
+ const add = (item) => {
118568
+ if (!scope.includes(item)) scope.push(item);
118569
+ };
118570
+ if (/\b(auth|oauth|jwt|login|register|session|role-based access|rbac|permissions?)\b/i.test(text)) {
118571
+ add("Implement authentication, authorization, and role-aware access rules");
118572
+ }
118573
+ if (/\b(incident|project|task|owner|assign|timeline|status|update)s?\b/i.test(text)) {
118574
+ add("Implement the core domain workflows and CRUD endpoints for the main entities");
118575
+ }
118576
+ if (/\b(alert|alerts|notif|notification|notifications|reminder|reminders|escalation|escalations)\b/i.test(text)) {
118577
+ add("Implement notifications, reminders, and escalation flows for key events");
118578
+ }
118579
+ if (/\b(background process|background worker|worker|queue|job|celery|bull|sidekiq|scheduler)\b/i.test(text)) {
118580
+ add("Implement the background processing pipeline required for asynchronous work");
118581
+ }
118582
+ if (/\b(admin view|admin panel|admin console|dashboard|audit history|audit log|activity history)\b/i.test(text)) {
118583
+ add("Implement administrative visibility and audit/history capabilities for operators");
118584
+ }
118585
+ if (scope.length < 3) {
118586
+ add("Implement the primary user workflow described in the request");
118587
+ add("Expose the key interactions needed to operate the system end to end");
118588
+ add("Add validation and persistence rules for the main business flow");
118589
+ }
118590
+ return scope.slice(0, 5);
118591
+ }
118564
118592
  function fallbackSpecData(type, rawIdea) {
118565
118593
  const title = rawIdea.slice(0, 120);
118566
118594
  const base = {
@@ -118588,7 +118616,12 @@ function fallbackSpecData(type, rawIdea) {
118588
118616
  base.acceptance_criteria = ["Infrastructure change applied and verified"];
118589
118617
  break;
118590
118618
  default:
118591
- base.acceptance_criteria = ["Feature works as described in the objective"];
118619
+ base.scope_v1 = deriveFeatureScopeFromRawIdea(rawIdea);
118620
+ base.acceptance_criteria = [
118621
+ "The primary workflow works end to end as requested",
118622
+ "Role, validation, or delivery constraints from the request are enforced",
118623
+ "The asynchronous/background behavior works for the main operational path"
118624
+ ];
118592
118625
  }
118593
118626
  return base;
118594
118627
  }
@@ -124987,6 +125020,20 @@ function buildMessage(event) {
124987
125020
  }
124988
125021
  return msg;
124989
125022
  }
125023
+ case "workerProgress": {
125024
+ const worker = formatWorkerString(event.role, {
125025
+ name: event.name,
125026
+ level: event.level
125027
+ });
125028
+ let msg = `\u23F3 ${worker} still working on #${event.issueId}: ${event.issueTitle}`;
125029
+ if (event.summary) msg += `
125030
+ ${event.summary}`;
125031
+ if (event.minutesActive != null) msg += `
125032
+ \u{1F552} Active for ${event.minutesActive} min`;
125033
+ msg += `
125034
+ \u{1F4CB} [Issue #${event.issueId}](${event.issueUrl})`;
125035
+ return msg;
125036
+ }
124990
125037
  case "workerComplete": {
124991
125038
  const icons = {
124992
125039
  done: "\u2705",
@@ -126282,6 +126329,25 @@ async function validatePrExistsForDeveloper(issueId, repoPath, provider, runComm
126282
126329
  const branchPrIsReviewable = !!branchPr?.url && branchPr.state !== PrState.MERGED && branchPr.state !== PrState.CLOSED;
126283
126330
  const prStatus = preferIssuePr ? issuePrIsReviewable ? issuePr : branchPr ?? issuePr : branchPrIsReviewable ? branchPr : issuePr;
126284
126331
  if (!prStatus.url || prStatus.state === PrState.MERGED || prStatus.state === PrState.CLOSED) {
126332
+ if (preferIssuePr && isCurrentProjectBaseBranch(branchName, baseBranch)) {
126333
+ const currentBase = branchName || baseBranch || "main";
126334
+ const suggestedBranch = `feature/${issueId}-${projectSlug.replace(/[^a-z0-9]+/g, "-").slice(0, 40)}`;
126335
+ throw new Error(
126336
+ `Cannot mark work_finish(done) while on the base branch ("${currentBase}") without an open PR.
126337
+
126338
+ You must implement changes on a feature branch and open a PR before calling work_finish.
126339
+
126340
+ Steps to fix:
126341
+ 1. git worktree add ../${projectSlug}.worktrees/${suggestedBranch} -b ${suggestedBranch}
126342
+ 2. cd ../${projectSlug}.worktrees/${suggestedBranch}
126343
+ 3. Implement the changes there, commit, push, and create a PR:
126344
+ git push -u origin ${suggestedBranch}
126345
+ gh pr create --base ${baseBranch ?? "main"} --head ${suggestedBranch} --title "feat: ..." --body "Closes #${issueId}"
126346
+ 4. Then call work_finish again.
126347
+
126348
+ If the worktree already exists, cd into it and continue from there.`
126349
+ );
126350
+ }
126285
126351
  const currentBranch = branchName || "current-branch";
126286
126352
  const reason = !prStatus.url ? `\u2717 No PR found for branch: ${currentBranch}` : prStatus.state === PrState.MERGED ? `\u2717 Last linked PR is already merged: ${prStatus.url}` : `\u2717 Last linked PR is closed and not reviewable: ${prStatus.url}`;
126287
126353
  throw new Error(
@@ -126290,7 +126356,7 @@ async function validatePrExistsForDeveloper(issueId, repoPath, provider, runComm
126290
126356
  ${reason}
126291
126357
 
126292
126358
  Please create a PR first:
126293
- gh pr create --base main --head ${currentBranch} --title "..." --body "..."
126359
+ gh pr create --base ${baseBranch ?? "main"} --head ${currentBranch} --title "..." --body "Closes #${issueId}"
126294
126360
 
126295
126361
  Then call work_finish again.`
126296
126362
  );
@@ -126722,6 +126788,10 @@ function createTaskCreateTool(ctx) {
126722
126788
  pickup: {
126723
126789
  type: "boolean",
126724
126790
  description: "If true, immediately pick up this issue for DEV after creation. Defaults to false."
126791
+ },
126792
+ parentIssueId: {
126793
+ type: "number",
126794
+ description: "Optional parent/epic issue ID when creating a child task as part of a decomposition plan."
126725
126795
  }
126726
126796
  }
126727
126797
  },
@@ -126730,12 +126800,17 @@ function createTaskCreateTool(ctx) {
126730
126800
  const description = params.description ?? "";
126731
126801
  const assignees = params.assignees ?? [];
126732
126802
  const pickup = params.pickup ?? false;
126803
+ const parentIssueId = Number.isFinite(params.parentIssueId) ? Number(params.parentIssueId) : null;
126733
126804
  const workspaceDir = requireWorkspaceDir(toolCtx);
126734
126805
  const { project, route } = await resolveProjectFromContext(workspaceDir, toolCtx, params.channelId);
126735
126806
  const resolvedConfig = await loadConfig(workspaceDir, project.slug);
126736
126807
  const label = resolvedConfig.workflow.states[resolvedConfig.workflow.initial]?.label ?? "Planning";
126737
126808
  const { provider, type: providerType } = await resolveProvider(project, ctx.runCommand);
126738
- const issue2 = await provider.createIssue(title, description, label, assignees);
126809
+ let body = description;
126810
+ if (parentIssueId) {
126811
+ body = `${description}${description.trim().length > 0 ? "\n\n" : ""}Parent issue: #${parentIssueId}`;
126812
+ }
126813
+ const issue2 = await provider.createIssue(title, body, label, assignees);
126739
126814
  provider.reactToIssue(issue2.iid, "eyes").catch(() => {
126740
126815
  });
126741
126816
  const primaryChannel = findPrimaryChannel(project);
@@ -126749,13 +126824,31 @@ function createTaskCreateTool(ctx) {
126749
126824
  );
126750
126825
  autoAssignOwnerLabel(workspaceDir, provider, issue2.iid, project).catch(() => {
126751
126826
  });
126827
+ if (parentIssueId) {
126828
+ await updateIssueRuntime(workspaceDir, project.slug, issue2.iid, {
126829
+ parentIssueId
126830
+ }).catch(() => {
126831
+ });
126832
+ const parentRuntime = project.issueRuntime?.[String(parentIssueId)] ?? {};
126833
+ const previousChildren = Array.isArray(parentRuntime.childIssueIds) ? parentRuntime.childIssueIds : [];
126834
+ await updateIssueRuntime(workspaceDir, project.slug, parentIssueId, {
126835
+ childIssueIds: [.../* @__PURE__ */ new Set([...previousChildren, issue2.iid])]
126836
+ }).catch(() => {
126837
+ });
126838
+ provider.addComment(
126839
+ parentIssueId,
126840
+ `\u{1F4CC} Child task created: [#${issue2.iid}: ${issue2.title}](${issue2.web_url})`
126841
+ ).catch(() => {
126842
+ });
126843
+ }
126752
126844
  await log(workspaceDir, "task_create", {
126753
126845
  project: project.name,
126754
126846
  issueId: issue2.iid,
126755
126847
  title,
126756
126848
  label,
126757
126849
  provider: providerType,
126758
- pickup
126850
+ pickup,
126851
+ parentIssueId
126759
126852
  });
126760
126853
  const hasBody = description && description.trim().length > 0;
126761
126854
  let announcement = `\u{1F4CB} Created #${issue2.iid}: "${title}" (${label})`;
@@ -128816,6 +128909,7 @@ async function dispatchTask(opts) {
128816
128909
  inconclusiveCompletionAt: null,
128817
128910
  inconclusiveCompletionReason: null,
128818
128911
  sessionCompletedAt: null,
128912
+ progressNotifiedAt: null,
128819
128913
  lastSessionKey: sessionKey
128820
128914
  }).catch((err) => {
128821
128915
  log(workspaceDir, "dispatch_warning", { step: "record_dispatch_requested", issue: issueId, err: String(err) }).catch(() => {
@@ -131313,6 +131407,7 @@ async function checkWorkerHealth(opts) {
131313
131407
  completionRecoveryWindowMs = COMPLETION_RECOVERY_WINDOW_MS,
131314
131408
  executionContractRecoveryWindowMs = EXECUTION_CONTRACT_RECOVERY_WINDOW_MS,
131315
131409
  runCommand,
131410
+ stallTimeoutMinutes = 25,
131316
131411
  notificationConfig
131317
131412
  } = opts;
131318
131413
  const fixes = [];
@@ -131812,7 +131907,120 @@ async function checkWorkerHealth(opts) {
131812
131907
  continue;
131813
131908
  }
131814
131909
  if (slot.active && slot.startTime && sessionKey && sessions && sessionAlive) {
131815
- const hours = (Date.now() - new Date(slot.startTime).getTime()) / 36e5;
131910
+ const startedAtMs = new Date(slot.startTime).getTime();
131911
+ const minutesActive = (Date.now() - startedAtMs) / 6e4;
131912
+ const hours = minutesActive / 60;
131913
+ let hasReviewableArtifact = Boolean(
131914
+ issueRuntime?.currentPrNumber || issueRuntime?.currentPrUrl || issueRuntime?.artifactOfRecord?.prNumber
131915
+ );
131916
+ if (!hasReviewableArtifact && issueIdNum) {
131917
+ try {
131918
+ const prStatus = await provider.getPrStatus(issueIdNum);
131919
+ hasReviewableArtifact = Boolean(
131920
+ prStatus.url && prStatus.state !== PrState.MERGED && prStatus.state !== PrState.CLOSED && prStatus.currentIssueMatch !== false
131921
+ );
131922
+ } catch {
131923
+ }
131924
+ }
131925
+ if (role === "developer" && issue2 && issueIdNum && !hasReviewableArtifact && minutesActive >= Math.max(5, Math.floor(stallTimeoutMinutes / 2)) && !issueRuntime?.progressNotifiedAt) {
131926
+ const channel = project.channels?.[0];
131927
+ await notify(
131928
+ {
131929
+ type: "workerProgress",
131930
+ project: project.name,
131931
+ issueId: issueIdNum,
131932
+ issueUrl: issue2.web_url,
131933
+ issueTitle: issue2.title,
131934
+ role,
131935
+ level,
131936
+ minutesActive: Math.round(minutesActive),
131937
+ summary: "Still iterating on implementation/QA without a PR yet.",
131938
+ dispatchCycleId: slot.dispatchCycleId ?? issueRuntime?.lastDispatchCycleId ?? null,
131939
+ dispatchRunId: slot.dispatchRunId ?? issueRuntime?.dispatchRunId ?? null
131940
+ },
131941
+ {
131942
+ workspaceDir,
131943
+ config: notificationConfig,
131944
+ target: channel ? {
131945
+ channelId: channel.channelId,
131946
+ channel: channel.channel,
131947
+ accountId: channel.accountId,
131948
+ messageThreadId: channel.messageThreadId
131949
+ } : void 0,
131950
+ runCommand
131951
+ }
131952
+ ).catch(() => {
131953
+ });
131954
+ await updateIssueRuntime(workspaceDir, projectSlug, issueIdNum, {
131955
+ progressNotifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
131956
+ lastSessionKey: sessionKey
131957
+ }).catch(() => {
131958
+ });
131959
+ }
131960
+ if (role === "developer" && issue2 && issueIdNum && !hasReviewableArtifact && minutesActive >= stallTimeoutMinutes) {
131961
+ const fix = {
131962
+ issue: {
131963
+ type: "completion_recovery_exhausted",
131964
+ severity: "critical",
131965
+ project: project.name,
131966
+ projectSlug,
131967
+ role,
131968
+ level,
131969
+ sessionKey,
131970
+ issueId: slot.issueId,
131971
+ slotIndex,
131972
+ message: `${role.toUpperCase()} ${level}[${slotIndex}] active for ${Math.round(minutesActive)}m without a PR or terminal result`
131973
+ },
131974
+ fixed: false
131975
+ };
131976
+ if (autoFix) {
131977
+ const channel = project.channels?.[0];
131978
+ await notify(
131979
+ {
131980
+ type: "workerRecoveryExhausted",
131981
+ project: project.name,
131982
+ issueId: issueIdNum,
131983
+ issueUrl: issue2.web_url,
131984
+ issueTitle: issue2.title,
131985
+ role,
131986
+ detail: `No PR or canonical completion after ${Math.round(minutesActive)} minutes of active work. Re-queueing for a fresh attempt.`,
131987
+ nextState: slotQueueLabel,
131988
+ dispatchCycleId: slot.dispatchCycleId ?? issueRuntime?.lastDispatchCycleId ?? null,
131989
+ dispatchRunId: slot.dispatchRunId ?? issueRuntime?.dispatchRunId ?? null
131990
+ },
131991
+ {
131992
+ workspaceDir,
131993
+ config: notificationConfig,
131994
+ target: channel ? {
131995
+ channelId: channel.channelId,
131996
+ channel: channel.channel,
131997
+ accountId: channel.accountId,
131998
+ messageThreadId: channel.messageThreadId
131999
+ } : void 0,
132000
+ runCommand
132001
+ }
132002
+ ).catch(() => {
132003
+ });
132004
+ await revertLabel(fix, expectedLabel, slotQueueLabel);
132005
+ if (!fix.labelRevertFailed) {
132006
+ await deactivateSlot();
132007
+ await updateIssueRuntime(workspaceDir, projectSlug, issueIdNum, {
132008
+ inconclusiveCompletionAt: (/* @__PURE__ */ new Date()).toISOString(),
132009
+ inconclusiveCompletionReason: "stalled_without_artifact",
132010
+ progressNotifiedAt: null
132011
+ }).catch(() => {
132012
+ });
132013
+ fix.fixed = true;
132014
+ await auditHealthFixApplied(workspaceDir, fix, {
132015
+ action: "requeue_issue",
132016
+ fromLabel: expectedLabel,
132017
+ toLabel: slotQueueLabel
132018
+ });
132019
+ }
132020
+ }
132021
+ fixes.push(fix);
132022
+ continue;
132023
+ }
131816
132024
  if (hours > staleWorkerHours) {
131817
132025
  const fix = {
131818
132026
  issue: {
@@ -139671,11 +139879,44 @@ var createTaskStep = {
139671
139879
  };
139672
139880
 
139673
139881
  // lib/intake/lib/triage-logic.ts
139674
- function calculateEffort(filesChanged, acCount) {
139675
- if (filesChanged <= 3 && acCount <= 3) return "small";
139676
- if (filesChanged <= 10 && acCount <= 7) return "medium";
139677
- if (filesChanged <= 25 && acCount <= 15) return "large";
139678
- return "xlarge";
139882
+ function detectRawIdeaComplexity(rawIdea) {
139883
+ const text = rawIdea.toLowerCase();
139884
+ const signals = [];
139885
+ const subsystemPatterns = [
139886
+ [/\b(worker|background.?job|background process|background service|background worker|queue|celery|bull|sidekiq|task.?runner|scheduler|job processor)\b/i, "background-worker"],
139887
+ [/\b(websocket|server.?sent|sse|real.?time|realtime|socket\.io|push.?notif)\b/i, "realtime"],
139888
+ [/\b(auth|oauth|jwt|login|register|session|user.?account|signup|role-based access|rbac|permission[s]?)\b/i, "auth"],
139889
+ [/\b(notif|notification[s]?|alert[s]?|email|sms|webhook|subscription|subscribe|reminder[s]?|escalation[s]?)\b/i, "notifications"],
139890
+ [/\b(database|banco|db|postgres|mysql|mongodb|redis|sqlite|orm|audit history|audit log|activity history)\b/i, "database"],
139891
+ [/\b(api\s+rest|rest\s+api|endpoint|rota|route|graphql|grpc)\b/i, "api-layer"],
139892
+ [/\b(docker|kubernetes|k8s|deploy|ci|cd|pipeline)\b/i, "infra"],
139893
+ [/\b(dashboard|frontend|interface|ui|tela|p[aá]gina|admin view|admin panel|admin console)\b/i, "frontend"]
139894
+ ];
139895
+ for (const [regex, label] of subsystemPatterns) {
139896
+ if (regex.test(text)) signals.push(label);
139897
+ }
139898
+ let floor = null;
139899
+ if (signals.length >= 4) floor = "large";
139900
+ else if (signals.length >= 3) floor = "medium";
139901
+ else if (signals.length >= 2) floor = "medium";
139902
+ return { floor, signals };
139903
+ }
139904
+ function calculateEffort(filesChanged, acCount, rawIdea) {
139905
+ let effort;
139906
+ if (filesChanged <= 3 && acCount <= 3) effort = "small";
139907
+ else if (filesChanged <= 10 && acCount <= 7) effort = "medium";
139908
+ else if (filesChanged <= 25 && acCount <= 15) effort = "large";
139909
+ else effort = "xlarge";
139910
+ if (rawIdea) {
139911
+ const { floor } = detectRawIdeaComplexity(rawIdea);
139912
+ if (floor) {
139913
+ const ORDER = ["small", "medium", "large", "xlarge"];
139914
+ if (ORDER.indexOf(floor) > ORDER.indexOf(effort)) {
139915
+ effort = floor;
139916
+ }
139917
+ }
139918
+ }
139919
+ return effort;
139679
139920
  }
139680
139921
  function calculatePriority(type, effort, totalRisks, matrix) {
139681
139922
  for (const rule of matrix.priority_rules_v2) {
@@ -139742,7 +139983,7 @@ function determineLevel(effort, targetState) {
139742
139983
  return level;
139743
139984
  }
139744
139985
  function runTriageLogic(input, matrix) {
139745
- const effort = calculateEffort(input.filesChanged, input.acCount);
139986
+ const effort = calculateEffort(input.filesChanged, input.acCount, input.rawIdea);
139746
139987
  const { priority, label: priorityLabel } = calculatePriority(input.type, effort, input.totalRisks, matrix);
139747
139988
  const effortLabel = matrix.effort_rules[effort]?.label ?? `effort:${effort}`;
139748
139989
  const typeLabel = matrix.auto_labels[input.type] ?? "";
@@ -139806,6 +140047,38 @@ function loadMatrix() {
139806
140047
  cachedMatrix = _require3("../configs/triage-matrix.json");
139807
140048
  return cachedMatrix;
139808
140049
  }
140050
+ function buildChildDrafts(payload, issueNumber, effort) {
140051
+ const spec = payload.spec;
140052
+ const scopeItems = spec.scope_v1.filter((item) => item.trim().length > 0);
140053
+ const maxChildren = effort === "xlarge" ? 4 : 3;
140054
+ const chunkSize = 2;
140055
+ const drafts = [];
140056
+ for (let i2 = 0; i2 < scopeItems.length && drafts.length < maxChildren; i2 += chunkSize) {
140057
+ const slice = scopeItems.slice(i2, i2 + chunkSize);
140058
+ if (slice.length === 0) continue;
140059
+ const childIndex = drafts.length + 1;
140060
+ drafts.push({
140061
+ title: `${spec.title} \u2014 Part ${childIndex}`,
140062
+ description: [
140063
+ `## Objective`,
140064
+ spec.objective,
140065
+ "",
140066
+ `## Parent Issue`,
140067
+ `Parent issue: #${issueNumber}`,
140068
+ "",
140069
+ `## This Part`,
140070
+ ...slice.map((item) => `- ${item}`),
140071
+ "",
140072
+ `## Acceptance Criteria`,
140073
+ ...spec.acceptance_criteria.map((item) => `- ${item}`),
140074
+ "",
140075
+ `## Definition of Done`,
140076
+ ...spec.definition_of_done.map((item) => `- ${item}`)
140077
+ ].join("\n")
140078
+ });
140079
+ }
140080
+ return drafts;
140081
+ }
139809
140082
  var triageStep = {
139810
140083
  name: "triage",
139811
140084
  shouldRun: (payload) => !!payload.issues?.length && !payload.dry_run,
@@ -139825,9 +140098,9 @@ var triageStep = {
139825
140098
  totalRisks: (impact?.risk_areas?.length ?? 0) + (payload.security?.spec_security_notes?.length ?? 0),
139826
140099
  objective: spec.objective,
139827
140100
  rawIdea: payload.raw_idea,
139828
- acText: spec.acceptance_criteria.join(" "),
139829
- scopeText: spec.scope_v1.join(" "),
139830
- oosText: spec.out_of_scope.join(" "),
140101
+ acText: spec.acceptance_criteria.join("\n"),
140102
+ scopeText: spec.scope_v1.join("\n"),
140103
+ oosText: spec.out_of_scope.join("\n"),
139831
140104
  authSignal: payload.metadata?.auth_gate?.signal ?? false
139832
140105
  }, matrix);
139833
140106
  const repoUrl = payload.scaffold?.repo_url ?? payload.metadata?.repo_url ?? "";
@@ -139837,8 +140110,12 @@ var triageStep = {
139837
140110
  const initialLabel = resolvedConfig?.workflow.states[resolvedConfig.workflow.initial]?.label ?? "Planning";
139838
140111
  const allLabels = [decision.priorityLabel, decision.effortLabel];
139839
140112
  if (decision.typeLabel) allLabels.push(decision.typeLabel);
139840
- if (!decision.readyForDispatch) allLabels.push("needs-human");
140113
+ if (!decision.readyForDispatch || decision.specQualityBlock) allLabels.push("needs-human");
139841
140114
  const uniqueLabels = Array.from(new Set(allLabels.filter(Boolean)));
140115
+ const shouldDecompose = decision.readyForDispatch && !decision.specQualityBlock && ["large", "xlarge"].includes(decision.effort);
140116
+ const decompositionDrafts = shouldDecompose ? buildChildDrafts(payload, issue2.number, decision.effort) : [];
140117
+ const canDecompose = decompositionDrafts.length >= 2;
140118
+ let createdChildIssueNumbers = [];
139842
140119
  if (ctx.createIssueProvider && repoPath) {
139843
140120
  try {
139844
140121
  const { provider } = await ctx.createIssueProvider({
@@ -139848,7 +140125,36 @@ var triageStep = {
139848
140125
  for (const label of uniqueLabels) {
139849
140126
  await provider.addLabel(issue2.number, label);
139850
140127
  }
139851
- if (decision.readyForDispatch) {
140128
+ if (decision.specQualityBlock) {
140129
+ await provider.addComment(issue2.number, [
140130
+ "\u{1F6AB} Spec quality gate blocked automatic dispatch.",
140131
+ "",
140132
+ "The request needs a stronger objective, more concrete scope items, and verifiable acceptance criteria before creating execution tasks."
140133
+ ].join("\n"));
140134
+ } else if (canDecompose) {
140135
+ await provider.addLabel(issue2.number, "decomposition:parent");
140136
+ const childIssues = [];
140137
+ for (const draft of decompositionDrafts) {
140138
+ const child = await provider.createIssue(draft.title, draft.description, initialLabel);
140139
+ childIssues.push({ iid: child.iid, title: child.title, web_url: child.web_url });
140140
+ createdChildIssueNumbers.push(child.iid);
140141
+ for (const label of uniqueLabels) {
140142
+ await provider.addLabel(child.iid, label);
140143
+ }
140144
+ await provider.addLabel(child.iid, "decomposition:child");
140145
+ }
140146
+ await provider.addComment(issue2.number, [
140147
+ "## Decomposition Plan",
140148
+ ...childIssues.map((child) => `- [ ] #${child.iid} ${child.title}`)
140149
+ ].join("\n"));
140150
+ } else if (shouldDecompose) {
140151
+ await provider.addLabel(issue2.number, "needs-human");
140152
+ await provider.addComment(issue2.number, [
140153
+ "\u26A0\uFE0F Automatic decomposition was requested, but the generated spec did not yield at least two independently scoped child tasks.",
140154
+ "",
140155
+ "Refine the scope/acceptance criteria or split the plan manually before dispatch."
140156
+ ].join("\n"));
140157
+ } else if (decision.readyForDispatch) {
139852
140158
  await provider.transitionLabel(issue2.number, initialLabel, decision.targetState);
139853
140159
  const dispatchLabels = [];
139854
140160
  if (decision.targetState === "To Do" && decision.dispatchLabel) {
@@ -139913,8 +140219,14 @@ var triageStep = {
139913
140219
  project_channel_id: null,
139914
140220
  labels_applied: uniqueLabels,
139915
140221
  issue_number: issue2.number,
139916
- ready_for_dispatch: decision.readyForDispatch && decision.errors.length === 0,
139917
- errors: decision.errors
140222
+ ready_for_dispatch: decision.readyForDispatch && decision.errors.length === 0 && !decision.specQualityBlock && !canDecompose,
140223
+ errors: [
140224
+ ...decision.errors,
140225
+ ...decision.specQualityBlock ? ["spec_quality_block"] : [],
140226
+ ...shouldDecompose && !canDecompose ? ["decomposition_needs_human"] : []
140227
+ ],
140228
+ decomposition_mode: canDecompose ? "parent_child" : "none",
140229
+ child_issue_numbers: createdChildIssueNumbers
139918
140230
  };
139919
140231
  ctx.log(`Triage: ${triage.priority}, effort=${triage.effort}, ready=${triage.ready_for_dispatch}`);
139920
140232
  return {
@@ -140996,6 +141308,22 @@ var BOOTSTRAP_MESSAGES = {
140996
141308
  pt: "Como voc\xEA quer chamar o projeto? Se preferir, posso escolher um nome.",
140997
141309
  en: "What do you want to name the project? If you prefer, I can pick one."
140998
141310
  },
141311
+ clarifyScope: {
141312
+ pt: (idea) => `Recebi! Seu pedido envolve v\xE1rios subsistemas (autentica\xE7\xE3o, notifica\xE7\xF5es, worker, banco de dados...). Para montar uma spec de qualidade, preciso de algumas defini\xE7\xF5es:
141313
+
141314
+ 1. **Stack/linguagem**: qual prefere? (Python/FastAPI, Node.js/Express, Go...)
141315
+ 2. **Banco de dados**: PostgreSQL, MongoDB, Redis, outro?
141316
+ 3. **Autentica\xE7\xE3o**: JWT, OAuth2, sess\xE3o?
141317
+
141318
+ Se preferir deixar a escolha comigo, responda "livre" e eu decido.`,
141319
+ en: (idea) => `Got it! Your request involves multiple subsystems (auth, notifications, background worker, DB...). To produce a quality spec, I need a few decisions:
141320
+
141321
+ 1. **Stack/language**: which do you prefer? (Python/FastAPI, Node.js/Express, Go...)
141322
+ 2. **Database**: PostgreSQL, MongoDB, Redis, other?
141323
+ 3. **Auth**: JWT, OAuth2, session?
141324
+
141325
+ If you want me to choose, reply "your call" and I'll decide.`
141326
+ },
140999
141327
  registered: {
141000
141328
  pt: (name, link) => `Projeto "${name}" registrado.
141001
141329
  Vou continuar o fluxo em ${link}`,
@@ -141003,12 +141331,39 @@ Vou continuar o fluxo em ${link}`,
141003
141331
  I'll continue the flow at ${link}`
141004
141332
  }
141005
141333
  };
141334
+ function detectScopeAmbiguity(rawIdea, stackHint) {
141335
+ const text = rawIdea.toLowerCase();
141336
+ if (/\b(livre|free.?choice|your.?call|pode.?escolher|voc[eê].?decide|qualquer)\b/i.test(text)) {
141337
+ return false;
141338
+ }
141339
+ const subsystems = [
141340
+ /\b(worker|background.?job|queue|task.?runner|celery|bull)\b/i,
141341
+ /\b(websocket|sse|real.?time|realtime|push.?notif)\b/i,
141342
+ /\b(auth|oauth|jwt|login|register|signup|autenticac)\b/i,
141343
+ /\b(notif|alert|assinatura|subscription|subscribe|email|sms)\b/i,
141344
+ /\b(banco|database|db|postgres|mysql|mongodb|redis|sqlite)\b/i,
141345
+ /\b(api\s+rest|rest\s+api|endpoint|graphql|grpc)\b/i,
141346
+ /\b(dashboard|frontend|interface|ui|tela)\b/i
141347
+ ];
141348
+ const matchedSubsystems = subsystems.filter((r2) => r2.test(text)).length;
141349
+ if (matchedSubsystems < 3) return false;
141350
+ const hasExplicitDB = /\b(postgres(ql)?|mysql|mongodb|mongo|redis|sqlite|supabase|dynamodb|cockroach)\b/i.test(text);
141351
+ const hasExplicitAuth = /\b(jwt|oauth2?|basic.?auth|api.?key|session.?based|cookie.?auth)\b/i.test(text);
141352
+ const hasExplicitStack = !!stackHint && !["api", "rest-api", "backend"].includes(stackHint);
141353
+ const unspecifiedDimensions = [!hasExplicitDB, !hasExplicitAuth, !hasExplicitStack].filter(Boolean).length;
141354
+ return unspecifiedDimensions >= 2;
141355
+ }
141006
141356
  function inferProjectSlug(text) {
141007
141357
  let cleaned = text.replace(/^(create|build|crie|cria|criar|fazer?|quero|i need|i want)\s+(uma|um|me\s+a?|an|a)?\s*/i, "").replace(/\s+(that|which|que|para|for|pra)\s+.*/i, "").trim();
141008
141358
  if (!cleaned) cleaned = text;
141009
141359
  const slug = cleaned.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 64);
141010
141360
  return slug || void 0;
141011
141361
  }
141362
+ function normalizeProjectNameCandidate(value) {
141363
+ const normalized = normalizeText3(value);
141364
+ if (!normalized) return void 0;
141365
+ return inferProjectSlug(normalized) ?? void 0;
141366
+ }
141012
141367
  function normalizeText3(value) {
141013
141368
  const trimmed = value?.trim();
141014
141369
  return trimmed ? trimmed : void 0;
@@ -141032,7 +141387,7 @@ function detectStackHint(text) {
141032
141387
  }
141033
141388
  function parseField(text, labels) {
141034
141389
  for (const label of labels) {
141035
- const regex = new RegExp(`^\\s*${label}\\s*:\\s*(.+)$`, "im");
141390
+ const regex = new RegExp(`(?:^|[\\n.,;!?]\\s*)${label}\\s*:\\s*(.+)$`, "im");
141036
141391
  const match = text.match(regex);
141037
141392
  if (match?.[1]) return normalizeText3(match[1]);
141038
141393
  }
@@ -141044,12 +141399,12 @@ function parseIdeaBlock(text) {
141044
141399
  }
141045
141400
  function parseExplicitProjectName(text) {
141046
141401
  const match = text.match(/\b(?:called|named|chamado)\s+[`"'“”‘’]?([a-z0-9][a-z0-9-]{1,63})[`"'“”‘’]?(?=$|[\s.,!?;:])/i);
141047
- return match?.[1]?.toLowerCase();
141402
+ return normalizeProjectNameCandidate(match?.[1]?.toLowerCase());
141048
141403
  }
141049
141404
  function parseBootstrapRequest(text) {
141050
141405
  const repoUrl = parseField(text, ["repository url", "repo url", "reposit[o\xF3]rio url", "github repo"]);
141051
141406
  const rawIdea = parseIdeaBlock(text) ?? text.trim();
141052
- const projectName = parseField(text, ["project name", "nome do projeto", "repo name", "repository name"]) ?? parseExplicitProjectName(text);
141407
+ const projectName = normalizeProjectNameCandidate(parseField(text, ["project name", "nome do projeto", "repo name", "repository name"])) ?? parseExplicitProjectName(text);
141053
141408
  const repoPath = parseField(text, ["local repository path", "repo path", "caminho local", "local path"]);
141054
141409
  const explicitStack = parseField(text, ["stack", "framework", "linguagem"]);
141055
141410
  const stackHint = (explicitStack ? normalizeStackHint(explicitStack) : "") || detectStackHint(text);
@@ -141228,25 +141583,26 @@ function parseClarificationResponse(text, session) {
141228
141583
  if (autoPatterns.test(normalizeUserResponse(text))) {
141229
141584
  return { recognized: true, projectName: inferProjectSlug(session.rawIdea) ?? `project-${Date.now()}`, stackHint: session.stackHint ?? void 0 };
141230
141585
  }
141231
- const nameField = parseField(text, ["project name", "nome do projeto", "nome", "name"]);
141586
+ const nameField = normalizeProjectNameCandidate(parseField(text, ["project name", "nome do projeto", "nome", "name"]));
141232
141587
  if (nameField) {
141233
141588
  return { recognized: true, projectName: nameField, stackHint: session.stackHint ?? void 0 };
141234
141589
  }
141235
- if (trimmed.length > 0 && trimmed.length <= 64) {
141236
- return { recognized: true, projectName: trimmed, stackHint: session.stackHint ?? void 0 };
141590
+ const normalizedInlineName = trimmed.length <= 64 ? normalizeProjectNameCandidate(trimmed) : void 0;
141591
+ if (normalizedInlineName) {
141592
+ return { recognized: true, projectName: normalizedInlineName, stackHint: session.stackHint ?? void 0 };
141237
141593
  }
141238
141594
  return { recognized: false };
141239
141595
  }
141596
+ const projectNameFromField = normalizeProjectNameCandidate(parseField(text, ["project name", "nome do projeto", "nome", "name"]));
141597
+ const nameItMatch = !projectNameFromField ? text.match(/(?:name|call|chamar?)\s+(?:it\s+)?([\w-]{2,64})/i) : null;
141598
+ const inlineName = projectNameFromField ?? normalizeProjectNameCandidate(nameItMatch?.[1]);
141240
141599
  const stackField = parseField(text, ["stack", "framework", "linguagem", "language"]);
141241
141600
  if (stackField) {
141242
141601
  const normalizedStack = normalizeStackHint(stackField) || detectStackHint(stackField) || stackField;
141243
- return { recognized: true, stackHint: normalizedStack };
141602
+ return { recognized: true, stackHint: normalizedStack, projectName: inlineName };
141244
141603
  }
141245
141604
  const detectedStack = detectStackHint(text);
141246
141605
  if (detectedStack) {
141247
- const projectNameFromField = parseField(text, ["project name", "nome do projeto", "nome", "name"]);
141248
- const nameItMatch = !projectNameFromField ? text.match(/(?:name|call|chamar?)\s+(?:it\s+)?([\w-]{2,64})/i) : null;
141249
- const inlineName = projectNameFromField ?? (nameItMatch ? nameItMatch[1].toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-") || void 0 : void 0);
141250
141606
  return { recognized: true, stackHint: detectedStack, projectName: inlineName };
141251
141607
  }
141252
141608
  const lower2 = normalizeUserResponse(text);
@@ -141471,6 +141827,27 @@ async function handleTelegramBootstrapDmMessage(ctx, rawConversationId, content)
141471
141827
  ));
141472
141828
  return;
141473
141829
  }
141830
+ if (detectScopeAmbiguity(content, parsed.stackHint)) {
141831
+ const existingSession2 = await readTelegramBootstrapSession(workspaceDir, conversationId);
141832
+ const alreadyAskedScope = existingSession2?.pendingClarification === "scope";
141833
+ if (!alreadyAskedScope) {
141834
+ await upsertTelegramBootstrapSession(workspaceDir, {
141835
+ conversationId,
141836
+ rawIdea: content,
141837
+ stackHint: parsed.stackHint ?? void 0,
141838
+ projectName: parsed.projectName ?? parsed.projectSlug ?? void 0,
141839
+ status: "clarifying",
141840
+ pendingClarification: "scope",
141841
+ language
141842
+ });
141843
+ const clarifyMsg = BOOTSTRAP_MESSAGES.clarifyScope[language](content);
141844
+ await sendTelegramText(ctx, rawConversationId, clarifyMsg);
141845
+ return;
141846
+ }
141847
+ if (existingSession2?.stackHint) {
141848
+ incomingRequest.stackHint = existingSession2.stackHint;
141849
+ }
141850
+ }
141474
141851
  const handled = await runBootstrapPreflightOrFail(
141475
141852
  ctx,
141476
141853
  conversationId,
@@ -143844,6 +144221,7 @@ async function performHealthPass(workspaceDir, projectSlug, project, sessions, p
143844
144221
  workflow: resolvedConfig?.workflow,
143845
144222
  dispatchConfirmTimeoutMs: resolvedConfig?.timeouts?.dispatchConfirmTimeoutMs,
143846
144223
  healthGracePeriodMs: resolvedConfig?.timeouts?.healthGracePeriodMs,
144224
+ stallTimeoutMinutes,
143847
144225
  runCommand,
143848
144226
  notificationConfig: notifyConfig
143849
144227
  });
@@ -146221,8 +146599,18 @@ function stringifyAnswerRecord(record2) {
146221
146599
  }
146222
146600
  return normalized;
146223
146601
  }
146224
- function deriveProjectName(repoUrl, projectName) {
146602
+ function extractExplicitProjectName(text) {
146603
+ if (!text) return null;
146604
+ const fieldMatch = text.match(/(?:^|[\n.,;!?]\s*)(?:project name|repo name|repository name|nome do projeto)\s*:\s*([a-z0-9][a-z0-9-]{1,63})\b/i);
146605
+ if (fieldMatch?.[1]) return fieldMatch[1].trim().toLowerCase();
146606
+ const inlineMatch = text.match(/\b(?:called|named|chamado)\s+[`"'“”‘’]?([a-z0-9][a-z0-9-]{1,63})[`"'“”‘’]?(?=$|[\s.,!?;:])/i);
146607
+ if (inlineMatch?.[1]) return inlineMatch[1].trim().toLowerCase();
146608
+ return null;
146609
+ }
146610
+ function deriveProjectName(repoUrl, projectName, freeText) {
146225
146611
  if (projectName) return projectName;
146612
+ const parsedFromText = extractExplicitProjectName(freeText ?? null);
146613
+ if (parsedFromText) return parsedFromText;
146226
146614
  if (!repoUrl) return null;
146227
146615
  const sanitized = repoUrl.replace(/\/+$/, "");
146228
146616
  const lastSegment = sanitized.split("/").pop();
@@ -146312,9 +146700,11 @@ function normalizeGenesisRequest(params, existingPayload) {
146312
146700
  throw new Error('phase is required and must be "discover" or "commit"');
146313
146701
  }
146314
146702
  const repoUrl = normalizeOptionalString(params.repo_url) ?? existingPayload?.metadata.repo_url ?? null;
146703
+ const freeTextProjectSource = normalizeOptionalString(params.idea) ?? normalizeOptionalString(params.command) ?? existingPayload?.raw_idea ?? null;
146315
146704
  const projectName = deriveProjectName(
146316
146705
  repoUrl,
146317
- normalizeOptionalString(params.project_name) ?? existingPayload?.metadata.project_name ?? null
146706
+ normalizeOptionalString(params.project_name) ?? existingPayload?.metadata.project_name ?? null,
146707
+ freeTextProjectSource
146318
146708
  );
146319
146709
  const answersJson = {
146320
146710
  ...existingPayload?.metadata.answers_json ?? {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mestreyoda/fabrica",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Autonomous software engineering pipeline for OpenClaw. Turns ideas into deployed code via intake, dispatch, review, test, and merge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",