karajan-code 3.0.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 +14 -6
- package/docs/README.es.md +1 -1
- package/package.json +8 -2
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +21 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +462 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +273 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +93 -2
- 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 +43 -10
- 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/audit-test-diet.mjs +151 -0
- 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/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- 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/runner.js +10 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +36 -2
- package/src/cli/register-pipeline.js +3 -0
- package/src/cli/register-plan.js +10 -1
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- 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/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- 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/prompts/tool-correctness-judge.js +31 -0
- package/src/rag/vec-store.js +17 -253
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +11 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- 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 -111
- 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/repo-cleaner.js +79 -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
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8c/8 — Story edit form (inline editor for the HU modal).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so the
|
|
4
|
+
// top-level `function` declarations are hoisted into the shared realm and
|
|
5
|
+
// `showStoryDetail` (story-detail-view.js) can call `renderStoryEditForm`
|
|
6
|
+
// when the user hits ✎ on a story.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - esc() (app.js)
|
|
10
|
+
// - shortStoryId() (utils/formatters.js)
|
|
11
|
+
// - projectInitialsCache (mutable in app.js — assignment crosses
|
|
12
|
+
// scripts transparently)
|
|
13
|
+
// - showStoryDetail() (utils/story-detail-view.js)
|
|
14
|
+
// - showError() (utils/modals.js)
|
|
15
|
+
// - closeModal() (app.js → window.closeModal, used by
|
|
16
|
+
// inline onclick on the modal close button)
|
|
17
|
+
// - renderBoard() (app.js)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Swap the story modal into an inline edit form. Accepts the full story
|
|
21
|
+
* record that `showStoryDetail` just rendered so we don't re-fetch —
|
|
22
|
+
* the user's intent is "edit what I'm looking at".
|
|
23
|
+
*
|
|
24
|
+
* Cancel restores the read-only view via `showStoryDetail`; Save posts a
|
|
25
|
+
* PATCH and, on success, re-opens in read-only with the refreshed row.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} story
|
|
28
|
+
*/
|
|
29
|
+
function renderStoryEditForm(story) {
|
|
30
|
+
const content = document.getElementById('modal-content');
|
|
31
|
+
const initials = projectInitialsCache[story.project_id] || 'kj';
|
|
32
|
+
const shortId = shortStoryId(story, initials);
|
|
33
|
+
|
|
34
|
+
const rawAc = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
|
|
35
|
+
// Represent AC as text: plain strings go verbatim, Gherkin objects
|
|
36
|
+
// collapse to "Given … | When … | Then …". On save we parse back —
|
|
37
|
+
// if the line matches the pattern we treat it as Gherkin, otherwise
|
|
38
|
+
// a free-form string.
|
|
39
|
+
const acInitial = rawAc
|
|
40
|
+
.map((c) => typeof c === 'string' ? c : (c.given ? `Given ${c.given} | When ${c.when} | Then ${c.then}` : JSON.stringify(c)))
|
|
41
|
+
.join('\n');
|
|
42
|
+
|
|
43
|
+
// Tests-first editor: one block per test with a type selector
|
|
44
|
+
// (shell | gherkin), the content textarea, optional file path, and
|
|
45
|
+
// a remove button. Save collects them into the structured v2.7.5
|
|
46
|
+
// array shape. Plain strings in the existing data are treated as
|
|
47
|
+
// legacy shell tests and auto-upgraded to the structured form.
|
|
48
|
+
const rawTests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
|
|
49
|
+
const testsInitial = rawTests.map((t) => {
|
|
50
|
+
if (typeof t === 'string') return { type: 'shell', content: t, file: '' };
|
|
51
|
+
if (t && typeof t === 'object') {
|
|
52
|
+
return {
|
|
53
|
+
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
54
|
+
content: typeof t.content === 'string' ? t.content : JSON.stringify(t),
|
|
55
|
+
file: typeof t.file === 'string' ? t.file : '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { type: 'shell', content: String(t ?? ''), file: '' };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const scopeInitial = story.original_text || story.certified_want || '';
|
|
62
|
+
const TASK_TYPES = ['sw', 'infra', 'doc', 'add-tests', 'refactor'];
|
|
63
|
+
|
|
64
|
+
content.innerHTML = `
|
|
65
|
+
<div class="modal__header">
|
|
66
|
+
<div>
|
|
67
|
+
<div class="modal__title" title="${esc(story.id)}">${esc(shortId)} <span style="font-size:0.75rem;color:var(--text-muted);font-weight:normal">(editing)</span></div>
|
|
68
|
+
<div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<button class="modal__close" onclick="closeModal()">×</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<form id="hu-edit-form" onsubmit="return false" style="display:flex;flex-direction:column;gap:14px;padding:8px 0">
|
|
74
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
75
|
+
<span style="color:var(--text-muted)">Title</span>
|
|
76
|
+
<input type="text" id="edit-title" value="${esc(story.title || '')}"
|
|
77
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem"
|
|
78
|
+
maxlength="200" required>
|
|
79
|
+
</label>
|
|
80
|
+
|
|
81
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
82
|
+
<span style="color:var(--text-muted)">Scope</span>
|
|
83
|
+
<textarea id="edit-scope" rows="4"
|
|
84
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.5;resize:vertical">${esc(scopeInitial)}</textarea>
|
|
85
|
+
</label>
|
|
86
|
+
|
|
87
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
88
|
+
<span style="color:var(--text-muted)">Task type</span>
|
|
89
|
+
<select id="edit-task-type"
|
|
90
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
|
|
91
|
+
${TASK_TYPES.map((t) => `<option value="${t}">${t}</option>`).join('')}
|
|
92
|
+
</select>
|
|
93
|
+
</label>
|
|
94
|
+
|
|
95
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
96
|
+
<span style="color:var(--text-muted)">
|
|
97
|
+
Acceptance criteria — one per line.
|
|
98
|
+
Gherkin: <code>Given X | When Y | Then Z</code>
|
|
99
|
+
</span>
|
|
100
|
+
<textarea id="edit-ac" rows="6"
|
|
101
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.85rem;line-height:1.5;resize:vertical">${esc(acInitial)}</textarea>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
<fieldset style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin:0">
|
|
105
|
+
<legend style="color:var(--text-muted);font-size:0.85rem;padding:0 6px">
|
|
106
|
+
Acceptance tests — the contract the coder must satisfy
|
|
107
|
+
</legend>
|
|
108
|
+
<div id="edit-tests-list" style="display:flex;flex-direction:column;gap:8px"></div>
|
|
109
|
+
<button type="button" id="edit-tests-add"
|
|
110
|
+
style="margin-top:8px;padding:4px 10px;font-size:0.8rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer">
|
|
111
|
+
+ Add test
|
|
112
|
+
</button>
|
|
113
|
+
</fieldset>
|
|
114
|
+
|
|
115
|
+
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)">
|
|
116
|
+
<button type="button" id="edit-cancel" class="control-btn"
|
|
117
|
+
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
118
|
+
Cancel
|
|
119
|
+
</button>
|
|
120
|
+
<button type="button" id="edit-save" class="control-btn"
|
|
121
|
+
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
122
|
+
Save
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</form>
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
// Tests editor state + renderers. Kept on the function scope (not
|
|
129
|
+
// on window) so reopening the form gives a fresh copy.
|
|
130
|
+
let testRows = [...testsInitial];
|
|
131
|
+
const listEl = document.getElementById('edit-tests-list');
|
|
132
|
+
|
|
133
|
+
function renderTestsEditor() {
|
|
134
|
+
listEl.innerHTML = testRows.map((t, i) => `
|
|
135
|
+
<div class="edit-test-row" data-idx="${i}"
|
|
136
|
+
style="display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:start;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-primary)">
|
|
137
|
+
<select data-field="type"
|
|
138
|
+
style="padding:4px 6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-size:0.8rem">
|
|
139
|
+
<option value="shell"${t.type === 'shell' ? ' selected' : ''}>shell</option>
|
|
140
|
+
<option value="gherkin"${t.type === 'gherkin' ? ' selected' : ''}>gherkin</option>
|
|
141
|
+
</select>
|
|
142
|
+
<div style="display:flex;flex-direction:column;gap:4px">
|
|
143
|
+
<textarea data-field="content" rows="${t.type === 'gherkin' ? 3 : 2}"
|
|
144
|
+
placeholder="${t.type === 'gherkin' ? 'Given …\\nWhen …\\nThen …' : 'npx vitest run test/foo.test.js'}"
|
|
145
|
+
style="padding:6px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.82rem;line-height:1.4;resize:vertical">${esc(t.content)}</textarea>
|
|
146
|
+
<input type="text" data-field="file" placeholder="Optional: file path (e.g. tests/login.test.ts)" value="${esc(t.file || '')}"
|
|
147
|
+
style="padding:4px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.75rem">
|
|
148
|
+
</div>
|
|
149
|
+
<button type="button" data-field="remove" title="Remove this test"
|
|
150
|
+
style="align-self:start;padding:4px 8px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem">✕</button>
|
|
151
|
+
</div>
|
|
152
|
+
`).join('');
|
|
153
|
+
|
|
154
|
+
// Bind the edit events. We re-bind on every render — cheap, and
|
|
155
|
+
// keeps the state → DOM mapping simple.
|
|
156
|
+
listEl.querySelectorAll('.edit-test-row').forEach((row) => {
|
|
157
|
+
const idx = Number(row.dataset.idx);
|
|
158
|
+
row.querySelector('[data-field="type"]').addEventListener('change', (e) => {
|
|
159
|
+
testRows[idx].type = e.target.value;
|
|
160
|
+
renderTestsEditor(); // re-render so placeholder updates
|
|
161
|
+
});
|
|
162
|
+
row.querySelector('[data-field="content"]').addEventListener('input', (e) => {
|
|
163
|
+
testRows[idx].content = e.target.value;
|
|
164
|
+
});
|
|
165
|
+
row.querySelector('[data-field="file"]').addEventListener('input', (e) => {
|
|
166
|
+
testRows[idx].file = e.target.value;
|
|
167
|
+
});
|
|
168
|
+
row.querySelector('[data-field="remove"]').addEventListener('click', () => {
|
|
169
|
+
testRows.splice(idx, 1);
|
|
170
|
+
renderTestsEditor();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
renderTestsEditor();
|
|
176
|
+
|
|
177
|
+
document.getElementById('edit-tests-add').addEventListener('click', () => {
|
|
178
|
+
testRows.push({ type: 'shell', content: '', file: '' });
|
|
179
|
+
renderTestsEditor();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
document.getElementById('edit-cancel').addEventListener('click', () => showStoryDetail(story.id));
|
|
183
|
+
document.getElementById('edit-save').addEventListener('click', () => saveStoryEdits(story, () => testRows));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Collect the form values, compute the diff against the story as it
|
|
188
|
+
* was on modal open, and PATCH only the changed fields. Empty diff is
|
|
189
|
+
* a no-op (close out of edit mode without an API call).
|
|
190
|
+
* @param {object} story
|
|
191
|
+
*/
|
|
192
|
+
async function saveStoryEdits(story, getTestRows) {
|
|
193
|
+
const title = document.getElementById('edit-title').value.trim();
|
|
194
|
+
const scope = document.getElementById('edit-scope').value;
|
|
195
|
+
const taskType = document.getElementById('edit-task-type').value;
|
|
196
|
+
const acRaw = document.getElementById('edit-ac').value;
|
|
197
|
+
|
|
198
|
+
if (!title) {
|
|
199
|
+
await showError('Title cannot be empty.', { title: 'Invalid input' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Parse AC: one per line; a line with "Given X | When Y | Then Z"
|
|
204
|
+
// (case-insensitive) becomes Gherkin; everything else stays a string.
|
|
205
|
+
// Blank lines are dropped.
|
|
206
|
+
const acceptance_criteria = acRaw.split('\n')
|
|
207
|
+
.map((line) => line.trim())
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.map((line) => {
|
|
210
|
+
const m = /^Given\s+(.*?)\s*\|\s*When\s+(.*?)\s*\|\s*Then\s+(.*)$/i.exec(line);
|
|
211
|
+
if (m) return { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
|
|
212
|
+
return line;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Collect the structured acceptance_tests from the editor state,
|
|
216
|
+
// dropping rows the user left empty.
|
|
217
|
+
const testRows = typeof getTestRows === 'function' ? getTestRows() : [];
|
|
218
|
+
const acceptance_tests = testRows
|
|
219
|
+
.map((t) => ({
|
|
220
|
+
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
221
|
+
content: (t.content || '').trim(),
|
|
222
|
+
file: (t.file || '').trim(),
|
|
223
|
+
}))
|
|
224
|
+
.filter((t) => t.content.length > 0)
|
|
225
|
+
.map((t) => (t.file ? t : { type: t.type, content: t.content }));
|
|
226
|
+
|
|
227
|
+
// Diff against the original so we don't send untouched fields and
|
|
228
|
+
// let the server's COALESCE keep existing values.
|
|
229
|
+
const patch = {};
|
|
230
|
+
const originalScope = story.original_text || story.certified_want || '';
|
|
231
|
+
if (title !== (story.title || '')) patch.title = title;
|
|
232
|
+
if (scope !== originalScope) patch.scope = scope;
|
|
233
|
+
if (taskType && taskType !== 'sw') patch.task_type = taskType;
|
|
234
|
+
const prevAcStr = story.acceptance_criteria || '[]';
|
|
235
|
+
const nextAcStr = JSON.stringify(acceptance_criteria);
|
|
236
|
+
if (nextAcStr !== prevAcStr) patch.acceptance_criteria = acceptance_criteria;
|
|
237
|
+
const prevTestsStr = story.acceptance_tests || '[]';
|
|
238
|
+
const nextTestsStr = JSON.stringify(acceptance_tests);
|
|
239
|
+
if (nextTestsStr !== prevTestsStr) patch.acceptance_tests = acceptance_tests;
|
|
240
|
+
|
|
241
|
+
if (Object.keys(patch).length === 0) {
|
|
242
|
+
showStoryDetail(story.id);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(`/api/stories/${encodeURIComponent(story.id)}`, {
|
|
248
|
+
method: 'PATCH',
|
|
249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
250
|
+
body: JSON.stringify(patch),
|
|
251
|
+
});
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
if (res.status === 404) {
|
|
254
|
+
await showError(
|
|
255
|
+
'The board server does not recognise the extended PATCH endpoint.\n\n'
|
|
256
|
+
+ 'Restart it to pick up v2.7.5+:\n\n'
|
|
257
|
+
+ ' kj board stop && kj board start',
|
|
258
|
+
{ title: 'Board out of date' }
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
263
|
+
await showError(msg, { title: 'Could not save HU' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
await renderBoard(); // card shows new title/counts
|
|
267
|
+
await showStoryDetail(story.id); // reopen modal with fresh data
|
|
268
|
+
} catch (err) {
|
|
269
|
+
await showError(err.message, { title: 'Could not save HU' });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Karajan Wiki · QMD semantic search</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<div class="header__logo"><div><div class="header__title">Karajan Wiki</div><div class="header__subtitle">QMD semantic search</div></div></div>
|
|
12
|
+
<nav class="header__nav">
|
|
13
|
+
<a class="nav-btn" href="/">← Board</a>
|
|
14
|
+
<a class="nav-btn" href="/rag.html">RAG</a>
|
|
15
|
+
<button class="nav-btn active">Wiki</button>
|
|
16
|
+
</nav>
|
|
17
|
+
</header>
|
|
18
|
+
<main class="main" id="wiki-app">
|
|
19
|
+
<section class="wiki-search">
|
|
20
|
+
<form id="wiki-form" class="wiki-form" autocomplete="off">
|
|
21
|
+
<input id="wiki-q" type="search" placeholder="Search the project wiki…" aria-label="Query" required>
|
|
22
|
+
<input id="wiki-collection" type="text" placeholder="collection (optional)" aria-label="Collection">
|
|
23
|
+
<label class="wiki-fast"><input id="wiki-fast" type="checkbox"> fast (no rerank)</label>
|
|
24
|
+
<button type="submit" class="control-btn" id="wiki-go">Search</button>
|
|
25
|
+
</form>
|
|
26
|
+
<p class="wiki-hint">QMD runs locally — 0 tokens, ~60s with rerank, ~5s in fast mode.</p>
|
|
27
|
+
</section>
|
|
28
|
+
<section id="wiki-results" class="wiki-results"></section>
|
|
29
|
+
</main>
|
|
30
|
+
<script src="/wiki.js"></script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// KJC-TSK-0508 — HU Board Wiki panel frontend. Calls POST /api/wiki/query
|
|
2
|
+
// (server-side spawns `qmd query --format json`) and renders the hits.
|
|
3
|
+
// A 503 with `installable:true` triggers an "Install QMD" CTA instead of
|
|
4
|
+
// a generic error so the user can act on the missing binary.
|
|
5
|
+
const TOKEN = new URLSearchParams(location.search).get("token") || localStorage.getItem("kj_board_token") || "";
|
|
6
|
+
const HEADERS = { "Content-Type": "application/json", ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}) };
|
|
7
|
+
const ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
8
|
+
|
|
9
|
+
function renderInstallCTA(root) {
|
|
10
|
+
root.innerHTML = `<div class="rag-card rag-card--error">
|
|
11
|
+
<h3>QMD not installed</h3>
|
|
12
|
+
<p>The semantic wiki engine is not available on this host. Install it once:</p>
|
|
13
|
+
<pre class="wiki-code">npm install -g @tobilu/qmd</pre>
|
|
14
|
+
<p class="rag-stat-sub">Then re-run <code>kj init</code> in this project to register the wiki collection.</p>
|
|
15
|
+
</div>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderHits(root, results) {
|
|
19
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
20
|
+
root.innerHTML = '<p class="rag-empty">no hits — try a broader query or run <code>qmd update</code>.</p>';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
root.innerHTML = results.map((r) => {
|
|
24
|
+
const src = r.source || r.path || r.file || "(unknown source)";
|
|
25
|
+
const snip = r.snippet || r.text || r.content || "";
|
|
26
|
+
const score = typeof r.score === "number" ? r.score.toFixed(3) : "";
|
|
27
|
+
return `<article class="wiki-hit">
|
|
28
|
+
<header class="wiki-hit__head"><span class="wiki-hit__src">${ESC(src)}</span>${score ? `<span class="wiki-hit__score">${ESC(score)}</span>` : ""}</header>
|
|
29
|
+
<pre class="wiki-hit__snip">${ESC(snip)}</pre>
|
|
30
|
+
</article>`;
|
|
31
|
+
}).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runSearch(ev) {
|
|
35
|
+
ev?.preventDefault();
|
|
36
|
+
const root = document.getElementById("wiki-results");
|
|
37
|
+
const query = document.getElementById("wiki-q").value.trim();
|
|
38
|
+
if (!query) return;
|
|
39
|
+
const collection = document.getElementById("wiki-collection").value.trim() || undefined;
|
|
40
|
+
const fast = document.getElementById("wiki-fast").checked;
|
|
41
|
+
root.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Searching the wiki…</p></div>';
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch("/api/wiki/query", { method: "POST", headers: HEADERS, body: JSON.stringify({ query, collection, fast }) });
|
|
44
|
+
const data = await res.json().catch(() => ({}));
|
|
45
|
+
if (res.status === 503 && data.installable) { renderInstallCTA(root); return; }
|
|
46
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
47
|
+
renderHits(root, data.results);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
root.innerHTML = `<div class="rag-card rag-card--error"><strong>Error:</strong> ${ESC(err.message || String(err))}</div>`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
document.getElementById("wiki-form").addEventListener("submit", runSearch);
|
|
@@ -349,11 +349,19 @@ function getDeep(obj, path) {
|
|
|
349
349
|
return cur;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
// KJC-BUG-0076: never walk into Object.prototype via crafted YAML keys
|
|
353
|
+
// (`__proto__.x`, `constructor.prototype.y`, `prototype.z`). Silent skip
|
|
354
|
+
// over a poisoned segment — the rest of the path is untouched.
|
|
355
|
+
const UNSAFE_KEY_RE = /^(?:__proto__|constructor|prototype)$/;
|
|
356
|
+
|
|
352
357
|
function setDeep(obj, path, value) {
|
|
358
|
+
if (typeof path !== 'string') return;
|
|
353
359
|
const parts = path.split('.');
|
|
354
360
|
const last = parts.pop();
|
|
361
|
+
if (UNSAFE_KEY_RE.test(last)) return;
|
|
355
362
|
let cur = obj;
|
|
356
363
|
for (const p of parts) {
|
|
364
|
+
if (UNSAFE_KEY_RE.test(p)) return;
|
|
357
365
|
if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
|
|
358
366
|
cur = cur[p];
|
|
359
367
|
}
|
|
@@ -361,10 +369,13 @@ function setDeep(obj, path, value) {
|
|
|
361
369
|
}
|
|
362
370
|
|
|
363
371
|
function deleteDeep(obj, path) {
|
|
372
|
+
if (typeof path !== 'string') return;
|
|
364
373
|
const parts = path.split('.');
|
|
365
374
|
const last = parts.pop();
|
|
375
|
+
if (UNSAFE_KEY_RE.test(last)) return;
|
|
366
376
|
let cur = obj;
|
|
367
377
|
for (const p of parts) {
|
|
378
|
+
if (UNSAFE_KEY_RE.test(p)) return;
|
|
368
379
|
if (cur == null || typeof cur !== 'object') return;
|
|
369
380
|
cur = cur[p];
|
|
370
381
|
}
|
|
@@ -516,3 +527,6 @@ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
|
|
|
516
527
|
renameSync(tmpFile, p);
|
|
517
528
|
return { path: p, scope, written: true, applied, errors: [] };
|
|
518
529
|
}
|
|
530
|
+
|
|
531
|
+
// Exposed for prototype-pollution regression tests (KJC-BUG-0076).
|
|
532
|
+
export const __pollutionInternals = { setDeep, deleteDeep };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
|
|
6
6
|
/** @type {import('better-sqlite3').Database | null} */
|
|
@@ -23,6 +23,19 @@ function vitestTmpKjHome() {
|
|
|
23
23
|
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
|
|
24
24
|
'.karajan'
|
|
25
25
|
);
|
|
26
|
+
// KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
|
|
27
|
+
// dir per fork × run. We rm the *parent* (the `karajan-vitest-<pid>-<rand>`
|
|
28
|
+
// wrapper), not the `.karajan` segment, so the whole tmp tree goes.
|
|
29
|
+
// Only fires on graceful exit; SIGKILL leaks are caught by the
|
|
30
|
+
// global-setup mtime purge at the start of the next run.
|
|
31
|
+
const parent = dirname(_vitestKjHome);
|
|
32
|
+
process.on('exit', () => {
|
|
33
|
+
try {
|
|
34
|
+
rmSync(parent, { recursive: true, force: true });
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort: never crash a test process on cleanup failure
|
|
37
|
+
}
|
|
38
|
+
});
|
|
26
39
|
return _vitestKjHome;
|
|
27
40
|
}
|
|
28
41
|
|
|
@@ -310,6 +323,11 @@ export function initDb() {
|
|
|
310
323
|
// unconditionally so solo devs who later promote a plan don't lose
|
|
311
324
|
// values they typed pre-share.
|
|
312
325
|
try { db.exec('ALTER TABLE stories ADD COLUMN assignee TEXT'); } catch { /* already migrated */ }
|
|
326
|
+
// KJC-TSK-0514 (Cost C): persisted USD cost of running this HU. NULL
|
|
327
|
+
// until the orchestrator (Cost D) calls setStoryCost on HU close. UI
|
|
328
|
+
// renders "—" while NULL. Stored as REAL so the SQL aggregations in
|
|
329
|
+
// Cost E (/api/projects/:id/cost) can SUM directly without parsing.
|
|
330
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN cost_usd REAL'); } catch { /* already migrated */ }
|
|
313
331
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
|
|
314
332
|
|
|
315
333
|
// is_test (KJC-TSK-0371 — board polish #3): per-project flag that
|
|
@@ -486,6 +504,32 @@ export function updateStoryStatus(storyId, status) {
|
|
|
486
504
|
return res.changes > 0;
|
|
487
505
|
}
|
|
488
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Persist the USD cost of a HU. Called by the orchestrator (Cost D) once
|
|
509
|
+
* the HU finishes — the value comes from `aggregateRunCost(events).totalUsd`
|
|
510
|
+
* (Cost B). `null` clears the cell so the UI renders "—" again. Other
|
|
511
|
+
* numeric inputs are coerced via `Number`; non-finite values reject with
|
|
512
|
+
* an explicit throw so callers don't silently NULL-out real cost rows.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} storyId
|
|
515
|
+
* @param {number|null} costUsd
|
|
516
|
+
* @returns {boolean} true when the row existed and was updated
|
|
517
|
+
*/
|
|
518
|
+
export function setStoryCost(storyId, costUsd) {
|
|
519
|
+
let value = null;
|
|
520
|
+
if (costUsd !== null && costUsd !== undefined) {
|
|
521
|
+
const n = Number(costUsd);
|
|
522
|
+
if (!Number.isFinite(n)) {
|
|
523
|
+
throw new Error(`setStoryCost: costUsd must be a finite number or null (got ${costUsd})`);
|
|
524
|
+
}
|
|
525
|
+
value = n;
|
|
526
|
+
}
|
|
527
|
+
const res = getDb()
|
|
528
|
+
.prepare('UPDATE stories SET cost_usd = ?, updated_at = ? WHERE id = ?')
|
|
529
|
+
.run(value, new Date().toISOString(), storyId);
|
|
530
|
+
return res.changes > 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
489
533
|
/**
|
|
490
534
|
* List every distinct `plan_id` stamped on the stories of a given project.
|
|
491
535
|
* Used by "mark plan ready" so the board can act on each plan of a project
|
|
@@ -585,6 +629,53 @@ export function getStoriesByProject(projectId) {
|
|
|
585
629
|
`).all(projectId);
|
|
586
630
|
}
|
|
587
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Aggregate $ cost for a project (KJC-TSK-0516, Cost E).
|
|
634
|
+
* Sums stories.cost_usd, groups by plan_id ('unassigned' for NULL).
|
|
635
|
+
* Returns null if project does not exist; otherwise an object with
|
|
636
|
+
* totalUsd=0 and byPlan=[] when no stories carry cost_usd yet.
|
|
637
|
+
* @param {string} projectId
|
|
638
|
+
* @returns {{ totalUsd:number, byPlan:Array<{planId:string,totalUsd:number,huCount:number}>, unknownModelTokens:object, currency:string } | null}
|
|
639
|
+
*/
|
|
640
|
+
export function getProjectCost(projectId) {
|
|
641
|
+
const project = getDb()
|
|
642
|
+
.prepare('SELECT id FROM projects WHERE id = ?')
|
|
643
|
+
.get(projectId);
|
|
644
|
+
if (!project) return null;
|
|
645
|
+
|
|
646
|
+
const rows = getDb()
|
|
647
|
+
.prepare(
|
|
648
|
+
`SELECT plan_id, cost_usd FROM stories
|
|
649
|
+
WHERE project_id = ? AND cost_usd IS NOT NULL`
|
|
650
|
+
)
|
|
651
|
+
.all(projectId);
|
|
652
|
+
|
|
653
|
+
const round4 = (n) => Math.round(n * 10000) / 10000;
|
|
654
|
+
const byPlanMap = new Map();
|
|
655
|
+
let totalUsd = 0;
|
|
656
|
+
for (const r of rows) {
|
|
657
|
+
const planId = r.plan_id || 'unassigned';
|
|
658
|
+
const cost = Number(r.cost_usd);
|
|
659
|
+
if (!Number.isFinite(cost)) continue;
|
|
660
|
+
totalUsd += cost;
|
|
661
|
+
const cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0 };
|
|
662
|
+
cur.totalUsd += cost;
|
|
663
|
+
cur.huCount += 1;
|
|
664
|
+
byPlanMap.set(planId, cur);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const byPlan = Array.from(byPlanMap.values())
|
|
668
|
+
.map((p) => ({ planId: p.planId, totalUsd: round4(p.totalUsd), huCount: p.huCount }))
|
|
669
|
+
.sort((a, b) => b.totalUsd - a.totalUsd);
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
totalUsd: round4(totalUsd),
|
|
673
|
+
byPlan,
|
|
674
|
+
unknownModelTokens: { tokensIn: 0, tokensOut: 0, models: [] },
|
|
675
|
+
currency: 'USD',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
588
679
|
/**
|
|
589
680
|
* Returns full story detail including context requests.
|
|
590
681
|
* @param {string} storyId
|
|
@@ -60,6 +60,85 @@ export function shortTask(text, max = MAX_TASK_CHARS) {
|
|
|
60
60
|
* created_at?: string|null }} session
|
|
61
61
|
* @returns {{ title: string, subtitle: string, idChip: string }}
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Format a per-HU cost (USD) for the card badge. Cost F (KJC-TSK-0517).
|
|
65
|
+
*
|
|
66
|
+
* Two outputs in one call so the UI can render both at once:
|
|
67
|
+
* - `label`: 2-decimal "$X.XX" shown on the card (compact).
|
|
68
|
+
* - `tooltip`: 4-decimal "Estimated cost: $X.XXXX" shown on hover
|
|
69
|
+
* (matches the precision aggregated by Cost B/C/D, see
|
|
70
|
+
* `packages/core/src/cost/index.js`).
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` for null/undefined/non-finite inputs so the caller
|
|
73
|
+
* skips the badge entirely (rendering "$0.00" for an unknown cost
|
|
74
|
+
* would be misleading — there is a real difference between "we know it
|
|
75
|
+
* was free" and "we haven't measured it yet").
|
|
76
|
+
*
|
|
77
|
+
* @param {number|null|undefined} costUsd
|
|
78
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function formatCost(costUsd) {
|
|
81
|
+
if (costUsd === null || costUsd === undefined) return null;
|
|
82
|
+
const n = Number(costUsd);
|
|
83
|
+
if (!Number.isFinite(n)) return null;
|
|
84
|
+
return {
|
|
85
|
+
label: `$${n.toFixed(2)}`,
|
|
86
|
+
tooltip: `Estimated cost: $${n.toFixed(4)}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format the aggregated project-level cost chip rendered in the board
|
|
92
|
+
* header. Cost G (KJC-TSK-0518). Consumes the shape returned by
|
|
93
|
+
* `GET /api/projects/:id/cost` (Cost E):
|
|
94
|
+
* { totalUsd, byPlan: [{planId, totalUsd, huCount}, …],
|
|
95
|
+
* unknownModelTokens: { tokensIn, tokensOut, models }, currency }
|
|
96
|
+
*
|
|
97
|
+
* Returns `{ label, tooltip }` where:
|
|
98
|
+
* - `label` is the compact "Total: $X.XX" shown in the header.
|
|
99
|
+
* - `tooltip` is a multi-line string with full precision, the
|
|
100
|
+
* per-plan breakdown, and a note when some tokens used a model we
|
|
101
|
+
* don't price (they aren't included in the total — surfacing the
|
|
102
|
+
* skip is more honest than silently swallowing it).
|
|
103
|
+
*
|
|
104
|
+
* Returns `null` when there is nothing to show (no input, or no
|
|
105
|
+
* measured cost yet and no plans). The chip is then hidden — same
|
|
106
|
+
* reasoning as `formatCost`: "$0.00" with no data is misleading.
|
|
107
|
+
*
|
|
108
|
+
* @param {{totalUsd:number, byPlan?:Array, unknownModelTokens?:object}|null|undefined} cost
|
|
109
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
110
|
+
*/
|
|
111
|
+
export function formatProjectCostSummary(cost) {
|
|
112
|
+
if (!cost || typeof cost !== "object") return null;
|
|
113
|
+
const total = Number(cost.totalUsd);
|
|
114
|
+
if (!Number.isFinite(total)) return null;
|
|
115
|
+
const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
|
|
116
|
+
if (total === 0 && byPlan.length === 0) return null;
|
|
117
|
+
|
|
118
|
+
const lines = [`Total: $${total.toFixed(4)}`];
|
|
119
|
+
if (byPlan.length > 0) {
|
|
120
|
+
lines.push("By plan:");
|
|
121
|
+
for (const p of byPlan) {
|
|
122
|
+
const planTotal = Number(p?.totalUsd);
|
|
123
|
+
if (!Number.isFinite(planTotal)) continue;
|
|
124
|
+
const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
|
|
125
|
+
const planLabel = p?.planId || "unassigned";
|
|
126
|
+
lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const unk = cost.unknownModelTokens;
|
|
130
|
+
if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
|
|
131
|
+
const unkTotal = unk.tokensIn + unk.tokensOut;
|
|
132
|
+
if (unkTotal > 0) {
|
|
133
|
+
lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
label: `Total: $${total.toFixed(2)}`,
|
|
138
|
+
tooltip: lines.join("\n"),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
63
142
|
export function formatSessionLabel(session) {
|
|
64
143
|
if (!session || !session.id) {
|
|
65
144
|
return { title: "(no session)", subtitle: "", idChip: "" };
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
-
import { writeJsonAtomicSync } from '
|
|
20
|
+
import { writeJsonAtomicSync } from '@karajan/core/atomic-write';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { trackRun, untrack } from './run-tracker.js';
|
|
23
23
|
import { getHuBoardRunsDir } from './db.js';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
|
-
import { updateHuStatus, certifyAllHus, updateHu } from '
|
|
26
|
-
import { validateBlockedByChange } from '
|
|
25
|
+
import { updateHuStatus, certifyAllHus, updateHu } from '@karajan/core/plan-hu-ops';
|
|
26
|
+
import { validateBlockedByChange } from '@karajan/core/plan-validation';
|
|
27
27
|
import { syncPlanFile } from './sync.js';
|
|
28
28
|
import { getDb } from './db.js';
|
|
29
29
|
|
|
@@ -230,7 +230,7 @@ export async function revertHuFromSnapshot({ planId, huId, projectId }) {
|
|
|
230
230
|
const projectDir = plan.projectDir;
|
|
231
231
|
if (!projectDir) return { ok: false, error: 'plan sin projectDir' };
|
|
232
232
|
|
|
233
|
-
const { restoreHuSnapshot } = await import('
|
|
233
|
+
const { restoreHuSnapshot } = await import('@karajan/core/hu-snapshot');
|
|
234
234
|
const restore = await restoreHuSnapshot({ projectDir, huId });
|
|
235
235
|
if (!restore.ok) return { ok: false, error: `restore falló: ${restore.error}` };
|
|
236
236
|
|