karajan-code 3.1.0 → 3.3.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 (68) hide show
  1. package/README.md +12 -4
  2. package/package.json +6 -1
  3. package/packages/hu-board/package.json +3 -1
  4. package/packages/hu-board/public/index.html +1 -0
  5. package/packages/hu-board/public/styles.css +32 -0
  6. package/packages/hu-board/public/utils/board-view.js +21 -1
  7. package/packages/hu-board/public/utils/formatters.js +68 -0
  8. package/packages/hu-board/public/wiki.html +32 -0
  9. package/packages/hu-board/public/wiki.js +53 -0
  10. package/packages/hu-board/src/db.js +137 -0
  11. package/packages/hu-board/src/format.js +124 -0
  12. package/packages/hu-board/src/plan-mutations.js +4 -4
  13. package/packages/hu-board/src/routes/api.js +16 -1
  14. package/packages/hu-board/src/routes/rag.js +1 -1
  15. package/packages/hu-board/src/routes/wiki.js +43 -0
  16. package/packages/hu-board/src/run-tracker.js +1 -1
  17. package/packages/hu-board/src/server.js +4 -2
  18. package/packages/hu-board/src/sync.js +1 -1
  19. package/scripts/git-hooks/post-merge +13 -2
  20. package/src/agents/aider-agent.js +42 -1
  21. package/src/agents/claude-agent.js +14 -3
  22. package/src/agents/codex-agent.js +44 -7
  23. package/src/agents/gemini-agent.js +37 -1
  24. package/src/agents/opencode-agent.js +34 -1
  25. package/src/audit/ai-slop-findings.js +102 -0
  26. package/src/audit/deterministic-summary.js +18 -0
  27. package/src/brain/standby-scheduler.js +10 -92
  28. package/src/brain/standby-store.js +12 -168
  29. package/src/checks/ai-trash.js +34 -0
  30. package/src/checks/qmd.js +41 -0
  31. package/src/checks/squeezr.js +36 -0
  32. package/src/cli/register-meta.js +21 -0
  33. package/src/cli/register-pipeline.js +3 -0
  34. package/src/commands/audit.js +6 -4
  35. package/src/commands/board.js +34 -0
  36. package/src/commands/doctor.js +6 -0
  37. package/src/commands/init.js +73 -8
  38. package/src/commands/qmd.js +86 -0
  39. package/src/commands/resume.js +6 -45
  40. package/src/commands/run.js +6 -50
  41. package/src/git/hu-snapshot.js +10 -105
  42. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
  43. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  44. package/src/orchestrator/flow-runner.js +10 -2
  45. package/src/orchestrator/hu-sub-pipeline.js +82 -8
  46. package/src/plan/plan-hu-ops.js +16 -338
  47. package/src/plan/plan-id.js +4 -45
  48. package/src/plan/plan-validation.js +4 -82
  49. package/src/rag/vec-store.js +17 -253
  50. package/src/roles/audit-role.js +11 -1
  51. package/src/session/journal/summary-writer.js +55 -1
  52. package/src/utils/ai-trash.js +48 -0
  53. package/src/utils/atomic-write.js +6 -43
  54. package/src/utils/budget.js +38 -9
  55. package/src/utils/cli-ask-question.js +76 -0
  56. package/src/utils/doc-links.js +2 -0
  57. package/src/utils/os-detect.js +10 -0
  58. package/src/utils/paths.js +14 -123
  59. package/src/utils/port-check.js +6 -45
  60. package/src/utils/process.js +4 -188
  61. package/src/utils/qmd-collection.js +72 -0
  62. package/src/utils/qmd-detect.js +18 -0
  63. package/src/utils/qmd-install.js +37 -0
  64. package/src/utils/run-registry.js +9 -116
  65. package/src/utils/shared-paths.js +12 -41
  66. package/src/utils/squeezr-detect.js +18 -0
  67. package/src/utils/squeezr-install.js +37 -0
  68. package/src/utils/telemetry.js +43 -0
