proposals-store.mjs
575 lines 20.3 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days 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 * task_meta?: { record_kind: string, proposal_kind: string, task_id?: string|null, loop_id?: string|null, occurrence_key?: string|null, cascade_task_ids?: string[] },
178 * }} input
179 */
180 export function createProposal(dataDir, input) {
181 const all = loadProposals(dataDir);
182 const now = new Date().toISOString();
183 const proposedBy =
184 typeof input.proposed_by === 'string' && input.proposed_by.trim() ? input.proposed_by.trim() : undefined;
185 const ext =
186 input.external_ref != null && String(input.external_ref).trim()
187 ? String(input.external_ref).trim().slice(0, 512)
188 : '';
189 const needPending = Boolean(input.evaluationRequired || input.evaluationForcedPending);
190 const evaluation_status = needPending ? 'pending' : 'none';
191 const rq =
192 input.review_queue != null && String(input.review_queue).trim()
193 ? String(input.review_queue).trim().slice(0, 64)
194 : undefined;
195 const rs =
196 input.review_severity === 'elevated' || input.review_severity === 'standard'
197 ? input.review_severity
198 : undefined;
199 const afr = Array.isArray(input.auto_flag_reasons)
200 ? input.auto_flag_reasons.map((x) => String(x).slice(0, 256)).filter(Boolean).slice(0, 32)
201 : [];
202 const proposal = {
203 proposal_id: randomUUID(),
204 path: input.path || `inbox/proposal-${Date.now()}.md`,
205 status: 'proposed',
206 vault_id: typeof input.vault_id === 'string' && input.vault_id.trim() ? input.vault_id.trim() : 'default',
207 intent: input.intent ?? undefined,
208 base_state_id: input.base_state_id ?? undefined,
209 external_ref: ext || undefined,
210 body: input.body ?? '',
211 frontmatter: input.frontmatter ?? {},
212 labels: normalizeLabels(input.labels),
213 source: normalizeSource(input.source),
214 suggested_labels: [],
215 assistant_notes: undefined,
216 assistant_model: undefined,
217 assistant_at: undefined,
218 assistant_suggested_frontmatter: undefined,
219 evaluation_status,
220 evaluation_grade: undefined,
221 evaluation_checklist: undefined,
222 evaluation_comment: undefined,
223 evaluated_by: undefined,
224 evaluated_at: undefined,
225 evaluation_waiver: undefined,
226 ...(rq && { review_queue: rq }),
227 ...(rs && { review_severity: rs }),
228 ...(afr.length ? { auto_flag_reasons: afr } : {}),
229 ...(input.flow_meta && typeof input.flow_meta === 'object'
230 ? {
231 flow_meta: {
232 kind: String(input.flow_meta.kind || 'new').slice(0, 16),
233 base_version:
234 input.flow_meta.base_version != null ? String(input.flow_meta.base_version).slice(0, 32) : null,
235 base_state_id: String(input.flow_meta.base_state_id || '').slice(0, 96),
236 },
237 }
238 : {}),
239 ...(input.capture_meta && typeof input.capture_meta === 'object'
240 ? {
241 capture_meta: {
242 proposal_kind: String(input.capture_meta.proposal_kind || '').slice(0, 32),
243 candidate_id: String(input.capture_meta.candidate_id || '').slice(0, 48),
244 confirmed_scope:
245 input.capture_meta.confirmed_scope != null
246 ? String(input.capture_meta.confirmed_scope).slice(0, 16)
247 : undefined,
248 merge_into_flow_id:
249 input.capture_meta.merge_into_flow_id != null
250 ? String(input.capture_meta.merge_into_flow_id).slice(0, 80)
251 : null,
252 },
253 }
254 : {}),
255 ...(input.delegation_meta && typeof input.delegation_meta === 'object'
256 ? {
257 delegation_meta: {
258 record_kind: String(input.delegation_meta.record_kind || '').slice(0, 32),
259 agent_id:
260 input.delegation_meta.agent_id != null
261 ? String(input.delegation_meta.agent_id).slice(0, 64)
262 : undefined,
263 consent_id:
264 input.delegation_meta.consent_id != null
265 ? String(input.delegation_meta.consent_id).slice(0, 64)
266 : undefined,
267 },
268 }
269 : {}),
270 ...(input.task_meta && typeof input.task_meta === 'object'
271 ? {
272 task_meta: {
273 record_kind: String(input.task_meta.record_kind || 'task').slice(0, 32),
274 proposal_kind: String(input.task_meta.proposal_kind || '').slice(0, 32),
275 task_id:
276 input.task_meta.task_id != null ? String(input.task_meta.task_id).slice(0, 64) : null,
277 loop_id:
278 input.task_meta.loop_id != null ? String(input.task_meta.loop_id).slice(0, 64) : null,
279 occurrence_key:
280 input.task_meta.occurrence_key != null
281 ? String(input.task_meta.occurrence_key).slice(0, 64)
282 : null,
283 ...(Array.isArray(input.task_meta.cascade_task_ids)
284 ? {
285 cascade_task_ids: input.task_meta.cascade_task_ids
286 .map((id) => String(id).slice(0, 64))
287 .slice(0, 500),
288 }
289 : {}),
290 },
291 }
292 : {}),
293 ...(input.media_meta && typeof input.media_meta === 'object'
294 ? {
295 media_meta: {
296 record_kind: String(input.media_meta.record_kind || '').slice(0, 32),
297 proposal_kind: String(input.media_meta.proposal_kind || '').slice(0, 32),
298 attachment_id: String(input.media_meta.attachment_id || '').slice(0, 80),
299 connector_id:
300 input.media_meta.connector_id != null
301 ? String(input.media_meta.connector_id).slice(0, 32)
302 : null,
303 consent_id:
304 input.media_meta.consent_id != null
305 ? String(input.media_meta.consent_id).slice(0, 32)
306 : null,
307 note_ref:
308 input.media_meta.note_ref != null
309 ? String(input.media_meta.note_ref).slice(0, 280)
310 : null,
311 },
312 }
313 : {}),
314 review_hints: undefined,
315 review_hints_at: undefined,
316 review_hints_model: undefined,
317 ...(proposedBy && { proposed_by: proposedBy }),
318 created_at: now,
319 updated_at: now,
320 };
321 all.push(proposal);
322 saveProposals(dataDir, all);
323 return proposal;
324 }
325
326 /**
327 * Approve / discard. When approving with a waiver, pass `extras.evaluation_waiver`.
328 * @param {string} dataDir
329 * @param {string} id
330 * @param {'approved'|'discarded'} status
331 * @param {{ evaluation_waiver?: { by: string, at: string, reason: string }, external_ref?: string }} [extras]
332 * @returns {object|null} Updated proposal or null
333 */
334 export function updateProposalStatus(dataDir, id, status, extras = {}) {
335 const all = loadProposals(dataDir);
336 const idx = all.findIndex((p) => p.proposal_id === id);
337 if (idx === -1) return null;
338 const now = new Date().toISOString();
339 let next = { ...all[idx], status, updated_at: now };
340 if (status === 'approved' && extras.evaluation_waiver) {
341 next = { ...next, evaluation_waiver: extras.evaluation_waiver };
342 }
343 if (status === 'approved' && extras.external_ref != null) {
344 const ref = normalizeExternalRef(extras.external_ref);
345 if (ref) next = { ...next, external_ref: ref };
346 }
347 all[idx] = next;
348 saveProposals(dataDir, all);
349 return all[idx];
350 }
351
352 const OUTCOME_TO_STATUS = {
353 pass: 'passed',
354 fail: 'failed',
355 needs_changes: 'needs_changes',
356 };
357
358 /**
359 * @param {string} dataDir
360 * @param {string} id
361 * @param {{
362 * outcome: string,
363 * evaluation_checklist: { id: string, label: string, passed: boolean }[],
364 * evaluation_grade?: string,
365 * evaluation_comment?: string,
366 * evaluated_by: string,
367 * }} payload
368 * @returns {{ ok: true, proposal: object } | { ok: false, error: string, code: string }}
369 */
370 export function submitProposalEvaluation(dataDir, id, payload) {
371 const all = loadProposals(dataDir);
372 const idx = all.findIndex((p) => p.proposal_id === id);
373 if (idx === -1) return { ok: false, error: 'Proposal not found', code: 'NOT_FOUND' };
374 const p = all[idx];
375 if (p.status !== 'proposed') {
376 return { ok: false, error: 'Can only evaluate proposed proposals', code: 'BAD_REQUEST' };
377 }
378 const rawOutcome = String(payload.outcome || '')
379 .trim()
380 .toLowerCase()
381 .replace(/-/g, '_');
382 const evaluation_status = OUTCOME_TO_STATUS[rawOutcome];
383 if (!evaluation_status) {
384 return { ok: false, error: 'outcome must be pass, fail, or needs_changes', code: 'BAD_REQUEST' };
385 }
386 const comment = payload.evaluation_comment != null ? String(payload.evaluation_comment).trim() : '';
387 if ((evaluation_status === 'failed' || evaluation_status === 'needs_changes') && comment.length < 1) {
388 return { ok: false, error: 'comment is required for fail and needs_changes', code: 'BAD_REQUEST' };
389 }
390 const checklist = Array.isArray(payload.evaluation_checklist) ? payload.evaluation_checklist : [];
391 if (evaluation_status === 'passed' && checklist.length > 0) {
392 const allPass = checklist.every((c) => c && c.passed === true);
393 if (!allPass) {
394 return { ok: false, error: 'All checklist items must pass for a pass outcome', code: 'BAD_REQUEST' };
395 }
396 }
397 const grade =
398 payload.evaluation_grade != null && String(payload.evaluation_grade).trim()
399 ? String(payload.evaluation_grade).trim().slice(0, 32)
400 : undefined;
401 const now = new Date().toISOString();
402 const evaluated_by =
403 typeof payload.evaluated_by === 'string' && payload.evaluated_by.trim()
404 ? payload.evaluated_by.trim().slice(0, 512)
405 : 'unknown';
406 all[idx] = {
407 ...p,
408 evaluation_status,
409 evaluation_grade: grade,
410 evaluation_checklist: checklist,
411 evaluation_comment: comment || undefined,
412 evaluated_by,
413 evaluated_at: now,
414 updated_at: now,
415 };
416 saveProposals(dataDir, all);
417 return { ok: true, proposal: all[idx] };
418 }
419
420 /**
421 * Whether approve is allowed without waiver (evaluation satisfied).
422 * @param {object} proposal
423 */
424 export function evaluationAllowsApprove(proposal) {
425 const es = getEvaluationStatus(proposal);
426 return es === 'none' || es === 'passed';
427 }
428
429 /**
430 * Tier-2 assistant fields (feature-flagged route).
431 * @param {string} dataDir
432 * @param {string} id
433 * @param {{
434 * assistant_notes: string,
435 * assistant_model: string,
436 * suggested_labels?: string[],
437 * assistant_suggested_frontmatter?: Record<string, unknown>,
438 * }} fields
439 * @returns {object|null}
440 */
441 export function updateProposalEnrichment(dataDir, id, fields) {
442 const all = loadProposals(dataDir);
443 const idx = all.findIndex((p) => p.proposal_id === id);
444 if (idx === -1) return null;
445 const now = new Date().toISOString();
446 const sug = normalizeLabels(fields.suggested_labels ?? []);
447 const fm = fields.assistant_suggested_frontmatter;
448 const nextFm =
449 fm && typeof fm === 'object' && !Array.isArray(fm) && Object.keys(fm).length > 0 ? { ...fm } : undefined;
450 all[idx] = {
451 ...all[idx],
452 assistant_notes: fields.assistant_notes,
453 assistant_model: fields.assistant_model,
454 assistant_at: now,
455 suggested_labels: sug.length ? sug : all[idx].suggested_labels || [],
456 ...(Object.prototype.hasOwnProperty.call(fields, 'assistant_suggested_frontmatter')
457 ? { assistant_suggested_frontmatter: nextFm }
458 : {}),
459 updated_at: now,
460 };
461 saveProposals(dataDir, all);
462 return all[idx];
463 }
464
465 /**
466 * Optional async LLM review hints (never merge authority).
467 * @param {string} dataDir
468 * @param {string} id
469 * @param {{ review_hints: string, review_hints_model: string }} fields
470 * @returns {object|null}
471 */
472 export function updateProposalReviewHints(dataDir, id, fields) {
473 const all = loadProposals(dataDir);
474 const idx = all.findIndex((p) => p.proposal_id === id);
475 if (idx === -1) return null;
476 const now = new Date().toISOString();
477 all[idx] = {
478 ...all[idx],
479 review_hints: fields.review_hints,
480 review_hints_model: fields.review_hints_model,
481 review_hints_at: now,
482 updated_at: now,
483 };
484 saveProposals(dataDir, all);
485 return all[idx];
486 }
487
488 /**
489 * Discard proposals in "proposed" state whose path is under path_prefix in the given vault.
490 * @param {string} dataDir
491 * @param {{ vault_id?: string, path_prefix: string }} opts
492 * @returns {number} count discarded
493 */
494 export function discardProposalsUnderPathPrefix(dataDir, opts) {
495 const pathPrefixRaw = opts && opts.path_prefix != null ? String(opts.path_prefix) : '';
496 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
497 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
498 const all = loadProposals(dataDir);
499 const now = new Date().toISOString();
500 let n = 0;
501 const next = all.map((p) => {
502 if (p.status !== 'proposed') return p;
503 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
504 if (pv !== vid) return p;
505 if (!notePathMatchesPrefix(p.path, prefixNorm)) return p;
506 n += 1;
507 return { ...p, status: 'discarded', updated_at: now };
508 });
509 saveProposals(dataDir, next);
510 return n;
511 }
512
513 /**
514 * Discard proposals in "proposed" state whose path is in the given set (exact match, vault-relative forward slashes).
515 * @param {string} dataDir
516 * @param {{ vault_id?: string, paths: string[] }} opts
517 * @returns {number} count discarded
518 */
519 export function discardProposalsAtPaths(dataDir, opts) {
520 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
521 const set = new Set((opts.paths || []).map((p) => String(p).replace(/\\/g, '/')));
522 if (set.size === 0) return 0;
523 const all = loadProposals(dataDir);
524 const now = new Date().toISOString();
525 let n = 0;
526 const next = all.map((p) => {
527 if (p.status !== 'proposed') return p;
528 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
529 if (pv !== vid) return p;
530 const normPath = String(p.path || '').replace(/\\/g, '/');
531 if (!set.has(normPath)) return p;
532 n += 1;
533 return { ...p, status: 'discarded', updated_at: now };
534 });
535 saveProposals(dataDir, next);
536 return n;
537 }
538
539 export function removeProposalsForVault(dataDir, vaultId) {
540 const vid = String(vaultId || '').trim();
541 if (!vid) return 0;
542 const all = loadProposals(dataDir);
543 const next = all.filter((p) => {
544 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
545 return pv !== vid;
546 });
547 const removed = all.length - next.length;
548 if (removed > 0) saveProposals(dataDir, next);
549 return removed;
550 }
551
552 /**
553 * Record OD-4 cascade task ids on an approved task proposal's task_meta (audit).
554 *
555 * @param {string} dataDir
556 * @param {string} proposalId
557 * @param {string[]} cascadeTaskIds
558 * @returns {object|null}
559 */
560 export function patchProposalTaskMetaCascade(dataDir, proposalId, cascadeTaskIds) {
561 const all = loadProposals(dataDir);
562 const idx = all.findIndex((p) => p.proposal_id === proposalId);
563 if (idx === -1) return null;
564 const meta = all[idx].task_meta && typeof all[idx].task_meta === 'object' ? all[idx].task_meta : {};
565 all[idx] = {
566 ...all[idx],
567 task_meta: {
568 ...meta,
569 cascade_task_ids: cascadeTaskIds.map((id) => String(id).slice(0, 64)).slice(0, 500),
570 },
571 updated_at: new Date().toISOString(),
572 };
573 saveProposals(dataDir, all);
574 return all[idx];
575 }
File History 5 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 43 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 50 days ago