@besales/ops-framework 0.1.18 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.21
4
+
5
+ - Added `verify-timeline.json` telemetry for Verify runs, including deterministic blocks, LLM input sizing, context-mode escalation, provider duration/failure and final verdict.
6
+ - Prevented Verify artifact-growth loops by compacting generated review/log artifacts (`verify.md`, `check.md`, `check-resolution.md`, `orchestration-log.md`) even in strict verifier context while preserving full execution evidence.
7
+ - Compacted `execution-ledger.json` in verifier input and marked files mentioned in `execution.md` so task-scope files do not remain in `unrelatedDirtyFiles`.
8
+
9
+ ## 0.1.20
10
+
11
+ - Added deterministic task Check gates for schema/migration plans: a real disposable/scratch database apply path is required before external Check, and Verify/Human Gate evidence must show a successful apply/migrate/psql run rather than static SQL review only.
12
+ - Added audit/entity_history ambiguity gates: plans and initiative synthesis must explicitly choose a functional DB trigger writer model or an application-code writer model.
13
+ - Added a draft-storage synthesis gate for Variant-A/draft-only apply contracts so `proposed_changes` must explicitly support deferred fact domains with entity/change type, JSON payload, source quote, confidence, financial event, related person and conflict metadata.
14
+
15
+ ## 0.1.19
16
+
17
+ - Strengthened deterministic Check preflight so external checker calls are skipped when the plan still has placeholder execution metadata, missing verification ladder or missing standards alignment.
18
+ - Tightened O3 optimization gating: plans must include a representative measurement path or explicit blocked/human-owned fallback before external Check.
19
+ - Added `check-timeline.json` telemetry for Check runs, including deterministic blocks, LLM input sizing, cache hits, provider start/completion/failure and context overflow events.
20
+
3
21
  ## 0.1.18
4
22
 
5
23
  - Added work-package dependency metadata (`Depends on`, `Unblocks`, `Can run parallel with`, `Sequencing notes`) to new initiative work packages.
@@ -1038,7 +1038,9 @@ function buildSynthesisReadinessGates({ requirements, sourceDocs }) {
1038
1038
  });
1039
1039
  addScopeStorageConflictGate({ gates, sourceText, requirementText });
1040
1040
  addApplyContractGate({ gates, sourceText, requirementText });
1041
+ addDraftStorageShapeGate({ gates, sourceText, requirementText });
1041
1042
  addSchemaCompatibilityGate({ gates, sourceText, requirements });
1043
+ addAuditWriterModelGate({ gates, sourceText, requirementText });
1042
1044
  addSchemaAttributionWarningGate({ gates, sourceText, requirements });
1043
1045
  addSequencingGate({ gates, sourceText, requirementText });
1044
1046
  addStrongerSourceRefGate({ gates, sourceDocs, requirements });
@@ -1105,6 +1107,65 @@ function addApplyContractGate({ gates, sourceText, requirementText }) {
1105
1107
  }
1106
1108
  }
1107
1109
 
1110
+ function addDraftStorageShapeGate({ gates, sourceText, requirementText }) {
1111
+ const combinedText = `${requirementText}\n${sourceText}`;
1112
+ const hasDraftOnlyContract = /(variant\s*a|draft[- ]only|draft|proposed only|not applied|только proposed|чернов)/i.test(combinedText);
1113
+ const hasDeferredFactDomains = /(commitments?|decisions?|risks?|projects?|financial_event|related_person_id|has_conflict)/i.test(combinedText);
1114
+ const mentionsProposedChanges = /proposed_changes?|proposedchange|proposed change/i.test(combinedText);
1115
+ if (!hasDraftOnlyContract || !hasDeferredFactDomains || !mentionsProposedChanges) {
1116
+ return;
1117
+ }
1118
+
1119
+ const requiredFields = [
1120
+ ['entity_type', /entity_type/i],
1121
+ ['change_type', /change_type/i],
1122
+ ['proposed_value JSONB', /(proposed_value|payload).{0,40}(jsonb|json)/i],
1123
+ ['source_quote/source_quote_raw', /source_quote(?:_raw)?/i],
1124
+ ['confidence', /confidence/i],
1125
+ ['financial_event_type', /financial_event_type/i],
1126
+ ['related_person_id', /related_person_id/i],
1127
+ ['has_conflict', /has_conflict/i],
1128
+ ];
1129
+ const missing = requiredFields
1130
+ .filter(([, pattern]) => !pattern.test(requirementText))
1131
+ .map(([name]) => name);
1132
+
1133
+ if (missing.length) {
1134
+ gates.push({
1135
+ id: 'draft-storage-proposed-changes-shape',
1136
+ severity: 'critical',
1137
+ blocking: true,
1138
+ title: 'Draft-only apply contract lacks proposed_changes storage shape',
1139
+ issue: `Variant/draft-only requirements rely on proposed_changes for deferred fact domains, but synthesized requirements do not explicitly cover ${missing.join(', ')}.`,
1140
+ action: 'Before WP planning, add acceptance that `proposed_changes` can store draft commitments/decisions/risks/projects/facts with entity_type, change_type, proposed_value JSONB, source quote fields, confidence, financial_event_type, related_person_id and conflict metadata, or narrow the draft output scope.',
1141
+ });
1142
+ }
1143
+ }
1144
+
1145
+ function addAuditWriterModelGate({ gates, sourceText, requirementText }) {
1146
+ const combinedText = `${requirementText}\n${sourceText}`;
1147
+ const mentionsAudit = /(entity_history|audit|before\/after|before and after|log_entity_change|app\.current_user_id|attribution)/i.test(combinedText);
1148
+ if (!mentionsAudit) {
1149
+ return;
1150
+ }
1151
+ const triggerModel = /(trigger|log_entity_change|before\/after|before and after|current_setting\('app\.current_user_id'\)|триггер)/i.test(requirementText);
1152
+ const appModel = /(application[- ]code writer|app(?:lication)? writes|apply[- ]code writes|manual writer|writes entity_history manually|пишет entity_history|прикладн)/i.test(requirementText);
1153
+ const ambiguous = /(trigger|триггер).{0,80}\b(or|или)\b.{0,80}(application|app|manual|код|вручн)/i.test(combinedText)
1154
+ || /(application|app|manual|код|вручн).{0,80}\b(or|или)\b.{0,80}(trigger|триггер)/i.test(combinedText)
1155
+ || /(table only|only table|таблиц[ауы].{0,80}без триггер|helper pattern only|entity_history plus helper pattern|helper pattern)/i.test(combinedText);
1156
+ if (!ambiguous && (triggerModel || appModel)) {
1157
+ return;
1158
+ }
1159
+ gates.push({
1160
+ id: 'audit-writer-model-ambiguous',
1161
+ severity: 'needs_decision',
1162
+ blocking: true,
1163
+ title: 'Audit history writer model is ambiguous',
1164
+ issue: 'Requirements mention audit/entity_history/attribution, but do not decide whether audit rows are written by functional DB triggers or by application/apply code.',
1165
+ action: 'Before WP-001 planning, choose the audit writer model explicitly. For DB-trigger audit, require `log_entity_change`, trigger attachment to core tables and `app.current_user_id`; for app-code audit, require the exact apply-code writer contract and covered entities.',
1166
+ });
1167
+ }
1168
+
1108
1169
  function addSchemaCompatibilityGate({ gates, sourceText, requirements }) {
1109
1170
  const schemaReq = findSchemaRequirement(requirements);
1110
1171
  if (!schemaReq) {
@@ -783,6 +783,120 @@ describe('initiative framework', () => {
783
783
  expect(schemaGate.issue).toContain('meeting_track');
784
784
  expect(schemaGate.action).toContain('meetings.raw_speaker_labels');
785
785
  });
786
+
787
+ it('blocks synthesis when draft-only proposed_changes storage shape is incomplete', () => {
788
+ const root = makeProject();
789
+ const docsDir = path.join(root, 'docs', 'delivery-os');
790
+ fs.mkdirSync(docsDir, { recursive: true });
791
+ fs.writeFileSync(path.join(docsDir, '99-feedback-log.md'), [
792
+ '# Feedback',
793
+ '',
794
+ '- Human-approved Variant A: commitments, decisions, risks and financial_event facts are draft-only ProposedChange outputs in Phase 1.',
795
+ '- Drafts need related_person_id, has_conflict and source quote attribution.',
796
+ ].join('\n'));
797
+ createInitiative({
798
+ projectRoot: root,
799
+ initiativeId: 'delivery-os-mvp',
800
+ title: 'Delivery OS MVP',
801
+ });
802
+ initiativeIntake({
803
+ projectRoot: root,
804
+ initiativeId: 'delivery-os-mvp',
805
+ docsDir: 'docs/delivery-os',
806
+ phase: 'phase-1',
807
+ });
808
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
809
+ fs.writeFileSync(path.join(initiativeDir, 'requirements-map.md'), [
810
+ '# Requirements Map',
811
+ '',
812
+ '## Requirement Details',
813
+ '',
814
+ '### REQ-005',
815
+ '',
816
+ '- Status: `approved`',
817
+ '- Source: `docs/delivery-os/99-feedback-log.md:1`',
818
+ '- Source hash: `sha`',
819
+ '- Phase: `phase-1`',
820
+ '- Quality: `complete`',
821
+ '- Quality issue: (none)',
822
+ '- Work package: WP-004',
823
+ '- Acceptance: Variant A keeps commitments/decisions/risks as draft ProposedChange records, not applied truth writes.',
824
+ '- Decision: `approve`',
825
+ '- Notes: Human-approved Variant A.',
826
+ '- Requirement: Phase 1 apply writes clients/people/meetings and keeps deferred domains as draft proposed_changes.',
827
+ '',
828
+ ].join('\n'));
829
+
830
+ const synthesis = initiativeRequirementsSynthesis({
831
+ projectRoot: root,
832
+ initiativeId: 'delivery-os-mvp',
833
+ });
834
+
835
+ const gate = synthesis.gates.find((item) => item.id === 'draft-storage-proposed-changes-shape');
836
+ expect(gate).toMatchObject({
837
+ severity: 'critical',
838
+ blocking: true,
839
+ });
840
+ expect(gate.issue).toContain('entity_type');
841
+ expect(gate.issue).toContain('related_person_id');
842
+ expect(synthesis.rewriteRequired).toBe(true);
843
+ });
844
+
845
+ it('blocks synthesis when audit/entity_history writer model is ambiguous', () => {
846
+ const root = makeProject();
847
+ const docsDir = path.join(root, 'docs', 'delivery-os');
848
+ fs.mkdirSync(docsDir, { recursive: true });
849
+ fs.writeFileSync(path.join(docsDir, '07-adrs.md'), [
850
+ '# ADR',
851
+ '',
852
+ '- Attribution requires app.current_user_id and entity_history audit before/after records.',
853
+ ].join('\n'));
854
+ createInitiative({
855
+ projectRoot: root,
856
+ initiativeId: 'delivery-os-mvp',
857
+ title: 'Delivery OS MVP',
858
+ });
859
+ initiativeIntake({
860
+ projectRoot: root,
861
+ initiativeId: 'delivery-os-mvp',
862
+ docsDir: 'docs/delivery-os',
863
+ phase: 'phase-1',
864
+ });
865
+ const initiativeDir = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp');
866
+ fs.writeFileSync(path.join(initiativeDir, 'requirements-map.md'), [
867
+ '# Requirements Map',
868
+ '',
869
+ '## Requirement Details',
870
+ '',
871
+ '### REQ-002',
872
+ '',
873
+ '- Status: `approved`',
874
+ '- Source: `docs/delivery-os/07-adrs.md:1`',
875
+ '- Source hash: `sha`',
876
+ '- Phase: `phase-1`',
877
+ '- Quality: `complete`',
878
+ '- Quality issue: (none)',
879
+ '- Work package: WP-001',
880
+ '- Acceptance: Schema includes entity_history and app.current_user_id helper pattern.',
881
+ '- Decision: `approve`',
882
+ '- Notes: Audit trigger scope may expand; minimum is entity_history plus helper pattern.',
883
+ '- Requirement: Phase 1 schema preserves attribution and audit history.',
884
+ '',
885
+ ].join('\n'));
886
+
887
+ const synthesis = initiativeRequirementsSynthesis({
888
+ projectRoot: root,
889
+ initiativeId: 'delivery-os-mvp',
890
+ });
891
+
892
+ const gate = synthesis.gates.find((item) => item.id === 'audit-writer-model-ambiguous');
893
+ expect(gate).toMatchObject({
894
+ severity: 'needs_decision',
895
+ blocking: true,
896
+ });
897
+ expect(gate.action).toContain('log_entity_change');
898
+ expect(synthesis.rewriteRequired).toBe(true);
899
+ });
786
900
  });
