proposals-store.mjs
496 lines 17.1 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 10 hours 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 * flow_meta?: { kind: string, base_version: string|null, base_state_id: string },
176 * capture_meta?: { proposal_kind: string, candidate_id: string, confirmed_scope?: string, merge_into_flow_id?: string|null },
177 * }} input
178 */
179 export function createProposal(dataDir, input) {
180 const all = loadProposals(dataDir);
181 const now = new Date().toISOString();
182 const proposedBy =
183 typeof input.proposed_by === 'string' && input.proposed_by.trim() ? input.proposed_by.trim() : undefined;
184 const ext =
185 input.external_ref != null && String(input.external_ref).trim()
186 ? String(input.external_ref).trim().slice(0, 512)
187 : '';
188 const needPending = Boolean(input.evaluationRequired || input.evaluationForcedPending);
189 const evaluation_status = needPending ? 'pending' : 'none';
190 const rq =
191 input.review_queue != null && String(input.review_queue).trim()
192 ? String(input.review_queue).trim().slice(0, 64)
193 : undefined;
194 const rs =
195 input.review_severity === 'elevated' || input.review_severity === 'standard'
196 ? input.review_severity
197 : undefined;
198 const afr = Array.isArray(input.auto_flag_reasons)
199 ? input.auto_flag_reasons.map((x) => String(x).slice(0, 256)).filter(Boolean).slice(0, 32)
200 : [];
201 const proposal = {
202 proposal_id: randomUUID(),
203 path: input.path || `inbox/proposal-${Date.now()}.md`,
204 status: 'proposed',
205 vault_id: typeof input.vault_id === 'string' && input.vault_id.trim() ? input.vault_id.trim() : 'default',
206 intent: input.intent ?? undefined,
207 base_state_id: input.base_state_id ?? undefined,
208 external_ref: ext || undefined,
209 body: input.body ?? '',
210 frontmatter: input.frontmatter ?? {},
211 labels: normalizeLabels(input.labels),
212 source: normalizeSource(input.source),
213 suggested_labels: [],
214 assistant_notes: undefined,
215 assistant_model: undefined,
216 assistant_at: undefined,
217 assistant_suggested_frontmatter: undefined,
218 evaluation_status,
219 evaluation_grade: undefined,
220 evaluation_checklist: undefined,
221 evaluation_comment: undefined,
222 evaluated_by: undefined,
223 evaluated_at: undefined,
224 evaluation_waiver: undefined,
225 ...(rq && { review_queue: rq }),
226 ...(rs && { review_severity: rs }),
227 ...(afr.length ? { auto_flag_reasons: afr } : {}),
228 ...(input.flow_meta && typeof input.flow_meta === 'object'
229 ? {
230 flow_meta: {
231 kind: String(input.flow_meta.kind || 'new').slice(0, 16),
232 base_version:
233 input.flow_meta.base_version != null ? String(input.flow_meta.base_version).slice(0, 32) : null,
234 base_state_id: String(input.flow_meta.base_state_id || '').slice(0, 96),
235 },
236 }
237 : {}),
238 ...(input.capture_meta && typeof input.capture_meta === 'object'
239 ? {
240 capture_meta: {
241 proposal_kind: String(input.capture_meta.proposal_kind || '').slice(0, 32),
242 candidate_id: String(input.capture_meta.candidate_id || '').slice(0, 48),
243 confirmed_scope:
244 input.capture_meta.confirmed_scope != null
245 ? String(input.capture_meta.confirmed_scope).slice(0, 16)
246 : undefined,
247 merge_into_flow_id:
248 input.capture_meta.merge_into_flow_id != null
249 ? String(input.capture_meta.merge_into_flow_id).slice(0, 80)
250 : null,
251 },
252 }
253 : {}),
254 review_hints: undefined,
255 review_hints_at: undefined,
256 review_hints_model: undefined,
257 ...(proposedBy && { proposed_by: proposedBy }),
258 created_at: now,
259 updated_at: now,
260 };
261 all.push(proposal);
262 saveProposals(dataDir, all);
263 return proposal;
264 }
265
266 /**
267 * Approve / discard. When approving with a waiver, pass `extras.evaluation_waiver`.
268 * @param {string} dataDir
269 * @param {string} id
270 * @param {'approved'|'discarded'} status
271 * @param {{ evaluation_waiver?: { by: string, at: string, reason: string }, external_ref?: string }} [extras]
272 * @returns {object|null} Updated proposal or null
273 */
274 export function updateProposalStatus(dataDir, id, status, extras = {}) {
275 const all = loadProposals(dataDir);
276 const idx = all.findIndex((p) => p.proposal_id === id);
277 if (idx === -1) return null;
278 const now = new Date().toISOString();
279 let next = { ...all[idx], status, updated_at: now };
280 if (status === 'approved' && extras.evaluation_waiver) {
281 next = { ...next, evaluation_waiver: extras.evaluation_waiver };
282 }
283 if (status === 'approved' && extras.external_ref != null) {
284 const ref = normalizeExternalRef(extras.external_ref);
285 if (ref) next = { ...next, external_ref: ref };
286 }
287 all[idx] = next;
288 saveProposals(dataDir, all);
289 return all[idx];
290 }
291
292 const OUTCOME_TO_STATUS = {
293 pass: 'passed',
294 fail: 'failed',
295 needs_changes: 'needs_changes',
296 };
297
298 /**
299 * @param {string} dataDir
300 * @param {string} id
301 * @param {{
302 * outcome: string,
303 * evaluation_checklist: { id: string, label: string, passed: boolean }[],
304 * evaluation_grade?: string,
305 * evaluation_comment?: string,
306 * evaluated_by: string,
307 * }} payload
308 * @returns {{ ok: true, proposal: object } | { ok: false, error: string, code: string }}
309 */
310 export function submitProposalEvaluation(dataDir, id, payload) {
311 const all = loadProposals(dataDir);
312 const idx = all.findIndex((p) => p.proposal_id === id);
313 if (idx === -1) return { ok: false, error: 'Proposal not found', code: 'NOT_FOUND' };
314 const p = all[idx];
315 if (p.status !== 'proposed') {
316 return { ok: false, error: 'Can only evaluate proposed proposals', code: 'BAD_REQUEST' };
317 }
318 const rawOutcome = String(payload.outcome || '')
319 .trim()
320 .toLowerCase()
321 .replace(/-/g, '_');
322 const evaluation_status = OUTCOME_TO_STATUS[rawOutcome];
323 if (!evaluation_status) {
324 return { ok: false, error: 'outcome must be pass, fail, or needs_changes', code: 'BAD_REQUEST' };
325 }
326 const comment = payload.evaluation_comment != null ? String(payload.evaluation_comment).trim() : '';
327 if ((evaluation_status === 'failed' || evaluation_status === 'needs_changes') && comment.length < 1) {
328 return { ok: false, error: 'comment is required for fail and needs_changes', code: 'BAD_REQUEST' };
329 }
330 const checklist = Array.isArray(payload.evaluation_checklist) ? payload.evaluation_checklist : [];
331 if (evaluation_status === 'passed' && checklist.length > 0) {
332 const allPass = checklist.every((c) => c && c.passed === true);
333 if (!allPass) {
334 return { ok: false, error: 'All checklist items must pass for a pass outcome', code: 'BAD_REQUEST' };
335 }
336 }
337 const grade =
338 payload.evaluation_grade != null && String(payload.evaluation_grade).trim()
339 ? String(payload.evaluation_grade).trim().slice(0, 32)
340 : undefined;
341 const now = new Date().toISOString();
342 const evaluated_by =
343 typeof payload.evaluated_by === 'string' && payload.evaluated_by.trim()
344 ? payload.evaluated_by.trim().slice(0, 512)
345 : 'unknown';
346 all[idx] = {
347 ...p,
348 evaluation_status,
349 evaluation_grade: grade,
350 evaluation_checklist: checklist,
351 evaluation_comment: comment || undefined,
352 evaluated_by,
353 evaluated_at: now,
354 updated_at: now,
355 };
356 saveProposals(dataDir, all);
357 return { ok: true, proposal: all[idx] };
358 }
359
360 /**
361 * Whether approve is allowed without waiver (evaluation satisfied).
362 * @param {object} proposal
363 */
364 export function evaluationAllowsApprove(proposal) {
365 const es = getEvaluationStatus(proposal);
366 return es === 'none' || es === 'passed';
367 }
368
369 /**
370 * Tier-2 assistant fields (feature-flagged route).
371 * @param {string} dataDir
372 * @param {string} id
373 * @param {{
374 * assistant_notes: string,
375 * assistant_model: string,
376 * suggested_labels?: string[],
377 * assistant_suggested_frontmatter?: Record<string, unknown>,
378 * }} fields
379 * @returns {object|null}
380 */
381 export function updateProposalEnrichment(dataDir, id, fields) {
382 const all = loadProposals(dataDir);
383 const idx = all.findIndex((p) => p.proposal_id === id);
384 if (idx === -1) return null;
385 const now = new Date().toISOString();
386 const sug = normalizeLabels(fields.suggested_labels ?? []);
387 const fm = fields.assistant_suggested_frontmatter;
388 const nextFm =
389 fm && typeof fm === 'object' && !Array.isArray(fm) && Object.keys(fm).length > 0 ? { ...fm } : undefined;
390 all[idx] = {
391 ...all[idx],
392 assistant_notes: fields.assistant_notes,
393 assistant_model: fields.assistant_model,
394 assistant_at: now,
395 suggested_labels: sug.length ? sug : all[idx].suggested_labels || [],
396 ...(Object.prototype.hasOwnProperty.call(fields, 'assistant_suggested_frontmatter')
397 ? { assistant_suggested_frontmatter: nextFm }
398 : {}),
399 updated_at: now,
400 };
401 saveProposals(dataDir, all);
402 return all[idx];
403 }
404
405 /**
406 * Optional async LLM review hints (never merge authority).
407 * @param {string} dataDir
408 * @param {string} id
409 * @param {{ review_hints: string, review_hints_model: string }} fields
410 * @returns {object|null}
411 */
412 export function updateProposalReviewHints(dataDir, id, fields) {
413 const all = loadProposals(dataDir);
414 const idx = all.findIndex((p) => p.proposal_id === id);
415 if (idx === -1) return null;
416 const now = new Date().toISOString();
417 all[idx] = {
418 ...all[idx],
419 review_hints: fields.review_hints,
420 review_hints_model: fields.review_hints_model,
421 review_hints_at: now,
422 updated_at: now,
423 };
424 saveProposals(dataDir, all);
425 return all[idx];
426 }
427
428 /**
429 * Discard proposals in "proposed" state whose path is under path_prefix in the given vault.
430 * @param {string} dataDir
431 * @param {{ vault_id?: string, path_prefix: string }} opts
432 * @returns {number} count discarded
433 */
434 export function discardProposalsUnderPathPrefix(dataDir, opts) {
435 const pathPrefixRaw = opts && opts.path_prefix != null ? String(opts.path_prefix) : '';
436 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
437 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
438 const all = loadProposals(dataDir);
439 const now = new Date().toISOString();
440 let n = 0;
441 const next = all.map((p) => {
442 if (p.status !== 'proposed') return p;
443 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
444 if (pv !== vid) return p;
445 if (!notePathMatchesPrefix(p.path, prefixNorm)) return p;
446 n += 1;
447 return { ...p, status: 'discarded', updated_at: now };
448 });
449 saveProposals(dataDir, next);
450 return n;
451 }
452
453 /**
454 * Discard proposals in "proposed" state whose path is in the given set (exact match, vault-relative forward slashes).
455 * @param {string} dataDir
456 * @param {{ vault_id?: string, paths: string[] }} opts
457 * @returns {number} count discarded
458 */
459 export function discardProposalsAtPaths(dataDir, opts) {
460 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
461 const set = new Set((opts.paths || []).map((p) => String(p).replace(/\\/g, '/')));
462 if (set.size === 0) return 0;
463 const all = loadProposals(dataDir);
464 const now = new Date().toISOString();
465 let n = 0;
466 const next = all.map((p) => {
467 if (p.status !== 'proposed') return p;
468 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
469 if (pv !== vid) return p;
470 const normPath = String(p.path || '').replace(/\\/g, '/');
471 if (!set.has(normPath)) return p;
472 n += 1;
473 return { ...p, status: 'discarded', updated_at: now };
474 });
475 saveProposals(dataDir, next);
476 return n;
477 }
478
479 /**
480 * Remove all proposals for a vault id (Hub delete vault).
481 * @param {string} dataDir
482 * @param {string} vaultId
483 * @returns {number} number removed
484 */
485 export function removeProposalsForVault(dataDir, vaultId) {
486 const vid = String(vaultId || '').trim();
487 if (!vid) return 0;
488 const all = loadProposals(dataDir);
489 const next = all.filter((p) => {
490 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
491 return pv !== vid;
492 });
493 const removed = all.length - next.length;
494 if (removed > 0) saveProposals(dataDir, next);
495 return removed;
496 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 10 hours ago