proposals-store.mjs
594 lines 21.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 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 import { applyPersonalSelfApplyEvaluationE1 } from '../lib/hub-proposal-personal-self-apply.mjs';
13
14 const FILENAME = 'hub_proposals.json';
15
16 export function getProposalsPath(dataDir) {
17 return path.join(dataDir, FILENAME);
18 }
19
20 function loadProposals(dataDir) {
21 const filePath = getProposalsPath(dataDir);
22 if (!fs.existsSync(filePath)) return [];
23 try {
24 const raw = fs.readFileSync(filePath, 'utf8');
25 return JSON.parse(raw);
26 } catch (_) {
27 return [];
28 }
29 }
30
31 function saveProposals(dataDir, proposals) {
32 const filePath = getProposalsPath(dataDir);
33 const dir = path.dirname(filePath);
34 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
35 fs.writeFileSync(filePath, JSON.stringify(proposals, null, 2), 'utf8');
36 }
37
38 function normalizeLabels(v) {
39 if (!Array.isArray(v)) return [];
40 return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))].slice(0, 32);
41 }
42
43 function normalizeSource(v) {
44 if (v == null || typeof v !== 'string') return undefined;
45 const s = v.trim();
46 if (!s) return undefined;
47 return s.slice(0, 64);
48 }
49
50 /**
51 * Effective evaluation status for gate logic (missing → none).
52 * @param {object} p
53 * @returns {string}
54 */
55 export function getEvaluationStatus(p) {
56 const s = p?.evaluation_status;
57 if (s == null || s === '') return 'none';
58 return String(s);
59 }
60
61 /**
62 * Merge rubric template with client checklist toggles.
63 * @param {{ id: string, label: string }[]} rubricItems
64 * @param {unknown} clientChecklist - array of { id, passed? }
65 * @returns {{ id: string, label: string, passed: boolean }[]}
66 */
67 export function mergeEvaluationChecklist(rubricItems, clientChecklist) {
68 const byId = new Map();
69 if (Array.isArray(clientChecklist)) {
70 for (const row of clientChecklist) {
71 if (!row || typeof row !== 'object') continue;
72 const id = typeof row.id === 'string' ? row.id.trim() : '';
73 if (!id) continue;
74 byId.set(id, Boolean(row.passed));
75 }
76 }
77 const out = (rubricItems || []).map(({ id, label }) => ({
78 id,
79 label,
80 passed: byId.has(id) ? byId.get(id) : false,
81 }));
82 return out;
83 }
84
85 /**
86 * @param {string} dataDir
87 * @param {{
88 * status?: string,
89 * vault_id?: string,
90 * limit?: number,
91 * offset?: number,
92 * label?: string,
93 * source?: string,
94 * path_prefix?: string,
95 * evaluation_status?: string,
96 * }} options
97 * @returns {{ proposals: object[], total: number }}
98 */
99 export function listProposals(dataDir, options = {}) {
100 const all = loadProposals(dataDir);
101 let list = all;
102 if (options.status) list = list.filter((p) => p.status === options.status);
103 if (options.vault_id != null) {
104 list = list.filter((p) => (p.vault_id ?? 'default') === options.vault_id);
105 }
106 if (options.source && String(options.source).trim()) {
107 const src = String(options.source).trim();
108 list = list.filter((p) => (p.source || '') === src);
109 }
110 if (options.label && String(options.label).trim()) {
111 const want = String(options.label).trim().toLowerCase();
112 list = list.filter((p) => {
113 const labels = Array.isArray(p.labels) ? p.labels : [];
114 return labels.some((l) => String(l).toLowerCase() === want);
115 });
116 }
117 if (options.path_prefix && String(options.path_prefix).trim()) {
118 let prefixNorm;
119 try {
120 prefixNorm = normalizePathPrefix(options.path_prefix);
121 } catch {
122 prefixNorm = null;
123 }
124 if (prefixNorm) {
125 list = list.filter((p) => notePathMatchesPrefix(p.path, prefixNorm));
126 }
127 }
128 if (options.evaluation_status && String(options.evaluation_status).trim()) {
129 const want = String(options.evaluation_status).trim();
130 list = list.filter((p) => getEvaluationStatus(p) === want);
131 }
132 if (options.review_queue && String(options.review_queue).trim()) {
133 const want = String(options.review_queue).trim();
134 list = list.filter((p) => (p.review_queue || '') === want);
135 }
136 if (options.review_severity && String(options.review_severity).trim()) {
137 const want = String(options.review_severity).trim();
138 list = list.filter((p) => (p.review_severity || '') === want);
139 }
140 const total = list.length;
141 const offset = Math.max(0, options.offset ?? 0);
142 const limit = Math.max(1, Math.min(options.limit ?? 50, 100));
143 list = list.slice(offset, offset + limit).map((p) => ({ ...p, evaluation_status: getEvaluationStatus(p) }));
144 return { proposals: list, total };
145 }
146
147 /**
148 * @param {string} dataDir
149 * @param {string} id
150 */
151 export function getProposal(dataDir, id) {
152 const all = loadProposals(dataDir);
153 const p = all.find((pr) => pr.proposal_id === id) ?? null;
154 if (!p) return null;
155 return { ...p, evaluation_status: getEvaluationStatus(p) };
156 }
157
158 /**
159 * @param {string} dataDir
160 * @param {{
161 * path?: string,
162 * body?: string,
163 * frontmatter?: object,
164 * intent?: string,
165 * base_state_id?: string,
166 * external_ref?: string,
167 * vault_id?: string,
168 * proposed_by?: string,
169 * labels?: string[],
170 * source?: string,
171 * evaluationRequired?: boolean,
172 * evaluationForcedPending?: boolean,
173 * review_queue?: string,
174 * review_severity?: 'standard'|'elevated',
175 * auto_flag_reasons?: string[],
176 * session_bound?: boolean,
177 * flow_meta?: { kind: string, base_version: string|null, base_state_id: string },
178 * capture_meta?: { proposal_kind: string, candidate_id: string, confirmed_scope?: string, merge_into_flow_id?: string|null },
179 * task_meta?: { record_kind: string, proposal_kind: string, task_id?: string|null, loop_id?: string|null, occurrence_key?: string|null, cascade_task_ids?: string[] },
180 * }} input
181 */
182 export function createProposal(dataDir, input) {
183 const all = loadProposals(dataDir);
184 const now = new Date().toISOString();
185 const proposedBy =
186 typeof input.proposed_by === 'string' && input.proposed_by.trim() ? input.proposed_by.trim() : undefined;
187 const ext =
188 input.external_ref != null && String(input.external_ref).trim()
189 ? String(input.external_ref).trim().slice(0, 512)
190 : '';
191 const needPending = Boolean(input.evaluationRequired || input.evaluationForcedPending);
192 const evaluation_status = needPending ? 'pending' : 'none';
193 const rq =
194 input.review_queue != null && String(input.review_queue).trim()
195 ? String(input.review_queue).trim().slice(0, 64)
196 : undefined;
197 const rs =
198 input.review_severity === 'elevated' || input.review_severity === 'standard'
199 ? input.review_severity
200 : undefined;
201 const afr = Array.isArray(input.auto_flag_reasons)
202 ? input.auto_flag_reasons.map((x) => String(x).slice(0, 256)).filter(Boolean).slice(0, 32)
203 : [];
204 const proposal = {
205 proposal_id: randomUUID(),
206 path: input.path || `inbox/proposal-${Date.now()}.md`,
207 status: 'proposed',
208 vault_id: typeof input.vault_id === 'string' && input.vault_id.trim() ? input.vault_id.trim() : 'default',
209 intent: input.intent ?? undefined,
210 base_state_id: input.base_state_id ?? undefined,
211 external_ref: ext || undefined,
212 body: input.body ?? '',
213 frontmatter: input.frontmatter ?? {},
214 labels: normalizeLabels(input.labels),
215 source: normalizeSource(input.source),
216 suggested_labels: [],
217 assistant_notes: undefined,
218 assistant_model: undefined,
219 assistant_at: undefined,
220 assistant_suggested_frontmatter: undefined,
221 evaluation_status,
222 evaluation_grade: undefined,
223 evaluation_checklist: undefined,
224 evaluation_comment: undefined,
225 evaluated_by: undefined,
226 evaluated_at: undefined,
227 evaluation_waiver: undefined,
228 ...(rq && { review_queue: rq }),
229 ...(rs && { review_severity: rs }),
230 ...(afr.length ? { auto_flag_reasons: afr } : {}),
231 ...(input.flow_meta && typeof input.flow_meta === 'object'
232 ? {
233 flow_meta: {
234 kind: String(input.flow_meta.kind || 'new').slice(0, 16),
235 base_version:
236 input.flow_meta.base_version != null ? String(input.flow_meta.base_version).slice(0, 32) : null,
237 base_state_id: String(input.flow_meta.base_state_id || '').slice(0, 96),
238 },
239 }
240 : {}),
241 ...(input.capture_meta && typeof input.capture_meta === 'object'
242 ? {
243 capture_meta: {
244 proposal_kind: String(input.capture_meta.proposal_kind || '').slice(0, 32),
245 candidate_id: String(input.capture_meta.candidate_id || '').slice(0, 48),
246 confirmed_scope:
247 input.capture_meta.confirmed_scope != null
248 ? String(input.capture_meta.confirmed_scope).slice(0, 16)
249 : undefined,
250 merge_into_flow_id:
251 input.capture_meta.merge_into_flow_id != null
252 ? String(input.capture_meta.merge_into_flow_id).slice(0, 80)
253 : null,
254 },
255 }
256 : {}),
257 ...(input.delegation_meta && typeof input.delegation_meta === 'object'
258 ? {
259 delegation_meta: {
260 record_kind: String(input.delegation_meta.record_kind || '').slice(0, 32),
261 agent_id:
262 input.delegation_meta.agent_id != null
263 ? String(input.delegation_meta.agent_id).slice(0, 64)
264 : undefined,
265 consent_id:
266 input.delegation_meta.consent_id != null
267 ? String(input.delegation_meta.consent_id).slice(0, 64)
268 : undefined,
269 },
270 }
271 : {}),
272 ...(input.task_meta && typeof input.task_meta === 'object'
273 ? {
274 task_meta: {
275 record_kind: String(input.task_meta.record_kind || 'task').slice(0, 32),
276 proposal_kind: String(input.task_meta.proposal_kind || '').slice(0, 32),
277 task_id:
278 input.task_meta.task_id != null ? String(input.task_meta.task_id).slice(0, 64) : null,
279 loop_id:
280 input.task_meta.loop_id != null ? String(input.task_meta.loop_id).slice(0, 64) : null,
281 occurrence_key:
282 input.task_meta.occurrence_key != null
283 ? String(input.task_meta.occurrence_key).slice(0, 64)
284 : null,
285 ...(Array.isArray(input.task_meta.cascade_task_ids)
286 ? {
287 cascade_task_ids: input.task_meta.cascade_task_ids
288 .map((id) => String(id).slice(0, 64))
289 .slice(0, 500),
290 }
291 : {}),
292 },
293 }
294 : {}),
295 ...(input.media_meta && typeof input.media_meta === 'object'
296 ? {
297 media_meta: {
298 record_kind: String(input.media_meta.record_kind || '').slice(0, 32),
299 proposal_kind: String(input.media_meta.proposal_kind || '').slice(0, 32),
300 attachment_id: String(input.media_meta.attachment_id || '').slice(0, 80),
301 connector_id:
302 input.media_meta.connector_id != null
303 ? String(input.media_meta.connector_id).slice(0, 32)
304 : null,
305 consent_id:
306 input.media_meta.consent_id != null
307 ? String(input.media_meta.consent_id).slice(0, 32)
308 : null,
309 note_ref:
310 input.media_meta.note_ref != null
311 ? String(input.media_meta.note_ref).slice(0, 280)
312 : null,
313 },
314 }
315 : {}),
316 review_hints: undefined,
317 review_hints_at: undefined,
318 review_hints_model: undefined,
319 ...(proposedBy && { proposed_by: proposedBy }),
320 created_at: now,
321 updated_at: now,
322 };
323 // HOSTED-WRITE-EVAL E1 — after severity/auto-flag fields are set (post-trigger).
324 // T5: Tasks/Media/Flow self-pass when session_bound + fingerprint hold.
325 const withE1 = applyPersonalSelfApplyEvaluationE1(proposal, {
326 evaluatedBy: proposedBy,
327 evaluatedAt: now,
328 sessionBound: input.session_bound === true,
329 authorActorId: proposedBy,
330 });
331 const finalProposal =
332 withE1.evaluation_status === 'passed'
333 ? {
334 ...proposal,
335 evaluation_status: 'passed',
336 evaluated_by: withE1.evaluated_by,
337 evaluated_at: withE1.evaluated_at,
338 }
339 : proposal;
340 all.push(finalProposal);
341 saveProposals(dataDir, all);
342 return finalProposal;
343 }
344
345 /**
346 * Approve / discard. When approving with a waiver, pass `extras.evaluation_waiver`.
347 * @param {string} dataDir
348 * @param {string} id
349 * @param {'approved'|'discarded'} status
350 * @param {{ evaluation_waiver?: { by: string, at: string, reason: string }, external_ref?: string }} [extras]
351 * @returns {object|null} Updated proposal or null
352 */
353 export function updateProposalStatus(dataDir, id, status, extras = {}) {
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 let next = { ...all[idx], status, updated_at: now };
359 if (status === 'approved' && extras.evaluation_waiver) {
360 next = { ...next, evaluation_waiver: extras.evaluation_waiver };
361 }
362 if (status === 'approved' && extras.external_ref != null) {
363 const ref = normalizeExternalRef(extras.external_ref);
364 if (ref) next = { ...next, external_ref: ref };
365 }
366 all[idx] = next;
367 saveProposals(dataDir, all);
368 return all[idx];
369 }
370
371 const OUTCOME_TO_STATUS = {
372 pass: 'passed',
373 fail: 'failed',
374 needs_changes: 'needs_changes',
375 };
376
377 /**
378 * @param {string} dataDir
379 * @param {string} id
380 * @param {{
381 * outcome: string,
382 * evaluation_checklist: { id: string, label: string, passed: boolean }[],
383 * evaluation_grade?: string,
384 * evaluation_comment?: string,
385 * evaluated_by: string,
386 * }} payload
387 * @returns {{ ok: true, proposal: object } | { ok: false, error: string, code: string }}
388 */
389 export function submitProposalEvaluation(dataDir, id, payload) {
390 const all = loadProposals(dataDir);
391 const idx = all.findIndex((p) => p.proposal_id === id);
392 if (idx === -1) return { ok: false, error: 'Proposal not found', code: 'NOT_FOUND' };
393 const p = all[idx];
394 if (p.status !== 'proposed') {
395 return { ok: false, error: 'Can only evaluate proposed proposals', code: 'BAD_REQUEST' };
396 }
397 const rawOutcome = String(payload.outcome || '')
398 .trim()
399 .toLowerCase()
400 .replace(/-/g, '_');
401 const evaluation_status = OUTCOME_TO_STATUS[rawOutcome];
402 if (!evaluation_status) {
403 return { ok: false, error: 'outcome must be pass, fail, or needs_changes', code: 'BAD_REQUEST' };
404 }
405 const comment = payload.evaluation_comment != null ? String(payload.evaluation_comment).trim() : '';
406 if ((evaluation_status === 'failed' || evaluation_status === 'needs_changes') && comment.length < 1) {
407 return { ok: false, error: 'comment is required for fail and needs_changes', code: 'BAD_REQUEST' };
408 }
409 const checklist = Array.isArray(payload.evaluation_checklist) ? payload.evaluation_checklist : [];
410 if (evaluation_status === 'passed' && checklist.length > 0) {
411 const allPass = checklist.every((c) => c && c.passed === true);
412 if (!allPass) {
413 return { ok: false, error: 'All checklist items must pass for a pass outcome', code: 'BAD_REQUEST' };
414 }
415 }
416 const grade =
417 payload.evaluation_grade != null && String(payload.evaluation_grade).trim()
418 ? String(payload.evaluation_grade).trim().slice(0, 32)
419 : undefined;
420 const now = new Date().toISOString();
421 const evaluated_by =
422 typeof payload.evaluated_by === 'string' && payload.evaluated_by.trim()
423 ? payload.evaluated_by.trim().slice(0, 512)
424 : 'unknown';
425 all[idx] = {
426 ...p,
427 evaluation_status,
428 evaluation_grade: grade,
429 evaluation_checklist: checklist,
430 evaluation_comment: comment || undefined,
431 evaluated_by,
432 evaluated_at: now,
433 updated_at: now,
434 };
435 saveProposals(dataDir, all);
436 return { ok: true, proposal: all[idx] };
437 }
438
439 /**
440 * Whether approve is allowed without waiver (evaluation satisfied).
441 * @param {object} proposal
442 */
443 export function evaluationAllowsApprove(proposal) {
444 const es = getEvaluationStatus(proposal);
445 return es === 'none' || es === 'passed';
446 }
447
448 /**
449 * Tier-2 assistant fields (feature-flagged route).
450 * @param {string} dataDir
451 * @param {string} id
452 * @param {{
453 * assistant_notes: string,
454 * assistant_model: string,
455 * suggested_labels?: string[],
456 * assistant_suggested_frontmatter?: Record<string, unknown>,
457 * }} fields
458 * @returns {object|null}
459 */
460 export function updateProposalEnrichment(dataDir, id, fields) {
461 const all = loadProposals(dataDir);
462 const idx = all.findIndex((p) => p.proposal_id === id);
463 if (idx === -1) return null;
464 const now = new Date().toISOString();
465 const sug = normalizeLabels(fields.suggested_labels ?? []);
466 const fm = fields.assistant_suggested_frontmatter;
467 const nextFm =
468 fm && typeof fm === 'object' && !Array.isArray(fm) && Object.keys(fm).length > 0 ? { ...fm } : undefined;
469 all[idx] = {
470 ...all[idx],
471 assistant_notes: fields.assistant_notes,
472 assistant_model: fields.assistant_model,
473 assistant_at: now,
474 suggested_labels: sug.length ? sug : all[idx].suggested_labels || [],
475 ...(Object.prototype.hasOwnProperty.call(fields, 'assistant_suggested_frontmatter')
476 ? { assistant_suggested_frontmatter: nextFm }
477 : {}),
478 updated_at: now,
479 };
480 saveProposals(dataDir, all);
481 return all[idx];
482 }
483
484 /**
485 * Optional async LLM review hints (never merge authority).
486 * @param {string} dataDir
487 * @param {string} id
488 * @param {{ review_hints: string, review_hints_model: string }} fields
489 * @returns {object|null}
490 */
491 export function updateProposalReviewHints(dataDir, id, fields) {
492 const all = loadProposals(dataDir);
493 const idx = all.findIndex((p) => p.proposal_id === id);
494 if (idx === -1) return null;
495 const now = new Date().toISOString();
496 all[idx] = {
497 ...all[idx],
498 review_hints: fields.review_hints,
499 review_hints_model: fields.review_hints_model,
500 review_hints_at: now,
501 updated_at: now,
502 };
503 saveProposals(dataDir, all);
504 return all[idx];
505 }
506
507 /**
508 * Discard proposals in "proposed" state whose path is under path_prefix in the given vault.
509 * @param {string} dataDir
510 * @param {{ vault_id?: string, path_prefix: string }} opts
511 * @returns {number} count discarded
512 */
513 export function discardProposalsUnderPathPrefix(dataDir, opts) {
514 const pathPrefixRaw = opts && opts.path_prefix != null ? String(opts.path_prefix) : '';
515 const prefixNorm = normalizePathPrefix(pathPrefixRaw);
516 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
517 const all = loadProposals(dataDir);
518 const now = new Date().toISOString();
519 let n = 0;
520 const next = all.map((p) => {
521 if (p.status !== 'proposed') return p;
522 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
523 if (pv !== vid) return p;
524 if (!notePathMatchesPrefix(p.path, prefixNorm)) return p;
525 n += 1;
526 return { ...p, status: 'discarded', updated_at: now };
527 });
528 saveProposals(dataDir, next);
529 return n;
530 }
531
532 /**
533 * Discard proposals in "proposed" state whose path is in the given set (exact match, vault-relative forward slashes).
534 * @param {string} dataDir
535 * @param {{ vault_id?: string, paths: string[] }} opts
536 * @returns {number} count discarded
537 */
538 export function discardProposalsAtPaths(dataDir, opts) {
539 const vid = opts.vault_id != null && String(opts.vault_id).trim() ? String(opts.vault_id).trim() : 'default';
540 const set = new Set((opts.paths || []).map((p) => String(p).replace(/\\/g, '/')));
541 if (set.size === 0) return 0;
542 const all = loadProposals(dataDir);
543 const now = new Date().toISOString();
544 let n = 0;
545 const next = all.map((p) => {
546 if (p.status !== 'proposed') return p;
547 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
548 if (pv !== vid) return p;
549 const normPath = String(p.path || '').replace(/\\/g, '/');
550 if (!set.has(normPath)) return p;
551 n += 1;
552 return { ...p, status: 'discarded', updated_at: now };
553 });
554 saveProposals(dataDir, next);
555 return n;
556 }
557
558 export function removeProposalsForVault(dataDir, vaultId) {
559 const vid = String(vaultId || '').trim();
560 if (!vid) return 0;
561 const all = loadProposals(dataDir);
562 const next = all.filter((p) => {
563 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
564 return pv !== vid;
565 });
566 const removed = all.length - next.length;
567 if (removed > 0) saveProposals(dataDir, next);
568 return removed;
569 }
570
571 /**
572 * Record OD-4 cascade task ids on an approved task proposal's task_meta (audit).
573 *
574 * @param {string} dataDir
575 * @param {string} proposalId
576 * @param {string[]} cascadeTaskIds
577 * @returns {object|null}
578 */
579 export function patchProposalTaskMetaCascade(dataDir, proposalId, cascadeTaskIds) {
580 const all = loadProposals(dataDir);
581 const idx = all.findIndex((p) => p.proposal_id === proposalId);
582 if (idx === -1) return null;
583 const meta = all[idx].task_meta && typeof all[idx].task_meta === 'object' ? all[idx].task_meta : {};
584 all[idx] = {
585 ...all[idx],
586 task_meta: {
587 ...meta,
588 cascade_task_ids: cascadeTaskIds.map((id) => String(id).slice(0, 64)).slice(0, 500),
589 },
590 updated_at: new Date().toISOString(),
591 };
592 saveProposals(dataDir, all);
593 return all[idx];
594 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago