karajan-code 3.12.0 → 3.12.2

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 (59) hide show
  1. package/package.json +2 -5
  2. package/packages/hu-board/package.json +1 -1
  3. package/packages/hu-board/src/plan-mutations.js +4 -4
  4. package/packages/hu-board/src/routes/api.js +1 -1
  5. package/packages/hu-board/src/routes/rag.js +1 -1
  6. package/packages/hu-board/src/routes/wiki.js +1 -1
  7. package/packages/hu-board/src/run-tracker.js +1 -1
  8. package/packages/hu-board/src/server.js +2 -2
  9. package/packages/hu-board/src/sync.js +1 -1
  10. package/scripts/verify-pack.mjs +38 -6
  11. package/src/brain/standby-scheduler.js +2 -2
  12. package/src/brain/standby-store.js +2 -2
  13. package/src/cli/register-meta.js +5 -20
  14. package/src/git/hu-snapshot.js +2 -2
  15. package/src/plan/plan-hu-ops.js +2 -2
  16. package/src/plan/plan-id.js +2 -2
  17. package/src/plan/plan-validation.js +2 -2
  18. package/src/rag/vec-store.js +2 -2
  19. package/src/utils/atomic-write.js +3 -3
  20. package/src/utils/paths.js +2 -2
  21. package/src/utils/port-check.js +3 -3
  22. package/src/utils/process.js +2 -2
  23. package/src/utils/run-registry.js +2 -2
  24. package/src/utils/shared-paths.js +3 -3
  25. package/src/utils/update-check.js +47 -0
  26. package/node_modules/@karajan/core/README.md +0 -42
  27. package/node_modules/@karajan/core/package.json +0 -49
  28. package/node_modules/@karajan/core/src/atomic-write.js +0 -43
  29. package/node_modules/@karajan/core/src/cost/index.js +0 -102
  30. package/node_modules/@karajan/core/src/hu-snapshot.js +0 -105
  31. package/node_modules/@karajan/core/src/index.js +0 -18
  32. package/node_modules/@karajan/core/src/paths.js +0 -123
  33. package/node_modules/@karajan/core/src/plan-hu-ops.js +0 -338
  34. package/node_modules/@karajan/core/src/plan-id.js +0 -45
  35. package/node_modules/@karajan/core/src/plan-validation.js +0 -82
  36. package/node_modules/@karajan/core/src/port-check.js +0 -45
  37. package/node_modules/@karajan/core/src/pricing/index.js +0 -64
  38. package/node_modules/@karajan/core/src/pricing/model-pricing.json +0 -28
  39. package/node_modules/@karajan/core/src/process.js +0 -188
  40. package/node_modules/@karajan/core/src/run-registry.js +0 -115
  41. package/node_modules/@karajan/core/src/shared-paths.js +0 -41
  42. package/node_modules/@karajan/core/src/standby-scheduler.js +0 -92
  43. package/node_modules/@karajan/core/src/standby-store.js +0 -168
  44. package/node_modules/@karajan/core/src/vec-store.js +0 -253
  45. package/node_modules/@karajan/core/tests/atomic-write.test.js +0 -30
  46. package/node_modules/@karajan/core/tests/cost/index.test.js +0 -89
  47. package/node_modules/@karajan/core/tests/hu-snapshot.test.js +0 -39
  48. package/node_modules/@karajan/core/tests/paths.test.js +0 -51
  49. package/node_modules/@karajan/core/tests/plan-hu-ops.test.js +0 -68
  50. package/node_modules/@karajan/core/tests/plan-id.test.js +0 -21
  51. package/node_modules/@karajan/core/tests/plan-validation.test.js +0 -39
  52. package/node_modules/@karajan/core/tests/port-check.test.js +0 -47
  53. package/node_modules/@karajan/core/tests/pricing/index.test.js +0 -95
  54. package/node_modules/@karajan/core/tests/process.test.js +0 -22
  55. package/node_modules/@karajan/core/tests/run-registry.test.js +0 -71
  56. package/node_modules/@karajan/core/tests/shared-paths.test.js +0 -30
  57. package/node_modules/@karajan/core/tests/standby.test.js +0 -66
  58. package/node_modules/@karajan/core/tests/vec-store.test.js +0 -98
  59. package/node_modules/@karajan/core/vitest.config.js +0 -12
