karajan-code 2.14.3 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude-agent.js +2 -2
  4. package/src/agents/codex-agent.js +1 -1
  5. package/src/audit/agent-readiness.js +1 -1
  6. package/src/audit/basal-cost.js +4 -4
  7. package/src/audit/webperf-input.js +1 -1
  8. package/src/brain/agent-error-classifier.js +101 -0
  9. package/src/brain/standby-scheduler.js +92 -0
  10. package/src/brain/standby-store.js +129 -0
  11. package/src/brain/with-brain-recovery.js +178 -0
  12. package/src/canvas/parse-md.js +1 -1
  13. package/src/cli/register-plan.js +29 -0
  14. package/src/cli/register-standby.js +26 -0
  15. package/src/cli.js +2 -0
  16. package/src/commands/architect.js +6 -2
  17. package/src/commands/audit.js +1 -1
  18. package/src/commands/discover.js +6 -2
  19. package/src/commands/init.js +47 -0
  20. package/src/commands/plan/_shared.js +19 -4
  21. package/src/commands/plan/fix.js +99 -0
  22. package/src/commands/plan/generate.js +112 -27
  23. package/src/commands/plan/index.js +2 -5
  24. package/src/commands/plan/migrate-result.js +41 -0
  25. package/src/commands/plan.js +2 -1
  26. package/src/commands/researcher.js +6 -2
  27. package/src/commands/run.js +33 -1
  28. package/src/commands/standby.js +114 -0
  29. package/src/commands/triage.js +6 -2
  30. package/src/commands/webperf.js +1 -1
  31. package/src/config/schema.js +40 -0
  32. package/src/domains/domain-synthesizer.js +1 -1
  33. package/src/git/hu-automation.js +2 -2
  34. package/src/git/hu-snapshot.js +105 -0
  35. package/src/guards/policy-resolver.js +38 -1
  36. package/src/hu/auto-generator.js +1 -1
  37. package/src/hu/lazy-planner.js +6 -1
  38. package/src/hu/model-router.js +108 -0
  39. package/src/hu/splitting-generator.js +6 -1
  40. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  41. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  42. package/src/orchestrator/repair/repair-detector.js +3 -3
  43. package/src/plan/adr-generator.js +5 -5
  44. package/src/plan/plan-executor.js +13 -4
  45. package/src/plan/plan-fixer.js +36 -4
  46. package/src/plan/plan-hu-ops.js +24 -1
  47. package/src/plan/plan-id.js +21 -1
  48. package/src/plan/plan-migrate-result.js +24 -0
  49. package/src/plan/plan-reviewer.js +40 -3
  50. package/src/plan/plan-schema.js +128 -1
  51. package/src/plan/plan-store.js +64 -9
  52. package/src/plan/plan-structural-pass.js +122 -0
  53. package/src/plan/plan-validation.js +82 -0
  54. package/src/plan/tests-synthesizer.js +8 -3
  55. package/src/prompts/planner.js +27 -1
  56. package/src/roles/agent-role.js +18 -3
  57. package/src/roles/perf-role.js +1 -1
  58. package/src/roles/planner-role.js +10 -3
  59. package/src/roles/solomon-role.js +10 -4
  60. package/src/session/journal/decisions-writer.js +1 -1
  61. package/src/session/journal/iteration-logger.js +1 -1
  62. package/src/session/journal/summary-writer.js +1 -1
  63. package/src/skills/addyosmani-catalog.js +1 -1
  64. package/src/skills/semantic-detector.js +17 -2
  65. package/src/skills/skills-cache.js +1 -1
  66. package/src/sonar/api.js +22 -4
  67. package/src/sonar/config-resolver.js +1 -1
  68. package/src/sonar/issue-filter.js +110 -0
  69. package/src/sonar/scanner.js +1 -1
  70. package/src/utils/cli-progress.js +58 -1
  71. package/src/utils/derive-project-name-from-cwd.js +3 -3
  72. package/src/utils/garbage-collector.js +85 -2
  73. package/src/utils/injection-guard.js +1 -1
  74. package/src/utils/messages.js +1 -1
  75. package/src/utils/run-registry.js +115 -0
@@ -189,23 +189,33 @@ function applyHuFilterIfRequested({ huBatch, flags, eventBase, emitter, logger }
189
189
  logger.warn(`--hu was empty after parsing — falling back to running every certified HU`);
190
190
  return;
191
191
  }
