@besales/ops-framework 0.1.17 → 0.1.20

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.20
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+
9
+ ## 0.1.19
10
+
11
+ - 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.
12
+ - Tightened O3 optimization gating: plans must include a representative measurement path or explicit blocked/human-owned fallback before external Check.
13
+ - Added `check-timeline.json` telemetry for Check runs, including deterministic blocks, LLM input sizing, cache hits, provider start/completion/failure and context overflow events.
14
+
15
+ ## 0.1.18
16
+
17
+ - Added work-package dependency metadata (`Depends on`, `Unblocks`, `Can run parallel with`, `Sequencing notes`) to new initiative work packages.
18
+ - Made `initiative-next` dependency-aware and prevented materialization when pending work packages are blocked by unmet dependencies.
19
+ - Added a work-package readiness gate that blocks materialization when a work package covers `REQ-*` items but has only process-level acceptance.
20
+
3
21
  ## 0.1.17
4
22
 
5
23
  - Broadened the schema compatibility gate so it blocks any schema/foundation requirement that omits fields required by approved dependent requirements, even when the schema text is not written as an explicit closed list.
package/README.md CHANGED
@@ -249,6 +249,8 @@ Initiative
249
249
 
250
250
  Work packages are materialized as normal tasks, so `brief/research/plan/check/execute/verify/retrospective/learning` still works at the audit boundary. Slices are not separate tasks; they use fast execution, micro-verify and slice evidence inside the work-package task. Create a separate task only when a slice triggers scope, risk, architecture or human-approval escalation.
251
251
 
252
+ Work packages can declare `Depends on`, `Unblocks`, `Can run parallel with` and `Sequencing notes`. `initiative-next` uses `Depends on` to avoid materializing a package before required packages are completed. If a work package lists `REQ-*` coverage, its `Acceptance` must include requirement-level done criteria or explicit `REQ-*` acceptance references; process-only acceptance blocks materialization.
253
+
252
254
  For source docs, use intake before work-package planning:
253
255
 
