proposals-store.mjs
468 lines 15.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * File-based proposal store. Phase 11 + augmentation (labels, enrich, external_ref) + human evaluation.
3 * Stores proposals in data_dir/hub_proposals.json.
4 */
5
6 import fs from 'fs';
7 import path from 'path';
8 import { randomUUID } from 'crypto';
9
10 import { notePathMatchesPrefix, normalizePathPrefix } from '../lib/write.mjs';
11 import { normalizeExternalRef } from '../lib/muse-thin-bridge.mjs';
12
13 const FILENAME = 'hub_proposals.json';
14
15 export function getProposalsPath(dataDir) {
16 return path.join(dataDir, FILENAME);
17 }
18
19 function loadProposals(dataDir) {
20 const filePath = getProposalsPath(dataDir);
21 if (!fs.existsSync(filePath)) return [];
22 try {
23 const raw = fs.readFileSync(filePath, 'utf8');
24 return JSON.parse(raw);
25 } catch (_) {
26 return [];
27 }
28 }
29
30 function saveProposals(dataDir, proposals) {
31 const filePath = getProposalsPath(dataDir);
32 const dir = path.dirname(filePath);
33 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
34 fs.writeFileSync(filePath, JSON.stringify(proposals, null, 2), 'utf8');
35 }
36
37 function normalizeLabels(v) {
38 if (!Array.isArray(v)) return [];
39 return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))].slice(0, 32);
40 }
41
42 function normalizeSource(v) {
43 if (v == null || typeof v !== 'string') return undefined;
44 const s = v.trim();
45 if (!s) return undefined;
46 return s.slice(0, 64);
47 }
48
49 /**
50 * Effective evaluation status for gate logic (missing → none).
51 * @param {object} p
52 * @returns {string}
53 */
54 export function getEvaluationStatus(p) {
55 const s = p?.evaluation_status;
56 if (s == null || s === '') return 'none';
57 return String(s);
58 }
59
60 /**
61 * Merge rubric template with client checklist toggles.
62 * @param {{ id: string, label: string }[]} rubricItems
63 * @param {unknown} clientChecklist - array of { id, passed? }
64 * @returns {{ id: string, label: string, passed: boolean }[]}
65 */
66 export function mergeEvaluationChecklist(rubricItems, clientChecklist) {
67 const byId = new Map();
68 if (Array.isArray(clientChecklist)) {
69 for (const row of clientChecklist) {
70 if (!row || typeof row !== 'object') continue;
71 const id = typeof row.id === 'string' ? row.id.trim() : '';
72 if (!id) continue;
73 byId.set(id, Boolean(row.passed));
74 }
75 }
76 const out = (rubricItems || []).map(({ id, label }) => ({
77 id,
78 label,
79 passed: byId.has(id) ? byId.get(id) : false,
80 }));
81 return out;
82 }
83
84 /**
85 * @param {string} dataDir
86 * @param {{
87 * status?: string,
88 * vault_id?: string,
89 * limit?: number,
90 * offset?: number,
91 * label?: string,
92 * source?: string,
93 * path_prefix?: string,
94 * evaluation_status?: string,
95 * }} options
96 * @returns {{ proposals: object[], total: number }}
97 */
98 export function listProposals(dataDir, options = {}) {
99 const all = loadProposals(dataDir);
100 let list = all;
101 if (options.status) list = list.filter((p) => p.status === options.status);
102 if (options.vault_id != null) {
103 list = list.filter((p) => (p.vault_id ?? 'default') === options.vault_id);
104 }
105 if (options.source && String(options.source).trim()) {
106 const src = String(options.source).trim();
107 list = list.filter((p) => (p.source || '') === src);
108 }
109 if (options.label && String(options.label).trim()) {
110 const want = String(options.label).trim().toLowerCase();
111 list = list.filter((p) => {
112 const labels = Array.isArray(p.labels) ? p.labels : [];
113 return labels.some((l) => String(l).toLowerCase() === want);
114 });
115 }
116 if (options.path_prefix && String(options.path_prefix).trim()) {
117 let prefixNorm;
118 try {
119 prefixNorm = normalizePathPrefix(options.path_prefix);
120 } catch {
121 prefixNorm = null;
122 }
123 if (prefixNorm) {
124 list = list.filter((p) => notePathMatchesPrefix(p.path, prefixNorm));
125 }
126 }
127 if (options.evaluation_status && String(options.evaluation_status).trim()) {
128 const want = String(options.evaluation_status).trim();
129 list = list.filter((p) => getEvaluationStatus(p) === want);
130 }
131 if (options.review_queue && String(options.review_queue).trim()) {
132 const want = String(options.review_queue).trim();
133 list = list.filter((p) => (p.review_queue || '') === want);
134 }
135 if (options.review_severity && String(options.review_severity).trim()) {
136 const want = String(options.review_severity).trim();
137 list = list.filter((p) => (p.review_severity || '') === want);
138 }
139 const total = list.length;
140 const offset = Math.max(0, options.offset ?? 0);
141 const limit = Math.max(1, Math.min(options.limit ?? 50, 100));
142 list = list.slice(offset, offset + limit).map((p) => ({ ...p, evaluation_status: getEvaluationStatus(p) }));
143 return { proposals: list, total };
144 }
145
146 /**
147 * @param {string} dataDir
148 * @param {string} id
149 */
150 export function getProposal(dataDir, id) {
151 const all = loadProposals(dataDir);
152 const p = all.find((pr) => pr.proposal_id === id) ?? null;
153 if (!p) return null;
154 return { ...p, evaluation_status: getEvaluationStatus(p) };
155 }
156
157 /**
158 * @param {string} dataDir
159 * @param {{
160 * path?: string,
161 * body?: string,
162 * frontmatter?: object,
163 * intent?: string,
164 * base_state_id?: string,
165 * external_ref?: string,
166 * vault_id?: string,
167 * proposed_by?: string,
168 * labels?: string[],
169 * source?: string,
170 * evaluationRequired?: boolean,
171 * evaluationForcedPending?: boolean,
172 * review_queue?: string,
173 * review_severity?: 'standard'|'elevated',
174 * auto_flag_reasons?: string[],
175 * }} input
176 */
177 export function createProposal(dataDir, input) {
178 const all = loadProposals(dataDir);
179 const now = new Date().toISOString();
180 const proposedBy =
181 typeof input.proposed_by === 'string' && input.proposed_by.trim() ? input.proposed_by.trim() : undefined;
182 const ext =
183 input.external_ref != null && String(input.external_ref).trim()
184 ? String(input.external_ref).trim().slice(0, 512)
185 : '';
186 const needPending = Boolean(input.evaluationRequired || input.evaluationForcedPending);
187 const evaluation_status = needPending ? 'pending' : 'none';
188 const rq =
189 input.review_queue != null && String(input.review_queue).trim()
190 ? String(input.review_queue).trim().slice(0, 64)
191 : undefined;
192 const rs =
193 input.review_severity === 'elevated' || input.review_severity === 'standard'
194 ? input.review_severity
195 : undefined;
196 const afr = Array.isArray(input.auto_flag_reasons)
197 ? input.auto_flag_reasons.map((x) => String(x).slice(0, 256)).filter(Boolean).slice(0, 32)
198 : [];
199 const proposal = {
200 proposal_id: randomUUID(),
201 path: input.path || `inbox/proposal-${Date.now()}.md`,
202 status: 'proposed',
203 vault_id: typeof input.vault_id === 'string' && input.vault_id.trim() ? input.vault_id.trim() : 'default',
204 intent: input.intent ?? undefined,
205 base_state_id: input.base_state_id ?? undefined,
206 external_ref: ext || undefined,
207 body: input.body ?? '',
208 frontmatter: input.frontmatter ?? {},
209 labels: normalizeLabels(input.labels),
210 source: normalizeSource(input.source),
211 suggested_labels: [],
212 assistant_notes: undefined,
213 assistant_model: undefined,
214 assistant_at: undefined,
215 assistant_suggested_frontmatter: undefined,
216 evaluation_status,
217 evaluation_grade: undefined,
218 evaluation_checklist: undefined,
219 evaluation_comment: undefined,
220 evaluated_by: undefined,
221 evaluated_at: undefined,
222 evaluation_waiver: undefined,
223 ...(rq && { review_queue: rq }),
224 ...(rs && { review_severity: rs }),
225 ...(afr.length ? { auto_flag_reasons: afr } : {}),
226 review_hints: undefined,
227 review_hints_at: undefined,
228 review_hints_model: undefined,
229 ...(proposedBy && { proposed_by: proposedBy }),
230 created_at: now,
231 updated_at: now,
232 };
233 all.push(proposal);
234 saveProposals(dataDir, all);
235 return proposal;
236 }
237
238 /**
239 * Approve / discard. When approving with a waiver, pass `extras.evaluation_waiver`.
240 * @param {string} dataDir
241 * @param {string} id
242 * @param {'approved'|'discarded'} status
243 * @param {{ evaluation_waiver?: { by: string, at: string, reason: string }, external_ref?: string }} [extras]
244 * @returns {object|null} Updated proposal or null
245 */
246 export function updateProposalStatus(dataDir, id, status, extras = {}) {
247 const all = loadProposals(dataDir);
248 const idx = all.findIndex((p) => p.proposal_id === id);
249 if (idx === -1) return null;
250 const now = new Date().toISOString();
251 let next = { ...all[idx], status, updated_at: now };
252 if (status === 'approved' && extras.evaluation_waiver) {
253 next = { ...next, evaluation_waiver: extras.evaluation_waiver };
254 }
255 if (status === 'approved' && extras.external_ref != null) {
256 const ref = normalizeExternalRef(extras.external_ref);
257 if (ref) next = { ...next, external_ref: ref };
258 }
259 all[idx] = next;
260 saveProposals(dataDir, all);
261 return all[idx];
262 }
263
264 const OUTCOME_TO_STATUS = {
265 pass: 'passed',
266 fail: 'failed',
267 needs_changes: 'needs_changes',
268 };
269
270 /**
271 * @param {string} dataDir
272 * @param {string} id
273 * @param {{
274 * outcome: string,
275 * evaluation_checklist: { id: string, label: string, passed: boolean }[],
276 * evaluation_grade?: string,
277 * evaluation_comment?: string,
278 * evaluated_by: string,
279 * }} payload
280 * @returns {{ ok: true, proposal: object } | { ok: false, error: string, code: string }}
281 */
282 export function submitProposalEvaluation(dataDir, id, payload) {
283 const all = loadProposals(dataDir);
284 const idx = all.findIndex((p) => p.proposal_id === id);
285 if (idx === -1) return { ok: false, error: 'Proposal not found', code: 'NOT_FOUND' };
286 const p = all[idx];
287 if (p.status !== 'proposed') {
288 return { ok: false, error: 'Can only evaluate proposed proposals', code: 'BAD_REQUEST' };
289 }
290 const rawOutcome = String(payload.outcome || '')
291 .trim()
292 .toLowerCase()
293 .replace(/-/g, '_');
294 const evaluation_status = OUTCOME_TO_STATUS[rawOutcome];
295 if (!evaluation_status) {
296 return { ok: false, error: 'outcome must be pass, fail, or needs_changes', code: 'BAD_REQUEST' };
297 }
298 const comment = payload.evaluation_comment != null ? String(payload.evaluation_comment).trim() : '';
299 if ((evaluation_status === 'failed' || evaluation_status === 'needs_changes') && comment.length < 1) {
300 return { ok: false, error: 'comment is required for fail and needs_changes', code: 'BAD_REQUEST' };
301 }
302 const checklist = Array.isArray(payload.evaluation_checklist) ? payload.evaluation_checklist : [];
303 if (evaluation_status === 'passed' && checklist.length > 0) {
304 const allPass = checklist.every((c) => c && c.passed === true);
305 if (!allPass) {
306 return { ok: false, error: 'All checklist items must pass for a pass outcome', code: 'BAD_REQUEST' };
307 }
308 }
309 const grade =
310 payload.evaluation_grade != null && String(payload.evaluation_grade).trim()
311 ? String(payload.evaluation_grade).trim().slice(0, 32)
312 : undefined;
313 const now = new Date().toISOString();
314 const evaluated_by =
315 typeof payload.evaluated_by === 'string' && payload.evaluated_by.trim()
316 ? payload.evaluated_by.trim().slice(0, 512)
317 : 'unknown';
318 all[idx] = {
319 ...p,
320 evaluation_status,
321 evaluation_grade: grade,
322 evaluation_checklist: checklist,
323 evaluation_comment: comment || undefined,
324 evaluated_by,
325 evaluated_at: now,
326 updated_at: now,
327 };
328 saveProposals(dataDir, all);
329 return { ok: true, proposal: all[idx] };
330 }
331
332 /**
333 * Whether approve is allowed without waiver (evaluation satisfied).
334 * @param {object} proposal
335 */
336 export function evaluationAllowsApprove(proposal) {
337 const es = getEvaluationStatus(proposal);
338 return es === 'none' || es === 'passed';
339 }
340
341 /**
342 * Tier-2 assistant fields (feature-flagged route).
343 * @param {string} dataDir
344 * @param {string} id
345 * @param {{
346 * assistant_notes: string,
347 * assistant_model: string,
348 * suggested_labels?: string[],
349 * assistant_suggested_frontmatter?: Record<string, unknown>,
350 * }} fields
351 * @returns {object|null}
352 */
353 export function updateProposalEnrichment(dataDir, id, fields) {
354 const all = loadProposals(dataDir);
355 const idx = all.findIndex((p) => p.proposal_id === id);
356 if (idx === -1) return null;
357 const now = new Date().toISOString();
358 const sug = normalizeLabels(fields.suggested_labels ?? []);
359 const fm = fields.assistant_suggested_frontmatter;
360 const nextFm =
361 fm && typeof fm === 'object' && !Array.isArray(fm) && Object.keys(fm).length > 0 ? { ...fm } : undefined;
362 all[idx] = {
363 ...all[idx],
364 assistant_notes: fields.assistant_notes,
365 assistant_model: fields.assistant_model,
366 assistant_at: now,
367 suggested_labels: sug.length ? sug : all[idx].suggested_labels || [],
368 ...(Object.prototype.hasOwnProperty.call(fields, 'assistant_suggested_frontmatter')
369 ? { assistant_suggested_frontmatter: nextFm }
370 : {}),
371 updated_at: now,
372 };
373 saveProposals(dataDir, all);
374 return all[idx];
375 }
376
377 /**
378 * Optional async LLM review hints (never merge authority).
379 * @param {string} dataDir
380 * @param {string} id
381 * @param {{ review_hints: string, review_hints_model: string }} fields
382 * @returns {object|null}
383 */
384 export function updateProposalReviewHints(dataDir, id, fields) {
385 const all = loadProposals(dataDir);
386 const idx = all.findIndex((p) => p.proposal_id === id);
387 if (idx === -1) return null;
388 const now = new Date().toISOString();
389 all[idx] = {
390 ...all[idx],
391 review_hints: fields.review_hints,
392 review_hints_model: fields.review_hints_model,
393 review_hints_at: now,
394 updated_at: now,
395 };
396 saveProposals(dataDir, all);
397 return all[idx];
398 }
399
400 /**
401 * Discard proposals in "proposed" state whose path is under path_prefix in the given vault.
402 * @param {string} dataDir
403 * @param {{ vault_id?: string, path_prefix: string }} opts
404 * @returns {number} count discarded
405 */
406 export function discardProposalsUnderPathPrefix(dataDir, opts) {
407 const pathPrefixRaw = opts && opts.path_prefix != null ? String(opts.path_prefix) : '';
408 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
409 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
410 const all = loadProposals(dataDir);
411 const now = new Date().toISOString();
412 let n = 0;
413 const next = all.map((p) => {
414 if (p.status !== 'proposed') return p;
415 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
416 if (pv !== vid) return p;
417 if (!notePathMatchesPrefix(p.path, prefixNorm)) return p;
418 n += 1;
419 return { ...p, status: 'discarded', updated_at: now };
420 });
421 saveProposals(dataDir, next);
422 return n;
423 }
424
425 /**
426 * Discard proposals in "proposed" state whose path is in the given set (exact match, vault-relative forward slashes).
427 * @param {string} dataDir
428 * @param {{ vault_id?: string, paths: string[] }} opts
429 * @returns {number} count discarded
430 */
431 export function discardProposalsAtPaths(dataDir, opts) {
432 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
433 const set = new Set((opts.paths || []).map((p) => String(p).replace(/\\/g, '/')));
434 if (set.size === 0) return 0;
435 const all = loadProposals(dataDir);
436 const now = new Date().toISOString();
437 let n = 0;
438 const next = all.map((p) => {
439 if (p.status !== 'proposed') return p;
440 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
441 if (pv !== vid) return p;
442 const normPath = String(p.path || '').replace(/\\/g, '/');
443 if (!set.has(normPath)) return p;
444 n += 1;
445 return { ...p, status: 'discarded', updated_at: now };
446 });
447 saveProposals(dataDir, next);
448 return n;
449 }
450
451 /**
452 * Remove all proposals for a vault id (Hub delete vault).
453 * @param {string} dataDir
454 * @param {string} vaultId
455 * @returns {number} number removed
456 */
457 export function removeProposalsForVault(dataDir, vaultId) {
458 const vid = String(vaultId || '').trim();
459 if (!vid) return 0;
460 const all = loadProposals(dataDir);
461 const next = all.filter((p) => {
462 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
463 return pv !== vid;
464 });
465 const removed = all.length - next.length;
466 if (removed > 0) saveProposals(dataDir, next);
467 return removed;
468 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago