PageSourceSearch

https://hogardepaz.netlify.app/lib/annual-lesson-harness.js?v=134dccfd5c81765e

js hogardepaz.netlify.app collected 2026-10-03 10:53:00 UTC 24,427 bytes, 463 lines download raw bytes

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.