1/* Hogares de Paz - annual lesson planning harness. 2 * Local, non-secret planning scaffold. It never generates, approves or 3 * publishes content automatically. Historical lesson objects are read-only. 4 */ 5(function registerAnnualLessonHarness(global) { 6 'use strict'; 7 8 const STORAGE_KEY = 'hdp.annual.lesson.harness.v1'; 9 const SCHEMA_VERSION = '2026-09-19.aaa3-calendar'; 10 const BRIEF_FIELDS = Object.freeze([ 11 'weekCount', 'quarterThemes', 'monthlyThemes', 12 ]); 13 const QUARTER_RANGES = Object.freeze([ 14 Object.freeze({ id:'Q1', start:1, end:13, months:Object.freeze([1,2,3]) }), 15 Object.freeze({ id:'Q2', start:14, end:26, months:Object.freeze([4,5,6]) }), 16 Object.freeze({ id:'Q3', start:27, end:39, months:Object.freeze([7,8,9]) }), 17 Object.freeze({ id:'Q4', start:40, end:52, months:Object.freeze([10,11,12]) }), 18 ]); 19 const ARTIFACT_TYPES = [ 20 'annual_brief_draft', 'annual_brief', 'theme_proposals', 'theme_decision', 21 'approved_theme', 'topic_map', 'batch_plan', 'batch_run', 'lesson_spec', 22 'draft', 'review_report', 'approved_revision', 'translation_set', 23 'export_package', 'publication_decision', 24 ]; 25 const GATES = [ 26 { id:'A', label:'Brief y tema anual', required:['annual_brief','theme_proposals','approved_theme'] }, 27 { id:'B', label:'Mapa anual de 52 o 53 semanas', required:['topic_map'] }, 28 { id:'C', label:'Pilotos y lotes pequeños', required:['batch_plan','batch_run','lesson_spec','draft'] }, 29 { id:'D', label:'Revisión bÃblica y pastoral', required:['review_report'] }, 30 { id:'E', label:'Edición humana', required:['approved_revision'] }, 31 { id:'F', label:'Traducciones revisadas', required:['translation_set'] }, 32 { id:'G', label:'Exportación validada', required:['export_package'] }, 33 { id:'H', label:'Decisión humana de publicación', required:['publication_decision'] }, 34 ]; 35 const EXPERT_PROFILE = Object.freeze({ 36 kind:'simulated_expert_profile', 37 claim:'No representa doctorados, licencia clÃnica, ordenación ni experiencia vivida de la IA.', 38 disciplines:['cuidado pastoral','teologÃa bÃblica y práctica','misiones y evangelismo','liderazgo y multiplicación'], 39 escalation:['crisis','protección infantil','materia clÃnica','materia legal'], 40 }); 41 const SECRET_FIELD = /(?:api[_-]?key|secret|access[_-]?token|refresh[_-]?token|password)/i; 42 43 function clone(value) { 44 return value == null ? value : JSON.parse(JSON.stringify(value)); 45 } 46 47 function nonSecret(value) { 48 if (Array.isArray(value)) return value.map(nonSecret); 49 if (!value || typeof value !== 'object') return value; 50 return Object.fromEntries(Object.entries(value) 51 .filter(([key]) => !SECRET_FIELD.test(key)) 52 .map(([key, item]) => [key, nonSecret(item)])); 53 } 54 55 function stable(value) { 56 if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; 57 if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; 58 return JSON.stringify(value); 59 } 60 61 async function sha256(value) { 62 const bytes = new TextEncoder().encode(String(value || '')); 63 const digest = await crypto.subtle.digest('SHA-256', bytes); 64 return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); 65 } 66 67 function id(prefix) { 68 return `${prefix}-${global.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; 69 } 70 71 function now() { 72 return new Date().toISOString(); 73 } 74 75 function emptyProject(year = new Date().getFullYear() + 1) { 76 const createdAt = now(); 77 return { 78 schemaVersion:SCHEMA_VERSION, 79 projectId:id(`hdp-${year}`), 80 year:Number(year), 81 status:'brief_draft', 82 currentGate:'A', 83 artifacts:{}, 84 approvals:[], 85 jobs:[], 86 history:[], 87 createdAt, 88 updatedAt:createdAt, 89 publicationMode:'human_decision_only', 90 }; 91 } 92 93 function normalizeProject(project) { 94 const source = project && typeof project === 'object' ? project : emptyProject(); 95 return nonSecret({ 96 ...source, 97 schemaVersion:SCHEMA_VERSION, 98 artifacts:source.artifacts || {}, 99 approvals:Array.isArray(source.approvals) ? source.approvals : [], 100 jobs:Array.isArray(source.jobs) ? source.jobs : [], 101 history:Array.isArray(source.history) ? source.history : [], 102 }); 103 } 104 105 function load(year) { 106 try { 107 const all = nonSecret(JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {});
108 const key = String(Number(year)); 109 return all[key] ? clone(normalizeProject(all[key])) : emptyProject(Number(year)); 110 } catch { return emptyProject(Number(year)); } 111 } 112 113 function save(project) { 114 const safe = normalizeProject({ ...project, updatedAt:now() }); 115 const all = (() => { try { return nonSecret(JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}); } catch { return {}; } })(); 116 all[String(safe.year)] = safe; 117 localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); 118 global.dispatchEvent?.(new CustomEvent('hdp:annual-harness', { detail:{ year:safe.year, status:safe.status, currentGate:safe.currentGate } })); 119 return clone(safe); 120 } 121 122 function corpusInventory() { 123 const manual = global.HP_MANUAL || {}; 124 const years = [2025, 2026].map(year => { 125 const weeks = Object.keys(manual[year] || {}).map(Number).filter(Number.isFinite).sort((a,b) => a-b); 126 const missingWeeks = Array.from({ length:52 }, (_, index) => index + 1).filter(week => !weeks.includes(week)); 127 return { year, count:weeks.length, weeks, missingWeeks, source:`HP_MANUAL.${year}`, mutable:false }; 128 }); 129 const recovered = global.HDP_MEMBER_LESSONS_2026_RECOVERED || {}; 130 const recoveredKeys = Object.keys(recovered); 131 const byLanguage = recoveredKeys.reduce((counts, key) => { 132 const language = String(recovered[key]?.lang || key.split('-').at(-1) || 'unknown').toUpperCase(); 133 counts[language] = (counts[language] || 0) + 1; 134 return counts; 135 }, {}); 136 return { 137 authority:'verified_historical_corpus_read_only', 138 years, 139 recoveredMemberLessons:{ count:recoveredKeys.length, byLanguage, source:'HDP_MEMBER_LESSONS_2026_RECOVERED', mutable:false }, 140 anomalies:years.flatMap(item => item.missingWeeks.map(week => `${item.year}-W${String(week).padStart(2,'0')}`)), 141 }; 142 } 143 144 function artifactVersions(project, type) { 145 return Array.isArray(project?.artifacts?.[type]) ? project.artifacts[type] : []; 146 } 147 148 function latest(project, type) { 149 return artifactVersions(project, type).at(-1) || null; 150 } 151 152 function latestFor(project, type, predicate) { 153 return artifactVersions(project, type).filter(item => predicate(item?.data || {}, item)).at(-1) || null; 154 } 155 156 function quarterForWeek(week) { 157 const number = Number(week); 158 return QUARTER_RANGES.find(item => number >= item.start && number <= item.end)?.id || 'Q4'; 159 } 160 161 function monthForWeek(week) { 162 const number = Math.max(1, Math.min(52, Number(week) || 1)); 163 const quarter = QUARTER_RANGES.find(item => number >= item.start && number <= item.end) || QUARTER_RANGES[3]; 164 const offset = number - quarter.start + 1; 165 const monthOffset = offset <= 4 ? 0 : offset <= 8 ? 1 : 2; 166 return quarter.months[monthOffset]; 167 } 168 169 async function recordArtifact(project, type, data, metadata = {}) { 170 if (!ARTIFACT_TYPES.includes(type)) throw new Error('artifact_type_not_allowed'); 171 const safeData = nonSecret(data || {}); 172 const safeMetadata = nonSecret(metadata || {}); 173 const prior = artifactVersions(project, type); 174 const recordedAt = now(); 175 const artifact = { 176 artifactId:id(type), 177 type, 178 version:prior.length + 1, 179 status:String(safeMetadata.status || 'draft'), 180 data:safeData, 181 author:String(safeMetadata.actor || 'lead_pastor'), 182 providerId:String(safeMetadata.providerId || 'not_used'), 183 modelId:String(safeMetadata.modelId || 'not_used'), 184 promptVersion:String(safeMetadata.promptVersion || 'not_used'), 185 inputHash:await sha256(stable(safeMetadata.input || safeData)), 186 outputHash:await sha256(stable(safeData)), 187 durationMs:Number.isFinite(Number(safeMetadata.durationMs)) ? Number(safeMetadata.durationMs) : 0, 188 costUsage:safeMetadata.costUsage || { status:'not_used' }, 189 warnings:Array.isArray(safeMetadata.warnings) ? safeMetadata.warnings : [], 190 provenance:safeMetadata.provenance || {}, 191 createdAt:recordedAt, 192 }; 193 const next = normalizeProject(project); 194 next.artifacts[type] = [...prior, artifact]; 195 next.history.push({ event:'artifact_recorded', type, artifactId:artifact.artifactId, version:artifact.version, at:recordedAt, actor:artifact.author }); 196 return save(next); 197 } 198 199 async function saveBriefDraft(project, brief, metadata = {}) { 200 const next = await recordArtifact(project, 'annual_brief_draft', brief, { ...metadata, status:'paused' }); 201 next.status = 'brief_paused'; 202 return save(next); 203 } 204 205 function validateBrief(brief) { 206 if (![52, 53].includes(Number(brief?.weekCount))) throw new Error('annual_brief_incomplete:weekCount'); 207 const quarterThemes = brief?.quarterThemes && typeof brief.quarterThemes === 'object' ? brief.quarterThemes : {}; 208 const monthlyThemes = brief?.monthlyThemes && typeof brief.monthlyThemes === 'object' ? brief.monthlyThemes : {}; 209 const quarterComplete = QUARTER_RANGES.every(item => String(quarterThemes[item.id] || '').trim()); 210 const monthComplete = Array.from({ length:12 }, (_, index) => String(monthlyThemes[index + 1] || '').trim()).every(Boolean); 211 if (!quarterComplete && !monthComplete) throw new Error('annual_brief_incomplete:quarterThemes_or_monthlyThemes'); 212 } 213 214 async function saveAnnualBrief(project, brief, metadata = {}) { 215 validateBrief(brief); 216 const normalized = { 217 calendarModel:'quarters_months_weeks', 218 weekCount:Number(brief.weekCount), 219 quarterThemes:QUARTER_RANGES.reduce((map, item) => ({ ...map, [item.id]:String(brief?.quarterThemes?.[item.id] || '').trim() }), {}), 220 monthlyThemes:Array.from({ length:12 }, (_, index) => [index + 1, String(brief?.monthlyThemes?.[index + 1] || '').trim()]) 221 .reduce((map, [month, value]) => ({ ...map, [month]:value }), {}), 222 }; 223 const next = await recordArtifact(project, 'annual_brief', normalized, { ...metadata, status:'ready_for_theme_proposals' }); 224 next.status = 'theme_proposals_required'; 225 return save(next); 226 } 227 228 async function saveThemeProposals(project, proposals, metadata = {}) {
229 if (!latest(project, 'annual_brief')) throw new Error('annual_brief_required'); 230 if (!Array.isArray(proposals) || proposals.length < 3 || proposals.length > 5) throw new Error('three_to_five_theme_proposals_required'); 231 if (proposals.some(item => !item?.id || !item?.title || !item?.summary || !item?.narrativeArc)) throw new Error('theme_proposal_incomplete'); 232 const next = await recordArtifact(project, 'theme_proposals', proposals, { ...metadata, status:'human_decision_required' }); 233 next.status = 'gate_a_human_approval_required'; 234 return save(next); 235 } 236 237 async function decideTheme(project, options = {}) { 238 const { proposalIds = [], mode = 'select', edits = {}, reason = '', actor, humanConfirmed } = options; 239 if (humanConfirmed !== true || !actor) throw new Error('human_approval_required'); 240 const proposals = latest(project, 'theme_proposals')?.data || []; 241 const selectedIds = [...new Set((proposalIds || []).map(String))]; 242 const selected = proposals.filter(item => selectedIds.includes(String(item.id))); 243 if (mode === 'reject') { 244 if (!selected.length || !String(reason).trim()) throw new Error('rejection_reason_required'); 245 let next = await recordArtifact(project, 'theme_decision', { mode:'reject', proposalIds:selectedIds, reason:String(reason).trim() }, { actor, status:'rejected', provenance:{ proposalIds:selectedIds } }); 246 next.status = 'theme_proposals_required'; 247 return save(next); 248 } 249 if (!selected.length) throw new Error('theme_proposal_not_found'); 250 if (mode === 'select' && selected.length !== 1) throw new Error('single_theme_required'); 251 if (!['select','combine'].includes(mode)) throw new Error('theme_decision_not_allowed'); 252 const base = mode === 'combine' ? { 253 id:id('combined-theme'), 254 title:selected.map(item => item.title).join(' + '), 255 summary:selected.map(item => item.summary).join('\n\n'), 256 narrativeArc:selected.map(item => item.narrativeArc).join('\n\n'), 257 combinedFrom:selectedIds, 258 } : clone(selected[0]); 259 const approved = { ...base, ...nonSecret(edits || {}), decisionMode:mode }; 260 let next = await recordArtifact(project, 'theme_decision', { mode, proposalIds:selectedIds, edits:nonSecret(edits || {}) }, { actor, status:'approved', provenance:{ proposalIds:selectedIds } }); 261 next = await recordArtifact(next, 'approved_theme', approved, { actor, status:'approved', provenance:{ proposalIds:selectedIds, decisionArtifactId:latest(next, 'theme_decision')?.artifactId } }); 262 next.approvals.push({ gate:'A', actor, humanConfirmed:true, artifactId:latest(next, 'approved_theme').artifactId, at:now() }); 263 next.currentGate = 'B'; 264 next.status = 'topic_map_required'; 265 return save(next); 266 } 267 268 async function approveTheme(project, { proposalId, proposalIds, mode, edits, actor, humanConfirmed } = {}) { 269 return decideTheme(project, { proposalIds:proposalIds || [proposalId], mode:mode || 'select', edits, actor, humanConfirmed }); 270 } 271 272 async function rejectTheme(project, { proposalIds, reason, actor, humanConfirmed } = {}) { 273 return decideTheme(project, { proposalIds, mode:'reject', reason, actor, humanConfirmed }); 274 } 275 276 function expectedWeekCount(project) { 277 const count = Number(latest(project, 'annual_brief')?.data?.weekCount || 52); 278 return count === 53 ? 53 : 52; 279 } 280 281 async function saveTopicMap(project, weeks, metadata = {}) { 282 if (!latest(project, 'approved_theme')) throw new Error('gate_a_required'); 283 const expected = expectedWeekCount(project); 284 const normalized = Array.isArray(weeks) ? weeks.map(item => ({ 285 ...item, 286 week:Number(item.week), 287 quarter:item.quarter || quarterForWeek(item.week), 288 month:Number(item.month || monthForWeek(item.week)), 289 pause:Boolean(item.pause), 290 })) : []; 291 const uniqueWeeks = new Set(normalized.map(item => item.week)); 292 if (normalized.length !== expected || uniqueWeeks.size !== expected || normalized.some(item => item.week < 1 || item.week > expected || !item.title || (!item.pause && (!item.objective || !item.axisText)))) { 293 throw new Error(`complete_${expected}_week_map_required`); 294 } 295 const next = await recordArtifact(project, 'topic_map', normalized.sort((a,b) => a.week - b.week), { ...metadata, status:'human_approval_required' }); 296 next.status = 'gate_b_human_approval_required'; 297 return save(next); 298 } 299 300 function gateRequirementMissing(project, gate) { 301 const missing = gate.required.filter(type => !latest(project, type)); 302 if (gate.id === 'C' && latest(project, 'batch_run')?.data?.status !== 'completed') missing.push('completed_batch_run'); 303 if (gate.id === 'F' && latest(project, 'translation_set')?.data?.humanReviewed !== true) missing.push('human_reviewed_translation_set'); 304 if (gate.id === 'G' && latest(project, 'export_package')?.data?.validated !== true) missing.push('validated_export_package'); 305 if (gate.id === 'H' && !['approved','rejected'].includes(latest
305(project, 'publication_decision')?.data?.decision)) missing.push('explicit_publication_decision'); 306 return [...new Set(missing)]; 307 } 308 309 function approveGate(project, gateId, actor, humanConfirmed = false) { 310 const gate = GATES.find(item => item.id === gateId); 311 if (!gate) throw new Error('gate_not_found'); 312 if (!actor || humanConfirmed !== true) throw new Error('human_approval_required'); 313 if (project.approvals?.some(item => item.gate === gateId && item.humanConfirmed === true)) throw new Error('gate_already_approved'); 314 const missing = gateRequirementMissing(project, gate); 315 if (missing.length) throw new Error(`gate_artifacts_missing:${missing.join(',')}`); 316 const next = normalizeProject(project); 317 next.approvals.push({ gate:gateId, actor, humanConfirmed:true, at:now() }); 318 const nextGate = GATES[GATES.findIndex(item => item.id === gateId) + 1]; 319 next.currentGate = nextGate?.id || 'complete'; 320 next.status = gateId === 'H' ? 'publication_decision_recorded' : `gate_${next.currentGate.toLowerCase()}_work_required`; 321 return save(next); 322 } 323 324 async function createBatch(project, options = {}) { 325 const { name, kind = 'small_batch', weeks = [], actor, humanConfirmed } = options; 326 if (!project.approvals?.some(item => item.gate === 'B' && item.humanConfirmed === true)) throw new Error('gate_b_required'); 327 if (!actor || humanConfirmed !== true) throw new Error('human_approval_required'); 328 if (!['pilot','small_batch'].includes(kind)) throw new Error('batch_kind_not_allowed'); 329 const expected = expectedWeekCount(project); 330 const normalizedWeeks = [...new Set(weeks.map(Number))].filter(week => week >= 1 && week <= expected).sort((a,b) => a-b); 331 if (!normalizedWeeks.length || normalizedWeeks.length > 4) throw new Error('one_to_four_weeks_required'); 332 let next = await recordArtifact(project, 'batch_plan', { batchId:id('batch'), name:String(name || '').trim() || `${kind}-${normalizedWeeks.join('-')}`, kind, weeks:normalizedWeeks }, { actor, status:'planned' }); 333 const plan = latest(next, 'batch_plan'); 334 const job = { 335 jobId:id('job'), batchId:plan.data.batchId, planArtifactId:plan.artifactId, 336 idempotencyKey:id('retry'), status:'planned', weeks:normalizedWeeks, 337 results:[], events:[], retryCount:0, createdAt:now(), updatedAt:now(), 338 }; 339 next.jobs.push(job); 340 next.history.push({ event:'batch_job_created', jobId:job.jobId, batchId:job.batchId, actor, at:now() }); 341 next.status = 'pilot_or_batch_ready'; 342 return save(next); 343 } 344 345 async function recordBatchWeek(project, options = {}) { 346 const { jobId, week, status, actor, idempotencyKey, outputArtifactId = null, warning = '' } = options; 347 if (!actor) throw new Error('actor_required'); 348 if (!['passed','failed','paused'].includes(status)) throw new Error('batch_week_status_not_allowed'); 349 const next = normalizeProject(project); 350 const jobIndex = next.jobs.findIndex(item => item.jobId === jobId); 351 if (jobIndex < 0) throw new Error('batch_job_not_found'); 352 const job = next.jobs[jobIndex]; 353 const key = String(idempotencyKey || `${jobId}:${week}:${status}`); 354 if (job.events.some(event => event.idempotencyKey === key)) return clone(next); 355 const weekNumber = Number(week); 356 if (!job.weeks.includes(weekNumber)) throw new Error('batch_week_not_in_plan'); 357 if (job.status === 'failed') throw new Error('batch_stopped_after_failure'); 358 const event = { idempotencyKey:key, week:weekNumber, status, outputArtifactId, warning:String(warning || ''), actor, at:now() }; 359 job.events.push(event); 360 job.results = [...job.results.filter(item => item.week !== weekNumber), event].sort((a,b) => a.week - b.week); 361 job.status = status === 'failed' ? 'failed' : status === 'paused' ? 'paused' : job.results.filter(item => item.status === 'passed').length === job.weeks.length ? 'completed' : 'in_progress'; 362 job.updatedAt = now(); 363 next.jobs[jobIndex] = job; 364 next.history.push({ event:'batch_week_recorded', jobId, week:weekNumber, status, actor, at:event.at }); 365 const recorded = await recordArtifact(next, 'batch_run', { jobId, batchId:job.batchId, status:job.status, weeks:job.weeks, results:job.results, retryCount:job.retryCount }, { actor, status:job.status, warnings:warning ? [warning] : [], provenance:{ planArtifactId:job.planArtifactId, idempotencyKey:key } }); 366 recorded.status = job.status === 'failed' ? 'batch_failed_review_required' : job.status === 'completed' ? 'gate_c_human_approval_required' : 'batch_in_progress'; 367 return save(recorded); 368 } 369 370 function retryBatchJob(project, options = {}) { 371 const { jobId, actor, humanConfirmed } = options; 372 if (!actor || humanConfirmed !== true) throw new Error('human_approval_required'); 373 const next = normalizeProject(project); 374 const prior = next.jobs.find(item => item.jobId === jobId); 375 if (!prior) throw new Error('batch_job_not_found'); 376 if (!['failed','paused'].includes(prior.status)) throw new Error('batch_retry_not_available'); 377 const replacement = { 378 ...clone(prior), jobId:id('job'), parentJobId:prior.jobId, idempotencyKey:id('retry'), 379 status:'planned', results:[], events:[], retryCount:Number(prior.retryCount || 0) + 1, 380 createdAt:now(), updatedAt:now(), 381 }; 382 next.jobs.push(replacement); 383 next.history.push({ event:'batch_job_retried', parentJobId:prior.jobId, jobId:replacement.jobId, actor, at:now() }); 384 return save(next); 385 } 386 387 async function rollbackArtifact(project, options = {}) { 388 const { type, version, actor, humanConfirmed, reason = '' } = options; 389 if (!actor || humanConfirmed !== true) throw new Error('human_approval_required'); 390 const target = artifactVersions(project, type).find(item => item.version === Number(version)); 391 if (!target) throw new Error('artifact_version_not_found'); 392 if (!String(reason).trim()) throw new Error('rollback_reason_required'); 393 return recordArtifact(project, type, target.data, { 394 actor, status:'restored', warnings:[`Rollback humanamente confirmado: ${String(reason).trim()}`], 395 provenance:{ rollbackFromArtifactId:target.artifactId, rollbackFromVersion:target.version, reason:String(reason).trim() }, 396 }); 397 } 398 399 async function recordTranslationSet(project, data, metadata = {}) { 400 if (data?.humanReviewed !== true) throw new Error('translation_human_review_required'); 401 if (!data?.translations?.EN || !data?.translations?.PT) throw new Error('complete_translation_set_required'); 402 return recordArtifact(project, 'translation_set', data, { ...metadata, status:'human_reviewed' }); 403 } 404 405 async function recordExportPackage(project, data, metadata = {}) { 406 if (data?.validated !== true || !Array.isArray(data?.formats) || !data.formats.length) throw new Error('validated_export_package_required'); 407 return recordArtifact(project, 'export_package', data, { ...metadata, status:'validated' }); 408 } 409 410 async function recordPublicationDecision(project, options = {}) { 411 const { decision, actor, humanConfirmed, notes = '' } = options; 412 if (!actor || humanConfirmed !== true) throw new Error('human_approval_required'); 413 if (!['approved','rejected'].includes(decision)) throw new Error('publication_decision_required'); 414 return recordArtifact(project, 'publication_decision', { decision, notes:String(notes || ''), humanConfirmed:true }, { actor, status:decision }); 415 } 416 417 function canGenerateDraft(project) { 418 return project.approvals?.some(item =>
418 item.gate === 'B' && item.humanConfirmed === true) && !!latest(project, 'topic_map'); 419 } 420 421 function canPublish(project) { 422 return project.approvals?.some(item => item.gate === 'H' && item.humanConfirmed === true) 423 && latest(project, 'publication_decision')?.data?.decision === 'approved'; 424 } 425 426 const api = { 427 schemaVersion:SCHEMA_VERSION, 428 briefFields:BRIEF_FIELDS, 429 artifactTypes:Object.freeze([...ARTIFACT_TYPES]), 430 gates:Object.freeze(GATES.map(gate => Object.freeze({ ...gate, required:Object.freeze([...gate.required]) }))), 431 expertProfile:EXPERT_PROFILE, 432 emptyProject, 433 load, 434 save, 435 latest, 436 latestFor, 437 artifactVersions, 438 corpusInventory, 439 quarterRanges:QUARTER_RANGES, 440 quarterForWeek, 441 monthForWeek, 442 recordArtifact, 443 saveBriefDraft, 444 saveAnnualBrief, 445 saveThemeProposals, 446 decideTheme, 447 approveTheme, 448 rejectTheme, 449 expectedWeekCount, 450 saveTopicMap, 451 approveGate, 452 createBatch, 453 recordBatchWeek, 454 retryBatchJob, 455 rollbackArtifact, 456 recordTranslationSet, 457 recordExportPackage, 458 recordPublicationDecision, 459 canGenerateDraft, 460 canPublish, 461 }; 462 global.HDPAnnualLessonHarness = Object.freeze(api); 463})(typeof window !== 'undefined' ? window : globalThis);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.