karajan-code 3.1.0 → 3.2.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 +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +10 -1
- package/packages/hu-board/public/utils/formatters.js +41 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +78 -0
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
|
@@ -74,6 +74,37 @@ 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
|
+
|
|
77
108
|
/**
|
|
78
109
|
* Build the per-HU outcome blob from whatever metadata the iteration
|
|
79
110
|
* loop returned. Tolerant by design — most fields are optional and we
|
|
@@ -90,9 +121,11 @@ function buildHuTask(story) {
|
|
|
90
121
|
* @param {object} args.iterResult — whatever runIterationFn returned
|
|
91
122
|
* @param {string} args.status — HU_STATUS.* terminal value
|
|
92
123
|
* @param {number} args.startedAt — Date.now() when runSingleHu started
|
|
124
|
+
* @param {Array} [args.huBudgetEntries] — slice of BudgetTracker
|
|
125
|
+
* entries recorded during this HU. Cost D (KJC-TSK-0515).
|
|
93
126
|
* @returns {object}
|
|
94
127
|
*/
|
|
95
|
-
function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
|
|
128
|
+
function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudgetEntries }) {
|
|
96
129
|
const r = iterResult || {};
|
|
97
130
|
const git = r.git || {};
|
|
98
131
|
// Iterations are tracked in the standard pipeline as
|
|
@@ -123,6 +156,7 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
|
|
|
123
156
|
pr_url: r.pr_url || r.prUrl || git.pr_url || null,
|
|
124
157
|
blockers,
|
|
125
158
|
summary,
|
|
159
|
+
cost_usd: sumCostUsd(huBudgetEntries),
|
|
126
160
|
finishedAt: new Date().toISOString(),
|
|
127
161
|
};
|
|
128
162
|
}
|
|
@@ -134,7 +168,7 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
|
|
|
134
168
|
* @param {object} params
|
|
135
169
|
* @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
|
|
136
170
|
*/
|
|
137
|
-
async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null }) {
|
|
171
|
+
async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
|
|
138
172
|
// PR1 (live HU status): every saveHuBatch should also notify the
|
|
139
173
|
// plan JSON so the board reflects state in real time. Defined as a
|
|
140
174
|
// local helper so we don't repeat the try/null-check boilerplate.
|
|
@@ -157,6 +191,16 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
|
|
|
157
191
|
// outcome (wall-clock per HU is what the user needs in the board,
|
|
158
192
|
// not internal stage timings).
|
|
159
193
|
const huStartedAt = Date.now();
|
|
194
|
+
// Cost D (KJC-TSK-0515): snapshot the budget cursor so we can slice
|
|
195
|
+
// out only the entries this HU produced. BudgetTracker is session-
|
|
196
|
+
// scoped (it accumulates across all HUs of the run), so without this
|
|
197
|
+
// cursor every HU after the first would inherit the previous ones'
|
|
198
|
+
// cost too. Null-safe — older callers without a tracker still work.
|
|
199
|
+
const huBudgetStartIdx = budgetTracker?.entries?.length ?? 0;
|
|
200
|
+
const sliceHuBudget = () => {
|
|
201
|
+
if (!budgetTracker?.entries) return [];
|
|
202
|
+
return budgetTracker.entries.slice(huBudgetStartIdx);
|
|
203
|
+
};
|
|
160
204
|
const story = batch.stories.find(s => s.id === storyId);
|
|
161
205
|
|
|
162
206
|
// --- Lazy refinement ---
|
|
@@ -226,7 +270,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
|
|
|
226
270
|
await saveHuBatch(batchSessionId, batch);
|
|
227
271
|
await notifyStatus(story.id, HU_STATUS.DONE);
|
|
228
272
|
// PR3: build + persist the per-HU outcome.
|
|
229
|
-
const okOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.DONE, startedAt: huStartedAt });
|
|
273
|
+
const okOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.DONE, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
|
|
230
274
|
await notifyOutcome(story.id, okOutcome);
|
|
231
275
|
emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
232
276
|
message: `HU ${story.id} status → done`,
|
|
@@ -242,7 +286,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
|
|
|
242
286
|
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
243
287
|
await saveHuBatch(batchSessionId, batch);
|
|
244
288
|
await notifyStatus(story.id, HU_STATUS.FAILED);
|
|
245
|
-
const failOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.FAILED, startedAt: huStartedAt });
|
|
289
|
+
const failOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.FAILED, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
|
|
246
290
|
await notifyOutcome(story.id, failOutcome);
|
|
247
291
|
emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
248
292
|
message: `HU ${story.id} status → failed`,
|
|
@@ -259,7 +303,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
|
|
|
259
303
|
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
260
304
|
await saveHuBatch(batchSessionId, batch);
|
|
261
305
|
await notifyStatus(story.id, HU_STATUS.FAILED);
|
|
262
|
-
const errOutcome = buildHuOutcome({ story, iterResult: { error: err.message }, status: HU_STATUS.FAILED, startedAt: huStartedAt });
|
|
306
|
+
const errOutcome = buildHuOutcome({ story, iterResult: { error: err.message }, status: HU_STATUS.FAILED, startedAt: huStartedAt, huBudgetEntries: sliceHuBudget() });
|
|
263
307
|
await notifyOutcome(story.id, errOutcome);
|
|
264
308
|
emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
265
309
|
message: `HU ${story.id} status → failed`,
|
|
@@ -287,7 +331,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
|
|
|
287
331
|
* @param {object} params.logger - Logger instance
|
|
288
332
|
* @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
|
|
289
333
|
*/
|
|
290
|
-
export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null, onStatusChange = null, onOutcome = null }) {
|
|
334
|
+
export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null, onStatusChange = null, onOutcome = null, budgetTracker = null }) {
|
|
291
335
|
const batchSessionId = huReviewerResult.batchSessionId;
|
|
292
336
|
let batch;
|
|
293
337
|
try {
|
|
@@ -404,7 +448,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
404
448
|
// Single HU: run as before, no worktree needed
|
|
405
449
|
const singleResult = await runSingleHu({
|
|
406
450
|
storyId: runnableIds[0], batch, batchSessionId, runIterationFn,
|
|
407
|
-
emitter, eventBase, logger, config, results, onStatusChange, onOutcome
|
|
451
|
+
emitter, eventBase, logger, config, results, onStatusChange, onOutcome, budgetTracker
|
|
408
452
|
});
|
|
409
453
|
results.push(singleResult);
|
|
410
454
|
if (!singleResult.approved) {
|
|
@@ -443,7 +487,7 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
443
487
|
storyId, batch, batchSessionId, runIterationFn,
|
|
444
488
|
emitter, eventBase, logger, config, results,
|
|
445
489
|
worktreePath: worktrees.get(storyId),
|
|
446
|
-
onStatusChange, onOutcome
|
|
490
|
+
onStatusChange, onOutcome, budgetTracker
|
|
447
491
|
});
|
|
448
492
|
});
|
|
449
493
|
|
package/src/plan/plan-hu-ops.js
CHANGED
|
@@ -1,338 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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";
|
package/src/plan/plan-id.js
CHANGED
|
@@ -1,45 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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";
|