@@ -74,6 +74,62 @@ function buildHuTask(story) {
74
74
  return `Implement HU ${story.id}`;
75
75
  }
76
76
 
77
+ /**
78
+ * Sum the cost_usd of a slice of BudgetTracker entries. Returns null
79
+ * (not 0) when the slice is empty or contains no usable cost data —
80
+ * the board uses null as "unmeasured" and renders no badge, while 0
81
+ * means "really free run". Mixing the two would silently hide free
82
+ * runs and falsely show "$0.00" for runs with no telemetry.
83
+ *
84
+ * Cost D (KJC-TSK-0515): the orchestrator calls this once per HU at
85
+ * runSingleHu exit, passing the slice of entries recorded during that
86
+ * HU (snapshotted via the entries.length cursor before the iteration
87
+ * starts). The resulting number flows through buildHuOutcome →
88
+ * notifyOutcome → setStoryCost in inject-loaded-plan.js.
89
+ *
90
+ * @param {Array<{cost_usd?: number}>} entries
91
+ * @returns {number|null}
92
+ */
93
+ function sumCostUsd(entries) {
94
+ if (!Array.isArray(entries) || entries.length === 0) return null;
95
+ let total = 0;
96
+ let hasAny = false;
97
+ for (const e of entries) {
98
+ const n = Number(e?.cost_usd);
99
+ if (Number.isFinite(n)) {
100
+ total += n;
101
+ hasAny = true;
102
+ }
103
+ }
104
+ if (!hasAny) return null;
105
+ return Math.round(total * 1e6) / 1e6;
106
+ }
107
+
108
+ /**
109
+ * Sum an integer token-counter field across a slice of BudgetTracker
110
+ * entries (Φ0-G, KJC-TSK-0525). Same null-vs-0 semantics as sumCostUsd:
111
+ * returns null when nothing measurable was recorded so the board can
112
+ * distinguish "unmeasured" from "really zero".
113
+ *
114
+ * @param {Array<object>} entries
115
+ * @param {string} field — entry key to sum, e.g. "cached_tokens"
116
+ * @returns {number|null}
117
+ */
118
+ function sumTokenField(entries, field) {
119
+ if (!Array.isArray(entries) || entries.length === 0) return null;
120
+ let total = 0;
121
+ let hasAny = false;
122
+ for (const e of entries) {
123
+ const n = Number(e?.[field]);
124
+ if (Number.isFinite(n) && n >= 0) {
125
+ total += n;
126
+ hasAny = true;
127
+ }
128
+ }
129
+ if (!hasAny) return null;
130
+ return Math.round(total);
131
+ }
132
+
77
133
  /**
78
134
  * Build the per-HU outcome blob from whatever metadata the iteration
79
135
  * loop returned. Tolerant by design — most fields are optional and we
@@ -90,9 +146,11 @@ function buildHuTask(story) {
90
146
  * @param {object} args.iterResult — whatever runIterationFn returned
91
147
  * @param {string} args.status — HU_STATUS.* terminal value
92
148
  * @param {number} args.startedAt — Date.now() when runSingleHu started
149
+ * @param {Array} [args.huBudgetEntries] — slice of BudgetTracker
150
+ * entries recorded during this HU. Cost D (KJC-TSK-0515).
93
151
  * @returns {object}
94
152
  */
95
- function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
153
+ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudgetEntries }) {
96
154
  const r = iterResult || {};
97
155
  const git = r.git || {};
98
156
  // Iterations are tracked in the standard pipeline as
@@ -123,6 +181,12 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
123
181
  pr_url: r.pr_url || r.prUrl || git.pr_url || null,
124
182
  blockers,
125
183
  summary,
184
+ cost_usd: sumCostUsd(huBudgetEntries),
185
+ // Φ0-G (KJC-TSK-0525): cached/input tokens drive the "🎯 N% cached"
186
+ // board badge. Null when nothing measurable was recorded so the UI
187
+ // can hide the chip on cold runs or providers without cache.
188
+ cached_tokens: sumTokenField(huBudgetEntries, 'cached_tokens'),
189
+ tokens_in: sumTokenField(huBudgetEntries, 'tokens_in'),
126
190
  finishedAt: new Date().toISOString(),
127
191
  };
