proposals-store.mjs
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