karajan-code 2.14.2 → 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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/brain/agent-error-classifier.js +101 -0
- package/src/brain/standby-scheduler.js +92 -0
- package/src/brain/standby-store.js +129 -0
- package/src/brain/with-brain-recovery.js +178 -0
- package/src/checks/project-checks.js +245 -0
- package/src/checks/runner.js +29 -0
- package/src/checks/tokens.js +24 -1
- package/src/checks/types.js +10 -0
- package/src/cli/register-pipeline.js +2 -0
- package/src/cli/register-plan.js +29 -0
- package/src/cli/register-standby.js +26 -0
- package/src/cli.js +2 -0
- package/src/commands/architect.js +6 -2
- package/src/commands/discover.js +6 -2
- package/src/commands/doctor.js +18 -5
- package/src/commands/init.js +47 -0
- package/src/commands/plan/_shared.js +19 -4
- package/src/commands/plan/fix.js +99 -0
- package/src/commands/plan/generate.js +112 -27
- package/src/commands/plan/index.js +2 -0
- package/src/commands/plan/migrate-result.js +41 -0
- package/src/commands/plan.js +2 -0
- package/src/commands/researcher.js +6 -2
- package/src/commands/run.js +33 -1
- package/src/commands/standby.js +114 -0
- package/src/commands/triage.js +6 -2
- package/src/config/schema.js +40 -0
- package/src/git/hu-snapshot.js +105 -0
- package/src/guards/policy-resolver.js +38 -1
- package/src/hu/lazy-planner.js +6 -1
- package/src/hu/model-router.js +108 -0
- package/src/hu/splitting-generator.js +6 -1
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +47 -8
- package/src/orchestrator/drivers/run-hu-batch.js +30 -4
- package/src/orchestrator/preflight-checks.js +5 -0
- package/src/plan/plan-executor.js +13 -4
- package/src/plan/plan-fixer.js +36 -4
- package/src/plan/plan-hu-ops.js +24 -1
- package/src/plan/plan-id.js +20 -0
- package/src/plan/plan-migrate-result.js +24 -0
- package/src/plan/plan-reviewer.js +40 -3
- package/src/plan/plan-schema.js +128 -1
- package/src/plan/plan-store.js +63 -8
- package/src/plan/plan-structural-pass.js +122 -0
- package/src/plan/plan-validation.js +82 -0
- package/src/plan/tests-synthesizer.js +8 -3
- package/src/prompts/planner.js +27 -1
- package/src/roles/agent-role.js +18 -3
- package/src/roles/planner-role.js +10 -3
- package/src/roles/solomon-role.js +10 -4
- package/src/utils/cli-progress.js +58 -1
- package/src/utils/garbage-collector.js +85 -2
- package/src/utils/run-registry.js +115 -0
package/src/plan/plan-hu-ops.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/plan/plan-id.js
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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
|
+
}
|
package/src/plan/plan-schema.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/plan/plan-store.js
CHANGED
|
@@ -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
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// KJC-TSK-0401: validación estructural de cambios en blocked_by desde
|
|
2
|
+
// el board. Se usa antes de persistir un PATCH /api/stories/:id para
|
|
3
|
+
// que el usuario no pueda crear ciclos ni referencias huérfanas. La UI
|
|
4
|
+
// preview la usa para deshabilitar las opciones que romperían el plan.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Verifica que `newBlockedBy` para `huId` no rompa la estructura del plan.
|
|
8
|
+
*
|
|
9
|
+
* Reglas:
|
|
10
|
+
* 1. Cada ref en newBlockedBy debe existir como hu.id en el plan.
|
|
11
|
+
* 2. No se permite auto-dependencia (huId ∈ newBlockedBy).
|
|
12
|
+
* 3. Aplicar el cambio NO debe crear un ciclo en el grafo blocked_by.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} plan
|
|
15
|
+
* @param {string} huId
|
|
16
|
+
* @param {string[]} newBlockedBy
|
|
17
|
+
* @returns {{ ok: true } | { ok: false, error: string, cycle?: string[] }}
|
|
18
|
+
*/
|
|
19
|
+
export function validateBlockedByChange(plan, huId, newBlockedBy) {
|
|
20
|
+
if (!plan || !Array.isArray(plan.hus)) {
|
|
21
|
+
return { ok: false, error: "plan inválido" };
|
|
22
|
+
}
|
|
23
|
+
const hu = plan.hus.find((h) => h.id === huId);
|
|
24
|
+
if (!hu) return { ok: false, error: `HU no encontrada: ${huId}` };
|
|
25
|
+
if (!Array.isArray(newBlockedBy)) {
|
|
26
|
+
return { ok: false, error: "blocked_by debe ser un array" };
|
|
27
|
+
}
|
|
28
|
+
const ids = new Set(plan.hus.map((h) => h.id));
|
|
29
|
+
for (const ref of newBlockedBy) {
|
|
30
|
+
if (typeof ref !== "string") {
|
|
31
|
+
return { ok: false, error: "blocked_by contiene un valor no-string" };
|
|
32
|
+
}
|
|
33
|
+
if (ref === huId) {
|
|
34
|
+
return { ok: false, error: `auto-dependencia no permitida: ${huId}` };
|
|
35
|
+
}
|
|
36
|
+
if (!ids.has(ref)) {
|
|
37
|
+
return { ok: false, error: `HU referenciada inexistente: ${ref}` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Simular el cambio sobre una copia del grafo y buscar ciclos.
|
|
41
|
+
const graph = new Map();
|
|
42
|
+
for (const h of plan.hus) {
|
|
43
|
+
graph.set(h.id, h.id === huId ? [...newBlockedBy] : (Array.isArray(h.blocked_by) ? [...h.blocked_by] : []));
|
|
44
|
+
}
|
|
45
|
+
const cycle = findCycleContaining(graph, huId);
|
|
46
|
+
if (cycle) {
|
|
47
|
+
return { ok: false, error: `Ciclo detectado: ${cycle.join(" → ")} → ${cycle[0]}`, cycle };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* DFS desde `start` siguiendo aristas blocked_by. Devuelve el primer
|
|
54
|
+
* ciclo encontrado que pase por `start`, o null. La búsqueda no es
|
|
55
|
+
* exhaustiva — basta con detectar UN ciclo para rechazar el cambio.
|
|
56
|
+
*
|
|
57
|
+
* @param {Map<string,string[]>} graph
|
|
58
|
+
* @param {string} start
|
|
59
|
+
* @returns {string[]|null}
|
|
60
|
+
*/
|
|
61
|
+
function findCycleContaining(graph, start) {
|
|
62
|
+
const onStack = new Set();
|
|
63
|
+
const visited = new Set();
|
|
64
|
+
const stack = [];
|
|
65
|
+
function dfs(node) {
|
|
66
|
+
if (onStack.has(node)) {
|
|
67
|
+
const idx = stack.indexOf(node);
|
|
68
|
+
if (idx >= 0) return stack.slice(idx);
|
|
69
|
+
return [node];
|
|
70
|
+
}
|
|
71
|
+
if (visited.has(node)) return null;
|
|
72
|
+
onStack.add(node); stack.push(node); visited.add(node);
|
|
73
|
+
for (const dep of graph.get(node) || []) {
|
|
74
|
+
if (!graph.has(dep)) continue;
|
|
75
|
+
const found = dfs(dep);
|
|
76
|
+
if (found) return found;
|
|
77
|
+
}
|
|
78
|
+
stack.pop(); onStack.delete(node);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return dfs(start);
|
|
82
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { extractFirstJson } from "../utils/json-extract.js";
|
|
24
24
|
import { normaliseAcceptanceTests } from "./plan-schema.js";
|
|
25
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Build the focused-pass prompt asking ONLY for tests for the given
|
|
@@ -85,7 +86,7 @@ export function buildSynthesizerPrompt({ task, husNeedingTests }) {
|
|
|
85
86
|
* @param {number} [args.timeoutMs]
|
|
86
87
|
* @returns {Promise<{ ok: boolean, tests?: Record<string, object[]>, error?: string }>}
|
|
87
88
|
*/
|
|
88
|
-
export async function synthesizeMissingTests({ agent, task, husNeedingTests, onOutput, silenceTimeoutMs, timeoutMs }) {
|
|
89
|
+
export async function synthesizeMissingTests({ agent, task, husNeedingTests, onOutput, silenceTimeoutMs, timeoutMs, emitter, eventBase, logger }) {
|
|
89
90
|
if (!Array.isArray(husNeedingTests) || husNeedingTests.length === 0) {
|
|
90
91
|
return { ok: true, tests: {} };
|
|
91
92
|
}
|
|
@@ -95,9 +96,13 @@ export async function synthesizeMissingTests({ agent, task, husNeedingTests, onO
|
|
|
95
96
|
if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
|
|
96
97
|
if (timeoutMs) runArgs.timeoutMs = timeoutMs;
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
// KJC-TSK-0413: Brain Recovery wraps el agent call.
|
|
100
|
+
const result = await withBrainRecovery({
|
|
101
|
+
agent, taskArgs: runArgs, role: "tests-synthesizer",
|
|
102
|
+
emitter, eventBase, logger,
|
|
103
|
+
});
|
|
99
104
|
if (!result.ok) {
|
|
100
|
-
return { ok: false, error: result.error ||
|
|
105
|
+
return { ok: false, error: result.error || `synthesizer agent failed (${result.recovery?.class || "unknown"})`, recovery: result.recovery, action: result.action };
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const parsed = extractFirstJson(result.output || "");
|