192
- const requestedSet = new Set(requested);
193
- const found = huBatch.stories.filter((s) => requestedSet.has(s.id));
194
- const missing = requested.filter((id) => !huBatch.stories.some((s) => s.id === id));
195
- if (missing.length > 0) {
196
- logger.warn(`--hu: ignored ${missing.length} unknown HU id(s): ${missing.join(", ")}`);
192
+ // Humanización IDs: resolver cada ref a un hu.id largo aceptando 3 formas:
193
+ // 1. `hu_<planId>_<NNN>` (id largo, exacto)
194
+ // 2. `INFRA-001` (short_id que el planner asignó)
195
+ // 3. `001` (sufijo numérico — busca un hu.id que termine en `_001`)
196
+ // El resolver es case-insensitive para short_id y sufijo.
197
+ const resolvedIds = new Set();
198
+ const unresolved = [];
199
+ for (const ref of requested) {
200
+ const id = resolveHuRef(ref, huBatch.stories);
201
+ if (id) resolvedIds.add(id);
202
+ else unresolved.push(ref);
203
+ }
204
+ const found = huBatch.stories.filter((s) => resolvedIds.has(s.id));
205
+ if (unresolved.length > 0) {
206
+ logger.warn(`--hu: ignored ${unresolved.length} unknown HU ref(s): ${unresolved.join(", ")}`);
197
207
  }
198
208
  if (found.length === 0) {
199
- throw new Error(`--hu: none of the requested HU id(s) exist in plan ${flags.plan}: ${requested.join(", ")}`);
209
+ throw new Error(`--hu: none of the requested HU ref(s) exist in plan ${flags.plan}: ${requested.join(", ")}`);
200
210
  }
201
211
  for (const story of huBatch.stories) {
202
- if (!requestedSet.has(story.id)) story.status = "done";
212
+ if (!resolvedIds.has(story.id)) story.status = "done";
203
213
  }
204
214
  huBatch.certified = found.length;
205
215
  logger.info(`Plan ${flags.plan}: --hu filter active — ${found.length}/${huBatch.total} HU(s) will run (${found.map((s) => s.id).join(", ")})`);
206
216
  emitProgress(emitter, makeEvent("plan:hu-filter", { ...eventBase, stage: "plan" }, {
207
217
  message: `--hu filter: ${found.length} HU(s) selected, ${huBatch.total - found.length} skipped`,
208
- detail: { planId: flags.plan, requested, ignored: missing, willRun: found.map((s) => s.id) }
218
+ detail: { planId: flags.plan, requested, ignored: unresolved, willRun: found.map((s) => s.id) }
209
219
  }));
210
220
  }
211
221
 
@@ -246,6 +256,35 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
246
256
  });
247
257
  }
248
258
 