128
192
  }
@@ -134,7 +198,7 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
134
198
  * @param {object} params
135
199
  * @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
136
200
  */
137
- async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null }) {
201
+ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
138
202
  // PR1 (live HU status): every saveHuBatch should also notify the
139
203
  // plan JSON so the board reflects state in real time. Defined as a
140
204
  // local helper so we don't repeat the try/null-check boilerplate.
@@ -157,6 +221,16 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
157
221
  // outcome (wall-clock per HU is what the user needs in the board,
158
222
  // not internal stage timings).
159
223
  const huStartedAt = Date.now();
224
+ // Cost D (KJC-TSK-0515): snapshot the budget cursor so we can slice
225
+ // out only the entries this HU produced. BudgetTracker is session-
226
+ // scoped (it accumulates across all HUs of the run), so without this
227
+ // cursor every HU after the first would inherit the previous ones'
228
+ // cost too. Null-safe — older callers without a tracker still work.
229
+ const huBudgetStartIdx = budgetTracker?.entries?.length ?? 0;
230
+ const sliceHuBudget = () => {
231
+ if (!budgetTracker?.entries) return [];
232
+ return budgetTracker.entries.slice(huBudgetStartIdx);
233
+ };
160
234
  const story = batch.stories.find(s => s.id === storyId);
161
235
 
162
236
  // --- Lazy refinement ---
@@ -226,7 +300,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
226
300
  await saveHuBatch(batchSessionId, batch);
227
301
  await notifyStatus(story.id, HU_STATUS.DONE);
228
302
  // PR3: build + persist the per-HU outcome.
229
- const okOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.DONE, startedAt: huStartedAt });
303
+ const okOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.DONE, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
230
304
  await notifyOutcome(story.id, okOutcome);
231
305
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
232
306
  message: `HU ${story.id} status → done`,
@@ -242,7 +316,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
242
316
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
243
317
  await saveHuBatch(batchSessionId, batch);
244
318
  await notifyStatus(story.id, HU_STATUS.FAILED);
245
- const failOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.FAILED, startedAt: huStartedAt });
319
+ const failOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.FAILED, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
246
320
  await notifyOutcome(story.id, failOutcome);
247
321
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
248
322
  message: `HU ${story.id} status → failed`,
@@ -259,7 +333,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
259
333
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
260
334
  await saveHuBatch(batchSessionId, batch);
261
335
  await notifyStatus(story.id, HU_STATUS.FAILED);
262
- const errOutcome = buildHuOutcome({ story, iterResult: { error: err.message }, status: HU_STATUS.FAILED, startedAt: huStartedAt });
336
+ const errOutcome = buildHuOutcome({ story, iterResult: { error: err.message }, status: HU_STATUS.FAILED, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
263
337
  await notifyOutcome(story.id, errOutcome);
264
338
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
265
339
  message: `HU ${story.id} status → failed`,
@@ -287,7 +361,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
287
361
  * @param {object} params.logger - Logger instance
288
362
  * @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
289
363
  */
290
- export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null, onStatusChange = null, onOutcome = null }) {
364
+ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
291
365
  const batchSessionId = huReviewerResult.batchSessionId;
292
366
  let batch;
293
367
  try {
@@ -404,7 +478,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
404
478
  // Single HU: run as before, no worktree needed
405
479
  const singleResult = await runSingleHu({
406
480
  storyId: runnableIds[0], batch, batchSessionId, runIterationFn,
407
- emitter, eventBase, logger, config, results, onStatusChange, onOutcome
481
+ emitter, eventBase, logger, config, results, onStatusChange, onOutcome, budgetTracker
408
482
  });
409
483
  results.push(singleResult);
410
484
  if (!singleResult.approved) {
@@ -443,7 +517,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
443
517
  storyId, batch, batchSessionId, runIterationFn,
444
518
  emitter, eventBase, logger, config, results,
445
519
  worktreePath: worktrees.get(storyId),
446
- onStatusChange, onOutcome
520
+ onStatusChange, onOutcome, budgetTracker
447
521
  });
448
522
  });