254
256
  ```text
@@ -202,12 +202,14 @@ export function initiativeNext({
202
202
  force = false,
203
203
  } = {}) {
204
204
  const status = initiativeStatus({ projectRoot, initiativeId });
205
- const next = status.workPackages.find((wp) => ['pending', 'ready'].includes(wp.status));
205
+ const readiness = buildWorkPackageReadiness(status.workPackages);
206
+ const next = readiness.find((item) => item.ready)?.workPackage || null;
206
207
  if (!next) {
207
208
  return {
208
209
  initiativeId,
209
210
  next: null,
210
211
  materializedTask: null,
212
+ readiness,
211
213
  };
212
214
  }
213
215
  let materializedTask = null;
@@ -229,6 +231,7 @@ export function initiativeNext({
229
231
  initiativeId,
230
232
  next,
231
233
  materializedTask,
234
+ readiness,
232
235
  };
233
236
  }
234
237
 
@@ -1035,7 +1038,9 @@ function buildSynthesisReadinessGates({ requirements, sourceDocs }) {
1035
1038
  });
1036
1039
  addScopeStorageConflictGate({ gates, sourceText, requirementText });
1037
1040
  addApplyContractGate({ gates, sourceText, requirementText });
1041
+ addDraftStorageShapeGate({ gates, sourceText, requirementText });
1038
1042
  addSchemaCompatibilityGate({ gates, sourceText, requirements });
1043
+ addAuditWriterModelGate({ gates, sourceText, requirementText });
1039
1044
  addSchemaAttributionWarningGate({ gates, sourceText, requirements });
1040
1045
  addSequencingGate({ gates, sourceText, requirementText });
1041
1046
  addStrongerSourceRefGate({ gates, sourceDocs, requirements });
@@ -1102,6 +1107,65 @@ function addApplyContractGate({ gates, sourceText, requirementText }) {
1102
1107
  }
1103
1108
  }
1104
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
+
1105
1169
  function addSchemaCompatibilityGate({ gates, sourceText, requirements }) {
1106
1170
  const schemaReq = findSchemaRequirement(requirements);
1107
1171
  if (!schemaReq) {
@@ -1327,6 +1391,7 @@ function listWorkPackages(initiativeDir) {
1327
1391
 
1328
1392
  function readWorkPackage(filePath, fallbackId) {
1329
1393
  const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
1394
+ const dependencies = readWorkPackageDependencies(content);
1330
1395
  return {
1331
1396
  id: readInlineField(content, 'ID') || fallbackId,
1332
1397
  title: readInlineField(content, 'Title') || humanizeSlug(fallbackId.replace(/^WP-\d{3}-/, '')),
@@ -1334,11 +1399,89 @@ function readWorkPackage(filePath, fallbackId) {
1334
1399
  task: readInlineField(content, 'Task') || '',
1335
1400
  mode: readInlineField(content, 'Mode') || 'standard_work_package',
1336
1401
  goal: readSection(content, 'Goal') || '',
1402
+ requirementsCoverage: readBulletsFromSection(content, 'Requirements Coverage'),
1337
1403
  slices: readBulletsFromSection(content, 'Slices'),
1404
+ acceptance: readBulletsFromSection(content, 'Acceptance'),
1405
+ dependencies,
1338
1406
  path: filePath,
1339
1407
  };
1340
1408
  }
1341
1409
 
1410
+ function readWorkPackageDependencies(content) {
1411
+ const section = readSection(content, 'Dependencies');
1412
+ return {
1413
+ dependsOn: readDependencyField(section, 'Depends on'),
1414
+ unblocks: readDependencyField(section, 'Unblocks'),
1415
+ parallelWith: readDependencyField(section, 'Can run parallel with'),
1416
+ sequencingNotes: readDependencyField(section, 'Sequencing notes'),
1417
+ };
1418
+ }
1419
+
1420
+ function readDependencyField(section, field) {
1421
+ if (!section) {
1422
+ return [];
1423
+ }
1424
+ const match = new RegExp(`^-\\s+${escapeRegExp(field)}:\\s*(.*)$`, 'im').exec(section);
1425
+ if (!match) {
1426
+ return [];
1427
+ }
1428
+ return splitDependencyRefs(match[1]);
1429
+ }
1430
+
1431
+ function splitDependencyRefs(value) {
1432
+ return String(value || '')
1433
+ .split(/[,;]/)
1434
+ .map((item) => item.trim())
1435
+ .filter((item) => item && !/^\(?none\)?$/i.test(item) && !/^\[?fill in\]?$/i.test(item));
1436
+ }
1437
+
1438
+ function buildWorkPackageReadiness(workPackages) {
1439
+ const byId = new Map(workPackages.map((wp) => [wp.id, wp]));
1440
+ return workPackages
1441
+ .filter((wp) => ['pending', 'ready'].includes(wp.status))
1442
+ .map((wp) => {
1443
+ const blockers = [];
1444
+ for (const dependencyId of wp.dependencies.dependsOn) {
1445
+ const dependency = byId.get(dependencyId);
1446
+ if (!dependency) {
1447
+ blockers.push(`Dependency ${dependencyId} is missing.`);
1448
+ continue;
1449
+ }
1450
+ if (!workPackageDependencySatisfied(dependency)) {
1451
+ blockers.push(`Dependency ${dependencyId} is ${dependency.status || 'unknown'}, not completed.`);
1452
+ }
1453
+ }
1454
+ if (workPackageAcceptanceIsProcessOnly(wp)) {
1455
+ blockers.push('Acceptance is process-only while Requirements Coverage is present; add requirement-level done criteria before materialize.');
1456
+ }
1457
+ return {
1458
+ workPackage: wp,
1459
+ ready: blockers.length === 0,
1460
+ blockers,
1461
+ };
1462
+ });
1463
+ }
1464
+
1465
+ function workPackageDependencySatisfied(workPackage) {
1466
+ return /^(done|complete|completed|verified|closed)$/i.test(workPackage.status || '');
1467
+ }
1468
+
1469
+ function workPackageAcceptanceIsProcessOnly(workPackage) {
1470
+ const coverageText = workPackage.requirementsCoverage.join('\n');
1471
+ if (!/\bREQ-\d{3}\b/.test(coverageText)) {
1472
+ return false;
1473
+ }
1474
+ const acceptance = workPackage.acceptance;
1475
+ if (!acceptance.length) {
1476
+ return true;
1477
+ }
1478
+ const acceptanceText = acceptance.join('\n');
1479
+ if (/\bREQ-\d{3}\b/.test(acceptanceText)) {
1480
+ return false;
1481
+ }
1482
+ return acceptance.every((item) => /(verify completed|completed verify|slice ledger|learning closeout|task closeout|execution\.md)/i.test(item));
1483
+ }
1484
+
1342
1485
  function updateWorkPackageStatus({ workPackagePath, status, taskId }) {
1343
1486
  const content = fs.readFileSync(workPackagePath, 'utf8');
1344
1487
  let updated = content
@@ -1438,6 +1581,17 @@ function buildWorkPackageMarkdown({ initiativeId, workPackageId, title, goal, mo
1438
1581
  '- Excluded:',
1439
1582
  '- Escalate to separate task if:',
1440
1583
  '',
1584
+ '## Requirements Coverage',
1585
+ '',
1586
+ '- REQ-000: Add covered requirement and keep acceptance aligned with requirement-level done criteria.',
1587
+ '',
1588
+ '## Dependencies',
1589
+ '',
1590
+ '- Depends on: (none)',
1591
+ '- Unblocks: (none)',
1592
+ '- Can run parallel with: (none)',
1593
+ '- Sequencing notes: (none)',
1594
+ '',
1441
1595
  '## Slices',
1442
1596
  '',
1443
1597
  '- Slice 1: Define the first implementation slice.',
@@ -1446,6 +1600,7 @@ function buildWorkPackageMarkdown({ initiativeId, workPackageId, title, goal, mo
1446
1600
  '',
1447
1601
  '## Acceptance',
1448
1602
  '',
1603
+ '- Covered REQ-* acceptance criteria are satisfied or explicitly deferred with human approval.',
1449
1604
  '- Work-package task has completed Verify.',
1450
1605
  '- Slice ledger is recorded in execution.md.',
1451
1606
  '- Learning closeout is completed before task closeout.',
@@ -1562,6 +1717,13 @@ function printInitiativeNext(result) {
1562
1717
  console.log(`Initiative next: ${result.initiativeId}`);
1563
1718
  if (!result.next) {
1564
1719
  console.log('- no pending work packages');
1720
+ const blocked = (result.readiness || []).filter((item) => !item.ready);
1721
+ for (const item of blocked) {
1722
+ console.log(`- blocked: ${item.workPackage.id}`);
1723
+ for (const blocker of item.blockers) {
1724
+ console.log(` - ${blocker}`);
1725
+ }
1726
+ }
1565
1727
  return;
1566
1728
  }
1567
1729
  console.log(`- workPackage: ${result.next.id}`);
@@ -76,6 +76,110 @@ describe('initiative framework', () => {
76
76
  expect(workPackage).toContain('Task: TASK-001-foundation');
77
77
  });
78
78
 
79
+ it('selects the next work package with dependency readiness', () => {
80
+ const root = makeProject();
81
+ createInitiative({
82
+ projectRoot: root,
83
+ initiativeId: 'delivery-os-mvp',
84
+ title: 'Delivery OS MVP',
85
+ });
86
+ addWorkPackage({
87
+ projectRoot: root,
88
+ initiativeId: 'delivery-os-mvp',
89
+ workPackageId: 'WP-001-foundation',
90
+ title: 'Foundation',
91
+ });
92
+ addWorkPackage({
93
+ projectRoot: root,
94
+ initiativeId: 'delivery-os-mvp',
95
+ workPackageId: 'WP-003-process-meeting',
96
+ title: 'Process Meeting',
97
+ });
98
+ const wp3Path = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp', 'work-packages', 'WP-003-process-meeting', 'work-package.md');
99
+ fs.writeFileSync(wp3Path, fs.readFileSync(wp3Path, 'utf8').replace(
100
+ '- Depends on: (none)',
101
+ '- Depends on: WP-001-foundation',
102
+ ));
103
+
104
+ let next = initiativeNext({
105
+ projectRoot: root,
106
+ initiativeId: 'delivery-os-mvp',
107
+ });
108
+
109
+ expect(next.next.id).toBe('WP-001-foundation');
110
+ expect(next.readiness.find((item) => item.workPackage.id === 'WP-003-process-meeting').blockers[0]).toContain('Dependency WP-001-foundation');
111
+
112
+ const wp1Path = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp', 'work-packages', 'WP-001-foundation', 'work-package.md');
113
+ fs.writeFileSync(wp1Path, fs.readFileSync(wp1Path, 'utf8').replace('Status: pending', 'Status: completed'));
114
+
115
+ next = initiativeNext({
116
+ projectRoot: root,
117
+ initiativeId: 'delivery-os-mvp',
118
+ });
119
+
120
+ expect(next.next.id).toBe('WP-003-process-meeting');
121
+ });
122
+
123
+ it('blocks materialization when covered requirements have process-only acceptance', () => {
124
+ const root = makeProject();
125
+ createInitiative({
126
+ projectRoot: root,
127
+ initiativeId: 'delivery-os-mvp',
128
+ title: 'Delivery OS MVP',
129
+ });
130
+ addWorkPackage({
131
+ projectRoot: root,
132
+ initiativeId: 'delivery-os-mvp',
133
+ workPackageId: 'WP-001-foundation',
134
+ title: 'Foundation',
135
+ });
136
+ const wpPath = path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp', 'work-packages', 'WP-001-foundation', 'work-package.md');
137
+ fs.writeFileSync(wpPath, [
138
+ '# Work Package',
139
+ '',
140
+ 'ID: WP-001-foundation',
141
+ 'Initiative: delivery-os-mvp',
142
+ 'Title: Foundation',
143
+ 'Status: pending',
144
+ 'Task:',
145
+ 'Mode: standard_work_package',
146
+ '',
147
+ '## Goal',
148
+ '',
149
+ 'Create foundation.',
150
+ '',
151
+ '## Requirements Coverage',
152
+ '',
153
+ '- REQ-002',
154
+ '',
155
+ '## Dependencies',
156
+ '',
157
+ '- Depends on: (none)',
158
+ '',
159
+ '## Slices',
160
+ '',
161
+ '- Slice 1: Build foundation.',
162
+ '',
163
+ '## Acceptance',
164
+ '',
165
+ '- Work-package task has completed Verify.',
166
+ '- Slice ledger is recorded in execution.md.',
167
+ '- Learning closeout is completed before task closeout.',
168
+ '',
169
+ ].join('\n'));
170
+
171
+ const next = initiativeNext({
172
+ projectRoot: root,
173
+ initiativeId: 'delivery-os-mvp',
174
+ materializeTask: true,
175
+ });
176
+
177
+ expect(next.next).toBeNull();
178
+ expect(next.materializedTask).toBeNull();
179
+ expect(next.readiness[0].blockers[0]).toContain('Acceptance is process-only');
180
+ expect(fs.existsSync(path.join(root, 'ops', 'agent-pipeline', 'tasks', 'TASK-001-foundation'))).toBe(false);
181
+ });
182
+
79
183
  it('indexes source docs and builds a requirements map', () => {
80
184
  const root = makeProject();
81
185
  const docsDir = path.join(root, 'docs', 'delivery-os');
@@ -679,6 +783,120 @@ describe('initiative framework', () => {
679
783
  expect(schemaGate.issue).toContain('meeting_track');
680
784
  expect(schemaGate.action).toContain('meetings.raw_speaker_labels');
681
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
+ });
682
900
  });
683
901
 
684
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
  }
@@ -13,12 +13,18 @@ import {
13
13
  riskRootWarnings,
14
14
  selectRelevantPlaybookNames,
15
15
  inspectComplexityPerformanceBudget,
16
+ inspectExecutionMetadata,
17
+ inspectAuditWriterModel,
18
+ inspectMigrationApplyPlan,
16
19
  inspectOptimizationStrategy,
17
20
  inspectProductionRolloutGate,
18
21
  inspectSourceSyncProviderGate,
22
+ inspectStandardsAlignment,
19
23
  inspectUiAcceptanceScenarios,
24
+ inspectVerificationLadder,
20
25
  parseMarkdownSections,
21
26
  requiresOptimizationStrategy,
27
+ requiresStandardsAlignment,
22
28
  validateExecutionEvidenceForPlan,
23
29
  } from './check-context-utils.mjs';
24
30
 
@@ -49,6 +55,176 @@ describe('agent pipeline quality gates', () => {
49
55
  expect(result.missingSignals).toContain('UI-visible risk detected but `## UI Acceptance Scenarios` is missing or incomplete.');