259
+ /**
260
+ * Resuelve una referencia humana a HU al `id` canónico largo. Acepta:
261
+ * - id largo exacto: `hu_<planId>_<NNN>` → match directo.
262
+ * - short_id: `INFRA-001`, `AUTH-SIGNUP` → match case-insensitive contra hu.short_id.
263
+ * - sufijo numérico: `001` → match contra hu.id terminado en `_001`.
264
+ * Devuelve el hu.id largo o null si no resuelve.
265
+ *
266
+ * @param {string} ref
267
+ * @param {Array<{id: string, short_id?: string|null}>} hus
268
+ * @returns {string|null}
269
+ */
270
+ function resolveHuRef(ref, hus) {
271
+ if (!ref || !Array.isArray(hus)) return null;
272
+ // Match exacto contra id largo (camino caliente).
273
+ const exact = hus.find((s) => s.id === ref);
274
+ if (exact) return exact.id;
275
+ // Match case-insensitive contra short_id.
276
+ const lower = ref.toLowerCase();
277
+ const byShort = hus.find((s) => s.short_id && String(s.short_id).toLowerCase() === lower);
278
+ if (byShort) return byShort.id;
279
+ // Match por sufijo numérico (`001` → cualquier hu cuyo id termine en `_001`).
280
+ if (/^\d+$/.test(ref)) {
281
+ const padded = ref.padStart(3, "0");
282
+ const bySuffix = hus.find((s) => s.id.endsWith(`_${padded}`));
283
+ if (bySuffix) return bySuffix.id;
284
+ }
285
+ return null;
286
+ }
287
+
249
288
  /**
250
289
  * Build a single plannedTask string for the non-HU / fallback
251
290
  * iteration path. The HU sub-pipeline doesn't read this; only the
@@ -31,6 +31,7 @@ import { classifyFailure, createFailureTracker } from "../repair/repair-detector
31
31
  import { runRepair } from "../repair/repair-runner.js";
32
32
  import { writeHistoryRecord } from "./post-loop.js";
33
33
  import { setReviewerFeedback } from "../../session/mutators.js";
34
+ import { createHuSnapshot } from "../../git/hu-snapshot.js";
34
35
 
35
36
  /**
36
37
  * @param {object} args
@@ -91,19 +92,44 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
91
92
  ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
92
93
  ctx.brainCtx.verificationTracker = fresh.verificationTracker;
93
94
  }
94
- // Apply per-HU policies based on task_type (infra skips reviewer/sonar/tdd)
95
- const { applyPolicies } = await import("../../guards/policy-resolver.js");
96
- const huPolicies = applyPolicies({ taskType: story.task_type, policies: ctx.config.policies });
95
+ // Apply per-HU policies based on task_type (infra skips reviewer/sonar/tdd).
96
+ // KJC-TSK-0400: `effectiveTaskType` mira primero story.task_type y, si
97
+ // no es válido, infiere del prefijo del title ([SPIKE], [DOC]…). Así
98
+ // un HU con title "[SPIKE] Decidir auth" entra automáticamente a las
99
+ // policies de spike (sin Sonar/TDD/tests).
100
+ const { applyPolicies, effectiveTaskType } = await import("../../guards/policy-resolver.js");
101
+ const resolvedTaskType = effectiveTaskType(story);
102
+ if (resolvedTaskType !== story.task_type) {
103
+ logger.info(`HU ${story.id}: task_type inferido del title → ${resolvedTaskType} (era ${story.task_type || "null"})`);
104
+ }
105
+ const huPolicies = applyPolicies({ taskType: resolvedTaskType, policies: ctx.config.policies });
97
106
  const savedFlags = { ...ctx.pipelineFlags };
98
107
  if (!huPolicies.reviewer) ctx.pipelineFlags.reviewerEnabled = false;
99
108
  if (!huPolicies.tdd) ctx.config.development = { ...ctx.config.development, methodology: "standard", require_test_changes: false };
100
109
  if (!huPolicies.sonar) ctx.config.sonarqube = { ...ctx.config.sonarqube, enabled: false };
101
110
  if (!huPolicies.testsRequired) ctx.pipelineFlags.testerEnabled = false;
102
- logger.info(`HU ${story.id} (${story.task_type}): policies → reviewer=${huPolicies.reviewer}, tdd=${huPolicies.tdd}, sonar=${huPolicies.sonar}, tests=${huPolicies.testsRequired}`);
111
+ logger.info(`HU ${story.id} (${resolvedTaskType}): policies → reviewer=${huPolicies.reviewer}, tdd=${huPolicies.tdd}, sonar=${huPolicies.sonar}, tests=${huPolicies.testsRequired}`);
103
112
 
104
113
  const branchName = await prepareHuBranch({ story, huBranches, config: ctx.config, logger });
105
114
  const projectDir = ctx.config.projectDir || process.cwd();
106
115
 
116
+ // KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
117
+ // coder. Si el usuario hace Undo después, restaura este SHA. El
118
+ // ref se crea sobre el HEAD ya checkouteado a la rama de la HU,
119
+ // así que apunta al estado limpio sobre el que el coder empieza.
120
+ // Idempotente — si la HU ya tenía snapshot, se sobreescribe.
121
+ try {
122
+ const snap = await createHuSnapshot({ projectDir, huId: story.id });
123
+ if (snap.ok) {
124
+ story.outcome = { ...(story.outcome || {}), snapshot_sha: snap.sha, snapshot_ref: snap.ref };
125
+ logger.info(`HU ${story.id}: snapshot ${snap.sha.slice(0, 7)} (ref ${snap.ref})`);
126
+ } else {
127
+ logger.warn(`HU ${story.id}: snapshot falló (non-blocking): ${snap.error}`);
128
+ }
129
+ } catch (err) {
130
+ logger.warn(`HU ${story.id}: snapshot threw (non-blocking): ${err.message}`);
131
+ }
132
+
107
133
  // If HU has acceptance_tests, Brain runs them as the gate instead of
108
134
  // the standard reviewer/tester pipeline. This is the radical fix:
109
135
  // concrete executable tests replace subjective reviewer opinions.
@@ -50,9 +50,9 @@ const INFEASIBLE_SIGNATURES = [
50
50
  export function failureSignature(cmd, errorOutput) {
51
51
  const cmdNorm = String(cmd || "").trim().slice(0, 200);
52
52
  const errNorm = String(errorOutput || "")
53
- .replace(/\s+/g, " ") // collapse whitespace
54
- .replace(/\d+/g, "N") // numbers tend to drift; line numbers, counts
55
- .replace(/\/[^\s]+/g, "/PATH") // absolute paths drift across runs
53
+ .replaceAll(/\s+/g, " ") // collapse whitespace
54
+ .replaceAll(/\d+/g, "N") // numbers tend to drift; line numbers, counts
55
+ .replaceAll(/\/[^\s]+/g, "/PATH") // absolute paths drift across runs
56
56
  .trim()
57
57
  .slice(0, 300);
58
58
  return `${cmdNorm}|||${errNorm}`;
@@ -38,11 +38,11 @@ function slugifyTitle(title) {
38
38
  // Decompose accents (NFD) then strip combining marks so "versión"
39
39
  // → "version", keeping the ADR id ASCII-only and filesystem-safe.
40
40
  .normalize("NFD")
41
- .replace(/[̀-ͯ]/g, "")
42
- .replace(/[^a-z0-9\s-]/g, "")
43
- .replace(/\s+/g, "-")
44
- .replace(/-+/g, "-")
45
- .replace(/^-|-$/g, "")
41
+ .replaceAll(/[̀-ͯ]/g, "")
42
+ .replaceAll(/[^a-z0-9\s-]/g, "")
43
+ .replaceAll(/\s+/g, "-")
44
+ .replaceAll(/-+/g, "-")
45
+ .replaceAll(/^-|-$/g, "")
46
46
  .slice(0, 80) || "untitled";
47
47
  }
48
48
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { isPlanV2 } from "./plan-schema.js";
8
- import { updateHuStatus, computePlanOutcome, setPlanOutcome } from "./plan-hu-ops.js";
8
+ import { updateHuStatus, updateHu, computePlanOutcome, setPlanOutcome } from "./plan-hu-ops.js";
9
9
 
10
10
  /**
11
11
  * Convert a v2 plan's HUs into the stageResults.huReviewer format
@@ -63,9 +63,18 @@ export function planToHuBatch(plan) {
63
63
  export function syncResultsToPlan(plan, subPipelineResult) {
64
64
  if (!isPlanV2(plan)) return;
65
65
 
66
+ // KJC-TSK-0403: status/result ortogonal. HU fallida queda en status=pending
67
+ // con result=fail (no status=failed) para que el usuario pueda relanzarla
68
+ // con el contexto del fallo previo. El plan se considera failed si CUALQUIER
69
+ // HU tiene result=fail.
66
70
  for (const r of subPipelineResult.results || []) {
67
- const status = r.approved ? "done" : "failed";
68
- updateHuStatus(plan, r.huId, status);
71
+ if (r.approved) {
72
+ updateHuStatus(plan, r.huId, "done");
73
+ updateHu(plan, r.huId, { result: "pass" });
74
+ } else {
75
+ updateHuStatus(plan, r.huId, "pending");
76
+ updateHu(plan, r.huId, { result: "fail" });
77
+ }
69
78
  }
70
79
 
71
80
  for (const blockedId of subPipelineResult.blockedIds || []) {
@@ -73,7 +82,7 @@ export function syncResultsToPlan(plan, subPipelineResult) {
73
82
  }
74
83
 
75
84
  const allDone = plan.hus.every(h => h.status === "done");
76
- const anyFailed = plan.hus.some(h => h.status === "failed");
85
+ const anyFailed = plan.hus.some(h => h.result === "fail");
77
86
  plan.status = allDone ? "done" : anyFailed ? "failed" : "running";
78
87
  plan.updatedAt = new Date().toISOString();
79
88
 
@@ -12,6 +12,7 @@
12
12
  import { extractFirstJson } from "../utils/json-extract.js";
13
13
  import { addHu, removeHu } from "./plan-hu-ops.js";
14
14
  import { normaliseAcceptanceTests } from "./plan-schema.js";
15
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
15
16
 
16
17
  export function buildFixerPrompt({ task, hus, findings }) {
17
18
  const huSummary = hus.map((h) => {
@@ -90,14 +91,18 @@ function normalisePatch(raw) {
90
91
  return out;
91
92
  }
92
93
 
93
- export async function applyReviewerFeedback({ agent, task, hus, findings, onOutput, silenceTimeoutMs, timeoutMs }) {
94
+ export async function applyReviewerFeedback({ agent, task, hus, findings, onOutput, silenceTimeoutMs, timeoutMs, emitter, eventBase, logger }) {
94
95
  const prompt = buildFixerPrompt({ task, hus, findings });
95
96
  const runArgs = { prompt, role: "planner" };
96
97
  if (onOutput) runArgs.onOutput = onOutput;
97
98
  if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
98
99
  if (timeoutMs) runArgs.timeoutMs = timeoutMs;
99
- const result = await agent.runTask(runArgs);
100
- if (!result.ok) return { ok: false, error: result.error || "fixer agent failed" };
100
+ // KJC-TSK-0413: Brain Recovery — fix loop ya no muere silencioso.
101
+ const result = await withBrainRecovery({
102
+ agent, taskArgs: runArgs, role: "plan-fixer",
103
+ emitter, eventBase, logger,
104
+ });
105
+ if (!result.ok) return { ok: false, error: result.error || `fixer agent failed (${result.recovery?.class || "unknown"})`, recovery: result.recovery, action: result.action };
101
106
  const parsed = extractFirstJson(result.output || "");
102
107
  if (!parsed || typeof parsed !== "object") {
103
108
  return { ok: false, error: "fixer returned no JSON object" };
@@ -110,16 +115,43 @@ export function applyFixerPatch(plan, patch) {
110
115
  for (const huId of patch.deletions || []) {
111
116
  if (removeHu(plan, huId)) counts.deleted += 1;
112
117
  }
118
+ // KJC-BUG-0053: las HUs añadidas por el fixer dejaban `short_id` y
119
+ // `blocked_by` vacíos porque addHu no recibía esos campos. Resultado:
120
+ // HUs huérfanas con id largo críptico y sin orden de ejecución.
121
+ //
122
+ // Fix: (1) pasar `short_id: a.id` (el id simbólico que el fixer LLM
123
+ // asigna a cada addition). (2) resolver `a.dependencies` simbólicas
124
+ // → hu.id largos contra el plan ya existente + el mapa de símbolos
125
+ // recién añadidos. La resolución necesita 2 pasadas (primera crea
126
+ // las HUs y registra el mapping, segunda asigna deps) para que
127
+ // additions que se referencian entre sí funcionen.
128
+ const symbolicToHuId = new Map();
129
+ for (const hu of plan.hus) {
130
+ if (hu.short_id) symbolicToHuId.set(hu.short_id, hu.id);
131
+ }
132
+ const addedHus = []; // {hu, depsSymbolic}
113
133
  for (const a of patch.additions || []) {
114
- addHu(plan, {
134
+ const symbolicId = typeof a.id === "string" && a.id.trim() ? a.id.trim() : null;
135
+ const hu = addHu(plan, {
115
136
  title: (a.description || "").slice(0, 80),
116
137
  scope: a.description,
138
+ short_id: symbolicId,
117
139
  spec_section: a.spec_section,
118
140
  acceptance_tests: a.acceptance_tests,
119
141
  reuse: a.reuse || [],
120
142
  });
143
+ if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
144
+ const depsSymbolic = Array.isArray(a.dependencies) ? a.dependencies : [];
145
+ addedHus.push({ hu, depsSymbolic });
121
146
  counts.added += 1;
122
147
  }
148
+ // Pasada 2: resolver deps simbólicas de las additions a ids largos.
149
+ for (const { hu, depsSymbolic } of addedHus) {
150
+ const resolved = depsSymbolic
151
+ .map((s) => typeof s === "string" ? symbolicToHuId.get(s.trim()) : null)
152
+ .filter(Boolean);
153
+ if (resolved.length > 0) hu.blocked_by = resolved;
154
+ }
123
155
  const knownIds = new Set(plan.hus.map(h => h.id));
124
156
  for (const { huId, on } of patch.deps_to_add || []) {
125
157
  if (!knownIds.has(huId) || !knownIds.has(on)) continue;
@@ -33,6 +33,18 @@ export function addHu(plan, huData) {
33
33
  title: huData.title,
34
34
  task_type: huData.task_type || "sw",
35
35
  status: "pending",
36
+ // KJC-TSK-0394: result ortogonal al status (último resultado conocido
37
+ // de la ejecución; null = nunca ejecutada). Se actualiza cuando el
38
+ // pipeline termina con pass / fail / partial. Nombre `result` (no
39
+ // `outcome`) porque `outcome` ya está en uso como blob JSON con
40
+ // iterations/duration/commits del run.
41
+ result: huData.result ?? null,
42
+ // Humanización IDs: short_id es el id legible que el planner asignó
43
+ // al step (ej "INFRA-001", "AUTH-SIGNUP"). El `id` largo
44
+ // (`hu_<planId>_<NNN>`) sigue siendo la clave canónica, pero el
45
+ // CLI / board prefieren short_id para mostrar y aceptarlo como
46
+ // referencia en `--hu`. Null cuando el planner no lo emitió.
47
+ short_id: huData.short_id || null,
36
48
  blocked_by: huData.blocked_by || [],
37
49
  // KJC-BUG-0044 / P3: ids of OTHER HUs whose implementation this HU
38
50
  // piggy-backs on instead of reimplementing the same logic. Set by
@@ -48,6 +60,14 @@ export function addHu(plan, huData) {
48
60
  // re-read. Free-form string — typically "5.3", "§5.3 Initial
49
61
  // Scope", or whatever the planner extracted from the source spec.
50
62
  spec_section: huData.spec_section || null,
63
+ // KJC-TSK-0405: coder_model y reviewer_model asignados por el
64
+ // triage según complexity. null = usar el del config global / role
65
+ // resolver. Independientes — el usuario puede overridearlos por HU
66
+ // desde el board sin tocar el resto del plan.
67
+ coder_model: huData.coder_model ?? null,
68
+ coder_provider: huData.coder_provider ?? null,
69
+ reviewer_model: huData.reviewer_model ?? null,
70
+ reviewer_provider: huData.reviewer_provider ?? null,
51
71
  createdAt: new Date().toISOString(),
52
72
  updatedAt: new Date().toISOString()
53
73
  };
@@ -83,7 +103,10 @@ export function removeHu(plan, huId) {
83
103
  export function updateHu(plan, huId, patch) {
84
104
  const hu = plan.hus.find(h => h.id === huId);
85
105
  if (!hu) return null;
86
- const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "reuse", "spec_section"];
106
+ // KJC-TSK-0405: coder_model y reviewer_model son override per-HU del
107
+ // routing automático del triage. Independientes — el usuario puede
108
+ // subir el reviewer sin tocar el coder, o viceversa.
109
+ const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "reuse", "spec_section", "result", "short_id", "coder_model", "reviewer_model", "coder_provider", "reviewer_provider"];
87
110
  for (const key of allowed) {
88
111
  if (patch[key] !== undefined) hu[key] = patch[key];
89
112
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export function generatePlanId() {
10
10
  const now = new Date();
11
- const ts = now.toISOString().replace(/[-:T]/g, "").replace(/\.\d+Z$/, "");
11
+ const ts = now.toISOString().replaceAll(/[-:T]/g, "").replace(/\.\d+Z$/, "");
12
12
  const rand = Math.random().toString(36).slice(2, 6);
13
13
  return `plan-${ts}-${rand}`;
14
14
  }
@@ -23,3 +23,23 @@ export function generatePlanId() {
23
23
  export function generateHuId(planId, seq) {
24
24
  return `hu_${planId}_${String(seq).padStart(3, "0")}`;
25
25
  }
26
+
27
+ /**
28
+ * Normalise a human-friendly name into a CLI-typeable alias.
29
+ * "Greta App" → "greta-app", "Linux Assistant Orchestrator" → "linux-assistant-orchestrator".
30
+ * Acotado a 60 chars para que no se vuelva tan ilegible como el planId.
31
+ *
32
+ * @param {string|null|undefined} name
33
+ * @returns {string|null} alias o null si name está vacío.
34
+ */
35
+ export function normaliseAlias(name) {
36
+ if (!name || typeof name !== "string") return null;
37
+ const slug = name
38
+ .normalize("NFKD")
39
+ .replaceAll(/[̀-ͯ]/g, "") // strip diacritics
40
+ .toLowerCase()
41
+ .replaceAll(/[^a-z0-9]+/g, "-") // non-alphanum → guion
42
+ .replaceAll(/^-+|-+$/g, "") // trim guiones extremos
43
+ .slice(0, 60);
44
+ return slug || null;
45
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * KJC-TSK-0394 step 4: migration helper para plan files existentes.
3
+ * Siembra `result` en las HUs vía inferResultFromLegacyStatus.
4
+ * Idempotente. NO toca `status` (eso será step 5).
5
+ */
6
+ import { inferResultFromLegacyStatus } from "./plan-schema.js";
7
+
8
+ /** Pura. Devuelve {plan: nuevo, changes: [...]}, sin mutar el input. */
9
+ export function migratePlanResult(plan) {
10
+ if (!plan || !Array.isArray(plan.hus)) return { plan, changes: [] };
11
+ const changes = [];
12
+ const hus = plan.hus.map((hu) => {
13
+ // Sembrar explícito (incluso null) marca el HU como migrado para
14
+ // pasadas posteriores. hasOwnProperty distingue "no migrado" de
15
+ // "migrado a null".
16
+ const already = Object.prototype.hasOwnProperty.call(hu, "result") && hu.result !== undefined;
17
+ if (already) return hu;
18
+ const inferred = inferResultFromLegacyStatus(hu);
19
+ changes.push({ huId: hu.id, result: inferred });
20
+ return { ...hu, result: inferred };
21
+ });
22
+ if (changes.length === 0) return { plan, changes };
23
+ return { plan: { ...plan, hus, updatedAt: new Date().toISOString() }, changes };
24
+ }
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { extractFirstJson } from "../utils/json-extract.js";
33
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
33
34
 
34
35
  /**
35
36
  * Build the focused review prompt. Exported for unit-testing the
@@ -113,7 +114,7 @@ export function buildReviewerPrompt({ task, hus }) {
113
114
  * @param {number} [args.timeoutMs]
114
115
  * @returns {Promise<{ ok: boolean, findings?: object, error?: string }>}
115
116
  */
116
- export async function reviewPlan({ agent, task, hus, onOutput, silenceTimeoutMs, timeoutMs }) {
117
+ export async function reviewPlan({ agent, task, hus, onOutput, silenceTimeoutMs, timeoutMs, emitter, eventBase, logger }) {
117
118
  if (!Array.isArray(hus) || hus.length === 0) {
118
119
  return { ok: true, findings: emptyFindings("Plan has no HUs to review.") };
119
120
  }
@@ -123,9 +124,15 @@ export async function reviewPlan({ agent, task, hus, onOutput, silenceTimeoutMs,
123
124
  if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
124
125
  if (timeoutMs) runArgs.timeoutMs = timeoutMs;
125
126
 
126
- const result = await agent.runTask(runArgs);
127
+ // KJC-TSK-0413: el caso real del 2026-05-16 (plan-fix iter 2 "failed
128
+ // 219.3s" sin clase) entraba aquí. Ahora Brain clasifica el fallo y
129
+ // decide retry/standby/hibernate/abort.
130
+ const result = await withBrainRecovery({
131
+ agent, taskArgs: runArgs, role: "plan-reviewer",
132
+ emitter, eventBase, logger,
133
+ });
127
134
  if (!result.ok) {
128
- return { ok: false, error: result.error || "reviewer agent failed" };
135
+ return { ok: false, error: result.error || `reviewer agent failed (${result.recovery?.class || "unknown"})`, recovery: result.recovery, action: result.action };
129
136
  }
130
137
  const parsed = extractFirstJson(result.output || "");
131
138
  if (!parsed || typeof parsed !== "object") {
@@ -197,3 +204,33 @@ export function findingsCount(findings) {
197
204
  // parallelisable_groups is informational, not an issue.
198
205
  );
199
206
  }
207
+
208
+ /**
209
+ * KJC-BUG-0054: count findings categorised por prioridad, para que
210
+ * el self-fix convergence guard pueda distinguir "regresión genuina"
211
+ * de "regresión por arreglar algo importante".
212
+ *
213
+ * Categorías:
214
+ * - priority: ciclos (order_issues con issue=='circular') + missing_hus.
215
+ * Estos NO pueden ignorarse — un ciclo deja el plan no ejecutable;
216
+ * una HU faltante es feature missing.
217
+ * - secondary: missing_dependencies + scope_overlaps + order_issues
218
+ * no-circulares. Importantes pero recuperables.
219
+ *
220
+ * @param {object} findings
221
+ * @returns {{ priority: number, secondary: number, total: number, cycles: number }}
222
+ */
223
+ export function findingsCountByType(findings) {
224
+ if (!findings) return { priority: 0, secondary: 0, total: 0, cycles: 0 };
225
+ const orderIssues = findings.order_issues || [];
226
+ const cycles = orderIssues.filter(
227
+ (i) => i && typeof i === "object" && i.issue === "circular"
228
+ ).length;
229
+ const orderNonCircular = orderIssues.length - cycles;
230
+ const missingHus = findings.missing_hus?.length || 0;
231
+ const missingDeps = findings.missing_dependencies?.length || 0;
232
+ const overlaps = findings.scope_overlaps?.length || 0;
233
+ const priority = cycles + missingHus;
234
+ const secondary = missingDeps + overlaps + orderNonCircular;
235
+ return { priority, secondary, total: priority + secondary, cycles };
236
+ }
@@ -7,10 +7,127 @@
7
7
  import { generatePlanId } from "./plan-id.js";
8
8
 
9
9
  const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
10
- const VALID_HU_STATUSES = new Set(["pending", "certified", "coding", "reviewing", "done", "failed", "blocked"]);
10
+ // KJC-TSK-0394 step 5: el modelo CANONICAL del lifecycle es
11
+ // {pending, running, done}. Los valores certified/coding/reviewing/
12
+ // failed/blocked/needs_context se mantienen como LEGACY (siguen
13
+ // aceptados por validatePlan para no romper plans existentes ni el
14
+ // runtime actual). El cleanup definitivo del runtime queda como
15
+ // follow-up; mientras tanto la UI puede usar canonicalStatus +
16
+ // effectiveResult para presentar el modelo de 3 estados sin tocar
17
+ // la persistencia.
18
+ //
19
+ // needs_context se PATCHeaba vía /api/stories/:id (dropdown libre del
20
+ // modal) sin que el enum lo aceptara → `kj plan validate` daba
21
+ // false-negative tras un set manual. Incluido aquí para alinear.
22
+ const VALID_HU_STATUSES = new Set([
23
+ // Canonical
24
+ "pending", "running", "done",
25
+ // Legacy
26
+ "certified", "coding", "reviewing", "failed", "blocked", "needs_context",
27
+ ]);
28
+ export const CANONICAL_HU_STATUSES = new Set(["pending", "running", "done"]);
29
+ // KJC-TSK-0394: nuevo campo `result` ortogonal a status. Reemplaza la
30
+ // sobrecarga semántica del status (certified=pass, failed=fail) por una
31
+ // distinción explícita entre lifecycle (status) y último resultado (result).
32
+ // Vacío `null` significa "nunca ejecutado". Migración no-breaking: status
33
+ // legacy sigue válido durante el período de transición.
34
+ // Nota: usamos `result` y NO `outcome` porque el campo `outcome` ya existe
35
+ // con otra semántica (blob JSON con iterations/duration/commits del run).
36
+ export const VALID_HU_RESULTS = new Set([null, "pass", "fail", "partial"]);
11
37
  const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "nocode"]);
12
38
  const VALID_TEST_TYPES = new Set(["shell", "gherkin"]);
13
39
 
40
+ /**
41
+ * KJC-TSK-0394 step 4: deduce `result` de un HU legacy. Idempotente.
42
+ * Heurística: done→pass (si hubiera fallado estaría en failed),
43
+ * failed→fail, resto→null. NO toca status (eso es step 5).
44
+ */
45
+ export function inferResultFromLegacyStatus(hu) {
46
+ if (hu && hu.result != null) return hu.result;
47
+ if (hu?.status === "done") return "pass";
48
+ if (hu?.status === "failed") return "fail";
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * KJC-TSK-0394 step 5: mapea cualquier status (legacy o canonical) al
54
+ * modelo CANONICAL {pending, running, done}. Permite que la UI y los
55
+ * agregados (counts, kanban columns) trabajen contra un enum pequeño
56
+ * sin tener que conocer todas las variantes legacy.
57
+ *
58
+ * certified | blocked | needs_context | pending → pending
59
+ * coding | reviewing | running → running
60
+ * done | failed → done
61
+ *
62
+ * `failed` mapea a `done` porque conceptualmente es lifecycle terminal
63
+ * — el "falló" lo lleva el campo `result` ortogonal (step 1). Lo mismo
64
+ * para `certified`: no es un lifecycle, es "ready to run" que en el
65
+ * modelo canónico vive como `pending` + acceptance_tests no vacío.
66
+ */
67
+ export function canonicalStatus(status) {
68
+ switch (status) {
69
+ case "running":
70
+ case "coding":
71
+ case "reviewing":
72
+ return "running";
73
+ case "done":
74
+ case "failed":
75
+ return "done";
76
+ default:
77
+ return "pending";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * KJC-TSK-0394 step 5: result "efectivo" para la UI. Si el HU tiene
83
+ * `result` poblado (plans migrados), lo usa directamente. Si no
84
+ * (plans legacy pre-migración), lo deduce del status — equivalente a
85
+ * inferResultFromLegacyStatus pero llamable directamente sobre cada
86
+ * HU al renderizar.
87
+ *
88
+ * Permite a la UI mostrar badges ✓/✗ en plans antiguos sin esperar a
89
+ * que el usuario corra `kj plan migrate-result`.
90
+ */
91
+ export function effectiveResult(hu) {
92
+ if (!hu) return null;
93
+ if (hu.result !== undefined && hu.result !== null) return hu.result;
94
+ return inferResultFromLegacyStatus(hu);
95
+ }
96
+
97
+ /**
98
+ * Map a legacy status to (newStatus, result) pair. Used by addHu() defaults
99
+ * and by the migration script in step 4. Idempotent: re-mapping a value that
100
+ * is already in the canonical {pending, running, done} set keeps it.
101
+ *
102
+ * @param {string|null|undefined} legacy
103
+ * @returns {{ status: string, result: string|null }}
104
+ */
105
+ export function mapLegacyStatusToResult(legacy) {
106
+ switch (legacy) {
107
+ case "pending":
108
+ case "blocked": // blocked es lifecycle no terminal → pending
109
+ case "needs_context": // ídem
110
+ case undefined:
111
+ case null:
112
+ return { status: "pending", result: null };
113
+ case "coding":
114
+ case "reviewing":
115
+ case "running":
116
+ return { status: "running", result: null };
117
+ case "certified":
118
+ return { status: "done", result: "pass" };
119
+ case "failed":
120
+ // KJC-TSK-0403: HU fallida vuelve a Pending con result=fail
121
+ // (no a Done) para que el usuario pueda relanzarla con el
122
+ // contexto del fallo previo (KJC-TSK-0404).
123
+ return { status: "pending", result: "fail" };
124
+ case "done":
125
+ return { status: "done", result: null }; // sin result conocido (raro)
126
+ default:
127
+ return { status: "pending", result: null };
128
+ }
129
+ }
130
+
14
131
  /**
15
132
  * Structured acceptance-test shape introduced in v2.7.5 (tests-first):
16
133
  *
@@ -74,6 +191,12 @@ export function createPlanV2(task, context = {}) {
74
191
  const planId = generatePlanId();
75
192
  return {
76
193
  planId,
194
+ // KJC-TSK humanización IDs: alias legible (ej "greta-app"). Se
195
+ // asigna en savePlan a partir de plan.name. El planId sigue
196
+ // siendo la ID canónica interna (timestamp+random), pero la CLI
197
+ // y el board prefieren el alias para mostrar y aceptan ambos
198
+ // como referencia (--plan greta-app o --plan plan-XXX-XX).
199
+ alias: null,
77
200
  version: 2,
78
201
  task,
79
202
  name: null, // set by caller via deriveProjectName or manually
@@ -144,6 +267,10 @@ export function validatePlan(plan) {
144
267
  huIds.add(hu.id);
145
268
  if (!hu.title) errors.push(`HU ${hu.id}: missing title`);
146
269
  if (!VALID_HU_STATUSES.has(hu.status)) errors.push(`HU ${hu.id}: invalid status ${hu.status}`);
270
+ // KJC-TSK-0394: result es opcional. Si está presente, debe ser pass/fail/partial.
271
+ if (hu.result !== undefined && hu.result !== null && !VALID_HU_RESULTS.has(hu.result)) {
272
+ errors.push(`HU ${hu.id}: invalid result ${hu.result} (expected pass|fail|partial|null)`);
273
+ }
147
274
  if (hu.task_type && !VALID_TASK_TYPES.has(hu.task_type)) errors.push(`HU ${hu.id}: invalid task_type ${hu.task_type}`);
148
275
  // acceptance_tests: may be absent (legacy) or an array of either
149
276
  // plain strings (legacy shell) or { type, content, file? } objects.