proposals-store.mjs
511 lines 17.7 KB
Raw
sha256:0279cd72f3b5db53d740fb647a4b0bf68d2c327a0d05cbcb6234c2b128d57c11 feat(delegation): implement 7C-6 agent identity/delegation … Human minor ⚠ breaking 33 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 * }} 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 ...(input.delegation_meta && typeof input.delegation_meta === 'object'
255 ? {
256 delegation_meta: {
257 record_kind: String(input.delegation_meta.record_kind || '').slice(0, 32),
258 agent_id:
259 input.delegation_meta.agent_id != null
260 ? String(input.delegation_meta.agent_id).slice(0, 64)
261 : undefined,
262 consent_id:
263 input.delegation_meta.consent_id != null
264 ? String(input.delegation_meta.consent_id).slice(0, 64)
265 : undefined,
266 },
267 }
268 : {}),
269 review_hints: undefined,
270 review_hints_at: undefined,
271 review_hints_model: undefined,
272 ...(proposedBy && { proposed_by: proposedBy }),
273 created_at: now,
274 updated_at: now,
275 };
276 all.push(proposal);
277 saveProposals(dataDir, all);
278 return proposal;
279 }
280
281 /**
282 * Approve / discard. When approving with a waiver, pass `extras.evaluation_waiver`.
283 * @param {string} dataDir
284 * @param {string} id
285 * @param {'approved'|'discarded'} status
286 * @param {{ evaluation_waiver?: { by: string, at: string, reason: string }, external_ref?: string }} [extras]
287 * @returns {object|null} Updated proposal or null
288 */
289 export function updateProposalStatus(dataDir, id, status, extras = {}) {
290 const all = loadProposals(dataDir);
291 const idx = all.findIndex((p) => p.proposal_id === id);
292 if (idx === -1) return null;
293 const now = new Date().toISOString();
294 let next = { ...all[idx], status, updated_at: now };
295 if (status === 'approved' && extras.evaluation_waiver) {
296 next = { ...next, evaluation_waiver: extras.evaluation_waiver };
297 }
298 if (status === 'approved' && extras.external_ref != null) {
299 const ref = normalizeExternalRef(extras.external_ref);
300 if (ref) next = { ...next, external_ref: ref };
301 }
302 all[idx] = next;
303 saveProposals(dataDir, all);
304 return all[idx];
305 }
306
307 const OUTCOME_TO_STATUS = {
308 pass: 'passed',
309 fail: 'failed',
310 needs_changes: 'needs_changes',
311 };
312
313 /**
314 * @param {string} dataDir
315 * @param {string} id
316 * @param {{
317 * outcome: string,
318 * evaluation_checklist: { id: string, label: string, passed: boolean }[],
319 * evaluation_grade?: string,
320 * evaluation_comment?: string,
321 * evaluated_by: string,
322 * }} payload
323 * @returns {{ ok: true, proposal: object } | { ok: false, error: string, code: string }}
324 */
325 export function submitProposalEvaluation(dataDir, id, payload) {
326 const all = loadProposals(dataDir);
327 const idx = all.findIndex((p) => p.proposal_id === id);
328 if (idx === -1) return { ok: false, error: 'Proposal not found', code: 'NOT_FOUND' };
329 const p = all[idx];
330 if (p.status !== 'proposed') {
331 return { ok: false, error: 'Can only evaluate proposed proposals', code: 'BAD_REQUEST' };
332 }
333 const rawOutcome = String(payload.outcome || '')
334 .trim()
335 .toLowerCase()
336 .replace(/-/g, '_');
337 const evaluation_status = OUTCOME_TO_STATUS[rawOutcome];
338 if (!evaluation_status) {
339 return { ok: false, error: 'outcome must be pass, fail, or needs_changes', code: 'BAD_REQUEST' };
340 }
341 const comment = payload.evaluation_comment != null ? String(payload.evaluation_comment).trim() : '';
342 if ((evaluation_status === 'failed' || evaluation_status === 'needs_changes') && comment.length < 1) {
343 return { ok: false, error: 'comment is required for fail and needs_changes', code: 'BAD_REQUEST' };
344 }
345 const checklist = Array.isArray(payload.evaluation_checklist) ? payload.evaluation_checklist : [];
346 if (evaluation_status === 'passed' && checklist.length > 0) {
347 const allPass = checklist.every((c) => c && c.passed === true);
348 if (!allPass) {
349 return { ok: false, error: 'All checklist items must pass for a pass outcome', code: 'BAD_REQUEST' };
350 }
351 }
352 const grade =
353 payload.evaluation_grade != null && String(payload.evaluation_grade).trim()
354 ? String(payload.evaluation_grade).trim().slice(0, 32)
355 : undefined;
356 const now = new Date().toISOString();
357 const evaluated_by =
358 typeof payload.evaluated_by === 'string' && payload.evaluated_by.trim()
359 ? payload.evaluated_by.trim().slice(0, 512)
360 : 'unknown';
361 all[idx] = {
362 ...p,
363 evaluation_status,
364 evaluation_grade: grade,
365 evaluation_checklist: checklist,
366 evaluation_comment: comment || undefined,
367 evaluated_by,
368 evaluated_at: now,
369 updated_at: now,
370 };
371 saveProposals(dataDir, all);
372 return { ok: true, proposal: all[idx] };
373 }
374
375 /**
376 * Whether approve is allowed without waiver (evaluation satisfied).
377 * @param {object} proposal
378 */
379 export function evaluationAllowsApprove(proposal) {
380 const es = getEvaluationStatus(proposal);
381 return es === 'none' || es === 'passed';
382 }
383
384 /**
385 * Tier-2 assistant fields (feature-flagged route).
386 * @param {string} dataDir
387 * @param {string} id
388 * @param {{
389 * assistant_notes: string,
390 * assistant_model: string,
391 * suggested_labels?: string[],
392 * assistant_suggested_frontmatter?: Record<string, unknown>,
393 * }} fields
394 * @returns {object|null}
395 */
396 export function updateProposalEnrichment(dataDir, id, fields) {
397 const all = loadProposals(dataDir);
398 const idx = all.findIndex((p) => p.proposal_id === id);
399 if (idx === -1) return null;
400 const now = new Date().toISOString();
401 const sug = normalizeLabels(fields.suggested_labels ?? []);
402 const fm = fields.assistant_suggested_frontmatter;
403 const nextFm =
404 fm && typeof fm === 'object' && !Array.isArray(fm) && Object.keys(fm).length > 0 ? { ...fm } : undefined;
405 all[idx] = {
406 ...all[idx],
407 assistant_notes: fields.assistant_notes,
408 assistant_model: fields.assistant_model,
409 assistant_at: now,
410 suggested_labels: sug.length ? sug : all[idx].suggested_labels || [],
411 ...(Object.prototype.hasOwnProperty.call(fields, 'assistant_suggested_frontmatter')
412 ? { assistant_suggested_frontmatter: nextFm }
413 : {}),
414 updated_at: now,
415 };
416 saveProposals(dataDir, all);
417 return all[idx];
418 }
419
420 /**
421 * Optional async LLM review hints (never merge authority).
422 * @param {string} dataDir
423 * @param {string} id
424 * @param {{ review_hints: string, review_hints_model: string }} fields
425 * @returns {object|null}
426 */
427 export function updateProposalReviewHints(dataDir, id, fields) {
428 const all = loadProposals(dataDir);
429 const idx = all.findIndex((p) => p.proposal_id === id);
430 if (idx === -1) return null;
431 const now = new Date().toISOString();
432 all[idx] = {
433 ...all[idx],
434 review_hints: fields.review_hints,
435 review_hints_model: fields.review_hints_model,
436 review_hints_at: now,
437 updated_at: now,
438 };
439 saveProposals(dataDir, all);
440 return all[idx];
441 }
442
443 /**
444 * Discard proposals in "proposed" state whose path is under path_prefix in the given vault.
445 * @param {string} dataDir
446 * @param {{ vault_id?: string, path_prefix: string }} opts
447 * @returns {number} count discarded
448 */
449 export function discardProposalsUnderPathPrefix(dataDir, opts) {
450 const pathPrefixRaw = opts && opts.path_prefix != null ? String(opts.path_prefix) : '';
451 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
452 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
453 const all = loadProposals(dataDir);
454 const now = new Date().toISOString();
455 let n = 0;
456 const next = all.map((p) => {
457 if (p.status !== 'proposed') return p;
458 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
459 if (pv !== vid) return p;
460 if (!notePathMatchesPrefix(p.path, prefixNorm)) return p;
461 n += 1;
462 return { ...p, status: 'discarded', updated_at: now };
463 });
464 saveProposals(dataDir, next);
465 return n;
466 }
467
468 /**
469 * Discard proposals in "proposed" state whose path is in the given set (exact match, vault-relative forward slashes).
470 * @param {string} dataDir
471 * @param {{ vault_id?: string, paths: string[] }} opts
472 * @returns {number} count discarded
473 */
474 export function discardProposalsAtPaths(dataDir, opts) {
475 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
476 const set = new Set((opts.paths || []).map((p) => String(p).replace(/\\/g, '/')));
477 if (set.size === 0) return 0;
478 const all = loadProposals(dataDir);
479 const now = new Date().toISOString();
480 let n = 0;
481 const next = all.map((p) => {
482 if (p.status !== 'proposed') return p;
483 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
484 if (pv !== vid) return p;
485 const normPath = String(p.path || '').replace(/\\/g, '/');
486 if (!set.has(normPath)) return p;
487 n += 1;
488 return { ...p, status: 'discarded', updated_at: now };
489 });
490 saveProposals(dataDir, next);
491 return n;
492 }
493
494 /**
495 * Remove all proposals for a vault id (Hub delete vault).
496 * @param {string} dataDir
497 * @param {string} vaultId
498 * @returns {number} number removed
499 */
500 export function removeProposalsForVault(dataDir, vaultId) {
501 const vid = String(vaultId || '').trim();
502 if (!vid) return 0;
503 const all = loadProposals(dataDir);
504 const next = all.filter((p) => {
505 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
506 return pv !== vid;
507 });
508 const removed = all.length - next.length;
509 if (removed > 0) saveProposals(dataDir, next);
510 return removed;
511 }
File History 1 commit
sha256:0279cd72f3b5db53d740fb647a4b0bf68d2c327a0d05cbcb6234c2b128d57c11 feat(delegation): implement 7C-6 agent identity/delegation … Human minor 33 days ago