449
523
 
@@ -1,338 +1,16 @@
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
- }
1
+ // Shim: plan-hu-ops now lives in @karajan/core/plan-hu-ops so the
2
+ // hu-board workspace can consume it without a relative dep on the CLI
3
+ // src tree. KJC-TSK-0511 PR5.
4
+ export {
5
+ addHu,
6
+ removeHu,
7
+ updateHu,
8
+ updateHuStatus,
9
+ setHuOutcome,
10
+ setPlanOutcome,
11
+ autoCertifyPendingHus,
12
+ assertPlanRunnable,
13
+ computePlanOutcome,
14
+ certifyAllHus,
15
+ reorderHus,
16
+ } from "@karajan/core/plan-hu-ops";
@@ -1,45 +1,4 @@
1
- /**
2
- * Plan + HU ID generation.
3
- * Globally unique IDs that never collide across plans.
4
- */
5
-
6
- /**
7
- * Generate a unique plan ID: plan-<YYYYMMDDHHMMSS>-<4-char random>
8
- */
9
- export function generatePlanId() {
10
- const now = new Date();
11
- const ts = now.toISOString().replaceAll(/[-:T]/g, "").replace(/\.\d+Z$/, "");
12
- const rand = Math.random().toString(36).slice(2, 6);
13
- return `plan-${ts}-${rand}`;
14
- }
15
-
16
- /**
17
- * Generate a globally unique HU ID scoped to a plan.
18
- * Format: hu_<planId>_<3-digit sequence>
19
- * @param {string} planId
20
- * @param {number} seq - 1-based sequence number
21
- * @returns {string}
22
- */
23
- export function generateHuId(planId, seq) {
24
- return `hu_${planId}_${String(seq).padStart(3, "0")}`;
25
- }
26
-
27
- /**
28
- * Normalise a human-friendly name into a CLI-typeable alias.
29
- * "Greta App" → "greta-app", "Linux Assistant Orchestrator" → "linux-assistant-orchestrator".
30
- * Acotado a 60 chars para que no se vuelva tan ilegible como el planId.
31
- *
32
- * @param {string|null|undefined} name
33
- * @returns {string|null} alias o null si name está vacío.
34
- */
35
- export function normaliseAlias(name) {
36
- if (!name || typeof name !== "string") return null;
37
- const slug = name
38
- .normalize("NFKD")
39
- .replaceAll(/[̀-ͯ]/g, "") // strip diacritics
40
- .toLowerCase()
41
- .replaceAll(/[^a-z0-9]+/g, "-") // non-alphanum → guion
42
- .replaceAll(/^-+|-+$/g, "") // trim guiones extremos
43
- .slice(0, 60);
44
- return slug || null;
45
- }
1
+ // Shim: plan-id primitives now live in @karajan/core/plan-id so the
2
+ // hu-board workspace can consume them without a relative dep on the
3
+ // CLI src tree. KJC-TSK-0511 PR5.
4
+ export { generatePlanId, generateHuId, normaliseAlias } from "@karajan/core/plan-id";
@@ -1,82 +1,4 @@
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
- }
1
+ // Shim: plan-validation now lives in @karajan/core/plan-validation so
2
+ // the hu-board workspace can consume it without a relative dep on the
3
+ // CLI src tree. KJC-TSK-0511 PR6.
4
+ export { validateBlockedByChange } from "@karajan/core/plan-validation";