karajan-code 2.14.3 → 2.15.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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/brain/agent-error-classifier.js +101 -0
  4. package/src/brain/standby-scheduler.js +92 -0
  5. package/src/brain/standby-store.js +129 -0
  6. package/src/brain/with-brain-recovery.js +178 -0
  7. package/src/cli/register-plan.js +29 -0
  8. package/src/cli/register-standby.js +26 -0
  9. package/src/cli.js +2 -0
  10. package/src/commands/architect.js +6 -2
  11. package/src/commands/discover.js +6 -2
  12. package/src/commands/init.js +47 -0
  13. package/src/commands/plan/_shared.js +19 -4
  14. package/src/commands/plan/fix.js +99 -0
  15. package/src/commands/plan/generate.js +112 -27
  16. package/src/commands/plan/index.js +2 -0
  17. package/src/commands/plan/migrate-result.js +41 -0
  18. package/src/commands/plan.js +2 -0
  19. package/src/commands/researcher.js +6 -2
  20. package/src/commands/run.js +33 -1
  21. package/src/commands/standby.js +114 -0
  22. package/src/commands/triage.js +6 -2
  23. package/src/config/schema.js +40 -0
  24. package/src/git/hu-snapshot.js +105 -0
  25. package/src/guards/policy-resolver.js +38 -1
  26. package/src/hu/lazy-planner.js +6 -1
  27. package/src/hu/model-router.js +108 -0
  28. package/src/hu/splitting-generator.js +6 -1
  29. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
  30. package/src/orchestrator/drivers/run-hu-batch.js +30 -4
  31. package/src/plan/plan-executor.js +13 -4
  32. package/src/plan/plan-fixer.js +36 -4
  33. package/src/plan/plan-hu-ops.js +24 -1
  34. package/src/plan/plan-id.js +20 -0
  35. package/src/plan/plan-migrate-result.js +24 -0
  36. package/src/plan/plan-reviewer.js +40 -3
  37. package/src/plan/plan-schema.js +128 -1
  38. package/src/plan/plan-store.js +63 -8
  39. package/src/plan/plan-structural-pass.js +122 -0
  40. package/src/plan/plan-validation.js +82 -0
  41. package/src/plan/tests-synthesizer.js +8 -3
  42. package/src/prompts/planner.js +27 -1
  43. package/src/roles/agent-role.js +18 -3
  44. package/src/roles/planner-role.js +10 -3
  45. package/src/roles/solomon-role.js +10 -4
  46. package/src/utils/cli-progress.js +58 -1
  47. package/src/utils/garbage-collector.js +85 -2
  48. package/src/utils/run-registry.js +115 -0
@@ -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
  }
