karajan-code 1.46.0 → 1.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Validates sub-HUs produced by the splitting generator against 4 mandatory criteria.
3
+ * Uses heuristic checks (no AI calls).
4
+ */
5
+
6
+ /** Maximum devPoints for a sub-HU to be considered completable in 3 days. */
7
+ const MAX_DEV_POINTS = 3;
8
+
9
+ /** Text length threshold — stories longer than this are likely too big for 3 days. */
10
+ const TEXT_LENGTH_THRESHOLD = 2000;
11
+
12
+ /**
13
+ * Regex patterns that indicate a horizontal (layer-only) split — both EN and ES.
14
+ * @type {RegExp[]}
15
+ */
16
+ export const HORIZONTAL_PATTERNS = [
17
+ /\bonly the (api|backend|frontend|database|ui)\b/i,
18
+ /\bjust the (endpoint|interface|model|schema)\b/i,
19
+ /\bsolo (el endpoint|la interfaz|el modelo|la base de datos|el backend|el frontend)\b/i,
20
+ /\bimplementar (el|la|los) \w+ sin\b/i
21
+ ];
22
+
23
+ /**
24
+ * Patterns that indicate a sub-HU is not independently deployable.
25
+ * @type {RegExp[]}
26
+ */
27
+ const DEPENDENCY_PATTERNS = [
28
+ /\bpart \d+ of \d+\b/i,
29
+ /\bdepends on .+ being deployed first\b/i,
30
+ /\brequires .+ to be completed before\b/i,
31
+ /\bparte \d+ de \d+\b/i
32
+ ];
33
+
34
+ /**
35
+ * Check whether the text has a role/goal/benefit structure (independently valuable).
36
+ * Looks for patterns like "As a <role>, I want <goal> so that <benefit>".
37
+ * Also accepts structured objects with role/goal/benefit fields.
38
+ * @param {object} subHU
39
+ * @returns {boolean}
40
+ */
41
+ function checkIndependentlyValuable(subHU) {
42
+ // Check structured fields first
43
+ const desc = subHU.descriptionStructured || subHU.description_structured;
44
+ if (Array.isArray(desc) && desc.length > 0) {
45
+ const entry = desc[0];
46
+ if (entry.role && entry.goal && entry.benefit) return true;
47
+ }
48
+ if (subHU.role && subHU.goal && subHU.benefit) return true;
49
+
50
+ // Check text for As a/I want/so that pattern
51
+ const text = extractText(subHU);
52
+ const hasRole = /\b(as an?|como)\b/i.test(text);
53
+ const hasGoal = /\b(i want|quiero|i need|necesito)\b/i.test(text);
54
+ const hasBenefit = /\b(so that|para que|in order to|de modo que)\b/i.test(text);
55
+ return hasRole && hasGoal && hasBenefit;
56
+ }
57
+
58
+ /**
59
+ * Check that the sub-HU does not reference sequential dependencies.
60
+ * @param {object} subHU
61
+ * @returns {boolean}
62
+ */
63
+ function checkDeployableAlone(subHU) {
64
+ const text = extractText(subHU);
65
+ return !DEPENDENCY_PATTERNS.some(p => p.test(text));
66
+ }
67
+
68
+ /**
69
+ * Check whether the sub-HU is completable within 3 days.
70
+ * Uses devPoints if available, otherwise falls back to text length heuristic.
71
+ * @param {object} subHU
72
+ * @returns {boolean}
73
+ */
74
+ function checkCompletableIn3Days(subHU) {
75
+ if (typeof subHU.devPoints === "number") {
76
+ return subHU.devPoints <= MAX_DEV_POINTS;
77
+ }
78
+ const text = extractText(subHU);
79
+ return text.length <= TEXT_LENGTH_THRESHOLD;
80
+ }
81
+
82
+ /**
83
+ * Check the sub-HU is a vertical slice (not a horizontal layer-only story).
84
+ * @param {object} subHU
85
+ * @returns {boolean}
86
+ */
87
+ function checkIsVertical(subHU) {
88
+ const text = extractText(subHU);
89
+ return !HORIZONTAL_PATTERNS.some(p => p.test(text));
90
+ }
91
+
92
+ /**
93
+ * Extract all relevant text from a sub-HU for pattern matching.
94
+ * @param {object} subHU
95
+ * @returns {string}
96
+ */
97
+ function extractText(subHU) {
98
+ const parts = [
99
+ subHU.title,
100
+ subHU.text,
101
+ subHU.description,
102
+ subHU.acceptanceCriteria
103
+ ].filter(Boolean);
104
+
105
+ // Include structured description text
106
+ const desc = subHU.descriptionStructured || subHU.description_structured;
107
+ if (Array.isArray(desc)) {
108
+ for (const entry of desc) {
109
+ if (entry.role) parts.push(entry.role);
110
+ if (entry.goal) parts.push(entry.goal);
111
+ if (entry.benefit) parts.push(entry.benefit);
112
+ }
113
+ }
114
+
115
+ return parts.join(" ");
116
+ }
117
+
118
+ /**
119
+ * Validate a sub-HU against 4 mandatory split criteria.
120
+ *
121
+ * @param {object} subHU - The sub-HU object to validate.
122
+ * @returns {{ valid: boolean, criteria: { independently_valuable: boolean, deployable_alone: boolean, completable_in_3_days: boolean, is_vertical: boolean }, failures: string[] }}
123
+ */
124
+ export function validateSplitCriteria(subHU) {
125
+ const criteria = {
126
+ independently_valuable: checkIndependentlyValuable(subHU),
127
+ deployable_alone: checkDeployableAlone(subHU),
128
+ completable_in_3_days: checkCompletableIn3Days(subHU),
129
+ is_vertical: checkIsVertical(subHU)
130
+ };
131
+
132
+ const failures = [];
133
+ if (!criteria.independently_valuable) failures.push("Sub-HU does not describe independent user-facing value (missing role/goal/benefit)");
134
+ if (!criteria.deployable_alone) failures.push("Sub-HU references sequential deployment dependencies");
135
+ if (!criteria.completable_in_3_days) failures.push("Sub-HU appears too large to complete in 3 days");
136
+ if (!criteria.is_vertical) failures.push("Sub-HU describes a horizontal slice instead of a vertical one");
137
+
138
+ return {
139
+ valid: failures.length === 0,
140
+ criteria,
141
+ failures
142
+ };
143
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Heuristic-based HU splitting detection.
3
+ * Analyzes user story text for indicators that suggest the HU should be split.
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} SplitIndicator
8
+ * @property {string} type - Indicator type (e.g. "multiple_and", "multiple_roles")
9
+ * @property {string} detail - Human-readable description of what was detected
10
+ * @property {number} weight - How strongly this indicator suggests splitting (1-3)
11
+ */
12
+
13
+ /** @type {Record<string, string>} */
14
+ export const HEURISTIC_DESCRIPTIONS = Object.freeze({
15
+ workflow_steps: "Split by workflow steps: each step of the user journey becomes its own HU.",
16
+ data_entity: "Split by data entity: each distinct entity (model, resource) gets its own HU.",
17
+ user_role: "Split by user role: each actor/persona gets their own HU.",
18
+ crud_operations: "Split by CRUD operations: separate create, read, update, delete into individual HUs.",
19
+ happy_sad_path: "Split by happy/sad path: main flow in one HU, error/edge cases in another.",
20
+ interface_boundary: "Split by interface boundary: frontend, backend, integration each get their own HU."
21
+ });
22
+
23
+ const HEURISTIC_KEYS = Object.keys(HEURISTIC_DESCRIPTIONS);
24
+
25
+ /**
26
+ * Detect indicators in HU text that suggest the story should be split.
27
+ * @param {string} text - The HU text to analyze.
28
+ * @returns {SplitIndicator[]} Array of detected indicators (empty if no splitting needed).
29
+ */
30
+ export function detectIndicators(text) {
31
+ if (!text || typeof text !== "string") return [];
32
+
33
+ const indicators = [];
34
+ const lower = text.toLowerCase();
35
+
36
+ // Multiple "and" connectors suggest compound story
37
+ const andMatches = lower.match(/\band\b/g);
38
+ if (andMatches && andMatches.length >= 2) {
39
+ indicators.push({
40
+ type: "multiple_and",
41
+ detail: `Found ${andMatches.length} "and" connectors suggesting compound story`,
42
+ weight: 2
43
+ });
44
+ }
45
+
46
+ // Multiple roles mentioned
47
+ const rolePatterns = /\bas (?:a |an )?(\w+)/gi;
48
+ const roles = [...text.matchAll(rolePatterns)].map(m => m[1].toLowerCase());
49
+ const uniqueRoles = new Set(roles);
50
+ if (uniqueRoles.size > 1) {
51
+ indicators.push({
52
+ type: "multiple_roles",
53
+ detail: `Multiple roles detected: ${[...uniqueRoles].join(", ")}`,
54
+ weight: 3
55
+ });
56
+ }
57
+
58
+ // Multiple acceptance criteria groups or numbered lists
59
+ const acCount = (text.match(/^[-*\d+.]\s/gm) || []).length;
60
+ if (acCount > 5) {
61
+ indicators.push({
62
+ type: "many_acceptance_criteria",
63
+ detail: `${acCount} acceptance criteria items detected`,
64
+ weight: 2
65
+ });
66
+ }
67
+
68
+ // CRUD keywords
69
+ const crudKeywords = ["create", "read", "update", "delete", "edit", "remove", "list", "view"];
70
+ const foundCrud = crudKeywords.filter(kw => lower.includes(kw));
71
+ if (foundCrud.length >= 3) {
72
+ indicators.push({
73
+ type: "crud_operations",
74
+ detail: `Multiple CRUD operations: ${foundCrud.join(", ")}`,
75
+ weight: 2
76
+ });
77
+ }
78
+
79
+ // Multiple "so that" / "in order to" benefits
80
+ const benefitMatches = lower.match(/\b(so that|in order to)\b/g);
81
+ if (benefitMatches && benefitMatches.length > 1) {
82
+ indicators.push({
83
+ type: "multiple_benefits",
84
+ detail: `${benefitMatches.length} benefit clauses detected`,
85
+ weight: 2
86
+ });
87
+ }
88
+
89
+ // Workflow/step keywords
90
+ const stepKeywords = ["first", "then", "after that", "finally", "next", "step"];
91
+ const foundSteps = stepKeywords.filter(kw => lower.includes(kw));
92
+ if (foundSteps.length >= 2) {
93
+ indicators.push({
94
+ type: "workflow_steps",
95
+ detail: `Sequential workflow indicators: ${foundSteps.join(", ")}`,
96
+ weight: 2
97
+ });
98
+ }
99
+
100
+ return indicators;
101
+ }
102
+
103
+ /**
104
+ * Select the best splitting heuristic based on detected indicators.
105
+ * @param {SplitIndicator[]} indicators - Detected split indicators.
106
+ * @param {string[]} [excludeHeuristics=[]] - Heuristics to skip (already tried).
107
+ * @returns {string|null} The selected heuristic key, or null if none applicable.
108
+ */
109
+ export function selectHeuristic(indicators, excludeHeuristics = []) {
110
+ if (!indicators || indicators.length === 0) return null;
111
+
112
+ const excluded = new Set(excludeHeuristics);
113
+
114
+ // Map indicator types to preferred heuristics
115
+ const heuristicScores = {};
116
+ for (const key of HEURISTIC_KEYS) {
117
+ if (!excluded.has(key)) {
118
+ heuristicScores[key] = 0;
119
+ }
120
+ }
121
+
122
+ for (const ind of indicators) {
123
+ switch (ind.type) {
124
+ case "multiple_roles":
125
+ if (!excluded.has("user_role")) heuristicScores.user_role = (heuristicScores.user_role || 0) + ind.weight;
126
+ break;
127
+ case "crud_operations":
128
+ if (!excluded.has("crud_operations")) heuristicScores.crud_operations = (heuristicScores.crud_operations || 0) + ind.weight;
129
+ break;
130
+ case "workflow_steps":
131
+ if (!excluded.has("workflow_steps")) heuristicScores.workflow_steps = (heuristicScores.workflow_steps || 0) + ind.weight;
132
+ break;
133
+ case "multiple_and":
134
+ case "many_acceptance_criteria":
135
+ if (!excluded.has("workflow_steps")) heuristicScores.workflow_steps = (heuristicScores.workflow_steps || 0) + ind.weight;
136
+ if (!excluded.has("data_entity")) heuristicScores.data_entity = (heuristicScores.data_entity || 0) + ind.weight;
137
+ break;
138
+ case "multiple_benefits":
139
+ if (!excluded.has("happy_sad_path")) heuristicScores.happy_sad_path = (heuristicScores.happy_sad_path || 0) + ind.weight;
140
+ if (!excluded.has("interface_boundary")) heuristicScores.interface_boundary = (heuristicScores.interface_boundary || 0) + ind.weight;
141
+ break;
142
+ default:
143
+ break;
144
+ }
145
+ }
146
+
147
+ // Pick highest scoring non-excluded heuristic
148
+ let best = null;
149
+ let bestScore = 0;
150
+ for (const [key, score] of Object.entries(heuristicScores)) {
151
+ if (score > bestScore) {
152
+ bestScore = score;
153
+ best = key;
154
+ }
155
+ }
156
+
157
+ return best;
158
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * HU Splitting Generator.
3
+ * Uses an AI agent to split a HU based on a selected heuristic,
4
+ * then formats proposals for FDE confirmation.
5
+ */
6
+
7
+ import { createAgent } from "../agents/index.js";
8
+ import { extractFirstJson } from "../utils/json-extract.js";
9
+ import { HEURISTIC_DESCRIPTIONS } from "./splitting-detector.js";
10
+
11
+ const SUBAGENT_PREAMBLE = [
12
+ "IMPORTANT: You are running as a Karajan sub-agent.",
13
+ "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
14
+ "Do NOT use any MCP tools. Focus only on splitting this HU."
15
+ ].join(" ");
16
+
17
+ /**
18
+ * Build the prompt for the AI agent that will split the HU.
19
+ * @param {{id: string, text: string}} hu - The HU to split.
20
+ * @param {string} heuristic - The heuristic key to apply.
21
+ * @returns {string} The assembled prompt.
22
+ */
23
+ export function buildSplitPrompt(hu, heuristic) {
24
+ const heuristicDesc = HEURISTIC_DESCRIPTIONS[heuristic] || heuristic;
25
+ const baseId = hu.id || "HU-XXX";
26
+
27
+ const sections = [SUBAGENT_PREAMBLE];
28
+
29
+ sections.push("## Task: Split User Story");
30
+ sections.push(
31
+ "You are a senior product owner. Split the following user story into smaller, independent sub-HUs.",
32
+ `Apply this splitting heuristic: **${heuristicDesc}**`
33
+ );
34
+
35
+ sections.push(`## Original User Story (${baseId})\n${hu.text}`);
36
+
37
+ sections.push(
38
+ "## Output Requirements",
39
+ "Return a single valid JSON object and nothing else.",
40
+ `JSON schema: {"subHUs":[{"id":string,"title":string,"text":string,"acceptanceCriteria":[string],"blocked_by":[string]}],"heuristic":string,"reason":string}`,
41
+ "Rules:",
42
+ `- Each sub-HU id must follow the pattern: ${baseId}-A, ${baseId}-B, ${baseId}-C, etc.`,
43
+ "- text must follow role/goal/benefit format: 'As a [role], I want to [goal], so that [benefit]'",
44
+ "- acceptanceCriteria must be concrete, testable statements",
45
+ "- blocked_by: first sub-HU has empty array, each subsequent one is blocked by the previous (sequential chain)",
46
+ "- Generate between 2 and 5 sub-HUs",
47
+ `- heuristic must be "${heuristic}"`,
48
+ "- reason must explain why this heuristic was applied"
49
+ );
50
+
51
+ return sections.join("\n\n");
52
+ }
53
+
54
+ /**
55
+ * Parse the AI agent's split output into a structured proposal.
56
+ * @param {string} raw - Raw agent output text.
57
+ * @returns {{subHUs: Array, heuristic: string, reason: string}|null}
58
+ */
59
+ export function parseSplitOutput(raw) {
60
+ const parsed = extractFirstJson(raw);
61
+ if (!parsed || !Array.isArray(parsed.subHUs) || parsed.subHUs.length < 2) return null;
62
+
63
+ const subHUs = parsed.subHUs
64
+ .filter(s => s && s.id && s.title && s.text)
65
+ .map(s => ({
66
+ id: s.id,
67
+ title: s.title,
68
+ text: s.text,
69
+ acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
70
+ blocked_by: Array.isArray(s.blocked_by) ? s.blocked_by : []
71
+ }));
72
+
73
+ if (subHUs.length < 2) return null;
74
+
75
+ return {
76
+ subHUs,
77
+ heuristic: parsed.heuristic || "unknown",
78
+ reason: parsed.reason || ""
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Use an AI agent to generate a split proposal for a HU based on a heuristic.
84
+ * @param {{id: string, text: string}} hu - The HU to split.
85
+ * @param {string} heuristic - The heuristic key to apply.
86
+ * @param {object} config - Karajan config object.
87
+ * @param {object} logger - Logger instance.
88
+ * @returns {Promise<{subHUs: Array, heuristic: string, reason: string}|null>}
89
+ */
90
+ export async function generateSplitProposal(hu, heuristic, config, logger) {
91
+ const provider = config?.roles?.hu_reviewer?.provider
92
+ || config?.roles?.coder?.provider
93
+ || "claude";
94
+
95
+ const agent = createAgent(provider, config, logger);
96
+ const prompt = buildSplitPrompt(hu, heuristic);
97
+
98
+ let result;
99
+ try {
100
+ result = await agent.runTask({ prompt, role: "hu-splitter" });
101
+ } catch (err) {
102
+ logger.warn(`HU split generation threw: ${err.message}`);
103
+ return null;
104
+ }
105
+
106
+ if (!result.ok) {
107
+ logger.warn(`HU split generation failed: ${result.error || "unknown"}`);
108
+ return null;
109
+ }
110
+
111
+ const proposal = parseSplitOutput(result.output);
112
+ if (!proposal) {
113
+ logger.warn("HU split generation returned unparseable output");
114
+ return null;
115
+ }
116
+
117
+ return proposal;
118
+ }
119
+
120
+ /**
121
+ * Format a split proposal as human-readable text for the FDE to review.
122
+ * @param {{subHUs: Array<{id: string, title: string, text: string, acceptanceCriteria?: string[], blocked_by?: string[]}>, heuristic: string, reason: string}} proposal
123
+ * @returns {string}
124
+ */
125
+ export function formatSplitProposalForFDE(proposal) {
126
+ const lines = [];
127
+ const heuristicDesc = HEURISTIC_DESCRIPTIONS[proposal.heuristic] || proposal.heuristic;
128
+
129
+ lines.push(`Heuristic: ${heuristicDesc}`);
130
+ lines.push(`Reason: ${proposal.reason}`);
131
+ lines.push("");
132
+
133
+ for (const sub of proposal.subHUs) {
134
+ lines.push(`--- ${sub.id}: ${sub.title} ---`);
135
+ lines.push(sub.text);
136
+ if (sub.acceptanceCriteria && sub.acceptanceCriteria.length > 0) {
137
+ lines.push("Acceptance Criteria:");
138
+ for (const ac of sub.acceptanceCriteria) {
139
+ lines.push(` - ${ac}`);
140
+ }
141
+ }
142
+ if (sub.blocked_by && sub.blocked_by.length > 0) {
143
+ lines.push(`Depends on: ${sub.blocked_by.join(", ")}`);
144
+ }
145
+ lines.push("");
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
150
+
151
+ /**
152
+ * Set blocked_by for each sub-HU: first inherits original's dependencies,
153
+ * each subsequent is blocked by its predecessor (sequential chain).
154
+ * @param {Array<{id: string, blocked_by?: string[]}>} subHUs - The sub-HUs to update.
155
+ * @param {{blocked_by?: string[]}} originalHu - The original HU with its dependencies.
156
+ * @returns {Array<{id: string, blocked_by: string[]}>} Updated sub-HUs with correct dependencies.
157
+ */
158
+ export function buildSplitDependencies(subHUs, originalHu) {
159
+ if (!subHUs || subHUs.length === 0) return [];
160
+
161
+ const originalDeps = originalHu?.blocked_by || [];
162
+
163
+ return subHUs.map((sub, idx) => {
164
+ const blockedBy = idx === 0
165
+ ? [...originalDeps]
166
+ : [subHUs[idx - 1].id];
167
+
168
+ return { ...sub, blocked_by: blockedBy };
169
+ });
170
+ }
package/src/hu/store.js CHANGED
@@ -90,7 +90,9 @@ export function updateStoryStatus(batch, storyId, status, extra = {}) {
90
90
  const story = batch.stories.find(s => s.id === storyId);
91
91
  if (!story) throw new Error(`Story ${storyId} not found`);
92
92
  story.status = status;
93
- story.updated_at = new Date().toISOString();
93
+ const now = new Date().toISOString();
94
+ story.statusChangedAt = now;
95
+ story.updated_at = now;
94
96
  Object.assign(story, extra);
95
97
  return story;
96
98
  }
@@ -210,6 +212,26 @@ export async function createHistoryRecord(sessionId, { task, result, approved, s
210
212
  return batch;
211
213
  }
212
214
 
215
+ /* ── Splitting metadata ──────────────────────────────────────────── */
216
+
217
+ /**
218
+ * Add splitting metadata to a story within a batch.
219
+ * Used to track the relationship between an original HU and its sub-HUs
220
+ * after a split operation.
221
+ *
222
+ * @param {object} batch - The batch object.
223
+ * @param {string} huId - Story ID to annotate.
224
+ * @param {{ original_hu_id?: string, split_children?: string[], indicators_detected?: string[], heuristic_applied?: string, split_confirmed_by_fde?: boolean, validation?: object }} metadata - Splitting metadata.
225
+ * @returns {object} The updated story.
226
+ */
227
+ export function addSplittingMetadata(batch, huId, metadata) {
228
+ const story = batch.stories.find(s => s.id === huId);
229
+ if (!story) throw new Error(`Story ${huId} not found`);
230
+ story.splitting = { ...(story.splitting || {}), ...metadata };
231
+ story.updated_at = new Date().toISOString();
232
+ return story;
233
+ }
234
+
213
235
  /* ── Manual HU management ─────────────────────────────────────────── */
214
236
 
215
237
  /**
@@ -918,7 +918,32 @@ async function handleArchitect(a, server, extra) {
918
918
 
919
919
  async function handleAudit(a, server, extra) {
920
920
  await runBootstrapGate(server, a);
921
- return handleAuditDirect(a, server, extra);
921
+ const result = await handleAuditDirect(a, server, extra);
922
+
923
+ // Return compact summary for MCP (full details in session log)
924
+ if (result?.ok && result?.summary) {
925
+ const compact = {
926
+ ok: true,
927
+ overallHealth: result.summary?.overallHealth || result.summary,
928
+ totalFindings: result.summary?.totalFindings,
929
+ critical: result.summary?.critical,
930
+ high: result.summary?.high,
931
+ topRecommendations: (result.topRecommendations || []).slice(0, 5).map(r => ({
932
+ priority: r.priority,
933
+ dimension: r.dimension,
934
+ action: r.action,
935
+ impact: r.impact
936
+ })),
937
+ textSummary: result.textSummary || result.summary,
938
+ basalCost: result.basalCost ? {
939
+ totalLines: result.basalCost.totalLines,
940
+ totalFiles: result.basalCost.totalFiles,
941
+ dependencies: result.basalCost.dependencies
942
+ } : undefined
943
+ };
944
+ return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
945
+ }
946
+ return result;
922
947
  }
923
948
 
924
949
  async function handleHu(a, server) {
@@ -114,13 +114,30 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
114
114
 
115
115
  updateStoryStatus(batch, story.id, HU_STATUS.CODING);
116
116
  await saveHuBatch(batchSessionId, batch);
117
+ emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
118
+ message: `HU ${story.id} status → coding`,
119
+ detail: { huId: story.id, status: HU_STATUS.CODING, timestamp: new Date().toISOString() }
120
+ }));
117
121
 
118
122
  try {
119
123
  const iterResult = await runIterationFn(huTask);
120
124
  const approved = Boolean(iterResult?.approved);
121
125
 
126
+ // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
127
+ updateStoryStatus(batch, story.id, HU_STATUS.REVIEWING);
128
+ await saveHuBatch(batchSessionId, batch);
129
+ emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
130
+ message: `HU ${story.id} status → reviewing`,
131
+ detail: { huId: story.id, status: HU_STATUS.REVIEWING, timestamp: new Date().toISOString() }
132
+ }));
133
+
122
134
  if (approved) {
123
135
  updateStoryStatus(batch, story.id, HU_STATUS.DONE);
136
+ await saveHuBatch(batchSessionId, batch);
137
+ emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
138
+ message: `HU ${story.id} status → done`,
139
+ detail: { huId: story.id, status: HU_STATUS.DONE, timestamp: new Date().toISOString() }
140
+ }));
124
141
  emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
125
142
  status: "ok",
126
143
  message: `HU ${story.id} completed successfully`,
@@ -129,6 +146,11 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
129
146
  return { huId: story.id, approved: true, result: iterResult };
130
147
  } else {
131
148
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
149
+ await saveHuBatch(batchSessionId, batch);
150
+ emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
151
+ message: `HU ${story.id} status → failed`,
152
+ detail: { huId: story.id, status: HU_STATUS.FAILED, timestamp: new Date().toISOString() }
153
+ }));
132
154
  emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
133
155
  status: "fail",
134
156
  message: `HU ${story.id} failed`,
@@ -138,6 +160,11 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
138
160
  }
139
161
  } catch (err) {
140
162
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
163
+ await saveHuBatch(batchSessionId, batch);
164
+ emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
165
+ message: `HU ${story.id} status → failed`,
166
+ detail: { huId: story.id, status: HU_STATUS.FAILED, timestamp: new Date().toISOString() }
167
+ }));
141
168
  emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
142
169
  status: "fail",
143
170
  message: `HU ${story.id} threw: ${err.message}`,
@@ -14,6 +14,8 @@ import { selectModelsForRoles } from "../utils/model-selector.js";
14
14
  import { createStallDetector } from "../utils/stall-detector.js";
15
15
  import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../hu/store.js";
16
16
  import { topologicalSort } from "../hu/graph.js";
17
+ import { detectIndicators, selectHeuristic } from "../hu/splitting-detector.js";
18
+ import { generateSplitProposal, formatSplitProposalForFDE, buildSplitDependencies } from "../hu/splitting-generator.js";
17
19
 
18
20
  const ROLE_NAMES = ["planner", "researcher", "architect", "refactorer", "reviewer", "tester", "security", "impeccable"];
19
21
 
@@ -679,6 +681,84 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
679
681
  batch = await createHuBatch(batchSessionId, stories);
680
682
  }
681
683
 
684
+ // --- Splitting detection: check each pending HU for split indicators before 6D evaluation ---
685
+ const storiesToSplit = [...batch.stories].filter(s => s.status === "pending");
686
+ for (const story of storiesToSplit) {
687
+ const indicators = detectIndicators(story.original.text);
688
+ if (indicators.length === 0) continue;
689
+
690
+ let heuristic = selectHeuristic(indicators);
691
+ if (!heuristic) continue;
692
+
693
+ const triedHeuristics = [];
694
+ let splitAccepted = false;
695
+
696
+ while (heuristic && !splitAccepted) {
697
+ const proposal = await generateSplitProposal(
698
+ { id: story.id, text: story.original.text },
699
+ heuristic, config, logger
700
+ );
701
+
702
+ if (!proposal) {
703
+ triedHeuristics.push(heuristic);
704
+ heuristic = selectHeuristic(indicators, triedHeuristics);
705
+ continue;
706
+ }
707
+
708
+ if (!askQuestion) {
709
+ // Autonomous mode: auto-accept the split
710
+ const updatedSubHUs = buildSplitDependencies(proposal.subHUs, story);
711
+ const storyIndex = batch.stories.findIndex(s => s.id === story.id);
712
+ const newStories = updatedSubHUs.map(sub => ({
713
+ id: sub.id, status: "pending",
714
+ original: { text: `${sub.title}\n\n${sub.text}\n\nAcceptance Criteria:\n${(sub.acceptanceCriteria || []).map(ac => `- ${ac}`).join("\n")}` },
715
+ blocked_by: sub.blocked_by || [], certified: null, quality: null,
716
+ context_requests: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()
717
+ }));
718
+ batch.stories.splice(storyIndex, 1, ...newStories);
719
+ splitAccepted = true;
720
+ emitProgress(emitter, makeEvent("hu-reviewer:split-accepted", { ...eventBase, stage: "hu-reviewer" }, {
721
+ message: `Auto-accepted split of ${story.id} into ${updatedSubHUs.length} sub-HUs`,
722
+ detail: { originalId: story.id, subHUs: updatedSubHUs.map(s => s.id), heuristic }
723
+ }));
724
+ } else {
725
+ // Interactive mode: ask FDE for confirmation
726
+ const formatted = formatSplitProposalForFDE(proposal);
727
+ const question = `Split proposed for ${story.id}:\n${formatted}\nAccept? (yes/no/try another)`;
728
+ emitProgress(emitter, makeEvent("hu-reviewer:split-proposal", { ...eventBase, stage: "hu-reviewer" }, {
729
+ message: `Split proposal for ${story.id} using heuristic: ${heuristic}`,
730
+ detail: { originalId: story.id, heuristic, subHUCount: proposal.subHUs.length }
731
+ }));
732
+ const answer = await askQuestion(question, { iteration: 0, stage: "hu-reviewer" });
733
+ if (!answer || answer.toLowerCase().startsWith("yes")) {
734
+ const updatedSubHUs = buildSplitDependencies(proposal.subHUs, story);
735
+ const storyIndex = batch.stories.findIndex(s => s.id === story.id);
736
+ const newStories = updatedSubHUs.map(sub => ({
737
+ id: sub.id, status: "pending",
738
+ original: { text: `${sub.title}\n\n${sub.text}\n\nAcceptance Criteria:\n${(sub.acceptanceCriteria || []).map(ac => `- ${ac}`).join("\n")}` },
739
+ blocked_by: sub.blocked_by || [], certified: null, quality: null,
740
+ context_requests: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()
741
+ }));
742
+ batch.stories.splice(storyIndex, 1, ...newStories);
743
+ splitAccepted = true;
744
+ emitProgress(emitter, makeEvent("hu-reviewer:split-accepted", { ...eventBase, stage: "hu-reviewer" }, {
745
+ message: `FDE accepted split of ${story.id} into ${updatedSubHUs.length} sub-HUs`,
746
+ detail: { originalId: story.id, subHUs: updatedSubHUs.map(s => s.id), heuristic }
747
+ }));
748
+ } else if (answer.toLowerCase().includes("try") || answer.toLowerCase().startsWith("no")) {
749
+ triedHeuristics.push(heuristic);
750
+ heuristic = selectHeuristic(indicators, triedHeuristics);
751
+ if (!heuristic) {
752
+ updateStoryStatus(batch, story.id, "needs_context");
753
+ }
754
+ continue;
755
+ }
756
+ }
757
+ break;
758
+ }
759
+ await saveHuBatch(batchSessionId, batch);
760
+ }
761
+
682
762
  // --- Evaluate loop (re-evaluate entire batch until all certified or needs_context with no askQuestion) ---
683
763
  const huReviewerOnOutput = ({ stream, line }) => {
684
764
  emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "hu-reviewer" }, {
@@ -523,6 +523,10 @@ async function becariaIncrementalPush({ config, session, gitCtx, task, logger, r
523
523
  const pushResult = await incrementalPush({ gitCtx, task, logger, session });
524
524
  if (!pushResult) return;
525
525
 
526
+ // Accumulate commits for PG card lifecycle tracking
527
+ const { accumulateCommit } = await import("./planning-game/pipeline-adapter.js");
528
+ for (const c of pushResult.commits) accumulateCommit(session, c);
529
+
526
530
  session.becaria_commits = [...(session.becaria_commits ?? []), ...pushResult.commits];
527
531
  await saveSession(session);
528
532
 
@@ -539,6 +543,10 @@ async function becariaCreateEarlyPr({ config, session, emitter, eventBase, gitCt
539
543
  const earlyPr = await earlyPrCreation({ gitCtx, task, logger, session, stageResults });
540
544
  if (!earlyPr) return;
541
545
 
546
+ // Accumulate commits for PG card lifecycle tracking
547
+ const { accumulateCommit } = await import("./planning-game/pipeline-adapter.js");
548
+ for (const c of earlyPr.commits) accumulateCommit(session, c);
549
+
542
550
  session.becaria_pr_number = earlyPr.prNumber;
543
551
  session.becaria_pr_url = earlyPr.prUrl;
544
552
  session.becaria_commits = earlyPr.commits;
@@ -740,6 +748,13 @@ async function handlePostLoopStages({ config, session, emitter, eventBase, coder
740
748
 
741
749
  async function finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i }) {
742
750
  const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults });
751
+
752
+ // Accumulate final commits for PG card lifecycle tracking
753
+ if (gitResult?.commits?.length) {
754
+ const { accumulateCommit } = await import("./planning-game/pipeline-adapter.js");
755
+ for (const c of gitResult.commits) accumulateCommit(session, c);
756
+ }
757
+
743
758
  if (stageResults.planner?.ok) {
744
759
  stageResults.planner.completedSteps = [...(stageResults.planner.steps ?? [])];
745
760
  }
@@ -94,6 +94,7 @@ export async function handlePgDecomposition({ triageResult, pgTaskId, pgProject,
94
94
 
95
95
  /**
96
96
  * Mark PG card as "To Validate" on approved session completion.
97
+ * Merges commits from gitResult and session.pg_commits (accumulated during pipeline).
97
98
  */
98
99
  export async function markPgCardToValidate({ pgCard, pgProject, config, session, gitResult, logger }) {
99
100
  if (!pgCard || !pgProject) return;
@@ -101,9 +102,21 @@ export async function markPgCardToValidate({ pgCard, pgProject, config, session,
101
102
  try {
102
103
  const { updateCard } = await import("./client.js");
103
104
  const { buildCompletionUpdates } = await import("./adapter.js");
105
+
106
+ // Merge commits: session.pg_commits (accumulated) + gitResult.commits (final)
107
+ const accumulatedCommits = session.pg_commits || [];
108
+ const finalCommits = gitResult?.commits || [];
109
+ const seenHashes = new Set(accumulatedCommits.map(c => c.hash));
110
+ const mergedCommits = [...accumulatedCommits];
111
+ for (const c of finalCommits) {
112
+ if (!seenHashes.has(c.hash)) {
113
+ mergedCommits.push(c);
114
+ }
115
+ }
116
+
104
117
  const pgUpdates = buildCompletionUpdates({
105
118
  approved: true,
106
- commits: gitResult?.commits || [],
119
+ commits: mergedCommits,
107
120
  startDate: session.pg_card?.startDate || session.created_at,
108
121
  codeveloper: config.planning_game?.codeveloper || null
109
122
  });
@@ -156,6 +169,25 @@ export function buildHuStoriesFromPgCard(pgCard) {
156
169
  return [{ id: storyId, text: storyText }];
157
170
  }
158
171
 
172
+ /**
173
+ * Accumulate a commit into the session's pg_commits array.
174
+ * Called after each successful commitAll during the iteration loop.
175
+ * Best-effort: never throws.
176
+ *
177
+ * @param {object} session - Pipeline session (mutated)
178
+ * @param {{ hash: string, message: string, date?: string, author?: string }} commitInfo
179
+ */
180
+ export function accumulateCommit(session, commitInfo) {
181
+ if (!session || !commitInfo?.hash) return;
182
+ if (!session.pg_commits) session.pg_commits = [];
183
+ session.pg_commits.push({
184
+ hash: commitInfo.hash,
185
+ message: commitInfo.message || "",
186
+ date: commitInfo.date || new Date().toISOString(),
187
+ author: commitInfo.author || "BecarIA"
188
+ });
189
+ }
190
+
159
191
  // --- Internal helpers ---
160
192
 
161
193
  async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {