@besales/ops-framework 0.1.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +328 -0
  3. package/bin/build-check-context.mjs +67 -0
  4. package/bin/build-execution-ledger.mjs +54 -0
  5. package/bin/estimate-llm-input.mjs +160 -0
  6. package/bin/guard-task.mjs +384 -0
  7. package/bin/hash-task-artifacts.mjs +44 -0
  8. package/bin/init-project.mjs +49 -0
  9. package/bin/intake-execution-feedback.mjs +207 -0
  10. package/bin/intake-feedback.test.mjs +73 -0
  11. package/bin/learning-loop.mjs +658 -0
  12. package/bin/learning-loop.test.mjs +175 -0
  13. package/bin/lib/bootstrap-utils.mjs +542 -0
  14. package/bin/lib/bootstrap-utils.test.mjs +156 -0
  15. package/bin/lib/check-context-utils.mjs +1448 -0
  16. package/bin/lib/check-context-utils.test.mjs +497 -0
  17. package/bin/lib/execution-ledger-utils.mjs +162 -0
  18. package/bin/lib/execution-ledger-utils.test.mjs +74 -0
  19. package/bin/lib/llm-input-pack-utils.mjs +663 -0
  20. package/bin/lib/llm-input-pack-utils.test.mjs +262 -0
  21. package/bin/lib/project-config.mjs +229 -0
  22. package/bin/lib/project-config.test.mjs +102 -0
  23. package/bin/lib/task-manifest-utils.mjs +512 -0
  24. package/bin/lib/task-manifest-utils.test.mjs +218 -0
  25. package/bin/lib/task-metrics-utils.mjs +63 -0
  26. package/bin/lib/task-metrics-utils.test.mjs +40 -0
  27. package/bin/lib/test-setup.mjs +37 -0
  28. package/bin/new-task.mjs +42 -0
  29. package/bin/ops-agent.mjs +81 -0
  30. package/bin/preflight.mjs +56 -0
  31. package/bin/providers/external-cli-checker.mjs +190 -0
  32. package/bin/providers/openai-checker.mjs +62 -0
  33. package/bin/quality-gates.mjs +92 -0
  34. package/bin/run-check.mjs +559 -0
  35. package/bin/run-plan-check-loop.mjs +392 -0
  36. package/bin/run-verify.mjs +627 -0
  37. package/bin/self-lint.mjs +88 -0
  38. package/bin/supervisor-turn.mjs +146 -0
  39. package/bin/supervisor-turn.test.mjs +72 -0
  40. package/bin/task-manifest.mjs +57 -0
  41. package/bin/task-metrics.mjs +48 -0
  42. package/bin/transition.mjs +94 -0
  43. package/bin/validate-check-artifacts.mjs +418 -0
  44. package/config/default-agents.json +100 -0
  45. package/package.json +28 -0
  46. package/playbooks/checker-context.md +9 -0
  47. package/playbooks/complexity-performance.md +13 -0
  48. package/playbooks/production-rollout.md +9 -0
  49. package/playbooks/source-sync-provider.md +9 -0
  50. package/playbooks/ui-acceptance.md +9 -0
  51. package/prompts/checker.md +170 -0
  52. package/prompts/executor.md +54 -0
  53. package/prompts/planner.md +128 -0
  54. package/prompts/researcher.md +44 -0
  55. package/prompts/supervisor.md +337 -0
  56. package/prompts/verifier.md +128 -0
  57. package/templates/brief.md +15 -0
  58. package/templates/check-resolution.md +69 -0
  59. package/templates/check-result.json +32 -0
  60. package/templates/check.md +46 -0
  61. package/templates/execution-feedback.md +25 -0
  62. package/templates/execution.md +101 -0
  63. package/templates/human-gate-summary.md +49 -0
  64. package/templates/orchestration-log.md +8 -0
  65. package/templates/plan.md +86 -0
  66. package/templates/research.md +13 -0
  67. package/templates/retrospective.md +48 -0
  68. package/templates/status.md +53 -0
  69. package/templates/verify-result.json +19 -0
  70. package/templates/verify.md +41 -0