@@ -1,102 +0,0 @@
1
- /**
2
- * Cost aggregator (Cost B — KJC-TSK-0513).
3
- *
4
- * Turns a list of token-usage events emitted by the orchestrator into
5
- * a USD breakdown for a single run / HU. The pipeline calls this once
6
- * when a HU finishes; the result is what Cost C will persist in
7
- * board.db and Cost E will expose as `/api/projects/:id/cost`.
8
- *
9
- * Inputs are deliberately permissive: any extra field on an event is
10
- * ignored. We only require `role`, `model`, `tokensIn`, `tokensOut`.
11
- *
12
- * Unknown models do NOT throw. Their token counts accumulate into
13
- * `unknownModelTokens` and the function returns a flag so the caller
14
- * can surface it in the UI (orange badge instead of `$X.XXXX`).
15
- */
16
-
17
- import { getModelPricing } from "../pricing/index.js";
18
-
19
- const DEFAULT_ROUND_DECIMALS = 4;
20
-
21
- function round(n, decimals = DEFAULT_ROUND_DECIMALS) {
22
- const f = 10 ** decimals;
23
- return Math.round(n * f) / f;
24
- }
25
-
26
- function priceTokens(pricing, tokensIn, tokensOut) {
27
- const inUsd = (tokensIn / 1_000_000) * pricing.inputPerMTok;
28
- const outUsd = (tokensOut / 1_000_000) * pricing.outputPerMTok;
29
- return inUsd + outUsd;
30
- }
31
-
32
- /**
33
- * Aggregate cost of a run.
34
- *
35
- * @param {object} args
36
- * @param {Array<{role:string, model:string, tokensIn:number, tokensOut:number}>} args.events
37
- * @param {(level:"warn"|"info", msg:string, ctx?:object)=>void} [args.logger]
38
- * Optional structured logger. When a model is unknown we call
39
- * logger("warn", ...) so the user sees it once per run.
40
- * @returns {{
41
- * totalUsd:number,
42
- * byRole:Record<string,number>,
43
- * byModel:Record<string,number>,
44
- * unknownModelTokens:{ tokensIn:number, tokensOut:number, models:string[] },
45
- * hasUnknown:boolean,
46
- * currency:string
47
- * }}
48
- */
49
- export function aggregateRunCost({ events, logger } = {}) {
50
- const safeEvents = Array.isArray(events) ? events : [];
51
- const byRole = {};
52
- const byModel = {};
53
- const unknownModels = new Set();
54
- let totalUsd = 0;
55
- let unknownIn = 0;
56
- let unknownOut = 0;
57
-
58
- for (const ev of safeEvents) {
59
- if (!ev || typeof ev !== "object") continue;
60
- const role = typeof ev.role === "string" && ev.role.length > 0 ? ev.role : "unknown";
61
- const model = typeof ev.model === "string" ? ev.model : "";
62
- const tokensIn = Number.isFinite(ev.tokensIn) ? ev.tokensIn : 0;
63
- const tokensOut = Number.isFinite(ev.tokensOut) ? ev.tokensOut : 0;
64
- if (tokensIn === 0 && tokensOut === 0) continue;
65
-
66
- const pricing = getModelPricing(model);
67
- if (!pricing) {
68
- unknownIn += tokensIn;
69
- unknownOut += tokensOut;
70
- if (model.length > 0) unknownModels.add(model);
71
- if (typeof logger === "function") {
72
- logger("warn", "cost-aggregator: unknown model pricing", { model, role, tokensIn, tokensOut });
73
- }
74
- continue;
75
- }
76
-
77
- const cost = priceTokens(pricing, tokensIn, tokensOut);
78
- totalUsd += cost;
79
- byRole[role] = (byRole[role] || 0) + cost;
80
- byModel[model] = (byModel[model] || 0) + cost;
81
- }
82
-
83
- const roundedByRole = Object.fromEntries(
84
- Object.entries(byRole).map(([k, v]) => [k, round(v)])
85
- );
86
- const roundedByModel = Object.fromEntries(
87
- Object.entries(byModel).map(([k, v]) => [k, round(v)])
88
- );
89
-
90
- return {
91
- totalUsd: round(totalUsd),
92
- byRole: roundedByRole,
93
- byModel: roundedByModel,
94
- unknownModelTokens: {
95
- tokensIn: unknownIn,
96
- tokensOut: unknownOut,
97
- models: Array.from(unknownModels).sort(),
98
- },
99
- hasUnknown: unknownIn > 0 || unknownOut > 0,
100
- currency: "USD",
101
- };
102
- }
@@ -1,105 +0,0 @@
1
- // KJC-TSK-0408: snapshots de ficheros por HU para soportar Undo.
2
- //
3
- // Antes de ejecutar una HU, guardamos un ref git apuntando al HEAD
4
- // actual (`refs/kj-snapshots/<huId>`). Si el usuario hace Undo después,
5
- // hacemos `git reset --hard <ref>` para volver al estado pre-run.
6
- //
7
- // Por qué refs en lugar de stash/tarball:
8
- // - Refs son objetos git nativos. No mueven blobs (todos compartidos
9
- // con HEAD), así que crear el snapshot es O(1).
10
- // - El snapshot persiste a través de checkouts y rebases.
11
- // - Si el ref ya apunta al SHA correcto, restore es no-op.
12
- //
13
- // El runner es inyectable para que los tests no necesiten un repo git real.
14
-
15
- import { runCommand } from "./process.js";
16
-
17
- const REF_PREFIX = "refs/kj-snapshots/";
18
-
19
- /**
20
- * Sanitiza un huId para uso como nombre de ref. Git rechaza:
21
- * - `..`, `:`, `~`, `^`, `?`, `*`, `[`, `\`, espacios, control chars
22
- * - segmentos que empiecen por `.` o terminen en `.lock`
23
- * Reemplazamos cualquier no [a-zA-Z0-9_-] por `_`.
24
- */
25
- export function snapshotRefForHu(huId) {
26
- if (!huId) throw new Error("huId requerido");
27
- const safe = String(huId).replaceAll(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
28
- return REF_PREFIX + safe;
29
- }
30
-
31
- async function defaultRunner(args, opts = {}) {
32
- return runCommand("git", args, opts);
33
- }
34
-
35
- /**
36
- * Crea (o actualiza) el ref del snapshot apuntando al HEAD actual.
37
- *
38
- * @param {object} args
39
- * @param {string} args.projectDir
40
- * @param {string} args.huId
41
- * @param {Function} [args.runner] - sustituye git CLI en tests
42
- * @returns {Promise<{ ok: boolean, ref?: string, sha?: string, error?: string }>}
43
- */
44
- export async function createHuSnapshot({ projectDir, huId, runner }) {
45
- const run = runner || defaultRunner;
46
- const ref = snapshotRefForHu(huId);
47
- const head = await run(["rev-parse", "HEAD"], { cwd: projectDir });
48
- if (head.exitCode !== 0) {
49
- return { ok: false, error: `rev-parse HEAD failed: ${(head.stderr || "").trim()}` };
50
- }
51
- const sha = (head.stdout || "").trim();
52
- if (!sha) return { ok: false, error: "HEAD vacío" };
53
- const update = await run(["update-ref", ref, sha], { cwd: projectDir });
54
- if (update.exitCode !== 0) {
55
- return { ok: false, error: `update-ref falló: ${(update.stderr || "").trim()}` };
56
- }
57
- return { ok: true, ref, sha };
58
- }
59
-
60
- /**
61
- * @param {object} args
62
- * @param {string} args.projectDir
63
- * @param {string} args.huId
64
- * @param {Function} [args.runner]
65
- * @returns {Promise<{ ok: boolean, sha?: string }>}
66
- */
67
- export async function hasHuSnapshot({ projectDir, huId, runner }) {
68
- const run = runner || defaultRunner;
69
- const ref = snapshotRefForHu(huId);
70
- const r = await run(["rev-parse", "--verify", "--quiet", ref], { cwd: projectDir });
71
- if (r.exitCode !== 0) return { ok: false };
72
- return { ok: true, sha: (r.stdout || "").trim() };
73
- }
74
-
75
- /**
76
- * Restaura el workspace al snapshot. DESTRUCTIVO: hace `git reset --hard`,
77
- * lo que descarta cambios no committeados. Caller debe avisar al usuario.
78
- *
79
- * @returns {Promise<{ ok: boolean, sha?: string, error?: string }>}
80
- */
81
- export async function restoreHuSnapshot({ projectDir, huId, runner }) {
82
- const run = runner || defaultRunner;
83
- const ref = snapshotRefForHu(huId);
84
- const has = await hasHuSnapshot({ projectDir, huId, runner: run });
85
- if (!has.ok) return { ok: false, error: `snapshot no encontrado para HU ${huId}` };
86
- const r = await run(["reset", "--hard", ref], { cwd: projectDir });
87
- if (r.exitCode !== 0) {
88
- return { ok: false, error: `reset --hard falló: ${(r.stderr || "").trim()}` };
89
- }
90
- return { ok: true, sha: has.sha };
91
- }
92
-
93
- /**
94
- * Borra el ref del snapshot. Idempotente: si no existe, devuelve ok:true.
95
- */
96
- export async function removeHuSnapshot({ projectDir, huId, runner }) {
97
- const run = runner || defaultRunner;
98
- const ref = snapshotRefForHu(huId);
99
- const r = await run(["update-ref", "-d", ref], { cwd: projectDir });
100
- // git update-ref -d devuelve 0 incluso si el ref no existía.
101
- if (r.exitCode !== 0) {
102
- return { ok: false, error: `update-ref -d falló: ${(r.stderr || "").trim()}` };
103
- }
104
- return { ok: true };
105
- }
@@ -1,18 +0,0 @@
1
- // Barrel export for @karajan/core. Subpath imports
2
- // (e.g. `@karajan/core/atomic-write`) are the preferred shape — this
3
- // barrel only exists so a single `import { writeJsonAtomic } from
4
- // "@karajan/core"` keeps working for callers that want the whole API.
5
-
6
- export * from "./atomic-write.js";
7
- export * from "./shared-paths.js";
8
- export * from "./port-check.js";
9
- export * from "./paths.js";
10
- export * from "./run-registry.js";
11
- export * from "./vec-store.js";
12
- export * from "./plan-id.js";
13
- export * from "./plan-hu-ops.js";
14
- export * from "./plan-validation.js";
15
- export * from "./process.js";
16
- export * from "./hu-snapshot.js";
17
- export * from "./standby-store.js";
18
- export * from "./standby-scheduler.js";
@@ -1,123 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- // Per-process vitest root. We memoise the *root* (not the suffixed
6
- // `.karajan` / `.kj` path) so callers with different legacy defaults
7
- // — plan-store wants `.kj`, db.js wants `.karajan` — can share one
8
- // random tmp prefix per test run without colliding.
9
- let _vitestRoot = null;
10
- function vitestRoot() {
11
- if (_vitestRoot) return _vitestRoot;
12
- _vitestRoot = path.join(
13
- os.tmpdir(),
14
- `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
15
- );
16
- // KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
17
- // dir per fork × run. `process.on('exit')` runs synchronously and only
18
- // for graceful exits — SIGKILL leaks survive but are caught by the
19
- // global-setup mtime purge at the start of the next run.
20
- process.on("exit", () => {
21
- try {
22
- fs.rmSync(_vitestRoot, { recursive: true, force: true });
23
- } catch {
24
- // best-effort: never crash a test process on cleanup failure
25
- }
26
- });
27
- return _vitestRoot;
28
- }
29
-
30
- function vitestTmpHome(defaultSegment) {
31
- return path.join(vitestRoot(), defaultSegment);
32
- }
33
-
34
- // One-shot warning so users with KJ_HOME set in their shell rcfile do
35
- // not get spammed once per `kj` invocation. Reset across processes,
36
- // which is the granularity that matters for a humans-reading-stderr
37
- // audience.
38
- let _kjHomeWarned = false;
39
- function emitKjHomeDeprecationWarning() {
40
- if (_kjHomeWarned) return;
41
- _kjHomeWarned = true;
42
- // eslint-disable-next-line no-console
43
- console.warn(
44
- "\x1b[33m[warn]\x1b[0m KJ_HOME is deprecated, rename to KARAJAN_HOME (KJ_HOME will be removed in a future release)"
45
- );
46
- }
47
-
48
- /**
49
- * Unified resolver for Karajan's HOME-level storage root. Used by every
50
- * helper that previously rolled its own `getKjHome()` with subtly
51
- * different defaults and VITEST handling.
52
- *
53
- * Precedence (highest first):
54
- * 1. KARAJAN_HOME env var — explicit, no warning
55
- * 2. KJ_HOME env var — explicit, prints deprecation warning once
56
- * 3. VITEST tmp dir — auto-isolation under `os.tmpdir()/karajan-vitest-<pid>-<rand>/<defaultSegment>`
57
- * 4. `~/<defaultSegment>` — production default
58
- *
59
- * Callers pass `defaultSegment` so we can preserve `.kj` for legacy
60
- * paths (plan-store, standby-store) and `.karajan` for the canonical
61
- * root, until PR 3 unifies the defaults too.
62
- *
63
- * @param {object} [options]
64
- * @param {string} [options.defaultSegment=".karajan"] HOME subdir name
65
- * @returns {string} absolute path
66
- */
67
- export function resolveHome({ defaultSegment = ".karajan" } = {}) {
68
- if (process.env.KARAJAN_HOME) {
69
- return path.resolve(process.env.KARAJAN_HOME);
70
- }
71
- if (process.env.KJ_HOME) {
72
- emitKjHomeDeprecationWarning();
73
- return path.resolve(process.env.KJ_HOME);
74
- }
75
- if (process.env.VITEST) {
76
- return vitestTmpHome(defaultSegment);
77
- }
78
- return path.join(os.homedir(), defaultSegment);
79
- }
80
-
81
- /**
82
- * Canonical Karajan home (`~/.karajan` by default). Every helper that
83
- * used to roll its own `getKjHome()` now goes through this one — see
84
- * KJC-PCS-0047 PR 3 for the consolidation.
85
- */
86
- export function getKarajanHome() {
87
- return resolveHome({ defaultSegment: ".karajan" });
88
- }
89
-
90
- // Test-only export. Tests that depend on observing the deprecation
91
- // warning being emitted exactly once need to reset the latch between
92
- // cases.
93
- export function __resetKjHomeWarningForTests() {
94
- _kjHomeWarned = false;
95
- }
96
-
97
- export function getSessionRoot() {
98
- return path.join(getKarajanHome(), "sessions");
99
- }
100
-
101
- export function getSonarComposePath() {
102
- return path.join(getKarajanHome(), "docker-compose.sonar.yml");
103
- }
104
-
105
- /** Ollama RAG embedder compose: `<karajan-home>/docker-compose.ollama.yml` — KJC-TSK-0435. */
106
- export function getOllamaComposePath() {
107
- return path.join(getKarajanHome(), "docker-compose.ollama.yml");
108
- }
109
-
110
- /** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
111
- export function getWebperfDir() {
112
- return path.join(getKarajanHome(), "webperf");
113
- }
114
-
115
- /** Run-registry: `<karajan-home>/runs/` — KJC-TSK-0420. */
116
- export function getRunsDir() {
117
- return path.join(getKarajanHome(), "runs");
118
- }
119
-
120
- /** Board prompt bridge: `<karajan-home>/prompts/` — KJC-TSK-0420. */
121
- export function getPromptsDir() {
122
- return path.join(getKarajanHome(), "prompts");
123
- }
@@ -1,338 +0,0 @@
1
- /**
2
- * HU CRUD operations on a v2 plan.
3
- * All functions mutate the plan in-place and return it.
4
- */
5
-
6
- import { generateHuId } from "./plan-id.js";
7
-
8
- /**
9
- * Get the next sequential HU number for this plan.
10
- */
11
- function nextSeq(plan) {
12
- if (!plan.hus || plan.hus.length === 0) return 1;
13
- const nums = plan.hus
14
- .map(h => {
15
- const m = h.id.match(/_(\d+)$/);
16
- return m ? Number(m[1]) : 0;
17
- })
18
- .filter(n => n > 0);
19
- return nums.length > 0 ? Math.max(...nums) + 1 : plan.hus.length + 1;
20
- }
21
-
22
- /**
23
- * Add an HU to the plan. Auto-generates a globally unique ID.
24
- * @param {object} plan - v2 plan
25
- * @param {object} huData - { title, task_type?, scope?, acceptance_criteria?, acceptance_tests?, blocked_by?, reuse? }
26
- * @returns {object} the created HU (with id assigned)
27
- */
28
- export function addHu(plan, huData) {
29
- const seq = nextSeq(plan);
30
- const id = generateHuId(plan.planId, seq);
31
- const hu = {
32
- id,
33
- title: huData.title,
34
- task_type: huData.task_type || "sw",
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,
48
- blocked_by: huData.blocked_by || [],
49
- // KJC-BUG-0044 / P3: ids of OTHER HUs whose implementation this HU
50
- // piggy-backs on instead of reimplementing the same logic. Set by
51
- // the planner; consumed by the coder prompt + plan-reviewer to
52
- // avoid duplicate code generation.
53
- reuse: huData.reuse || [],
54
- scope: huData.scope || null,
55
- acceptance_criteria: huData.acceptance_criteria || [],
56
- acceptance_tests: huData.acceptance_tests || [],
57
- // Spec mapping (v2.7.5 PR C): the SPEC.md section / heading this
58
- // HU implements. Lets the coder cite "this implements SPEC §5.3"
59
- // in PRs and the reviewer trace coverage gaps when the SPEC is
60
- // re-read. Free-form string — typically "5.3", "§5.3 Initial
61
- // Scope", or whatever the planner extracted from the source spec.
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,
71
- // KJC-PRP-0002 PR6: optional handle of the human (or AI dev_XXX) who
72
- // owns this HU in a team-shared board. null = unassigned. Free-form
73
- // string — the board prints it as-is; no entity table is enforced
74
- // because team rosters are out of scope (and would force every solo
75
- // dev to register themselves).
76
- assignee: huData.assignee ?? null,
77
- createdAt: new Date().toISOString(),
78
- updatedAt: new Date().toISOString()
79
- };
80
- plan.hus.push(hu);
81
- plan.updatedAt = new Date().toISOString();
82
- return hu;
83
- }
84
-
85
- /**
86
- * Remove an HU from the plan. Also cleans blocked_by references.
87
- * @returns {boolean} true if removed
88
- */
89
- export function removeHu(plan, huId) {
90
- const idx = plan.hus.findIndex(h => h.id === huId);
91
- if (idx === -1) return false;
92
- plan.hus.splice(idx, 1);
93
- // Clean up blocked_by + reuse refs
94
- for (const hu of plan.hus) {
95
- hu.blocked_by = (hu.blocked_by || []).filter(dep => dep !== huId);
96
- hu.reuse = (hu.reuse || []).filter(dep => dep !== huId);
97
- }
98
- plan.updatedAt = new Date().toISOString();
99
- return true;
100
- }
101
-
102
- /**
103
- * Partial update of an HU.
104
- * @param {object} plan
105
- * @param {string} huId
106
- * @param {object} patch - fields to update (title, scope, task_type, acceptance_criteria, acceptance_tests, blocked_by)
107
- * @returns {object|null} updated HU or null if not found
108
- */
109
- export function updateHu(plan, huId, patch) {
110
- const hu = plan.hus.find(h => h.id === huId);
111
- if (!hu) return null;
112
- // KJC-TSK-0405: coder_model y reviewer_model son override per-HU del
113
- // routing automático del triage. Independientes — el usuario puede
114
- // subir el reviewer sin tocar el coder, o viceversa.
115
- 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", "assignee"];
116
- for (const key of allowed) {
117
- if (patch[key] !== undefined) hu[key] = patch[key];
118
- }
119
- hu.updatedAt = new Date().toISOString();
120
- plan.updatedAt = new Date().toISOString();
121
- return hu;
122
- }
123
-
124
- /**
125
- * Update HU status.
126
- * @returns {boolean} true if updated
127
- */
128
- export function updateHuStatus(plan, huId, status) {
129
- const hu = plan.hus.find(h => h.id === huId);
130
- if (!hu) return false;
131
- hu.status = status;
132
- hu.updatedAt = new Date().toISOString();
133
- plan.updatedAt = new Date().toISOString();
134
- return true;
135
- }
136
-
137
- /**
138
- * Stamp the per-HU outcome — what actually happened during execution.
139
- * Written ONCE per HU, at the end of runSingleHu in hu-sub-pipeline.
140
- * Consumed by the board to render the per-HU summary indicator and
141
- * by `kj report` for retrospective inspection.
142
- *
143
- * Shape (every field optional except status + finishedAt):
144
- * {
145
- * status: "done" | "failed" | "blocked",
146
- * iterations: number, // how many coder→reviewer rounds
147
- * duration_ms: number, // wall-clock for this HU
148
- * branch: string|null, // git branch the coder used
149
- * commits: string[], // SHA list captured during the run
150
- * pr_url: string|null,
151
- * blockers: string[], // why it failed/was blocked, plain
152
- * summary: string, // 1-2 sentence human summary
153
- * finishedAt: ISO timestamp
154
- * }
155
- *
156
- * @returns {boolean} true if the HU was found and the outcome stored
157
- */
158
- export function setHuOutcome(plan, huId, outcome) {
159
- const hu = plan.hus.find(h => h.id === huId);
160
- if (!hu) return false;
161
- hu.outcome = {
162
- status: outcome.status || hu.status || null,
163
- iterations: outcome.iterations ?? null,
164
- duration_ms: outcome.duration_ms ?? null,
165
- branch: outcome.branch ?? null,
166
- commits: Array.isArray(outcome.commits) ? outcome.commits : [],
167
- pr_url: outcome.pr_url ?? null,
168
- blockers: Array.isArray(outcome.blockers) ? outcome.blockers : [],
169
- summary: outcome.summary || "",
170
- finishedAt: outcome.finishedAt || new Date().toISOString(),
171
- };
172
- hu.updatedAt = new Date().toISOString();
173
- plan.updatedAt = new Date().toISOString();
174
- return true;
175
- }
176
-
177
- /**
178
- * Stamp the plan-level rollup once every HU has finished. Built from
179
- * the per-HU outcomes so the board can show "Plan finished · 8 done
180
- * · 2 failed · 1 blocked · 15 min" in one banner.
181
- *
182
- * {
183
- * status: "done" | "partial" | "failed",
184
- * total: number,
185
- * counts: { done, failed, blocked, pending },
186
- * duration_ms: number,
187
- * prs: string[], // unique pr_url across HUs
188
- * blockers: string[], // dedup'd blockers
189
- * finishedAt: ISO timestamp,
190
- * }
191
- */
192
- export function setPlanOutcome(plan, outcome) {
193
- plan.outcome = {
194
- status: outcome.status || "partial",
195
- total: outcome.total ?? (plan.hus?.length || 0),
196
- counts: outcome.counts || { done: 0, failed: 0, blocked: 0, pending: 0 },
197
- duration_ms: outcome.duration_ms ?? null,
198
- prs: Array.isArray(outcome.prs) ? [...new Set(outcome.prs.filter(Boolean))] : [],
199
- blockers: Array.isArray(outcome.blockers) ? [...new Set(outcome.blockers.filter(Boolean))] : [],
200
- finishedAt: outcome.finishedAt || new Date().toISOString(),
201
- };
202
- plan.updatedAt = new Date().toISOString();
203
- }
204
-
205
- /**
206
- * PR-E: auto-promote every pending HU with at least one acceptance
207
- * test to "certified". The intermediate "certified" state was an
208
- * invisible wall — there's no UI button for it and the user had no
209
- * way to discover `kj plan ready`. Treating pending-with-tests as
210
- * runnable removes the wall while keeping the test contract gate.
211
- *
212
- * Mutates plan in place. HUs in done / failed / blocked are left
213
- * alone so re-runs stay explicit (use --hu <id>).
214
- *
215
- * @returns {number} how many HUs were promoted (0 if nothing changed).
216
- */
217
- export function autoCertifyPendingHus(plan) {
218
- if (!plan?.hus?.length) return 0;
219
- let promoted = 0;
220
- const now = new Date().toISOString();
221
- for (const hu of plan.hus) {
222
- if (hu.status === "pending" && Array.isArray(hu.acceptance_tests) && hu.acceptance_tests.length > 0) {
223
- hu.status = "certified";
224
- hu.updatedAt = now;
225
- promoted += 1;
226
- }
227
- }
228
- if (promoted > 0) plan.updatedAt = now;
229
- return promoted;
230
- }
231
-
232
- /**
233
- * PR-E: assert that the plan has at least one HU that's actually
234
- * going to run. Throws with a plain-Spanish message otherwise so
235
- * `kj run --plan` can never silently fall back to single-task mode
236
- * and burn tokens for nothing.
237
- *
238
- * The set of "runnable" statuses matches the sub-pipeline filter
239
- * (today: certified). The caller should run autoCertifyPendingHus()
240
- * BEFORE this so pending-with-tests get a chance.
241
- *
242
- * @throws {Error} when no HU is runnable.
243
- */
244
- export function assertPlanRunnable(plan, planId) {
245
- if (!plan?.hus?.length) {
246
- throw new Error(
247
- `El plan ${planId} no contiene ninguna HU. `
248
- + `Edita el plan o vuelve a generarlo con \`kj plan\`. `
249
- + `Aborto para no malgastar tokens en un fallback de "single task".`
250
- );
251
- }
252
- const certified = plan.hus.filter((h) => h.status === "certified").length;
253
- if (certified > 0) return;
254
- const counts = plan.hus.reduce((acc, h) => {
255
- const k = h.status || "pending";
256
- acc[k] = (acc[k] || 0) + 1;
257
- return acc;
258
- }, {});
259
- const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ");
260
- throw new Error(
261
- `Plan ${planId}: 0 HU(s) listas para ejecutar (estados: ${summary}). `
262
- + `Si las HU(s) están en done/failed/blocked y quieres relanzarlas, usa --hu <id>. `
263
- + `Aborto para no malgastar tokens en un fallback de "single task".`
264
- );
265
- }
266
-
267
- /**
268
- * Compute the rollup from the per-HU outcomes already stamped on the
269
- * plan. Invoked by syncResultsToPlan in plan-executor when the run
270
- * ends so the caller doesn't have to assemble the counts by hand.
271
- */
272
- export function computePlanOutcome(plan, { duration_ms = null } = {}) {
273
- const hus = plan.hus || [];
274
- const counts = { done: 0, failed: 0, blocked: 0, pending: 0 };
275
- const prs = [];
276
- const blockers = [];
277
- for (const hu of hus) {
278
- const status = hu.outcome?.status || hu.status || "pending";
279
- if (counts[status] !== undefined) counts[status] += 1;
280
- else counts.pending += 1;
281
- if (hu.outcome?.pr_url) prs.push(hu.outcome.pr_url);
282
- if (Array.isArray(hu.outcome?.blockers)) blockers.push(...hu.outcome.blockers);
283
- }
284
- let status = "done";
285
- if (counts.failed > 0 || counts.blocked > 0) status = counts.done > 0 ? "partial" : "failed";
286
- if (counts.done === 0 && counts.failed === 0 && counts.blocked === 0) status = "pending";
287
- return {
288
- status,
289
- total: hus.length,
290
- counts,
291
- duration_ms,
292
- // Dedupe at the rollup boundary — same PR / blocker reported by
293
- // multiple HUs is noise in the banner.
294
- prs: [...new Set(prs.filter(Boolean))],
295
- blockers: [...new Set(blockers.filter(Boolean))],
296
- finishedAt: new Date().toISOString(),
297
- };
298
- }
299
-
300
- /**
301
- * Mark all pending HUs as certified (ready to execute).
302
- * Sets plan status to "ready".
303
- * @returns {number} count of certified HUs
304
- */
305
- export function certifyAllHus(plan) {
306
- let count = 0;
307
- for (const hu of plan.hus) {
308
- if (hu.status === "pending") {
309
- hu.status = "certified";
310
- hu.updatedAt = new Date().toISOString();
311
- count++;
312
- }
313
- }
314
- plan.status = "ready";
315
- plan.updatedAt = new Date().toISOString();
316
- return count;
317
- }
318
-
319
- /**
320
- * Reorder HUs by providing an ordered list of IDs.
321
- * Updates blocked_by to reflect the linear order.
322
- * @param {object} plan
323
- * @param {string[]} orderedIds
324
- */
325
- export function reorderHus(plan, orderedIds) {
326
- const map = new Map(plan.hus.map(h => [h.id, h]));
327
- const reordered = [];
328
- for (const id of orderedIds) {
329
- const hu = map.get(id);
330
- if (hu) reordered.push(hu);
331
- }
332
- // Add any HUs not in the ordered list at the end
333
- for (const hu of plan.hus) {
334
- if (!orderedIds.includes(hu.id)) reordered.push(hu);
335
- }
336
- plan.hus = reordered;
337
- plan.updatedAt = new Date().toISOString();
338
- }