50
56
  });
51
57
 
58
+ it('blocks external check locally when execution metadata is placeholder-only', () => {
59
+ const plan = [
60
+ '# Plan',
61
+ '',
62
+ '## Risk tier and execution budget',
63
+ '',
64
+ '- Risk tier: `R0 | R1 | R2 | R3 | R4 | R5`',
65
+ '- Speed mode: `Fast | Standard | Strict`',
66
+ '- Approved execution target:',
67
+ '- Fast-loop allowed inside this slice:',
68
+ '- Requires return to Plan/Check if:',
69
+ ].join('\n');
70
+
71
+ const result = analyzePlanQualityGates({
72
+ planContent: plan,
73
+ risk: {
74
+ riskProfile: 'low',
75
+ riskTriggers: ['docs-only'],
76
+ },
77
+ });
78
+
79
+ expect(result.executionMetadata.present).toBe(false);
80
+ expect(result.missingSignals).toContain('Plan must include `## Risk tier and execution budget` with explicit risk tier, speed mode, execution target and budget/stop rule.');
81
+ });
82
+
83
+ it('accepts explicit execution metadata and verification ladder', () => {
84
+ const sections = parseMarkdownSections([
85
+ '# Plan',
86
+ '',
87
+ '## Risk tier and execution budget',
88
+ '',
89
+ '- Risk tier: `R2`',
90
+ '- Speed mode: `Standard`',
91
+ '- Approved execution target: local schema files and API DTOs only.',
92
+ '- Fast-loop allowed inside this slice: yes, until migration scope changes.',
93
+ '- Requires return to Plan/Check if: destructive migration or production data write appears.',
94
+ '',
95
+ '## План проверки',
96
+ '',
97
+ '### Verification ladder',
98
+ '',
99
+ '- Micro-verify during Execute: node --check.',
100
+ '- Slice-verify before completion: tests.',
101
+ '- External Verify required before closeout: no, internal_supervisor is enough.',
102
+ ].join('\n'));
103
+
104
+ expect(inspectExecutionMetadata(sections).present).toBe(true);
105
+ expect(inspectVerificationLadder(sections).present).toBe(true);
106
+ });
107
+
108
+ it('requires standards alignment only when standards references are present', () => {
109
+ expect(requiresStandardsAlignment({
110
+ referencedFiles: ['../.claude/CLAUDE.MD'],
111
+ structuralLines: [],
112
+ })).toBe(true);
113
+ expect(requiresStandardsAlignment({
114
+ referencedFiles: ['docs/01-overview.md'],
115
+ structuralLines: [],
116
+ })).toBe(false);
117
+
118
+ const missing = analyzePlanQualityGates({
119
+ planContent: '# Plan\n',
120
+ risk: {
121
+ riskProfile: 'low',
122
+ riskTriggers: ['docs-only'],
123
+ },
124
+ referencedFiles: ['../.claude/CLAUDE.MD'],
125
+ });
126
+ expect(missing.missingSignals).toContain('Standards file/reference detected but `## Global Standards Alignment` / `## Standards Alignment` is missing or incomplete.');
127
+
128
+ const sections = parseMarkdownSections([
129
+ '# Plan',
130
+ '',
131
+ '## Global Standards Alignment',
132
+ '',
133
+ '- Local interpretation: apply repo naming and validation rules to this task.',
134
+ '- Validation: run lint/test commands from the plan.',
135
+ ].join('\n'));
136
+ expect(inspectStandardsAlignment(sections).present).toBe(true);
137
+ });
138
+
139
+ it('requires a real disposable migration apply path for schema work before external check', () => {
140
+ const result = analyzePlanQualityGates({
141
+ planContent: [
142
+ '# Plan',
143
+ '',
144
+ '## Risk tier and execution budget',
145
+ '',
146
+ '- Risk tier: `R3`',
147
+ '- Speed mode: `Standard`',
148
+ '- Approved execution target: plain SQL migration.',
149
+ '- Requires return to Plan/Check if: database apply cannot run.',
150
+ '',
151
+ '## План проверки',
152
+ '',
153
+ '### Verification ladder',
154
+ '',
155
+ '- Micro-verify during Execute: static SQL review.',
156
+ '- Slice-verify before completion: blocked if Postgres unavailable.',
157
+ '- External Verify required before closeout: no.',
158
+ '',
159
+ '## Production Rollout Gate',
160
+ '',
161
+ '- Impact / blast radius: local schema migration only.',
162
+ '- Environment / deploy variables: none.',
163
+ '- Rollback / disable path: revert migration file.',
164
+ '- Post-deploy evidence: static SQL review if Postgres unavailable.',
165
+ ].join('\n'),
166
+ risk: {
167
+ riskProfile: 'high',
168
+ riskTriggers: ['prisma-schema'],
169
+ },
170
+ });
171
+
172
+ expect(result.migrationApply.required).toBe(true);
173
+ expect(result.migrationApply.present).toBe(false);
174
+ expect(result.missingSignals).toContain('Schema/migration work requires a real apply path on a disposable/scratch database before closeout; static SQL review alone is not enough.');
175
+ });
176
+
177
+ it('accepts migration apply plan with disposable database target', () => {
178
+ const sections = parseMarkdownSections([
179
+ '# Plan',
180
+ '',
181
+ '## Production Rollout Gate',
182
+ '',
183
+ '- Impact / blast radius: local schema migration only.',
184
+ '- Environment / deploy variables: temporary Railway Postgres scratch database.',
185
+ '- Rollback / disable path: drop scratch database and revert migration file.',
186
+ '- Post-deploy evidence: apply plain SQL migration with psql against throwaway Postgres; static SQL review alone is not enough.',
187
+ ].join('\n'));
188
+
189
+ const result = inspectMigrationApplyPlan(sections, ['prisma-schema']);
190
+
191
+ expect(result.present).toBe(true);
192
+ expect(result.hasApplyCommand).toBe(true);
193
+ expect(result.hasDisposableTarget).toBe(true);
194
+ });
195
+
196
+ it('requires audit writer model when entity_history audit is in scope', () => {
197
+ const sections = parseMarkdownSections([
198
+ '# Plan',
199
+ '',
200
+ '## Риски и открытые вопросы',
201
+ '',
202
+ '- Full audit triggers may expand scope; minimum is entity_history plus helper pattern.',
203
+ ].join('\n'));
204
+
205
+ const result = inspectAuditWriterModel(sections, Array.from(sections.values()).join('\n'));
206
+
207
+ expect(result.required).toBe(true);
208
+ expect(result.present).toBe(false);
209
+ expect(result.ambiguous).toBe(true);
210
+ });
211
+
212
+ it('accepts explicit DB trigger audit writer model', () => {
213
+ const sections = parseMarkdownSections([
214
+ '# Plan',
215
+ '',
216
+ '## Шаги реализации',
217
+ '',
218
+ '- Create entity_history.',
219
+ "- Create log_entity_change trigger function using current_setting('app.current_user_id') and attach triggers to core tables for before/after audit.",
220
+ ].join('\n'));
221
+
222
+ const result = inspectAuditWriterModel(sections, Array.from(sections.values()).join('\n'));
223
+
224
+ expect(result.present).toBe(true);
225
+ expect(result.writerModel).toBe('db_trigger');
226
+ });
227
+
52
228
  it('accepts UI acceptance scenarios with intent, steps, expected visible result and must-catch regressions', () => {
53
229
  const sections = parseMarkdownSections([
54
230
  '# Plan',
@@ -199,6 +375,54 @@ describe('agent pipeline quality gates', () => {
199
375
  expect(result.hasApproach).toBe(true);
200
376
  expect(result.hasAntiPatterns).toBe(true);
201
377
  expect(result.hasBudget).toBe(true);
378
+ expect(result.hasMeasurementPath).toBe(true);
379
+ });
380
+
381
+ it('requires a measurement path for O3 optimization strategy before external check', () => {
382
+ const result = analyzePlanQualityGates({
383
+ planContent: [
384
+ '# Plan',
385
+ '',
386
+ '## Risk tier and execution budget',
387
+ '',
388
+ '- Risk tier: `R3`',
389
+ '- Speed mode: `Standard`',
390
+ '- Approved execution target: worker hot path.',
391
+ '- Requires return to Plan/Check if: budget changes.',
392
+ '',
393
+ '## План проверки',
394
+ '',
395
+ '### Verification ladder',
396
+ '',
397
+ '- Micro-verify during Execute: unit test.',
398
+ '- Slice-verify before completion: smoke test.',
399
+ '- External Verify required before closeout: no.',
400
+ '',
401
+ '## Complexity / Performance Budget',
402
+ '',
403
+ '- Hot paths: worker materializer.',
404
+ '- Expected data size / row counts: 150k rows.',
405
+ '- Complexity risks: N+1 queries.',
406
+ '- Budget / stop rule: < 3 seconds.',
407
+ '',
408
+ '## Optimization Strategy',
409
+ '',
410
+ '- Optimization tier: O3 measured review.',
411
+ '- Hot paths: worker materializer.',
412
+ '- Expected data size / row counts: 150k rows.',
413
+ '- Chosen efficient approach: batch query and indexed lookup.',
414
+ '- Anti-patterns avoided: N+1 queries and repeated scans.',
415
+ '- Optimizer budget / stop rule: one focused review; defer speculative ideas.',
416
+ ].join('\n'),
417
+ risk: {
418
+ riskProfile: 'high',
419
+ riskTriggers: ['production-runtime'],
420
+ },
421
+ });
422
+
423
+ expect(result.optimizationTier).toBe('O3');
424
+ expect(result.optimizationStrategy.present).toBe(false);
425
+ expect(result.missingSignals).toContain('Optimization tier O3 detected but `## Optimization Strategy` is missing or incomplete.');
202
426
  });
203
427
 
204
428
  it('builds a checker context pack with exact quality-gate questions', () => {
@@ -431,6 +655,42 @@ describe('agent pipeline quality gates', () => {
431
655
  expect(issues).toEqual([]);
432
656
  });
433
657
 
658
+ it('pre-verify evidence gate blocks migration plans without successful scratch DB apply evidence', () => {
659
+ const plan = [
660
+ '# Plan',
661
+ '',
662
+ '## Production Rollout Gate',
663
+ '',
664
+ '- Impact / blast radius: local schema migration only.',
665
+ '- Environment / deploy variables: temporary Railway Postgres scratch database.',
666
+ '- Rollback / disable path: drop scratch database and revert migration file.',
667
+ '- Post-deploy evidence: apply plain SQL migration with psql against throwaway Postgres; static SQL review alone is not enough.',
668
+ '',
669
+ '## Шаги реализации',
670
+ '',
671
+ '- Create plain SQL migration with CREATE TABLE statements.',
672
+ ].join('\n');
673
+ const execution = [
674
+ '# Execution',
675
+ '',
676
+ '## Production Rollout Evidence',
677
+ '',
678
+ '| Check | Result | Evidence | Notes |',
679
+ '| --- | --- | --- | --- |',
680
+ '| migration | blocked | Postgres unavailable; static SQL review only | n/a |',
681
+ ].join('\n');
682
+
683
+ const issues = validateExecutionEvidenceForPlan({
684
+ planContent: plan,
685
+ executionContent: execution,
686
+ });
687
+
688
+ expect(issues).toContainEqual({
689
+ category: 'insufficient_evidence',
690
+ message: 'Migration Apply Evidence must show a successful apply/migrate/psql run against a disposable/scratch database target.',
691
+ });
692
+ });
693
+
434
694
  it('requires production rollout gate for production runtime triggers', () => {
435
695
  const result = analyzePlanQualityGates({
436
696
  planContent: '# Plan\n\n## Затронутые модули и файлы\n\n- deploy runtime env variable for worker',
@@ -26,7 +26,18 @@ describe('task manifest utilities', () => {
26
26
  '',
27
27
  '## Risk tier and execution budget',
28
28
  '',
29
+ '- Risk tier: `R1`',
29
30
  '- Speed mode: `Fast`',
31
+ '- Approved execution target: docs/example.md only.',
32
+ '- Requires return to Plan/Check if: code changes are needed.',
33
+ '',
34
+ '## План проверки',
35
+ '',
36
+ '### Verification ladder',
37
+ '',
38
+ '- Micro-verify during Execute: markdown review.',
39
+ '- Slice-verify before completion: self-test.',
40
+ '- External Verify required before closeout: no.',
30
41
  '',
31
42
  '## Затронутые модули и файлы',
32
43
  '',
@@ -94,7 +105,18 @@ describe('task manifest utilities', () => {
94
105
  '',
95
106
  '## Risk tier and execution budget',
96
107
  '',
108
+ '- Risk tier: `R1`',
97
109
  '- Speed mode: `Fast`',
110
+ '- Approved execution target: docs/example.md only.',
111
+ '- Requires return to Plan/Check if: code changes are needed.',
112
+ '',
113
+ '## План проверки',
114
+ '',
115
+ '### Verification ladder',
116
+ '',
117
+ '- Micro-verify during Execute: markdown review.',
118
+ '- Slice-verify before completion: self-test.',
119
+ '- External Verify required before closeout: no.',
98
120
  '',
99
121
  '## Затронутые модули и файлы',
100
122
  '',
package/bin/run-check.mjs CHANGED
@@ -66,6 +66,13 @@ async function runMain() {
66
66
  const noCache = getFlag(args, 'no-cache', false) === true;
67
67
  const checkerConfig = resolveCheckerConfig(args);
68
68
  const runStartedAt = new Date();
69
+ appendCheckTimeline(taskDir, {
70
+ event: 'check_started',
71
+ provider: checkerConfig.provider,
72
+ model: checkerConfig.model,
73
+ noCache,
74
+ dryRun,
75
+ });
69
76
 
70
77
  let checkContext = ensureFreshCheckContext(taskDir, taskId);
71
78
  const deterministicPrecheck = syncManifestAndStatusBeforeCheck({ taskDir, taskId, checkContext });
@@ -79,6 +86,12 @@ async function runMain() {
79
86
  issues: deterministicPrecheck.issues,
80
87
  startedAt: runStartedAt,
81
88
  });
89
+ appendCheckTimeline(taskDir, {
90
+ event: 'deterministic_precheck_blocked',
91
+ verdict: 'return_to_plan',
92
+ issues: deterministicPrecheck.issues.map((issue) => issue.message),
93
+ timing: buildTiming(runStartedAt),
94
+ });
82
95
  runValidator(taskArg);
83
96
  console.log(`Checker preflight blocked ${taskId}: return_to_plan`);
84
97
  console.log(`- deterministicIssues: ${deterministicPrecheck.issues.length}`);
@@ -146,6 +159,13 @@ async function runMain() {
146
159
  cacheKey,
147
160
  contextMode,
148
161
  });
162
+ appendCheckTimeline(taskDir, {
163
+ event: 'llm_input_built',
164
+ contextMode,
165
+ cacheKeySha,
166
+ packMeta: promptPayload.pack.meta,
167
+ timing: buildTiming(runStartedAt),
168
+ });
149
169
 
150
170
  console.log(`Checker LLM input for ${taskId}`);
151
171
  for (const line of summarizePackForConsole(promptPayload.pack)) {
@@ -169,6 +189,13 @@ async function runMain() {
169
189
  message: `Strict LLM input pack exceeds cap: estimatedTokens=${promptPayload.pack.meta.estimatedTokens}, capTokens=${promptPayload.pack.meta.capTokens}`,
170
190
  rawOutput: null,
171
191
  });
192
+ appendCheckTimeline(taskDir, {
193
+ event: 'context_overflow',
194
+ contextMode,
195
+ cacheKeySha,
196
+ packMeta: promptPayload.pack.meta,
197
+ timing: buildTiming(runStartedAt),
198
+ });
172
199
  recordLlmInputUsage({
173
200
  taskDir,
174
201
  stage: 'check',
@@ -184,6 +211,13 @@ async function runMain() {
184
211
 
185
212
  if (!noCache && restoreFromCache({ taskDir, taskArg, cacheKeySha })) {
186
213
  llmInputAttempts.push(buildAttemptRecord(promptPayload.pack.meta, 'cache_hit'));
214
+ appendCheckTimeline(taskDir, {
215
+ event: 'cache_hit',
216
+ contextMode,
217
+ cacheKeySha,
218
+ packMeta: promptPayload.pack.meta,
219
+ timing: buildTiming(runStartedAt),
220
+ });
187
221
  recordLlmInputUsage({
188
222
  taskDir,
189
223
  stage: 'check',
@@ -197,11 +231,32 @@ async function runMain() {
197
231
  }
198
232
 
199
233
  try {
234
+ const providerStartedAt = new Date();
235
+ appendCheckTimeline(taskDir, {
236
+ event: 'provider_started',
237
+ provider: checkerConfig.provider,
238
+ model: checkerConfig.model,
239
+ reasoningEffort: checkerConfig.reasoningEffort,
240
+ contextMode,
241
+ cacheKeySha,
242
+ packMeta: promptPayload.pack.meta,
243
+ timing: buildTiming(runStartedAt),
244
+ });
200
245
  providerOutput = await runProvider({
201
246
  checkerConfig,
202
247
  messages: promptPayload.messages,
203
248
  prompt: promptPayload.prompt,
204
249
  });
250
+ appendCheckTimeline(taskDir, {
251
+ event: 'provider_completed',
252
+ provider: checkerConfig.provider,
253
+ model: checkerConfig.model,
254
+ contextMode,
255
+ cacheKeySha,
256
+ verdict: providerOutput.checkResultJson?.verdict || null,
257
+ providerTiming: buildTiming(providerStartedAt),
258
+ timing: buildTiming(runStartedAt),
259
+ });
205
260
  } catch (error) {
206
261
  const failureReason = error.failureReason || 'unknown';
207
262
  writeFailureArtifacts({
@@ -215,6 +270,16 @@ async function runMain() {
215
270
  message: error.message,
216
271
  rawOutput: error.rawOutput || null,
217
272
  });
273
+ appendCheckTimeline(taskDir, {
274
+ event: 'provider_failed',
275
+ provider: checkerConfig.provider,
276
+ model: checkerConfig.model,
277
+ contextMode,
278
+ cacheKeySha,
279
+ failureReason,
280
+ message: error.message,
281
+ timing: buildTiming(runStartedAt),
282
+ });
218
283
  llmInputAttempts.push(buildAttemptRecord(promptPayload.pack.meta, `provider_failed:${failureReason}`));
219
284
  recordLlmInputUsage({
220
285
  taskDir,
@@ -246,6 +311,13 @@ async function runMain() {
246
311
  cacheKey,
247
312
  providerOutput,
248
313
  });
314
+ appendCheckTimeline(taskDir, {
315
+ event: 'check_completed',
316
+ verdict: providerOutput.checkResultJson?.verdict || null,
317
+ contextMode: promptPayload.pack.meta.mode,
318
+ cacheKeySha,
319
+ timing: buildTiming(runStartedAt),
320
+ });
249
321
  if (!isContextInsufficientResult(providerOutput.checkResultJson)) {
250
322
  storeInCache({ taskDir, cacheKeySha });
251
323
  }
@@ -263,6 +335,26 @@ async function runMain() {
263
335
  console.log(`- finalEstimatedInputTokens: ${promptPayload.pack.meta.estimatedTokens}`);
264
336
  }
265
337
 
338
+ function appendCheckTimeline(taskDir, event) {
339
+ const timelinePath = path.join(taskDir, 'check-timeline.json');
340
+ let existing = [];
341
+ if (fs.existsSync(timelinePath)) {
342
+ try {
343
+ const parsed = JSON.parse(fs.readFileSync(timelinePath, 'utf8'));
344
+ if (Array.isArray(parsed)) {
345
+ existing = parsed;
346
+ }
347
+ } catch {
348
+ existing = [];
349
+ }
350
+ }
351
+ existing.push({
352
+ at: new Date().toISOString(),
353
+ ...event,
354
+ });
355
+ writeTaskFile(taskDir, 'check-timeline.json', JSON.stringify(existing, null, 2));
356
+ }
357
+
266
358
  function buildAttemptRecord(packMeta, outcome) {
267
359
  return {
268
360
  mode: packMeta.mode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.17",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"