@@ -0,0 +1,497 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ analyzePlanQualityGates,
6
+ buildCheckerContextPack,
7
+ buildCheckContext,
8
+ classifyRisk,
9
+ computeTaskContextInputs,
10
+ determineOptimizationTier,
11
+ readRelevantPlaybookMetadata,
12
+ readRelevantPlaybooks,
13
+ riskRootWarnings,
14
+ selectRelevantPlaybookNames,
15
+ inspectComplexityPerformanceBudget,
16
+ inspectOptimizationStrategy,
17
+ inspectProductionRolloutGate,
18
+ inspectSourceSyncProviderGate,
19
+ inspectUiAcceptanceScenarios,
20
+ parseMarkdownSections,
21
+ requiresOptimizationStrategy,
22
+ validateExecutionEvidenceForPlan,
23
+ } from './check-context-utils.mjs';
24
+
25
+ describe('agent pipeline quality gates', () => {
26
+ it('requires use-case UI acceptance scenarios for UI-visible plans', () => {
27
+ const plan = [
28
+ '# Plan',
29
+ '',
30
+ '## Затронутые модули и файлы',
31
+ '',
32
+ '- `web/app/src/features/example/page.tsx`',
33
+ '',
34
+ '## План проверки',
35
+ '',
36
+ '- Open the page and confirm it renders.',
37
+ ].join('\n');
38
+
39
+ const result = analyzePlanQualityGates({
40
+ planContent: plan,
41
+ risk: {
42
+ riskProfile: 'medium',
43
+ riskTriggers: ['panel-ui', 'ui-visible-api'],
44
+ },
45
+ });
46
+
47
+ expect(result.uiRequired).toBe(true);
48
+ expect(result.uiAcceptance.present).toBe(false);
49
+ expect(result.missingSignals).toContain('UI-visible risk detected but `## UI Acceptance Scenarios` is missing or incomplete.');
50
+ });
51
+
52
+ it('accepts UI acceptance scenarios with intent, steps, expected visible result and must-catch regressions', () => {
53
+ const sections = parseMarkdownSections([
54
+ '# Plan',
55
+ '',
56
+ '## UI Acceptance Scenarios',
57
+ '',
58
+ '| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |',
59
+ '| --- | --- | --- | --- | --- | --- |',
60
+ '| UI-001 | Compare modes | April data | Switch mode and expand row | Visible totals and child rows update | stale mode, missing child rows |',
61
+ ].join('\n'));
62
+
63
+ const result = inspectUiAcceptanceScenarios(sections);
64
+
65
+ expect(result.present).toBe(true);
66
+ expect(result.scenarioCount).toBe(1);
67
+ expect(result.hasMustCatch).toBe(true);
68
+ });
69
+
70
+ it('uses project-configured roots for UI and worker risk triggers', () => {
71
+ const risk = classifyRisk({
72
+ structuralLines: [
73
+ '- `web/app/src/page.tsx`',
74
+ '- `workers/backend/jobs/example.ts`',
75
+ ],
76
+ referencedFiles: [
77
+ 'web/app/src/page.tsx',
78
+ 'workers/backend/jobs/example.ts',
79
+ ],
80
+ planSections: new Map(),
81
+ riskConfig: {
82
+ uiRoots: ['web/app'],
83
+ workerRoots: ['workers/backend'],
84
+ },
85
+ });
86
+
87
+ expect(risk.riskTriggers).toContain('panel-ui');
88
+ expect(risk.riskTriggers).toContain('worker-queue');
89
+ });
90
+
91
+ it('warns when project risk roots are empty', () => {
92
+ expect(riskRootWarnings({ uiRoots: [], backendRoots: [], workerRoots: [] })).toEqual([
93
+ 'risk.uiRoots is empty; path-based UI detection is disabled until the project config is filled.',
94
+ 'risk.backendRoots is empty; path-based user-visible API detection is limited to text signals.',
95
+ 'risk.workerRoots is empty; path-based worker detection is limited to text signals.',
96
+ ]);
97
+ });
98
+
99
+ it('requires complexity budget for hot-path triggers', () => {
100
+ const plan = [
101
+ '# Plan',
102
+ '',
103
+ '## Затронутые модули и файлы',
104
+ '',
105
+ '- `platform/backend-core/src/core/app/example/get-summary.use-case.ts`',
106
+ ].join('\n');
107
+
108
+ const result = analyzePlanQualityGates({
109
+ planContent: plan,
110
+ risk: {
111
+ riskProfile: 'high',
112
+ riskTriggers: ['dto-readmodel', 'materializer'],
113
+ },
114
+ });
115
+
116
+ expect(result.complexityRequired).toBe(true);
117
+ expect(result.complexityBudget.present).toBe(false);
118
+ expect(result.missingSignals).toContain('Complexity/performance risk detected but `## Complexity / Performance Budget` is missing or incomplete.');
119
+ });
120
+
121
+ it('accepts complexity budget with hot paths, data size, risks and budget', () => {
122
+ const sections = parseMarkdownSections([
123
+ '# Plan',
124
+ '',
125
+ '## Complexity / Performance Budget',
126
+ '',
127
+ '- Hot paths: summary read model and table render.',
128
+ '- Expected data size / row counts: 150k rows for April.',
129
+ '- Complexity risks: N+1 queries and nested scans.',
130
+ '- Planned mitigation: batch queries and map-based grouping.',
131
+ '- Budget / stop rule: API response < 3 seconds.',
132
+ ].join('\n'));
133
+
134
+ const result = inspectComplexityPerformanceBudget(sections);
135
+
136
+ expect(result.present).toBe(true);
137
+ expect(result.hasHotPaths).toBe(true);
138
+ expect(result.hasDataSize).toBe(true);
139
+ expect(result.hasRisks).toBe(true);
140
+ expect(result.hasBudget).toBe(true);
141
+ });
142
+
143
+ it('classifies optimization tiers from risk triggers', () => {
144
+ expect(determineOptimizationTier(['materializer'])).toBe('O3');
145
+ expect(determineOptimizationTier(['source-sync-provider'])).toBe('O3');
146
+ expect(determineOptimizationTier(['panel-ui'])).toBe('O2');
147
+ expect(determineOptimizationTier(['dto-readmodel'])).toBe('O2');
148
+ expect(determineOptimizationTier(['docs-only'])).toBe('O0');
149
+ expect(determineOptimizationTier([])).toBe('O0');
150
+ expect(requiresOptimizationStrategy('O1')).toBe(false);
151
+ expect(requiresOptimizationStrategy('O2')).toBe(true);
152
+ expect(requiresOptimizationStrategy('O3')).toBe(true);
153
+ });
154
+
155
+ it('requires optimization strategy for O2 and O3 plans', () => {
156
+ const result = analyzePlanQualityGates({
157
+ planContent: [
158
+ '# Plan',
159
+ '',
160
+ '## Complexity / Performance Budget',
161
+ '',
162
+ '- Hot paths: summary read model.',
163
+ '- Expected data size / row counts: 150k rows.',
164
+ '- Complexity risks: N+1 queries.',
165
+ '- Budget / stop rule: < 3 seconds.',
166
+ ].join('\n'),
167
+ risk: {
168
+ riskProfile: 'high',
169
+ riskTriggers: ['dto-readmodel'],
170
+ },
171
+ });
172
+
173
+ expect(result.optimizationTier).toBe('O2');
174
+ expect(result.optimizationRequired).toBe(true);
175
+ expect(result.optimizationStrategy.present).toBe(false);
176
+ expect(result.missingSignals).toContain('Optimization tier O2 detected but `## Optimization Strategy` is missing or incomplete.');
177
+ });
178
+
179
+ it('accepts optimization strategy with tier, hot paths, size, approach, anti-patterns and budget', () => {
180
+ const sections = parseMarkdownSections([
181
+ '# Plan',
182
+ '',
183
+ '## Optimization Strategy',
184
+ '',
185
+ '- Optimization tier: O3 measured review.',
186
+ '- Hot paths: worker materializer and summary read model.',
187
+ '- Expected data size / row counts: 150k rows.',
188
+ '- Chosen efficient approach: batch query, map-based grouping and indexed lookup.',
189
+ '- Anti-patterns avoided: N+1 queries, repeated scans and O(n^2) nested loops.',
190
+ '- Optimizer budget / stop rule: one focused review plus one timing measurement; defer speculative ideas.',
191
+ ].join('\n'));
192
+
193
+ const result = inspectOptimizationStrategy(sections);
194
+
195
+ expect(result.present).toBe(true);
196
+ expect(result.tier).toBe('O3');
197
+ expect(result.hasHotPaths).toBe(true);
198
+ expect(result.hasDataSize).toBe(true);
199
+ expect(result.hasApproach).toBe(true);
200
+ expect(result.hasAntiPatterns).toBe(true);
201
+ expect(result.hasBudget).toBe(true);
202
+ });
203
+
204
+ it('builds a checker context pack with exact quality-gate questions', () => {
205
+ const taskArtifacts = new Map([
206
+ ['status.md', '## Следующий шаг\n\nValidate Human Gate readiness.\n\n## Active slice\n\nSlice A.'],
207
+ ['plan.md', '## UI Acceptance Scenarios\n\n| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |\n| --- | --- | --- | --- | --- | --- |\n| UI-001 | Inspect table | Data | Click | Row updates | stale rows |'],
208
+ ]);
209
+ const risk = {
210
+ riskProfile: 'high',
211
+ riskTriggers: ['panel-ui', 'dto-readmodel'],
212
+ };
213
+ const qualityGates = analyzePlanQualityGates({
214
+ planContent: taskArtifacts.get('plan.md'),
215
+ risk,
216
+ });
217
+
218
+ const pack = buildCheckerContextPack({
219
+ taskId: 'TASK-999-example',
220
+ risk,
221
+ qualityGates,
222
+ referencedFiles: ['web/app/src/features/example/page.tsx'],
223
+ structuralLines: ['- `web/app/src/features/example/page.tsx`'],
224
+ taskArtifacts,
225
+ });
226
+
227
+ expect(pack).toContain('## Checker Questions');
228
+ expect(pack).toContain('## Relevant Playbooks');
229
+ expect(pack).toContain('UI Acceptance (`ui-acceptance`)');
230
+ expect(pack).not.toContain('UI Acceptance Playbook');
231
+ expect(pack).toContain('Will the UI Acceptance Scenarios catch the concrete regressions a user would care about?');
232
+ expect(pack).toContain('Does the plan avoid N+1 queries');
233
+ });
234
+
235
+ it('selects relevant playbooks from risk triggers and includes project overlays', () => {
236
+ const projectPlaybooksRoot = path.join(process.cwd(), 'ops', 'agent-pipeline', 'playbooks');
237
+ fs.mkdirSync(projectPlaybooksRoot, { recursive: true });
238
+ fs.writeFileSync(path.join(projectPlaybooksRoot, 'ui-acceptance.md'), '# Project UI Overlay\n\nProject route notes.\n');
239
+
240
+ expect(selectRelevantPlaybookNames(['panel-ui', 'production-runtime'])).toEqual([
241
+ 'complexity-performance',
242
+ 'production-rollout',
243
+ 'ui-acceptance',
244
+ ]);
245
+
246
+ const playbooks = readRelevantPlaybooks(['panel-ui']);
247
+ const uiPlaybook = playbooks.find((playbook) => playbook.name === 'ui-acceptance');
248
+
249
+ expect(uiPlaybook.sharedContent).toContain('UI Acceptance Playbook');
250
+ expect(uiPlaybook.projectContent).toContain('Project UI Overlay');
251
+
252
+ const metadata = readRelevantPlaybookMetadata(['panel-ui']);
253
+ const uiMetadata = metadata.find((playbook) => playbook.name === 'ui-acceptance');
254
+ expect(uiMetadata.sharedSha).toMatch(/^sha256:/);
255
+ expect(uiMetadata.projectSha).toMatch(/^sha256:/);
256
+ expect(uiMetadata).not.toHaveProperty('sharedContent');
257
+ expect(uiMetadata).not.toHaveProperty('projectContent');
258
+ });
259
+
260
+ it('stores relevant playbook metadata, not full text, in check context', () => {
261
+ const taskDir = path.join(process.cwd(), 'ops', 'agent-pipeline', 'tasks', 'TASK-999-playbook-metadata');
262
+ fs.mkdirSync(taskDir, { recursive: true });
263
+ fs.writeFileSync(path.join(taskDir, 'brief.md'), '# Brief\n\nTest.\n');
264
+ fs.writeFileSync(path.join(taskDir, 'research.md'), '# Research\n\n## Findings\n\n- `web/app/src/page.tsx`\n');
265
+ fs.writeFileSync(path.join(taskDir, 'plan.md'), [
266
+ '# Plan',
267
+ '',
268
+ '## Затронутые модули и файлы',
269
+ '',
270
+ '- `web/app/src/page.tsx`',
271
+ '',
272
+ '## UI Acceptance Scenarios',
273
+ '',
274
+ '| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |',
275
+ '| --- | --- | --- | --- | --- | --- |',
276
+ '| UI-001 | Inspect | Data | Open page | Visible page | blank page |',
277
+ ].join('\n'));
278
+ fs.writeFileSync(path.join(taskDir, 'status.md'), '# Status\n\n## Текущий этап\n\nplan\n');
279
+
280
+ const context = buildCheckContext({
281
+ taskId: 'TASK-999-playbook-metadata',
282
+ inputs: computeTaskContextInputs(taskDir),
283
+ createdAt: null,
284
+ });
285
+ const serialized = JSON.stringify(context);
286
+
287
+ expect(context.relevantPlaybooks[0]).toHaveProperty('sharedSha');
288
+ expect(context.relevantPlaybooks[0]).not.toHaveProperty('sharedContent');
289
+ expect(serialized).not.toContain('UI Acceptance Playbook');
290
+ });
291
+
292
+ it('pre-verify evidence gate blocks missing UI and complexity evidence', () => {
293
+ const plan = [
294
+ '# Plan',
295
+ '',
296
+ '## UI Acceptance Scenarios',
297
+ '',
298
+ '| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |',
299
+ '| --- | --- | --- | --- | --- | --- |',
300
+ '| UI-001 | Inspect table | Data | Click | Row updates | stale rows |',
301
+ '',
302
+ '## Complexity / Performance Budget',
303
+ '',
304
+ '- Hot paths: summary read model.',
305
+ '- Expected data size / row counts: 150k rows.',
306
+ '- Complexity risks: N+1 queries.',
307
+ '- Budget / stop rule: < 3 seconds.',
308
+ ].join('\n');
309
+ const execution = [
310
+ '# Execution',
311
+ '',
312
+ '## Краткое summary',
313
+ '',
314
+ 'Implemented.',
315
+ ].join('\n');
316
+
317
+ const issues = validateExecutionEvidenceForPlan({
318
+ planContent: plan,
319
+ executionContent: execution,
320
+ });
321
+
322
+ expect(issues).toEqual([
323
+ {
324
+ category: 'ui_verification_gap',
325
+ message: 'Plan contains UI Acceptance Scenarios but execution.md is missing UI Acceptance Evidence.',
326
+ },
327
+ {
328
+ category: 'unrun_required_check',
329
+ message: 'Plan contains Complexity / Performance Budget but execution.md is missing Complexity / Performance Evidence.',
330
+ },
331
+ ]);
332
+ });
333
+
334
+ it('pre-verify evidence gate accepts scenario and performance evidence', () => {
335
+ const plan = [
336
+ '# Plan',
337
+ '',
338
+ '## UI Acceptance Scenarios',
339
+ '',
340
+ '| ID | User intent | Setup/data | Steps | Expected visible result | Must catch |',
341
+ '| --- | --- | --- | --- | --- | --- |',
342
+ '| UI-001 | Inspect table | Data | Click | Row updates | stale rows |',
343
+ '',
344
+ '## Complexity / Performance Budget',
345
+ '',
346
+ '- Hot paths: summary read model.',
347
+ '- Expected data size / row counts: 150k rows.',
348
+ '- Complexity risks: N+1 queries.',
349
+ '- Budget / stop rule: < 3 seconds.',
350
+ ].join('\n');
351
+ const execution = [
352
+ '# Execution',
353
+ '',
354
+ '## UI Acceptance Evidence',
355
+ '',
356
+ '| Scenario ID | Result | Observed evidence / screenshot / payload | Notes |',
357
+ '| --- | --- | --- | --- |',
358
+ '| UI-001 | pass | observed visible updated row, screenshot `/tmp/ui.png` | ok |',
359
+ '',
360
+ '## Complexity / Performance Evidence',
361
+ '',
362
+ '| Hot path / budget | Result | Timing / rows / EXPLAIN / N+1 evidence | Notes |',
363
+ '| --- | --- | --- | --- |',
364
+ '| summary read model < 3s | pass | duration 1200ms, rows 150000, no N+1 | ok |',
365
+ ].join('\n');
366
+
367
+ const issues = validateExecutionEvidenceForPlan({
368
+ planContent: plan,
369
+ executionContent: execution,
370
+ });
371
+
372
+ expect(issues).toEqual([]);
373
+ });
374
+
375
+ it('pre-verify evidence gate blocks missing optimization evidence for O2 and O3 plans', () => {
376
+ const plan = [
377
+ '# Plan',
378
+ '',
379
+ '## Optimization Strategy',
380
+ '',
381
+ '- Optimization tier: O2 focused review.',
382
+ '- Hot paths: dashboard read model.',
383
+ '- Expected data size / row counts: 25k rows.',
384
+ '- Chosen efficient approach: map-based grouping and indexed lookup.',
385
+ '- Anti-patterns avoided: N+1 queries and repeated scans.',
386
+ '- Optimizer budget / stop rule: one focused review; defer speculative ideas.',
387
+ ].join('\n');
388
+ const execution = '# Execution\n\nImplemented.';
389
+
390
+ const issues = validateExecutionEvidenceForPlan({
391
+ planContent: plan,
392
+ executionContent: execution,
393
+ });
394
+
395
+ expect(issues).toEqual([
396
+ {
397
+ category: 'unrun_required_check',
398
+ message: 'Plan contains O2/O3 Optimization Strategy but execution.md is missing Optimization Review Evidence.',
399
+ },
400
+ ]);
401
+ });
402
+
403
+ it('pre-verify evidence gate accepts optimization review evidence', () => {
404
+ const plan = [
405
+ '# Plan',
406
+ '',
407
+ '## Optimization Strategy',
408
+ '',
409
+ '- Optimization tier: O3 measured review.',
410
+ '- Hot paths: materializer.',
411
+ '- Expected data size / row counts: 150k rows.',
412
+ '- Chosen efficient approach: batch query and map-based grouping.',
413
+ '- Anti-patterns avoided: N+1 queries and O(n^2) nested loops.',
414
+ '- Optimizer budget / stop rule: one focused review plus one benchmark; defer speculative ideas.',
415
+ ].join('\n');
416
+ const execution = [
417
+ '# Execution',
418
+ '',
419
+ '## Optimization Review Evidence',
420
+ '',
421
+ '| Hot path | Review result | Measurement / finding | Notes |',
422
+ '| --- | --- | --- | --- |',
423
+ '| materializer | pass | reviewed for N+1 and repeated scan; benchmark 1200ms / 150000 rows | no obvious hotspot |',
424
+ ].join('\n');
425
+
426
+ const issues = validateExecutionEvidenceForPlan({
427
+ planContent: plan,
428
+ executionContent: execution,
429
+ });
430
+
431
+ expect(issues).toEqual([]);
432
+ });
433
+
434
+ it('requires production rollout gate for production runtime triggers', () => {
435
+ const result = analyzePlanQualityGates({
436
+ planContent: '# Plan\n\n## Затронутые модули и файлы\n\n- deploy runtime env variable for worker',
437
+ risk: {
438
+ riskProfile: 'high',
439
+ riskTriggers: ['production-runtime'],
440
+ },
441
+ });
442
+
443
+ expect(result.productionRolloutRequired).toBe(true);
444
+ expect(result.productionRollout.present).toBe(false);
445
+ expect(result.missingSignals).toContain('Production rollout risk detected but `## Production Rollout Gate` is missing or incomplete.');
446
+ });
447
+
448
+ it('accepts production rollout gate with impact env rollback and post-deploy evidence', () => {
449
+ const sections = parseMarkdownSections([
450
+ '# Plan',
451
+ '',
452
+ '## Production Rollout Gate',
453
+ '',
454
+ '- Impact / blast radius: one worker queue.',
455
+ '- Environment / deploy variables: RAILWAY env flag.',
456
+ '- Rollback / disable path: revert env flag and redeploy.',
457
+ '- Post-deploy evidence: logs, metrics and smoke.',
458
+ ].join('\n'));
459
+
460
+ const result = inspectProductionRolloutGate(sections);
461
+
462
+ expect(result.present).toBe(true);
463
+ expect(result.hasRollback).toBe(true);
464
+ });
465
+
466
+ it('requires source sync provider gate for source sync triggers', () => {
467
+ const result = analyzePlanQualityGates({
468
+ planContent: '# Plan\n\n## Затронутые модули и файлы\n\n- source sync worker retry and raw records',
469
+ risk: {
470
+ riskProfile: 'high',
471
+ riskTriggers: ['source-sync-provider'],
472
+ },
473
+ });
474
+
475
+ expect(result.sourceSyncProviderRequired).toBe(true);
476
+ expect(result.sourceSyncProvider.present).toBe(false);
477
+ expect(result.missingSignals).toContain('Source sync/provider risk detected but `## Source Sync / Provider Gate` is missing or incomplete.');
478
+ });
479
+
480
+ it('accepts source sync provider gate with scope idempotency failure handling and coverage', () => {
481
+ const sections = parseMarkdownSections([
482
+ '# Plan',
483
+ '',
484
+ '## Source Sync / Provider Gate',
485
+ '',
486
+ '- Scope / provider window: GetCourse stream for May, paginated by window.',
487
+ '- Idempotency / duplicate handling: idempotency key and dedupe on retry.',
488
+ '- Failure handling / retry boundaries: backoff, timeout and partial failure resume.',
489
+ '- Coverage / parity evidence: count audit and raw record samples.',
490
+ ].join('\n'));
491
+
492
+ const result = inspectSourceSyncProviderGate(sections);
493
+
494
+ expect(result.present).toBe(true);
495
+ expect(result.hasCoverageEvidence).toBe(true);
496
+ });
497
+ });
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export function collectGitExecutionState({ repoRoot, taskDir }) {
6
+ const statusLines = runGitLines(repoRoot, ['status', '--short']);
7
+ const unstagedDiff = runGitLines(repoRoot, ['diff', '--name-only']);
8
+ const stagedDiff = runGitLines(repoRoot, ['diff', '--cached', '--name-only']);
9
+ const taskRelativePath = normalizePath(path.relative(repoRoot, taskDir));
10
+
11
+ const statusEntries = statusLines.map(parseGitStatusLine).filter(Boolean);
12
+ const changedFiles = mergeChangedFiles({
13
+ statusEntries,
14
+ unstagedDiff,
15
+ stagedDiff,
16
+ taskRelativePath,
17
+ });
18
+
19
+ return {
20
+ statusEntries,
21
+ changedFiles,
22
+ taskRelativePath,
23
+ };
24
+ }
25
+
26
+ export function buildExecutionLedger({
27
+ taskId,
28
+ taskDir,
29
+ repoRoot,
30
+ planSha,
31
+ executionSha,
32
+ createdAt = new Date().toISOString(),
33
+ }) {
34
+ const git = collectGitExecutionState({ repoRoot, taskDir });
35
+ const taskArtifacts = listTaskArtifacts(taskDir);
36
+
37
+ return {
38
+ schemaVersion: 1,
39
+ taskId,
40
+ createdAt,
41
+ planSha,
42
+ executionSha,
43
+ git: {
44
+ taskRelativePath: git.taskRelativePath,
45
+ changedFiles: compactLedgerFiles(git.changedFiles),
46
+ unrelatedDirtyFiles: compactLedgerFiles(git.changedFiles.filter((file) => !file.isTaskArtifact && !file.isOpsFrameworkFile)),
47
+ },
48
+ taskArtifacts,
49
+ notes: [
50
+ 'This ledger is generated from git status/diff and task artifact presence.',
51
+ 'Executor must still summarize semantic intent in execution.md.',
52
+ ],
53
+ };
54
+ }
55
+
56
+ function compactLedgerFiles(files) {
57
+ return files.map((file) => ({
58
+ path: file.path,
59
+ status: file.status,
60
+ isTaskArtifact: file.isTaskArtifact,
61
+ isOpsFrameworkFile: file.isOpsFrameworkFile,
62
+ }));
63
+ }
64
+
65
+ export function parseGitStatusLine(line) {
66
+ if (!line || line.length < 4) {
67
+ return null;
68
+ }
69
+ const status = line.slice(0, 2);
70
+ const rawPath = line.slice(3).trim();
71
+ if (!rawPath) {
72
+ return null;
73
+ }
74
+ const renameParts = rawPath.split(' -> ');
75
+ const filePath = normalizePath(renameParts.at(-1));
76
+ return {
77
+ status,
78
+ path: filePath,
79
+ originalPath: renameParts.length > 1 ? normalizePath(renameParts[0]) : null,
80
+ };
81
+ }
82
+
83
+ export function mergeChangedFiles({
84
+ statusEntries,
85
+ unstagedDiff,
86
+ stagedDiff,
87
+ taskRelativePath,
88
+ }) {
89
+ const byPath = new Map();
90
+ for (const entry of statusEntries) {
91
+ byPath.set(entry.path, {
92
+ path: entry.path,
93
+ status: entry.status,
94
+ staged: stagedDiff.includes(entry.path) || entry.status[0] !== ' ' && entry.status[0] !== '?',
95
+ unstaged: unstagedDiff.includes(entry.path) || entry.status[1] !== ' ',
96
+ untracked: entry.status === '??',
97
+ originalPath: entry.originalPath,
98
+ });
99
+ }
100
+ for (const filePath of [...unstagedDiff, ...stagedDiff]) {
101
+ const normalizedPath = normalizePath(filePath);
102
+ const existing = byPath.get(normalizedPath) || {
103
+ path: normalizedPath,
104
+ status: '',
105
+ staged: false,
106
+ unstaged: false,
107
+ untracked: false,
108
+ originalPath: null,
109
+ };
110
+ existing.staged = existing.staged || stagedDiff.includes(filePath);
111
+ existing.unstaged = existing.unstaged || unstagedDiff.includes(filePath);
112
+ byPath.set(normalizedPath, existing);
113
+ }
114
+
115
+ return Array.from(byPath.values())
116
+ .map((entry) => ({
117
+ ...entry,
118
+ isTaskArtifact: entry.path === taskRelativePath || entry.path.startsWith(`${taskRelativePath}/`),
119
+ isOpsFrameworkFile: isFrameworkOwnedPath(entry.path)
120
+ && !(entry.path === taskRelativePath || entry.path.startsWith(`${taskRelativePath}/`)),
121
+ }))
122
+ .sort((a, b) => a.path.localeCompare(b.path));
123
+ }
124
+
125
+ function isFrameworkOwnedPath(filePath) {
126
+ return filePath.startsWith('shared-platform/packages/ops-framework/')
127
+ || filePath.startsWith('ops/agent-pipeline/bin/')
128
+ || filePath.startsWith('ops/agent-pipeline/config/')
129
+ || filePath.startsWith('ops/agent-pipeline/prompts/')
130
+ || filePath.startsWith('ops/agent-pipeline/templates/')
131
+ || filePath === 'ops/agent-pipeline/README.md'
132
+ || filePath === 'ops/agent-pipeline/vitest.config.ts';
133
+ }
134
+
135
+ function listTaskArtifacts(taskDir) {
136
+ if (!fs.existsSync(taskDir)) {
137
+ return [];
138
+ }
139
+ return fs.readdirSync(taskDir)
140
+ .filter((fileName) => fs.statSync(path.join(taskDir, fileName)).isFile())
141
+ .sort()
142
+ .map((fileName) => ({
143
+ fileName,
144
+ sizeBytes: fs.statSync(path.join(taskDir, fileName)).size,
145
+ }));
146
+ }
147
+
148
+ function runGitLines(repoRoot, args) {
149
+ const result = spawnSync('git', args, {
150
+ cwd: repoRoot,
151
+ encoding: 'utf8',
152
+ });
153
+ if (result.status !== 0) {
154
+ const detail = `${result.stdout || ''}${result.stderr || ''}`.trim();
155
+ throw new Error(`git ${args.join(' ')} failed: ${detail}`);
156
+ }
157
+ return result.stdout.split('\n').map((line) => line.trimEnd()).filter(Boolean);
158
+ }
159
+
160
+ function normalizePath(value) {
161
+ return value.split(path.sep).join('/');
162
+ }