@@ -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
+ .replace(/[̀-ͯ]/g, "") // strip diacritics
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-") // non-alphanum → guion
42
+ .replace(/^-+|-+$/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.
@@ -7,7 +7,7 @@
7
7
  import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
- import { generatePlanId } from "./plan-id.js";
10
+ import { generatePlanId, normaliseAlias } from "./plan-id.js";
11
11
  import { isPlanV2, migratePlanV1toV2 } from "./plan-schema.js";
12
12
 
13
13
  // Per-process tmp dir for VITEST runs that forget to set KJ_HOME. We
@@ -77,6 +77,12 @@ export async function savePlan(projectDir, planResult) {
77
77
  // engineering it from the slug is lossy (underscores in the original
78
78
  // name collide with underscores introduced by slash-substitution).
79
79
  if (!planResult.projectDir && projectDir) planResult.projectDir = projectDir;
80
+ // Humanización IDs: si el plan no tiene alias todavía, derívalo de
81
+ // plan.name (lo que el planner pidió al usuario). Resuelve
82
+ // colisiones con sufijo -2, -3… leyendo el dir antes.
83
+ if (!planResult.alias && planResult.name) {
84
+ planResult.alias = await resolveUniqueAlias(dir, planResult.planId, normaliseAlias(planResult.name));
85
+ }
80
86
  await fs.writeFile(filePath, JSON.stringify(planResult, null, 2), "utf8");
81
87
  return planResult.planId;
82
88
  }
@@ -99,19 +105,67 @@ export async function savePlan(projectDir, planResult) {
99
105
  }
100
106
 
101
107
  /**
102
- * Load a plan by ID. Auto-migrates v1 to v2 on read (lazy).
103
- * @returns {Promise<object|null>} v2 plan or null
108
+ * Resuelve un alias único en `dir`. Si `base` ya está en uso por otro
109
+ * plan, intenta `base-2`, `base-3`… hasta encontrar libre.
110
+ * @param {string} dir
111
+ * @param {string} ownPlanId - planId del plan que se está guardando (su propio archivo se ignora en el lookup).
112
+ * @param {string|null} base
113
+ * @returns {Promise<string|null>}
104
114
  */
105
- export async function loadPlan(projectDir, planId) {
106
- const filePath = path.join(plansDir(projectDir), `${planId}.json`);
115
+ async function resolveUniqueAlias(dir, ownPlanId, base) {
116
+ if (!base) return null;
117
+ let files;
118
+ try { files = await fs.readdir(dir); } catch { return base; }
119
+ const taken = new Set();
120
+ for (const f of files) {
121
+ if (!f.endsWith(".json")) continue;
122
+ try {
123
+ const record = JSON.parse(await fs.readFile(path.join(dir, f), "utf8"));
124
+ if (record.planId === ownPlanId) continue;
125
+ if (record.alias) taken.add(record.alias);
126
+ } catch { /* skip corrupt */ }
127
+ }
128
+ if (!taken.has(base)) return base;
129
+ for (let i = 2; i < 1000; i += 1) {
130
+ const candidate = `${base}-${i}`;
131
+ if (!taken.has(candidate)) return candidate;
132
+ }
133
+ return `${base}-${ownPlanId.slice(-4)}`; // fallback improbable
134
+ }
135
+
136
+ /**
137
+ * Load a plan by ID or alias. Auto-migrates v1 to v2 on read (lazy).
138
+ * Acepta tanto el planId canónico (plan-XXX-XX) como el alias humano
139
+ * (greta-app). Primero busca por filename exacto (planId.json), luego
140
+ * escanea el dir y matchea por alias.
141
+ *
142
+ * @returns {Promise<object|null>} v2 plan o null
143
+ */
144
+ export async function loadPlan(projectDir, ref) {
145
+ if (!ref) return null;
146
+ const dir = plansDir(projectDir);
147
+ // 1. Match exacto por planId.json (camino caliente).
148
+ const direct = path.join(dir, `${ref}.json`);
107
149
  try {
108
- const data = await fs.readFile(filePath, "utf8");
150
+ const data = await fs.readFile(direct, "utf8");
109
151
  const plan = JSON.parse(data);
110
152
  if (!isPlanV2(plan)) return migratePlanV1toV2(plan);
111
153
  return plan;
112
- } catch {
113
- return null;
154
+ } catch { /* fall through to alias scan */ }
155
+ // 2. Escaneo del dir por alias. Coste O(N) pero N suele ser <10.
156
+ let files;
157
+ try { files = await fs.readdir(dir); } catch { return null; }
158
+ for (const f of files) {
159
+ if (!f.endsWith(".json")) continue;
160
+ try {
161
+ const record = JSON.parse(await fs.readFile(path.join(dir, f), "utf8"));
162
+ if (record.alias === ref || record.planId === ref) {
163
+ if (!isPlanV2(record)) return migratePlanV1toV2(record);
164
+ return record;
165
+ }
166
+ } catch { /* skip corrupt */ }
114
167
  }
168
+ return null;
115
169
  }
116
170
 
117
171
  /**
@@ -148,6 +202,7 @@ export async function listPlans(projectDir) {
148
202
  const record = JSON.parse(data);
149
203
  plans.push({
150
204
  planId: record.planId,
205
+ alias: record.alias || null,
151
206
  task: record.task,
152
207
  name: record.name || null,
153
208
  status: record.status || "draft",
@@ -0,0 +1,122 @@
1
+ // KJC-TSK-0399: deterministic post-review pass — cycles, orphan refs,
2
+ // missing short_id. No LLM. Fix shape: { kind, huId?, ...details };
3
+ // stored in plan.review.structural_fixes.
4
+
5
+ export function runStructuralPass(plan) {
6
+ const fixes = [];
7
+ if (!plan || !Array.isArray(plan.hus) || plan.hus.length === 0) {
8
+ return { fixes, modified: false };
9
+ }
10
+ fixes.push(...removeOrphanRefs(plan));
11
+ fixes.push(...breakCycles(plan));
12
+ fixes.push(...assignMissingShortIds(plan));
13
+ const modified = fixes.length > 0;
14
+ if (modified) plan.updatedAt = new Date().toISOString();
15
+ return { fixes, modified };
16
+ }
17
+
18
+ export function removeOrphanRefs(plan) {
19
+ const fixes = [];
20
+ const known = new Set(plan.hus.map((h) => h.id));
21
+ for (const hu of plan.hus) {
22
+ const removed = [];
23
+ if (Array.isArray(hu.blocked_by) && hu.blocked_by.length > 0) {
24
+ hu.blocked_by = hu.blocked_by.filter((d) => {
25
+ if (known.has(d)) return true;
26
+ removed.push(d); return false;
27
+ });
28
+ }
29
+ if (removed.length > 0) fixes.push({ kind: "orphan_ref_removed", huId: hu.id, removed });
30
+ }
31
+ return fixes;
32
+ }
33
+
34
+ export function breakCycles(plan) {
35
+ const fixes = [];
36
+ let cycles = findCycles(plan.hus);
37
+ let budget = plan.hus.length;
38
+ while (cycles.length > 0 && budget-- > 0) {
39
+ const cycle = cycles[0];
40
+ const dropped = breakCycle(plan, cycle);
41
+ if (!dropped) break;
42
+ fixes.push({ kind: "cycle_break", cycle: [...cycle], droppedEdge: dropped });
43
+ cycles = findCycles(plan.hus);
44
+ }
45
+ return fixes;
46
+ }
47
+
48
+ export function findCycles(hus) {
49
+ const graph = new Map();
50
+ for (const hu of hus) graph.set(hu.id, Array.isArray(hu.blocked_by) ? hu.blocked_by : []);
51
+ const cycles = [];
52
+ const seen = new Set();
53
+ const onStack = new Set();
54
+ const stack = [];
55
+ function dfs(node) {
56
+ onStack.add(node); stack.push(node); seen.add(node);
57
+ for (const dep of graph.get(node) || []) {
58
+ if (!graph.has(dep)) continue;
59
+ if (onStack.has(dep)) {
60
+ const idx = stack.indexOf(dep);
61
+ if (idx >= 0) {
62
+ const cycle = stack.slice(idx);
63
+ if (!cycles.some((c) => sameCycle(c, cycle))) cycles.push(cycle);
64
+ }
65
+ } else if (!seen.has(dep)) dfs(dep);
66
+ }
67
+ stack.pop(); onStack.delete(node);
68
+ }
69
+ for (const id of graph.keys()) if (!seen.has(id)) dfs(id);
70
+ return cycles;
71
+ }
72
+
73
+ function sameCycle(a, b) {
74
+ if (a.length !== b.length) return false;
75
+ return [...a].sort().join("|") === [...b].sort().join("|");
76
+ }
77
+
78
+ // Heurística: dropea arista cuyo origen tiene menos incoming. Tiebreak edge.to.
79
+ export function breakCycle(plan, cycle) {
80
+ const incoming = new Map();
81
+ for (const hu of plan.hus) {
82
+ for (const dep of hu.blocked_by || []) incoming.set(dep, (incoming.get(dep) || 0) + 1);
83
+ }
84
+ const edges = [];
85
+ for (let i = 0; i < cycle.length; i++) {
86
+ const from = cycle[i];
87
+ const to = cycle[(i + 1) % cycle.length];
88
+ const hu = plan.hus.find((h) => h.id === from);
89
+ if (hu && Array.isArray(hu.blocked_by) && hu.blocked_by.includes(to)) edges.push({ from, to });
90
+ }
91
+ if (edges.length === 0) return null;
92
+ edges.sort((a, b) => {
93
+ const d = (incoming.get(a.from) || 0) - (incoming.get(b.from) || 0);
94
+ return d !== 0 ? d : a.to.localeCompare(b.to);
95
+ });
96
+ const drop = edges[0];
97
+ const hu = plan.hus.find((h) => h.id === drop.from);
98
+ hu.blocked_by = hu.blocked_by.filter((d) => d !== drop.to);
99
+ return drop;
100
+ }
101
+
102
+ export function assignMissingShortIds(plan) {
103
+ const fixes = [];
104
+ const used = new Set();
105
+ let maxAuto = 0;
106
+ for (const hu of plan.hus) {
107
+ if (!hu.short_id) continue;
108
+ used.add(hu.short_id);
109
+ const m = String(hu.short_id).match(/^AUTOFIX-(\d+)$/);
110
+ if (m) maxAuto = Math.max(maxAuto, Number(m[1]));
111
+ }
112
+ for (const hu of plan.hus) {
113
+ if (hu.short_id) continue;
114
+ maxAuto += 1;
115
+ const candidate = `AUTOFIX-${String(maxAuto).padStart(3, "0")}`;
116
+ if (used.has(candidate)) continue;
117
+ hu.short_id = candidate;
118
+ used.add(candidate);
119
+ fixes.push({ kind: "short_id_assigned", huId: hu.id, assigned: candidate });
120
+ }
121
+ return fixes;
122
+ }