787
901
 
788
902
  function makeProject() {
@@ -228,6 +228,8 @@ export function computeTaskContextInputs(taskDir) {
228
228
  const qualityGates = analyzePlanQualityGates({
229
229
  planContent: taskArtifacts.get('plan.md'),
230
230
  risk,
231
+ referencedFiles,
232
+ structuralLines,
231
233
  });
232
234
 
233
235
  return {
@@ -755,7 +757,7 @@ ${missingSignals.length ? missingSignals.map((signal) => `- ${signal}`).join('\n
755
757
  `;
756
758
  }
757
759
 
758
- export function analyzePlanQualityGates({ planContent, risk }) {
760
+ export function analyzePlanQualityGates({ planContent, risk, referencedFiles = [], structuralLines = [] }) {
759
761
  const sections = parseMarkdownSections(planContent);
760
762
  const uiRequired = requiresUiAcceptanceScenarios(risk.riskTriggers);
761
763
  const complexityRequired = requiresComplexityBudget(risk.riskTriggers);
@@ -763,6 +765,12 @@ export function analyzePlanQualityGates({ planContent, risk }) {
763
765
  const optimizationRequired = requiresOptimizationStrategy(optimizationTier);
764
766
  const productionRolloutRequired = requiresProductionRolloutGate(risk.riskTriggers);
765
767
  const sourceSyncProviderRequired = requiresSourceSyncProviderGate(risk.riskTriggers);
768
+ const executionMetadata = inspectExecutionMetadata(sections);
769
+ const verificationLadder = inspectVerificationLadder(sections);
770
+ const standardsAlignmentRequired = requiresStandardsAlignment({ referencedFiles, structuralLines });
771
+ const standardsAlignment = inspectStandardsAlignment(sections);
772
+ const migrationApply = inspectMigrationApplyPlan(sections, risk.riskTriggers);
773
+ const auditWriterModel = inspectAuditWriterModel(sections, planContent);
766
774
  const uiAcceptance = inspectUiAcceptanceScenarios(sections);
767
775
  const complexityBudget = inspectComplexityPerformanceBudget(sections);
768
776
  const optimizationStrategy = inspectOptimizationStrategy(sections);
@@ -770,6 +778,21 @@ export function analyzePlanQualityGates({ planContent, risk }) {
770
778
  const sourceSyncProvider = inspectSourceSyncProviderGate(sections);
771
779
  const missingSignals = [];
772
780
 
781
+ if (!executionMetadata.present) {
782
+ missingSignals.push('Plan must include `## Risk tier and execution budget` with explicit risk tier, speed mode, execution target and budget/stop rule.');
783
+ }
784
+ if (!verificationLadder.present) {
785
+ missingSignals.push('Plan must include a `Verification ladder` with micro-verify, slice-verify and external Verify decision.');
786
+ }
787
+ if (standardsAlignmentRequired && !standardsAlignment.present) {
788
+ missingSignals.push('Standards file/reference detected but `## Global Standards Alignment` / `## Standards Alignment` is missing or incomplete.');
789
+ }
790
+ if (migrationApply.required && !migrationApply.present) {
791
+ missingSignals.push('Schema/migration work requires a real apply path on a disposable/scratch database before closeout; static SQL review alone is not enough.');
792
+ }
793
+ if (auditWriterModel.required && !auditWriterModel.present) {
794
+ missingSignals.push('Audit/entity_history requirement must declare the writer model: functional DB trigger versus explicit application-code writer, not an ambiguous table-only audit.');
795
+ }
773
796
  if (uiRequired && !uiAcceptance.present) {
774
797
  missingSignals.push('UI-visible risk detected but `## UI Acceptance Scenarios` is missing or incomplete.');
775
798
  }
@@ -782,6 +805,9 @@ export function analyzePlanQualityGates({ planContent, risk }) {
782
805
  if (optimizationRequired && !optimizationStrategy.present) {
783
806
  missingSignals.push(`Optimization tier ${optimizationTier} detected but \`## Optimization Strategy\` is missing or incomplete.`);
784
807
  }
808
+ if (optimizationTier === 'O3' && optimizationStrategy.present && !optimizationStrategy.hasMeasurementPath) {
809
+ missingSignals.push('Optimization tier O3 requires a representative measurement path or explicit blocked/human-owned fallback.');
810
+ }
785
811
  if (productionRolloutRequired && !productionRollout.present) {
786
812
  missingSignals.push('Production rollout risk detected but `## Production Rollout Gate` is missing or incomplete.');
787
813
  }
@@ -790,6 +816,12 @@ export function analyzePlanQualityGates({ planContent, risk }) {
790
816
  }
791
817
 
792
818
  return {
819
+ executionMetadata,
820
+ verificationLadder,
821
+ standardsAlignmentRequired,
822
+ standardsAlignment,
823
+ migrationApply,
824
+ auditWriterModel,
793
825
  uiRequired,
794
826
  uiAcceptance,
795
827
  complexityRequired,
@@ -805,6 +837,173 @@ export function analyzePlanQualityGates({ planContent, risk }) {
805
837
  };
806
838
  }
807
839
 
840
+ export function inspectExecutionMetadata(sections) {
841
+ const body = readCanonicalSection(sections, [
842
+ 'risk tier and execution budget',
843
+ 'risk tier',
844
+ 'execution budget',
845
+ ]);
846
+ if (!body) {
847
+ return {
848
+ present: false,
849
+ hasRiskTier: false,
850
+ hasSpeedMode: false,
851
+ hasExecutionTarget: false,
852
+ hasBudget: false,
853
+ };
854
+ }
855
+
856
+ const normalized = body.toLowerCase();
857
+ const targetValue = readLabeledValue(body, ['approved execution target', 'execution target', 'target']);
858
+ const budgetValue = firstNonEmptyValue([
859
+ readLabeledValue(body, ['execution budget', 'budget', 'optimizer budget / stop rule']),
860
+ readLabeledValue(body, ['fast-loop allowed inside this slice', 'fast-loop allowed']),
861
+ readLabeledValue(body, ['requires return to plan/check if', 'requires return']),
862
+ ]);
863
+ const result = {
864
+ present: true,
865
+ hasRiskTier: /risk tier\s*:\s*`?\s*R[0-5]\b/i.test(body) && !/risk tier\s*:\s*`?\s*R0\s*\|/i.test(body),
866
+ hasSpeedMode: /speed mode\s*:\s*`?\s*(fast|standard|strict)\b/i.test(body) && !/speed mode\s*:\s*`?\s*fast\s*\|/i.test(body),
867
+ hasExecutionTarget: Boolean(targetValue && !/^(tbd|todo|n\/a|none|-)?$/i.test(targetValue.trim())),
868
+ hasBudget: Boolean(budgetValue && !/^(tbd|todo|n\/a|none|-)?$/i.test(budgetValue.trim())) || /(?:budget|stop rule|лимит|бюджет)\s*:\s*\S/i.test(normalized),
869
+ };
870
+ result.complete = result.hasRiskTier && result.hasSpeedMode && result.hasExecutionTarget && result.hasBudget;
871
+ result.present = result.complete;
872
+ return result;
873
+ }
874
+
875
+ export function inspectVerificationLadder(sections) {
876
+ const body = [
877
+ readCanonicalSection(sections, ['verification ladder']),
878
+ readCanonicalSection(sections, ['план проверки', 'verification plan', 'verification and replay plan']),
879
+ ].filter(Boolean).join('\n');
880
+ if (!body) {
881
+ return {
882
+ present: false,
883
+ hasMicroVerify: false,
884
+ hasSliceVerify: false,
885
+ hasExternalVerifyDecision: false,
886
+ };
887
+ }
888
+
889
+ const normalized = body.toLowerCase();
890
+ const result = {
891
+ present: true,
892
+ hasMicroVerify: /micro-?verify|micro verify|микро/.test(normalized),
893
+ hasSliceVerify: /slice-?verify|slice verify|слайс/.test(normalized),
894
+ hasExternalVerifyDecision: /external verify|required before closeout|yes\s*\|\s*no|external_cli|internal_supervisor|внешн/.test(normalized),
895
+ };
896
+ result.complete = result.hasMicroVerify && result.hasSliceVerify && result.hasExternalVerifyDecision;
897
+ result.present = result.complete;
898
+ return result;
899
+ }
900
+
901
+ export function requiresStandardsAlignment({ referencedFiles = [], structuralLines = [] } = {}) {
902
+ const text = [...referencedFiles, ...structuralLines].join('\n').toLowerCase();
903
+ return /(^|\/|\.)(claude|standards|standard|rules|conventions)(\.md|\.mdc|\/|$)/.test(text)
904
+ || /\b(standards|conventions|coding rules|global rules|claude\.md)\b/.test(text);
905
+ }
906
+
907
+ export function inspectStandardsAlignment(sections) {
908
+ const body = readCanonicalSection(sections, [
909
+ 'global standards alignment',
910
+ 'standards alignment',
911
+ 'standards',
912
+ ]);
913
+ if (!body) {
914
+ return {
915
+ present: false,
916
+ hasLocalInterpretation: false,
917
+ hasValidation: false,
918
+ };
919
+ }
920
+
921
+ const normalized = body.toLowerCase();
922
+ const result = {
923
+ present: true,
924
+ hasLocalInterpretation: /local interpretation|applied here|applies|интерпретац|применим|учитываем|следуем/.test(normalized),
925
+ hasValidation: /validate|verification|check|test|lint|провер/.test(normalized),
926
+ };
927
+ result.complete = result.hasLocalInterpretation && result.hasValidation;
928
+ result.present = result.complete;
929
+ return result;
930
+ }
931
+
932
+ export function inspectMigrationApplyPlan(sections, riskTriggers = []) {
933
+ const required = riskTriggers.includes('prisma-schema');
934
+ const body = [
935
+ readCanonicalSection(sections, ['production rollout gate', 'production rollout', 'rollout gate', 'deployment gate']),
936
+ readCanonicalSection(sections, ['план проверки', 'verification plan', 'verification and replay plan']),
937
+ readCanonicalSection(sections, ['risk tier and execution budget']),
938
+ ].filter(Boolean).join('\n');
939
+ if (!required) {
940
+ return {
941
+ required: false,
942
+ present: true,
943
+ hasApplyCommand: false,
944
+ hasDisposableTarget: false,
945
+ rejectsStaticOnly: false,
946
+ };
947
+ }
948
+ if (!body) {
949
+ return {
950
+ required,
951
+ present: false,
952
+ hasApplyCommand: false,
953
+ hasDisposableTarget: false,
954
+ rejectsStaticOnly: false,
955
+ };
956
+ }
957
+
958
+ const normalized = body.toLowerCase();
959
+ const result = {
960
+ required,
961
+ present: true,
962
+ hasApplyCommand: /\b(apply|migrate|psql|db push|migration)\b/.test(normalized),
963
+ hasDisposableTarget: /\b(disposable|scratch|throwaway|temporary|temp|ephemeral|test database|local postgres|railway postgres|staging db|однораз|временн|тестов)\b/.test(normalized),
964
+ rejectsStaticOnly: /static(?: sql)? review alone is not enough|not static[- ]only|статическ.{0,40}недостаточно|blocked evidence.{0,80}not accepted/i.test(body),
965
+ };
966
+ result.complete = result.hasApplyCommand && result.hasDisposableTarget;
967
+ result.present = result.complete;
968
+ return result;
969
+ }
970
+
971
+ export function inspectAuditWriterModel(sections, planContent = '') {
972
+ const text = [
973
+ planContent,
974
+ readCanonicalSection(sections, ['production rollout gate', 'production rollout']),
975
+ readCanonicalSection(sections, ['шаги реализации', 'implementation steps']),
976
+ readCanonicalSection(sections, ['риски и открытые вопросы', 'known risks']),
977
+ ].filter(Boolean).join('\n');
978
+ const required = /(entity_history|audit|before\/after|before and after|log_entity_change|current_setting\('app\.current_user_id'\)|app\.current_user_id|attribution)/i.test(text);
979
+ if (!required) {
980
+ return {
981
+ required: false,
982
+ present: true,
983
+ writerModel: null,
984
+ ambiguous: false,
985
+ };
986
+ }
987
+
988
+ const triggerModel = /(trigger|log_entity_change|before\/after|before and after|current_setting\('app\.current_user_id'\)|навеш|триггер)/i.test(text);
989
+ const appModel = /(application[- ]code writer|app(?:lication)? writes|apply[- ]code writes|manual writer|writes entity_history manually|пишет entity_history|прикладн)/i.test(text);
990
+ const tableOnlyAmbiguity = /(table only|only table|таблиц[ауы].{0,80}без триггер|entity_history.{0,120}(without|no) trigger|helper pattern only|minimum is entity_history plus helper pattern)/i.test(text);
991
+ const eitherOrAmbiguity = /(trigger|триггер).{0,80}\b(or|или)\b.{0,80}(application|app|manual|код|вручн)/i.test(text)
992
+ || /(application|app|manual|код|вручн).{0,80}\b(or|или)\b.{0,80}(trigger|триггер)/i.test(text);
993
+ const ambiguous = tableOnlyAmbiguity || eitherOrAmbiguity || (!triggerModel && !appModel);
994
+ const writerModel = triggerModel && !eitherOrAmbiguity
995
+ ? 'db_trigger'
996
+ : appModel && !eitherOrAmbiguity
997
+ ? 'application_code'
998
+ : null;
999
+ return {
1000
+ required,
1001
+ present: Boolean(writerModel && !ambiguous),
1002
+ writerModel,
1003
+ ambiguous,
1004
+ };
1005
+ }
1006
+
808
1007
  export function requiresUiAcceptanceScenarios(riskTriggers = []) {
809
1008
  return riskTriggers.includes('panel-ui') || riskTriggers.includes('ui-visible-api');
810
1009
  }
@@ -953,13 +1152,16 @@ export function inspectOptimizationStrategy(sections) {
953
1152
  hasApproach: /strategy|approach|chosen|batch|map|set|index|query|window|memo|cache|подход|стратег/.test(normalized),
954
1153
  hasAntiPatterns: /n\+1|repeated scan|nested|o\(n\^2\)|unbounded|full scan|render|anti-pattern|антипаттерн/.test(normalized),
955
1154
  hasBudget: /budget|stop rule|stop-rule|minutes|минут|measurement|timing|benchmark|explain|defer|бюджет|лимит/.test(normalized),
1155
+ hasMeasurementPath: /measurement|timing|benchmark|profile|profiling|explain|representative|smoke metric|human-owned fallback|blocked fallback|измер|замер|метрик/.test(normalized),
956
1156
  };
1157
+ const tierRequiresMeasurement = result.tier === 'O3';
957
1158
  result.complete = Boolean(result.tier)
958
1159
  && result.hasHotPaths
959
1160
  && result.hasDataSize
960
1161
  && result.hasApproach
961
1162
  && result.hasAntiPatterns
962
- && result.hasBudget;
1163
+ && result.hasBudget
1164
+ && (!tierRequiresMeasurement || result.hasMeasurementPath);
963
1165
  result.present = result.complete;
964
1166
  return result;
965
1167
  }
@@ -1034,6 +1236,21 @@ function readCanonicalSection(sections, names) {
1034
1236
  return '';
1035
1237
  }
1036
1238
 
1239
+ function readLabeledValue(body, labels) {
1240
+ for (const label of labels) {
1241
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1242
+ const match = new RegExp(`${escaped}\\s*:\\s*([^\\n]+)`, 'i').exec(body);
1243
+ if (match) {
1244
+ return match[1].replace(/^[-\s`]*/, '').replace(/`+$/, '').trim();
1245
+ }
1246
+ }
1247
+ return '';
1248
+ }
1249
+
1250
+ function firstNonEmptyValue(values) {
1251
+ return values.find((value) => typeof value === 'string' && value.trim()) || '';
1252
+ }
1253
+
1037
1254
  export function buildCheckerContextPack({
1038
1255
  taskId,
1039
1256
  risk,
@@ -1313,6 +1530,28 @@ export function validateExecutionEvidenceForPlan({ planContent, executionContent
1313
1530
  }
1314
1531
  }
1315
1532
 
1533
+ if (planRequiresRealMigrationApply(planContent)) {
1534
+ const evidence = readAnySection(executionSections, [
1535
+ 'migration apply evidence',
1536
+ 'schema migration evidence',
1537
+ 'production rollout evidence',
1538
+ 'rollout evidence',
1539
+ 'preflight evidence',
1540
+ 'micro-verify evidence',
1541
+ ]);
1542
+ if (!evidence) {
1543
+ errors.push({
1544
+ category: 'unrun_required_check',
1545
+ message: 'Schema/migration plan requires real disposable/scratch DB apply evidence before Verify/Human Gate.',
1546
+ });
1547
+ } else if (!/(apply|migrate|psql|migration|db push)/i.test(evidence) || !/(pass|passed|success|ok|applied|успеш)/i.test(evidence) || !/(scratch|throwaway|disposable|temporary|temp|ephemeral|test database|local postgres|railway postgres|однораз|временн|тестов)/i.test(evidence)) {
1548
+ errors.push({
1549
+ category: 'insufficient_evidence',
1550
+ message: 'Migration Apply Evidence must show a successful apply/migrate/psql run against a disposable/scratch database target.',
1551
+ });
1552
+ }
1553
+ }
1554
+
1316
1555
  if (hasAnySection(planSections, ['source sync / provider gate', 'source sync provider gate', 'source sync gate', 'provider gate'])) {
1317
1556
  const evidence = readAnySection(executionSections, ['source sync / provider evidence', 'source sync evidence', 'provider evidence']);
1318
1557
  if (!evidence) {
@@ -1331,6 +1570,16 @@ export function validateExecutionEvidenceForPlan({ planContent, executionContent
1331
1570
  return errors;
1332
1571
  }
1333
1572
 
1573
+ function planRequiresRealMigrationApply(planContent) {
1574
+ const sections = parseMarkdownSections(planContent || '');
1575
+ const planText = planContent || '';
1576
+ const hasMigration = /(schema\.prisma|prisma\/migrations|migration|migrations?|create table|alter table|plain sql|DDL|схем|миграц)/i.test(planText);
1577
+ if (!hasMigration) {
1578
+ return false;
1579
+ }
1580
+ return inspectMigrationApplyPlan(sections, ['prisma-schema']).present;
1581
+ }
1582
+
1334
1583
  function hasAnySection(sections, names) {
1335
1584
  return Boolean(readAnySection(sections, names));
1336
1585
  }