@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,1448 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ deepMerge,
6
+ readJsonIfExists,
7
+ resolveProjectContext,
8
+ } from './project-config.mjs';
9
+
10
+ export const projectContext = resolveProjectContext();
11
+
12
+ export const frameworkRoot = projectContext.frameworkRoot;
13
+ export const pipelineRoot = frameworkRoot;
14
+ export const projectPipelineRoot = projectContext.pipelineRoot;
15
+ export const repoRoot = projectContext.repoRoot;
16
+ export const tasksRoot = projectContext.tasksRoot;
17
+ export const memoryRoot = projectContext.memoryRoot;
18
+ export const cacheRoot = projectContext.cacheRoot;
19
+ export const promptsRoot = projectContext.promptsRoot;
20
+ export const configRoot = projectContext.configRoot;
21
+
22
+ export const REQUIRED_TASK_ARTIFACTS = ['brief.md', 'research.md', 'plan.md', 'status.md'];
23
+ export const OPTIONAL_TASK_ARTIFACTS = ['feedback.md', 'execution-feedback.md'];
24
+ export const MEMORY_FILES = [
25
+ 'project-context-digest.md',
26
+ 'module-boundaries.md',
27
+ 'standards-digest.md',
28
+ 'recurring-failures.md',
29
+ 'domain-glossary.md',
30
+ 'checker-rubric.md',
31
+ ];
32
+
33
+ export const RISK_CONFIG = {
34
+ low: {
35
+ contextBudgetTokens: 6000,
36
+ maxRepoReads: 5,
37
+ },
38
+ medium: {
39
+ contextBudgetTokens: 15000,
40
+ maxRepoReads: 12,
41
+ },
42
+ high: {
43
+ contextBudgetTokens: 30000,
44
+ maxRepoReads: 25,
45
+ },
46
+ };
47
+
48
+ export const ALLOWED_RISK_PROFILES = Object.keys(RISK_CONFIG);
49
+ export const ALLOWED_RISK_TRIGGERS = ['auth-security', 'docs-only', 'dto-readmodel', 'ingestion-provider', 'materializer', 'panel-ui', 'prisma-schema', 'production-runtime', 'source-sync-provider', 'ui-visible-api', 'worker-queue'];
50
+
51
+ export const CHECKER_CONTEXT_PACK_FILE = 'checker-context-pack.md';
52
+ export const PLAYBOOK_TRIGGER_MAP = new Map([
53
+ ['panel-ui', ['ui-acceptance', 'complexity-performance']],
54
+ ['ui-visible-api', ['ui-acceptance']],
55
+ ['production-runtime', ['production-rollout']],
56
+ ['prisma-schema', ['production-rollout']],
57
+ ['auth-security', ['production-rollout']],
58
+ ['source-sync-provider', ['source-sync-provider']],
59
+ ['ingestion-provider', ['source-sync-provider']],
60
+ ['worker-queue', ['complexity-performance']],
61
+ ['materializer', ['complexity-performance']],
62
+ ['dto-readmodel', ['complexity-performance']],
63
+ ]);
64
+
65
+ export const ALLOWED_VERDICTS = ['return_to_plan', 'ready_for_human_gate', 'human_arbitration_required', 'context_insufficient', 'checker_failed'];
66
+
67
+ export const ALLOWED_FAILURE_REASONS = ['provider_unavailable', 'invalid_json', 'schema_validation_failed', 'context_overflow', 'context_insufficient', 'repo_read_limit_exceeded', 'memory_snapshot_mismatch', 'timeout', 'unknown'];
68
+
69
+ export const ALLOWED_SEVERITIES = ['blocking', 'non_blocking', 'question'];
70
+ export const ALLOWED_CLAIM_CATEGORIES = [
71
+ 'missing_verification',
72
+ 'missing_evidence',
73
+ 'architecture_violation',
74
+ 'risk_unaddressed',
75
+ 'standards_violation',
76
+ 'scope_drift',
77
+ 'human_decision_required',
78
+ 'checker_failure',
79
+ ];
80
+
81
+ export const ALLOWED_REF_TYPES = [
82
+ 'file',
83
+ 'standards',
84
+ 'task_boundary',
85
+ 'plan_section',
86
+ 'research_section',
87
+ 'brief_section',
88
+ 'status_section',
89
+ 'human_decision',
90
+ ];
91
+
92
+ export const ALLOWED_RESOLUTION_DECISIONS = [
93
+ 'accepted',
94
+ 'partially_accepted',
95
+ 'rejected',
96
+ 'needs_research',
97
+ 'needs_human_decision',
98
+ ];
99
+
100
+ export const PLAN_SECTIONS = new Map([
101
+ ['цель', 'цель'],
102
+ ['goal', 'цель'],
103
+ ['опора на findings из research', 'опора на findings из research'],
104
+ ['опора на research', 'опора на findings из research'],
105
+ ['допущения', 'допущения'],
106
+ ['assumptions', 'допущения'],
107
+ ['затронутые модули и файлы', 'затронутые модули и файлы'],
108
+ ['scope', 'затронутые модули и файлы'],
109
+ ['шаги реализации', 'шаги реализации'],
110
+ ['implementation steps', 'шаги реализации'],
111
+ ['implementation slices', 'шаги реализации'],
112
+ ['runtime flow', 'шаги реализации'],
113
+ ['риски и открытые вопросы', 'риски и открытые вопросы'],
114
+ ['known risks', 'риски и открытые вопросы'],
115
+ ['open check items', 'риски и открытые вопросы'],
116
+ ['verification plan', 'verification plan'],
117
+ ['verification and replay plan', 'verification plan'],
118
+ ['план проверки', 'verification plan'],
119
+ ['ui verification path', 'UI verification path'],
120
+ ['ui flow', 'UI verification path'],
121
+ ['ui verification path или явное not applicable', 'UI verification path'],
122
+ ['global standards alignment', 'global standards alignment'],
123
+ ['standards alignment', 'global standards alignment'],
124
+ ['что требует human approval', 'что требует human approval'],
125
+ ['constraints', 'допущения'],
126
+ ['core invariants', 'core invariants'],
127
+ ['инварианты', 'core invariants'],
128
+ ]);
129
+
130
+ export const STRUCTURAL_PLAN_SECTIONS = new Set([
131
+ 'затронутые модули и файлы',
132
+ 'шаги реализации',
133
+ 'verification plan',
134
+ 'UI verification path',
135
+ 'global standards alignment',
136
+ 'что требует human approval',
137
+ 'core invariants',
138
+ ]);
139
+
140
+ export function resolveTaskDir(taskArg) {
141
+ const candidate = taskArg.startsWith('TASK-')
142
+ ? path.join(tasksRoot, taskArg)
143
+ : path.resolve(repoRoot, taskArg);
144
+
145
+ const resolved = path.resolve(candidate);
146
+ const relativeToTasks = path.relative(tasksRoot, resolved);
147
+ if (relativeToTasks.startsWith('..') || path.isAbsolute(relativeToTasks)) {
148
+ throw new Error(`Task path must be under ${relativePath(tasksRoot)}: ${taskArg}`);
149
+ }
150
+
151
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
152
+ throw new Error(`Task directory not found: ${relativePath(resolved)}`);
153
+ }
154
+
155
+ return resolved;
156
+ }
157
+
158
+ export function readTaskArtifacts(taskDir) {
159
+ const artifacts = new Map();
160
+ for (const fileName of REQUIRED_TASK_ARTIFACTS) {
161
+ const filePath = path.join(taskDir, fileName);
162
+ if (!fs.existsSync(filePath)) {
163
+ throw new Error(`Missing required task artifact: ${relativePath(filePath)}`);
164
+ }
165
+ artifacts.set(fileName, fs.readFileSync(filePath, 'utf8'));
166
+ }
167
+ for (const fileName of OPTIONAL_TASK_ARTIFACTS) {
168
+ const filePath = path.join(taskDir, fileName);
169
+ if (fs.existsSync(filePath)) {
170
+ artifacts.set(fileName, fs.readFileSync(filePath, 'utf8'));
171
+ }
172
+ }
173
+ return artifacts;
174
+ }
175
+
176
+ export function readMemoryFiles() {
177
+ const files = [];
178
+ for (const fileName of MEMORY_FILES) {
179
+ const filePath = path.join(memoryRoot, fileName);
180
+ if (!fs.existsSync(filePath)) {
181
+ throw new Error(`Missing Project Memory file: ${relativePath(filePath)}`);
182
+ }
183
+ const content = normalizeLineEndings(fs.readFileSync(filePath, 'utf8'));
184
+ files.push({
185
+ path: relativePath(filePath),
186
+ sha: sha256(content),
187
+ });
188
+ }
189
+ return files;
190
+ }
191
+
192
+ export function readMemorySnapshot(memoryFiles) {
193
+ return memoryFiles.map((memoryFile) => {
194
+ const filePath = path.join(repoRoot, memoryFile.path);
195
+ if (!fs.existsSync(filePath)) {
196
+ throw new Error(`Memory snapshot file missing: ${memoryFile.path}`);
197
+ }
198
+ const content = normalizeLineEndings(fs.readFileSync(filePath, 'utf8'));
199
+ const actualSha = sha256(content);
200
+ if (actualSha !== memoryFile.sha) {
201
+ throw new Error(`Memory snapshot mismatch for ${memoryFile.path}: expected ${memoryFile.sha}, got ${actualSha}`);
202
+ }
203
+ return {
204
+ ...memoryFile,
205
+ content,
206
+ };
207
+ });
208
+ }
209
+
210
+ export function computeTaskContextInputs(taskDir) {
211
+ const taskArtifacts = readTaskArtifacts(taskDir);
212
+ const memoryFiles = readMemoryFiles();
213
+ const planFingerprint = computePlanFingerprint(taskArtifacts.get('plan.md'));
214
+ const memorySha = computeMemorySha(memoryFiles);
215
+ const parsedPlanSections = parseMarkdownSections(taskArtifacts.get('plan.md'));
216
+ const parsedResearchSections = parseMarkdownSections(taskArtifacts.get('research.md'));
217
+ const structuralLines = collectStructuralLines({
218
+ planSections: parsedPlanSections,
219
+ researchSections: parsedResearchSections,
220
+ });
221
+ const referencedFiles = extractReferencedFiles(structuralLines);
222
+ const risk = classifyRisk({
223
+ structuralLines,
224
+ referencedFiles,
225
+ planSections: parsedPlanSections,
226
+ riskConfig: projectContext.risk,
227
+ });
228
+ const qualityGates = analyzePlanQualityGates({
229
+ planContent: taskArtifacts.get('plan.md'),
230
+ risk,
231
+ });
232
+
233
+ return {
234
+ taskArtifacts,
235
+ memoryFiles,
236
+ planFingerprint,
237
+ memorySha,
238
+ parsedPlanSections,
239
+ parsedResearchSections,
240
+ structuralLines,
241
+ referencedFiles,
242
+ risk,
243
+ qualityGates,
244
+ };
245
+ }
246
+
247
+ export function riskRootWarnings(riskConfig = projectContext.risk) {
248
+ const warnings = [];
249
+ if (!normalizeRoots(riskConfig.uiRoots).length) {
250
+ warnings.push('risk.uiRoots is empty; path-based UI detection is disabled until the project config is filled.');
251
+ }
252
+ if (!normalizeRoots(riskConfig.backendRoots).length) {
253
+ warnings.push('risk.backendRoots is empty; path-based user-visible API detection is limited to text signals.');
254
+ }
255
+ if (!normalizeRoots(riskConfig.workerRoots).length) {
256
+ warnings.push('risk.workerRoots is empty; path-based worker detection is limited to text signals.');
257
+ }
258
+ return warnings;
259
+ }
260
+
261
+ export function buildCheckContext({ taskId, inputs, createdAt = new Date().toISOString() }) {
262
+ const budget = RISK_CONFIG[inputs.risk.riskProfile];
263
+ const checkerContextPack = buildCheckerContextPack({
264
+ taskId,
265
+ risk: inputs.risk,
266
+ qualityGates: inputs.qualityGates,
267
+ referencedFiles: inputs.referencedFiles,
268
+ structuralLines: inputs.structuralLines,
269
+ taskArtifacts: inputs.taskArtifacts,
270
+ });
271
+
272
+ return {
273
+ taskId,
274
+ stage: 'Check',
275
+ planSha: inputs.planFingerprint.planSha,
276
+ planFingerprintVersion: inputs.planFingerprint.planFingerprintVersion,
277
+ memorySha: inputs.memorySha,
278
+ riskProfile: inputs.risk.riskProfile,
279
+ riskTriggers: inputs.risk.riskTriggers,
280
+ riskWarnings: riskRootWarnings(),
281
+ createdAt,
282
+ memoryFiles: inputs.memoryFiles,
283
+ taskArtifacts: Array.from(inputs.taskArtifacts.entries()).map(([artifactPath, content]) => ({
284
+ path: artifactPath,
285
+ sha: sha256(normalizeLineEndings(content)),
286
+ })),
287
+ evidenceFile: 'check-evidence.md',
288
+ checkerContextPackFile: CHECKER_CONTEXT_PACK_FILE,
289
+ checkerContextPackSha: sha256(normalizeLineEndings(checkerContextPack)),
290
+ referencedFiles: inputs.referencedFiles,
291
+ relevantPlaybooks: readRelevantPlaybookMetadata(inputs.risk.riskTriggers),
292
+ memoryDeliveryMode: 'inline',
293
+ contextBudgetTokens: budget.contextBudgetTokens,
294
+ maxRepoReads: budget.maxRepoReads,
295
+ };
296
+ }
297
+
298
+ export function readCheckContext(taskDir) {
299
+ return readJsonFile(path.join(taskDir, 'check-context.json'));
300
+ }
301
+
302
+ export function isCheckContextCurrent(taskDir) {
303
+ try {
304
+ const taskId = path.basename(taskDir);
305
+ const inputs = computeTaskContextInputs(taskDir);
306
+ const expected = buildCheckContext({ taskId, inputs, createdAt: null });
307
+ const actual = readCheckContext(taskDir);
308
+ return {
309
+ current: actual.taskId === expected.taskId
310
+ && actual.planSha === expected.planSha
311
+ && actual.planFingerprintVersion === expected.planFingerprintVersion
312
+ && actual.memorySha === expected.memorySha
313
+ && actual.checkerContextPackFile === expected.checkerContextPackFile
314
+ && actual.checkerContextPackSha === expected.checkerContextPackSha
315
+ && JSON.stringify(actual.riskTriggers) === JSON.stringify(expected.riskTriggers)
316
+ && actual.riskProfile === expected.riskProfile
317
+ && JSON.stringify(actual.memoryFiles) === JSON.stringify(expected.memoryFiles)
318
+ && JSON.stringify(actual.taskArtifacts) === JSON.stringify(expected.taskArtifacts),
319
+ actual,
320
+ expected,
321
+ };
322
+ } catch (error) {
323
+ return {
324
+ current: false,
325
+ error,
326
+ };
327
+ }
328
+ }
329
+
330
+ export function readAgentsConfig() {
331
+ const defaultConfigPath = path.join(configRoot, 'default-agents.json');
332
+ const projectConfig = readJsonIfExists(projectContext.projectAgentsConfigPath);
333
+ return deepMerge(readJsonIfExists(defaultConfigPath), projectConfig);
334
+ }
335
+
336
+ export function resolveConfigValue(value, env = process.env) {
337
+ if (typeof value !== 'string') {
338
+ return value;
339
+ }
340
+ const match = /^\$\{([A-Z0-9_]+)\}$/.exec(value.trim());
341
+ if (!match) {
342
+ return value;
343
+ }
344
+ return env[match[1]] || null;
345
+ }
346
+
347
+ export function parseCliArgs(argv) {
348
+ const args = {
349
+ positional: [],
350
+ flags: new Map(),
351
+ };
352
+
353
+ for (let index = 0; index < argv.length; index += 1) {
354
+ const token = argv[index];
355
+ if (!token.startsWith('--')) {
356
+ args.positional.push(token);
357
+ continue;
358
+ }
359
+
360
+ const withoutPrefix = token.slice(2);
361
+ const [key, inlineValue] = withoutPrefix.split('=', 2);
362
+ if (inlineValue !== undefined) {
363
+ args.flags.set(key, inlineValue);
364
+ continue;
365
+ }
366
+
367
+ const next = argv[index + 1];
368
+ if (next && !next.startsWith('--')) {
369
+ args.flags.set(key, next);
370
+ index += 1;
371
+ } else {
372
+ args.flags.set(key, true);
373
+ }
374
+ }
375
+
376
+ return args;
377
+ }
378
+
379
+ export function getFlag(args, name, fallback = null) {
380
+ return args.flags.has(name) ? args.flags.get(name) : fallback;
381
+ }
382
+
383
+ export function readPrompt(name) {
384
+ return fs.readFileSync(path.join(promptsRoot, name), 'utf8');
385
+ }
386
+
387
+ export function computePromptSha(name) {
388
+ return sha256(normalizeMarkdownBody(readPrompt(name)));
389
+ }
390
+
391
+ export function readTaskFile(taskDir, fileName) {
392
+ const filePath = path.join(taskDir, fileName);
393
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
394
+ }
395
+
396
+ export function writeTaskFile(taskDir, fileName, content) {
397
+ fs.writeFileSync(path.join(taskDir, fileName), content.endsWith('\n') ? content : `${content}\n`);
398
+ }
399
+
400
+ export function updateMarkdownSection(markdown, heading, body) {
401
+ const normalized = normalizeLineEndings(markdown);
402
+ const escapedHeading = escapeRegExp(heading);
403
+ const pattern = new RegExp(`(^##\\s+${escapedHeading}\\s*\\n)([\\s\\S]*?)(?=^##\\s+|\\s*$)`, 'm');
404
+ const replacement = `## ${heading}\n\n${body.trim()}\n\n`;
405
+ if (pattern.test(normalized)) {
406
+ return normalized.replace(pattern, replacement);
407
+ }
408
+ return `${normalized.trimEnd()}\n\n${replacement}`;
409
+ }
410
+
411
+ export function readStatusStage(taskDir) {
412
+ const status = readTaskFile(taskDir, 'status.md');
413
+ const sections = parseMarkdownSections(status);
414
+ const stage = sections.get('Текущий этап') || sections.get('Current stage') || '';
415
+ return stage.replace(/[`*_]/g, '').trim();
416
+ }
417
+
418
+ export function updateStatus(taskDir, updates) {
419
+ let status = readTaskFile(taskDir, 'status.md') || '# Status\n';
420
+ const sectionMap = new Map([
421
+ ['stage', 'Текущий этап'],
422
+ ['owner', 'Текущий owner'],
423
+ ['routingDecision', 'Latest routing decision'],
424
+ ['planSha', 'Plan SHA'],
425
+ ['memorySha', 'Memory SHA'],
426
+ ['checkVerdict', 'Latest check verdict'],
427
+ ['checkResult', 'Latest check result'],
428
+ ['checkResolution', 'Latest check resolution'],
429
+ ['humanGateSummary', 'Human Gate summary'],
430
+ ['feedbackIntake', 'Feedback intake'],
431
+ ['executionFeedback', 'Execution feedback intake'],
432
+ ['supervisorAction', 'Последнее действие supervisor'],
433
+ ['nextStep', 'Следующий шаг'],
434
+ ['humanApproval', 'Нужен ли сейчас human approval'],
435
+ ]);
436
+
437
+ for (const [key, value] of Object.entries(updates)) {
438
+ const heading = sectionMap.get(key);
439
+ if (!heading || value === undefined || value === null) {
440
+ continue;
441
+ }
442
+ status = updateMarkdownSection(status, heading, String(value));
443
+ }
444
+
445
+ writeTaskFile(taskDir, 'status.md', status);
446
+ }
447
+
448
+ export function appendOrchestrationLog(taskDir, entry) {
449
+ const now = new Date().toISOString();
450
+ const logPath = path.join(taskDir, 'orchestration-log.md');
451
+ const existing = fs.existsSync(logPath)
452
+ ? fs.readFileSync(logPath, 'utf8').trimEnd()
453
+ : '# Orchestration Log\n\n## Entries';
454
+ const serialized = typeof entry === 'string' ? entry : JSON.stringify(entry);
455
+ fs.writeFileSync(logPath, `${existing}\n- \`${now}\` — ${serialized}\n`);
456
+ }
457
+
458
+ export function buildCheckerFailureResult({
459
+ taskId,
460
+ checkContext,
461
+ checkerProvider,
462
+ checkerModel,
463
+ failureReason,
464
+ }) {
465
+ return {
466
+ taskId,
467
+ stage: 'Check',
468
+ checkerProvider,
469
+ checkerModel,
470
+ planSha: checkContext.planSha,
471
+ memorySha: checkContext.memorySha,
472
+ riskProfile: checkContext.riskProfile,
473
+ verdict: 'checker_failed',
474
+ failureReason,
475
+ blockingFindings: 0,
476
+ nonBlockingFindings: 0,
477
+ humanQuestions: 0,
478
+ findings: [],
479
+ readyForHumanGate: false,
480
+ createdAt: new Date().toISOString(),
481
+ };
482
+ }
483
+
484
+ export function normalizeCheckResult(result, {
485
+ taskId,
486
+ checkContext,
487
+ checkerProvider,
488
+ checkerModel,
489
+ }) {
490
+ const findings = (Array.isArray(result.findings) ? result.findings : []).map((finding, index) => ({
491
+ ...finding,
492
+ id: `F-${String(index + 1).padStart(3, '0')}`,
493
+ }));
494
+ return {
495
+ taskId,
496
+ stage: 'Check',
497
+ checkerProvider,
498
+ checkerModel,
499
+ planSha: checkContext.planSha,
500
+ memorySha: checkContext.memorySha,
501
+ riskProfile: checkContext.riskProfile,
502
+ verdict: result.verdict,
503
+ failureReason: result.failureReason ?? null,
504
+ blockingFindings: findings.filter((finding) => finding.severity === 'blocking').length,
505
+ nonBlockingFindings: findings.filter((finding) => finding.severity === 'non_blocking').length,
506
+ humanQuestions: findings.filter((finding) => finding.severity === 'question').length,
507
+ findings,
508
+ readyForHumanGate: result.verdict === 'ready_for_human_gate',
509
+ createdAt: result.createdAt || new Date().toISOString(),
510
+ };
511
+ }
512
+
513
+ export function computeMemorySha(memoryFiles) {
514
+ return sha256Json(memoryFiles.map(({ path: filePath, sha }) => ({ path: filePath, sha })));
515
+ }
516
+
517
+ export function computePlanFingerprint(planContent) {
518
+ const sections = parseMarkdownSections(planContent);
519
+ const semanticChunks = [];
520
+ for (const [rawTitle, body] of sections.entries()) {
521
+ const canonicalTitle = PLAN_SECTIONS.get(normalizeHeading(rawTitle));
522
+ if (canonicalTitle) {
523
+ semanticChunks.push(`## ${canonicalTitle}\n${normalizeMarkdownBody(body)}`);
524
+ }
525
+ }
526
+
527
+ if (semanticChunks.length === 0) {
528
+ return {
529
+ planSha: sha256(normalizeMarkdownBody(planContent)),
530
+ planFingerprintVersion: 'v1-raw-fallback',
531
+ };
532
+ }
533
+
534
+ return {
535
+ planSha: sha256(semanticChunks.join('\n\n')),
536
+ planFingerprintVersion: 'v1',
537
+ };
538
+ }
539
+
540
+ export function parseMarkdownSections(content) {
541
+ const sections = new Map();
542
+ let currentTitle = null;
543
+ let currentLines = [];
544
+
545
+ for (const line of normalizeLineEndings(content).split('\n')) {
546
+ const match = /^##\s+(.+?)\s*$/.exec(line);
547
+ if (match) {
548
+ if (currentTitle) {
549
+ sections.set(currentTitle, currentLines.join('\n'));
550
+ }
551
+ currentTitle = match[1].trim();
552
+ currentLines = [];
553
+ continue;
554
+ }
555
+
556
+ if (currentTitle) {
557
+ currentLines.push(line);
558
+ }
559
+ }
560
+
561
+ if (currentTitle) {
562
+ sections.set(currentTitle, currentLines.join('\n'));
563
+ }
564
+
565
+ return sections;
566
+ }
567
+
568
+ export function collectStructuralLines({ planSections, researchSections }) {
569
+ const lines = [];
570
+
571
+ for (const [title, body] of planSections.entries()) {
572
+ const canonicalTitle = PLAN_SECTIONS.get(normalizeHeading(title));
573
+ const includeWholeSection = canonicalTitle && STRUCTURAL_PLAN_SECTIONS.has(canonicalTitle);
574
+ for (const line of body.split('\n')) {
575
+ if (includeWholeSection || isEvidenceLikeLine(line)) {
576
+ lines.push(line);
577
+ }
578
+ }
579
+ }
580
+
581
+ for (const [title, body] of researchSections.entries()) {
582
+ const normalizedTitle = normalizeHeading(title);
583
+ const includeWholeSection = /finding|evidence|repo|архитект|модул|файл|провер|verification|standards/.test(normalizedTitle);
584
+ for (const line of body.split('\n')) {
585
+ if (includeWholeSection || isEvidenceLikeLine(line)) {
586
+ lines.push(line);
587
+ }
588
+ }
589
+ }
590
+
591
+ return lines.map((line) => line.trim()).filter(Boolean);
592
+ }
593
+
594
+ export function isEvidenceLikeLine(line) {
595
+ const trimmed = line.trim();
596
+ return /(^|\s)(Evidence|Finding|File|Path|Command|Источник|Файл|Команда|Провер|Риск|Модул|Route|API):/i.test(trimmed)
597
+ || /`?[A-Za-z0-9._@-]+\/[A-Za-z0-9._@/+:-]+/.test(trimmed)
598
+ || /`?(yarn|node|npm|pnpm|prisma)\s+/.test(trimmed);
599
+ }
600
+
601
+ export function extractReferencedFiles(lines) {
602
+ const refs = new Set();
603
+ const pathPattern = /(?:^|[\s`'"])(?!https?:\/\/)([A-Za-z0-9._@-]+\/[A-Za-z0-9._@/+:-]+)(?=[\s`'",).;:]|$)/g;
604
+
605
+ for (const line of lines) {
606
+ for (const match of line.matchAll(pathPattern)) {
607
+ const cleaned = match[1]
608
+ .replace(/:L?\d+$/i, '')
609
+ .replace(/[),.;:]+$/g, '');
610
+ refs.add(cleaned);
611
+ }
612
+ }
613
+
614
+ return Array.from(refs).sort();
615
+ }
616
+
617
+ export function classifyRisk({ structuralLines, referencedFiles, planSections, riskConfig = {} }) {
618
+ const triggers = new Set();
619
+ const structuralText = structuralLines.join('\n').toLowerCase();
620
+
621
+ const hasRef = (predicate) => referencedFiles.some(predicate);
622
+ const hasText = (pattern) => pattern.test(structuralText);
623
+
624
+ if (hasRef((ref) => ref === 'prisma/schema.prisma' || ref.startsWith('prisma/migrations/')) || hasText(/\b(prisma migrate|migration|schema\.prisma)\b/)) {
625
+ triggers.add('prisma-schema');
626
+ }
627
+ if (hasText(/\b(auth|session|cookie|security|encryption|secret|credential)\b/)) {
628
+ triggers.add('auth-security');
629
+ }
630
+ const uiRoots = normalizeRoots(riskConfig.uiRoots);
631
+ const backendRoots = normalizeRoots(riskConfig.backendRoots);
632
+ const workerRoots = normalizeRoots(riskConfig.workerRoots);
633
+
634
+ if (hasRef((ref) => matchesAnyRoot(ref, workerRoots)) || hasText(/\b(worker|scheduler|queue|bullmq|backfill|pacing)\b/)) {
635
+ triggers.add('worker-queue');
636
+ }
637
+ if (hasText(/\b(source ingestion|source-ingestion|connector|oauth|callback|provider api|sync plan)\b/)) {
638
+ triggers.add('ingestion-provider');
639
+ }
640
+ if (hasText(/\b(source sync|source-sync|raw record|raw records|pagination|rate limit|retry|retries|idempotency|partial failure|provider stream|sync worker|sync scheduler)\b/)) {
641
+ triggers.add('source-sync-provider');
642
+ }
643
+ if (hasText(/\b(production|railway|deploy|runtime env|environment variable)\b/)) {
644
+ triggers.add('production-runtime');
645
+ }
646
+ if (hasText(/\b(materialization|materialize|canonical entity|projection)\b/)) {
647
+ triggers.add('materializer');
648
+ }
649
+ if (hasText(/\b(api contract|route|endpoint|payload|read model|ui-visible)\b/)) {
650
+ triggers.add('ui-visible-api');
651
+ }
652
+ if (hasRef((ref) => matchesAnyRoot(ref, uiRoots))) {
653
+ triggers.add('panel-ui');
654
+ }
655
+ if (hasRef((ref) => matchesAnyRoot(ref, backendRoots)) && hasText(/\b(api contract|route|endpoint|payload|read model|ui-visible)\b/)) {
656
+ triggers.add('ui-visible-api');
657
+ }
658
+ if (hasText(/\b(dto|validation|read model|payload shape|contract)\b/)) {
659
+ triggers.add('dto-readmodel');
660
+ }
661
+
662
+ if (triggers.size === 0 && isDocsOnly(referencedFiles, planSections)) {
663
+ triggers.add('docs-only');
664
+ }
665
+
666
+ const riskProfile = Array.from(triggers).some((trigger) => [
667
+ 'prisma-schema',
668
+ 'auth-security',
669
+ 'worker-queue',
670
+ 'ingestion-provider',
671
+ 'production-runtime',
672
+ 'source-sync-provider',
673
+ 'materializer',
674
+ ].includes(trigger))
675
+ ? 'high'
676
+ : Array.from(triggers).some((trigger) => [
677
+ 'ui-visible-api',
678
+ 'panel-ui',
679
+ 'dto-readmodel',
680
+ ].includes(trigger))
681
+ ? 'medium'
682
+ : 'low';
683
+
684
+ return {
685
+ riskProfile,
686
+ riskTriggers: Array.from(triggers).sort(),
687
+ };
688
+ }
689
+
690
+ export function isDocsOnly(referencedFiles, planSections) {
691
+ if (referencedFiles.length > 0) {
692
+ return referencedFiles.every((ref) => ref.startsWith('docs/') || ref.startsWith('ops/agent-pipeline/'));
693
+ }
694
+
695
+ const titleText = Array.from(planSections.keys()).join('\n').toLowerCase();
696
+ return /\b(docs|documentation|template|prompt|artifact)\b/.test(titleText);
697
+ }
698
+
699
+ export function buildEvidenceMarkdown({
700
+ taskId,
701
+ risk,
702
+ qualityGates,
703
+ referencedFiles,
704
+ structuralLines,
705
+ planFingerprint,
706
+ memorySha,
707
+ taskArtifacts,
708
+ }) {
709
+ const missingSignals = [];
710
+ if (!taskArtifacts.get('research.md').trim()) {
711
+ missingSignals.push('`research.md` is empty.');
712
+ }
713
+ if (!taskArtifacts.get('plan.md').trim()) {
714
+ missingSignals.push('`plan.md` is empty.');
715
+ }
716
+ if (referencedFiles.length === 0) {
717
+ missingSignals.push('No structural file references were extracted from `plan.md` or `research.md`.');
718
+ }
719
+ for (const signal of qualityGates?.missingSignals || []) {
720
+ missingSignals.push(signal);
721
+ }
722
+
723
+ const claimRows = structuralLines.slice(0, 80).map((line) => `| ${escapeTableCell(line)} | extracted structural line | Checker should verify if relevant |`);
724
+
725
+ return `# Check Evidence
726
+
727
+ Task: \`${taskId}\`
728
+
729
+ Generated at: \`${new Date().toISOString()}\`
730
+
731
+ ## Fingerprints
732
+
733
+ - Plan SHA: \`${planFingerprint.planSha}\`
734
+ - Plan fingerprint version: \`${planFingerprint.planFingerprintVersion}\`
735
+ - Memory SHA: \`${memorySha}\`
736
+
737
+ ## Risk trigger summary
738
+
739
+ - Risk profile: \`${risk.riskProfile}\`
740
+ - Risk triggers: ${risk.riskTriggers.length ? risk.riskTriggers.map((trigger) => `\`${trigger}\``).join(', ') : '`none`'}
741
+
742
+ ## Claims
743
+
744
+ | Claim | Evidence | Implication |
745
+ | --- | --- | --- |
746
+ ${claimRows.length ? claimRows.join('\n') : '| No structural claims extracted | n/a | Checker should rely on task artifacts directly |'}
747
+
748
+ ## Referenced Files
749
+
750
+ ${referencedFiles.length ? referencedFiles.map((ref) => `- \`${ref}\``).join('\n') : '- No referenced files extracted.'}
751
+
752
+ ## Missing Evidence Signals
753
+
754
+ ${missingSignals.length ? missingSignals.map((signal) => `- ${signal}`).join('\n') : '- No missing evidence signals detected by deterministic builder.'}
755
+ `;
756
+ }
757
+
758
+ export function analyzePlanQualityGates({ planContent, risk }) {
759
+ const sections = parseMarkdownSections(planContent);
760
+ const uiRequired = requiresUiAcceptanceScenarios(risk.riskTriggers);
761
+ const complexityRequired = requiresComplexityBudget(risk.riskTriggers);
762
+ const optimizationTier = determineOptimizationTier(risk.riskTriggers);
763
+ const optimizationRequired = requiresOptimizationStrategy(optimizationTier);
764
+ const productionRolloutRequired = requiresProductionRolloutGate(risk.riskTriggers);
765
+ const sourceSyncProviderRequired = requiresSourceSyncProviderGate(risk.riskTriggers);
766
+ const uiAcceptance = inspectUiAcceptanceScenarios(sections);
767
+ const complexityBudget = inspectComplexityPerformanceBudget(sections);
768
+ const optimizationStrategy = inspectOptimizationStrategy(sections);
769
+ const productionRollout = inspectProductionRolloutGate(sections);
770
+ const sourceSyncProvider = inspectSourceSyncProviderGate(sections);
771
+ const missingSignals = [];
772
+
773
+ if (uiRequired && !uiAcceptance.present) {
774
+ missingSignals.push('UI-visible risk detected but `## UI Acceptance Scenarios` is missing or incomplete.');
775
+ }
776
+ if (uiRequired && uiAcceptance.present && !uiAcceptance.hasMustCatch) {
777
+ missingSignals.push('`## UI Acceptance Scenarios` exists but does not include a `Must catch` column/field.');
778
+ }
779
+ if (complexityRequired && !complexityBudget.present) {
780
+ missingSignals.push('Complexity/performance risk detected but `## Complexity / Performance Budget` is missing or incomplete.');
781
+ }
782
+ if (optimizationRequired && !optimizationStrategy.present) {
783
+ missingSignals.push(`Optimization tier ${optimizationTier} detected but \`## Optimization Strategy\` is missing or incomplete.`);
784
+ }
785
+ if (productionRolloutRequired && !productionRollout.present) {
786
+ missingSignals.push('Production rollout risk detected but `## Production Rollout Gate` is missing or incomplete.');
787
+ }
788
+ if (sourceSyncProviderRequired && !sourceSyncProvider.present) {
789
+ missingSignals.push('Source sync/provider risk detected but `## Source Sync / Provider Gate` is missing or incomplete.');
790
+ }
791
+
792
+ return {
793
+ uiRequired,
794
+ uiAcceptance,
795
+ complexityRequired,
796
+ complexityBudget,
797
+ optimizationTier,
798
+ optimizationRequired,
799
+ optimizationStrategy,
800
+ productionRolloutRequired,
801
+ productionRollout,
802
+ sourceSyncProviderRequired,
803
+ sourceSyncProvider,
804
+ missingSignals,
805
+ };
806
+ }
807
+
808
+ export function requiresUiAcceptanceScenarios(riskTriggers = []) {
809
+ return riskTriggers.includes('panel-ui') || riskTriggers.includes('ui-visible-api');
810
+ }
811
+
812
+ export function requiresComplexityBudget(riskTriggers = []) {
813
+ return riskTriggers.some((trigger) => [
814
+ 'dto-readmodel',
815
+ 'materializer',
816
+ 'panel-ui',
817
+ 'production-runtime',
818
+ 'source-sync-provider',
819
+ 'ui-visible-api',
820
+ 'worker-queue',
821
+ ].includes(trigger));
822
+ }
823
+
824
+ export function determineOptimizationTier(riskTriggers = []) {
825
+ const triggers = new Set(riskTriggers);
826
+ if ([
827
+ 'source-sync-provider',
828
+ 'ingestion-provider',
829
+ 'worker-queue',
830
+ 'materializer',
831
+ 'production-runtime',
832
+ ].some((trigger) => triggers.has(trigger))) {
833
+ return 'O3';
834
+ }
835
+ if ([
836
+ 'dto-readmodel',
837
+ 'panel-ui',
838
+ 'ui-visible-api',
839
+ 'prisma-schema',
840
+ ].some((trigger) => triggers.has(trigger))) {
841
+ return 'O2';
842
+ }
843
+ if (triggers.size > 0 && !triggers.has('docs-only')) {
844
+ return 'O1';
845
+ }
846
+ return 'O0';
847
+ }
848
+
849
+ export function requiresOptimizationStrategy(optimizationTier) {
850
+ return optimizationTier === 'O2' || optimizationTier === 'O3';
851
+ }
852
+
853
+ export function requiresProductionRolloutGate(riskTriggers = []) {
854
+ return riskTriggers.some((trigger) => [
855
+ 'auth-security',
856
+ 'prisma-schema',
857
+ 'production-runtime',
858
+ 'worker-queue',
859
+ ].includes(trigger));
860
+ }
861
+
862
+ export function requiresSourceSyncProviderGate(riskTriggers = []) {
863
+ return riskTriggers.includes('ingestion-provider') || riskTriggers.includes('source-sync-provider');
864
+ }
865
+
866
+ export function inspectUiAcceptanceScenarios(sections) {
867
+ const body = readCanonicalSection(sections, ['ui acceptance scenarios', 'ui acceptance', 'ui scenarios']);
868
+ if (!body) {
869
+ return {
870
+ present: false,
871
+ scenarioCount: 0,
872
+ hasUserIntent: false,
873
+ hasSteps: false,
874
+ hasExpectedVisibleResult: false,
875
+ hasMustCatch: false,
876
+ };
877
+ }
878
+
879
+ const normalized = body.toLowerCase();
880
+ const scenarioCount = (body.match(/\bUI-\d{3}\b/g) || []).length;
881
+ const result = {
882
+ present: true,
883
+ scenarioCount,
884
+ hasUserIntent: /user intent|пользователь|намерени/.test(normalized),
885
+ hasSteps: /steps|шаги|действия/.test(normalized),
886
+ hasExpectedVisibleResult: /expected visible result|visible result|ожидаем|видим/.test(normalized),
887
+ hasMustCatch: /must catch|должн[аоы]? поймать|ловит|поймать/.test(normalized),
888
+ };
889
+ result.complete = result.scenarioCount > 0
890
+ && result.hasUserIntent
891
+ && result.hasSteps
892
+ && result.hasExpectedVisibleResult
893
+ && result.hasMustCatch;
894
+ result.present = result.complete;
895
+ return result;
896
+ }
897
+
898
+ export function inspectComplexityPerformanceBudget(sections) {
899
+ const body = readCanonicalSection(sections, [
900
+ 'complexity / performance budget',
901
+ 'complexity performance budget',
902
+ 'performance budget',
903
+ 'complexity budget',
904
+ ]);
905
+ if (!body) {
906
+ return {
907
+ present: false,
908
+ hasHotPaths: false,
909
+ hasDataSize: false,
910
+ hasRisks: false,
911
+ hasBudget: false,
912
+ };
913
+ }
914
+
915
+ const normalized = body.toLowerCase();
916
+ const result = {
917
+ present: true,
918
+ hasHotPaths: /hot path|hot paths|горяч/.test(normalized),
919
+ hasDataSize: /data size|row count|rows|объем|строк/.test(normalized),
920
+ hasRisks: /complexity risk|risk|n\+1|nested|render|риск/.test(normalized),
921
+ hasBudget: /budget|<\s*\d|ms|sec|seconds|s\b|бюджет|лимит/.test(normalized),
922
+ };
923
+ result.complete = result.hasHotPaths && result.hasDataSize && result.hasRisks && result.hasBudget;
924
+ result.present = result.complete;
925
+ return result;
926
+ }
927
+
928
+ export function inspectOptimizationStrategy(sections) {
929
+ const body = readCanonicalSection(sections, [
930
+ 'optimization strategy',
931
+ 'optimization review strategy',
932
+ 'optimization gate',
933
+ ]);
934
+ if (!body) {
935
+ return {
936
+ present: false,
937
+ tier: null,
938
+ hasHotPaths: false,
939
+ hasDataSize: false,
940
+ hasApproach: false,
941
+ hasAntiPatterns: false,
942
+ hasBudget: false,
943
+ };
944
+ }
945
+
946
+ const normalized = body.toLowerCase();
947
+ const tierMatch = /\bO[0-3]\b/i.exec(body);
948
+ const result = {
949
+ present: true,
950
+ tier: tierMatch ? tierMatch[0].toUpperCase() : null,
951
+ hasHotPaths: /hot path|hot paths|горяч/.test(normalized),
952
+ hasDataSize: /data size|row count|rows|объем|строк|expected/.test(normalized),
953
+ hasApproach: /strategy|approach|chosen|batch|map|set|index|query|window|memo|cache|подход|стратег/.test(normalized),
954
+ hasAntiPatterns: /n\+1|repeated scan|nested|o\(n\^2\)|unbounded|full scan|render|anti-pattern|антипаттерн/.test(normalized),
955
+ hasBudget: /budget|stop rule|stop-rule|minutes|минут|measurement|timing|benchmark|explain|defer|бюджет|лимит/.test(normalized),
956
+ };
957
+ result.complete = Boolean(result.tier)
958
+ && result.hasHotPaths
959
+ && result.hasDataSize
960
+ && result.hasApproach
961
+ && result.hasAntiPatterns
962
+ && result.hasBudget;
963
+ result.present = result.complete;
964
+ return result;
965
+ }
966
+
967
+ export function inspectProductionRolloutGate(sections) {
968
+ const body = readCanonicalSection(sections, [
969
+ 'production rollout gate',
970
+ 'production rollout',
971
+ 'rollout gate',
972
+ 'deployment gate',
973
+ ]);
974
+ if (!body) {
975
+ return {
976
+ present: false,
977
+ hasImpact: false,
978
+ hasEnvironment: false,
979
+ hasRollback: false,
980
+ hasPostDeployEvidence: false,
981
+ };
982
+ }
983
+
984
+ const normalized = body.toLowerCase();
985
+ const result = {
986
+ present: true,
987
+ hasImpact: /impact|scope|blast radius|migration|worker|cron|auth|billing|api|влияни|радиус/.test(normalized),
988
+ hasEnvironment: /env|environment|secret|variable|railway|runtime|deploy|окружени|переменн/.test(normalized),
989
+ hasRollback: /rollback|revert|disable|kill switch|feature flag|откат|выключ/.test(normalized),
990
+ hasPostDeployEvidence: /post-?deploy|smoke|monitor|logs|metric|evidence|после deploy|лог|метрик/.test(normalized),
991
+ };
992
+ result.complete = result.hasImpact && result.hasEnvironment && result.hasRollback && result.hasPostDeployEvidence;
993
+ result.present = result.complete;
994
+ return result;
995
+ }
996
+
997
+ export function inspectSourceSyncProviderGate(sections) {
998
+ const body = readCanonicalSection(sections, [
999
+ 'source sync / provider gate',
1000
+ 'source sync provider gate',
1001
+ 'source sync gate',
1002
+ 'provider gate',
1003
+ ]);
1004
+ if (!body) {
1005
+ return {
1006
+ present: false,
1007
+ hasScope: false,
1008
+ hasIdempotency: false,
1009
+ hasFailureHandling: false,
1010
+ hasCoverageEvidence: false,
1011
+ };
1012
+ }
1013
+
1014
+ const normalized = body.toLowerCase();
1015
+ const result = {
1016
+ present: true,
1017
+ hasScope: /scope|window|provider|stream|pagination|rate limit|raw record|объем|окно|провайдер/.test(normalized),
1018
+ hasIdempotency: /idempot|dedupe|duplicate|retry|replay|повтор|дубликат/.test(normalized),
1019
+ hasFailureHandling: /partial failure|failure|timeout|backoff|resume|dead letter|ошиб|частич/.test(normalized),
1020
+ hasCoverageEvidence: /coverage|parity|count|sample|audit|evidence|metric|сверк|покрыт/.test(normalized),
1021
+ };
1022
+ result.complete = result.hasScope && result.hasIdempotency && result.hasFailureHandling && result.hasCoverageEvidence;
1023
+ result.present = result.complete;
1024
+ return result;
1025
+ }
1026
+
1027
+ function readCanonicalSection(sections, names) {
1028
+ const wanted = new Set(names.map(normalizeHeading));
1029
+ for (const [title, body] of sections.entries()) {
1030
+ if (wanted.has(normalizeHeading(title))) {
1031
+ return body;
1032
+ }
1033
+ }
1034
+ return '';
1035
+ }
1036
+
1037
+ export function buildCheckerContextPack({
1038
+ taskId,
1039
+ risk,
1040
+ qualityGates,
1041
+ referencedFiles,
1042
+ structuralLines,
1043
+ taskArtifacts,
1044
+ }) {
1045
+ const statusSections = parseMarkdownSections(taskArtifacts.get('status.md') || '');
1046
+ const planSections = parseMarkdownSections(taskArtifacts.get('plan.md') || '');
1047
+ const activeSlice = statusSections.get('Current slice')
1048
+ || statusSections.get('Active slice')
1049
+ || statusSections.get('Активный slice')
1050
+ || 'Not explicitly declared in status.md.';
1051
+ const decision = statusSections.get('Следующий шаг')
1052
+ || statusSections.get('Next step')
1053
+ || 'Validate whether plan is safe to move to Human Gate.';
1054
+ const requiredFiles = referencedFiles.slice(0, RISK_CONFIG[risk.riskProfile].maxRepoReads);
1055
+ const risks = [
1056
+ ...risk.riskTriggers.map((trigger) => `- \`${trigger}\``),
1057
+ ...(qualityGates.missingSignals || []).map((signal) => `- ${signal}`),
1058
+ ];
1059
+ const checkerQuestions = buildCheckerQuestions({ risk, qualityGates });
1060
+ const relevantPlaybooks = readRelevantPlaybookMetadata(risk.riskTriggers);
1061
+
1062
+ return [
1063
+ '# Checker Context Pack',
1064
+ '',
1065
+ `Task: \`${taskId}\``,
1066
+ '',
1067
+ '## Decision To Validate',
1068
+ '',
1069
+ decision.trim(),
1070
+ '',
1071
+ '## Active Slice',
1072
+ '',
1073
+ activeSlice.trim(),
1074
+ '',
1075
+ '## Why This Is Risky',
1076
+ '',
1077
+ risks.length ? risks.join('\n') : '- No risk triggers detected.',
1078
+ '',
1079
+ '## Required Repo Context',
1080
+ '',
1081
+ requiredFiles.length
1082
+ ? requiredFiles.map((file) => `- \`${file}\` — referenced by current plan/research; inspect if it is material to the claim.`).join('\n')
1083
+ : '- No repo files were deterministically extracted; Checker must rely on task artifacts and ask for better plan context if needed.',
1084
+ '',
1085
+ '## Prior Relevant Failure Hints',
1086
+ '',
1087
+ '- UI smoke without use-case assertions has historically missed important visual/semantic regressions.',
1088
+ '- Execution ledgers that omit changed files or command evidence cause expensive Verify loops.',
1089
+ '- Complexity/performance budgets must be explicit for read-models, materializers, workers and dashboard-like UI.',
1090
+ '',
1091
+ '## UI Acceptance Expectations',
1092
+ '',
1093
+ qualityGates.uiRequired
1094
+ ? [
1095
+ '- UI-visible risk detected.',
1096
+ `- UI Acceptance Scenarios complete: \`${qualityGates.uiAcceptance.present ? 'yes' : 'no'}\`.`,
1097
+ '- Checker must return `return_to_plan` if scenarios do not specify user intent, setup/steps, expected visible result and must-catch regressions.',
1098
+ ].join('\n')
1099
+ : '- UI acceptance scenarios are not required by detected triggers.',
1100
+ '',
1101
+ '## Complexity / Performance Expectations',
1102
+ '',
1103
+ qualityGates.complexityRequired
1104
+ ? [
1105
+ '- Complexity/performance risk detected.',
1106
+ `- Complexity / Performance Budget complete: \`${qualityGates.complexityBudget.present ? 'yes' : 'no'}\`.`,
1107
+ '- Checker must return `return_to_plan` if hot paths, data size, complexity risks and budget/evidence are not named.',
1108
+ ].join('\n')
1109
+ : '- Complexity/performance budget is not required by detected triggers.',
1110
+ '',
1111
+ '## Optimization Expectations',
1112
+ '',
1113
+ qualityGates.optimizationRequired
1114
+ ? [
1115
+ `- Optimization tier detected: \`${qualityGates.optimizationTier}\`.`,
1116
+ `- Optimization Strategy complete: \`${qualityGates.optimizationStrategy.present ? 'yes' : 'no'}\`.`,
1117
+ '- Checker must return `return_to_plan` if O2/O3 hot-path work lacks bounded strategy, anti-pattern review and stop rule.',
1118
+ ].join('\n')
1119
+ : `- Optimization tier: \`${qualityGates.optimizationTier || 'O0'}\`; dedicated strategy section is not required.`,
1120
+ '',
1121
+ '## Production Rollout Expectations',
1122
+ '',
1123
+ qualityGates.productionRolloutRequired
1124
+ ? [
1125
+ '- Production/runtime rollout risk detected.',
1126
+ `- Production Rollout Gate complete: \`${qualityGates.productionRollout.present ? 'yes' : 'no'}\`.`,
1127
+ '- Checker must return `return_to_plan` if impact, environment, rollback and post-deploy evidence are not named.',
1128
+ ].join('\n')
1129
+ : '- Production rollout gate is not required by detected triggers.',
1130
+ '',
1131
+ '## Source Sync / Provider Expectations',
1132
+ '',
1133
+ qualityGates.sourceSyncProviderRequired
1134
+ ? [
1135
+ '- Source sync/provider risk detected.',
1136
+ `- Source Sync / Provider Gate complete: \`${qualityGates.sourceSyncProvider.present ? 'yes' : 'no'}\`.`,
1137
+ '- Checker must return `return_to_plan` if scope/window, idempotency, failure handling and coverage evidence are not named.',
1138
+ ].join('\n')
1139
+ : '- Source sync/provider gate is not required by detected triggers.',
1140
+ '',
1141
+ '## Relevant Playbooks',
1142
+ '',
1143
+ renderRelevantPlaybookIndex(relevantPlaybooks),
1144
+ '',
1145
+ '## Checker Questions',
1146
+ '',
1147
+ checkerQuestions.map((question, index) => `${index + 1}. ${question}`).join('\n'),
1148
+ '',
1149
+ '## Structural Lines Sample',
1150
+ '',
1151
+ structuralLines.slice(0, 30).map((line) => `- ${line}`).join('\n') || '- No structural lines extracted.',
1152
+ '',
1153
+ '## Plan Sections Seen',
1154
+ '',
1155
+ Array.from(planSections.keys()).map((title) => `- ${title}`).join('\n') || '- No level-2 plan sections found.',
1156
+ '',
1157
+ ].join('\n');
1158
+ }
1159
+
1160
+ export function selectRelevantPlaybookNames(riskTriggers = []) {
1161
+ const names = new Set();
1162
+ for (const trigger of riskTriggers) {
1163
+ for (const playbookName of PLAYBOOK_TRIGGER_MAP.get(trigger) || []) {
1164
+ names.add(playbookName);
1165
+ }
1166
+ }
1167
+ return Array.from(names).sort();
1168
+ }
1169
+
1170
+ export function readRelevantPlaybooks(riskTriggers = []) {
1171
+ return selectRelevantPlaybookNames(riskTriggers).map((name) => {
1172
+ const fileName = `${name}.md`;
1173
+ const sharedPath = path.join(projectContext.sharedPlaybooksRoot || projectContext.playbooksRoot, fileName);
1174
+ const projectPath = projectContext.projectPlaybooksRoot
1175
+ ? path.join(projectContext.projectPlaybooksRoot, fileName)
1176
+ : null;
1177
+ return {
1178
+ name,
1179
+ title: titleFromPlaybookName(name),
1180
+ sharedPath: fs.existsSync(sharedPath) ? relativePath(sharedPath) : null,
1181
+ sharedContent: fs.existsSync(sharedPath) ? fs.readFileSync(sharedPath, 'utf8') : '',
1182
+ projectPath: projectPath && fs.existsSync(projectPath) ? relativePath(projectPath) : null,
1183
+ projectContent: projectPath && fs.existsSync(projectPath) ? fs.readFileSync(projectPath, 'utf8') : '',
1184
+ };
1185
+ }).filter((playbook) => playbook.sharedContent || playbook.projectContent);
1186
+ }
1187
+
1188
+ export function readRelevantPlaybookMetadata(riskTriggers = []) {
1189
+ return readRelevantPlaybooks(riskTriggers).map((playbook) => ({
1190
+ name: playbook.name,
1191
+ title: playbook.title,
1192
+ selected: true,
1193
+ sharedPath: playbook.sharedPath,
1194
+ sharedSha: playbook.sharedContent ? sha256(normalizeLineEndings(playbook.sharedContent)) : null,
1195
+ projectPath: playbook.projectPath,
1196
+ projectSha: playbook.projectContent ? sha256(normalizeLineEndings(playbook.projectContent)) : null,
1197
+ hasProjectOverlay: Boolean(playbook.projectContent),
1198
+ }));
1199
+ }
1200
+
1201
+ export function renderRelevantPlaybooks(playbooks, { mode = 'compact' } = {}) {
1202
+ if (!playbooks.length) {
1203
+ return '- No relevant playbooks selected by risk triggers.';
1204
+ }
1205
+ return playbooks.map((playbook) => [
1206
+ `### Shared Playbook: ${playbook.title}`,
1207
+ '',
1208
+ playbook.sharedPath ? `Path: \`${playbook.sharedPath}\`` : 'Path: not found',
1209
+ '',
1210
+ mode === 'strict' ? playbook.sharedContent.trim() : excerptMarkdown(playbook.sharedContent),
1211
+ '',
1212
+ `### Project Overlay: ${playbook.title}`,
1213
+ '',
1214
+ playbook.projectPath ? `Path: \`${playbook.projectPath}\`` : 'Path: not configured/found',
1215
+ '',
1216
+ playbook.projectContent
1217
+ ? (mode === 'strict' ? playbook.projectContent.trim() : excerptMarkdown(playbook.projectContent))
1218
+ : '- No project overlay for this playbook.',
1219
+ ].join('\n')).join('\n\n');
1220
+ }
1221
+
1222
+ export function renderRelevantPlaybookIndex(playbooks) {
1223
+ if (!playbooks.length) {
1224
+ return '- No relevant playbooks selected by risk triggers.';
1225
+ }
1226
+ return playbooks.map((playbook) => [
1227
+ `- ${playbook.title} (\`${playbook.name}\`)`,
1228
+ ` - shared: ${playbook.sharedPath ? `\`${playbook.sharedPath}\` (${playbook.sharedSha})` : 'not found'}`,
1229
+ ` - project overlay: ${playbook.projectPath ? `\`${playbook.projectPath}\` (${playbook.projectSha})` : 'not configured/found'}`,
1230
+ ].join('\n')).join('\n');
1231
+ }
1232
+
1233
+ export function validateExecutionEvidenceForPlan({ planContent, executionContent }) {
1234
+ const errors = [];
1235
+ const planSections = parseMarkdownSections(planContent || '');
1236
+ const executionSections = parseMarkdownSections(executionContent || '');
1237
+
1238
+ if (hasAnySection(planSections, ['ui acceptance scenarios', 'ui acceptance', 'ui scenarios'])) {
1239
+ const evidence = readAnySection(executionSections, ['ui acceptance evidence', 'ui/browser evidence', 'ui smoke evidence']);
1240
+ if (!evidence) {
1241
+ errors.push({
1242
+ category: 'ui_verification_gap',
1243
+ message: 'Plan contains UI Acceptance Scenarios but execution.md is missing UI Acceptance Evidence.',
1244
+ });
1245
+ } else {
1246
+ if (!/\bUI-\d{3}\b/.test(evidence)) {
1247
+ errors.push({
1248
+ category: 'ui_verification_gap',
1249
+ message: 'UI Acceptance Evidence must reference scenario ids like UI-001.',
1250
+ });
1251
+ }
1252
+ if (!/\b(pass|passed|fail|failed|blocked|human-owned)\b/i.test(evidence)) {
1253
+ errors.push({
1254
+ category: 'ui_verification_gap',
1255
+ message: 'UI Acceptance Evidence must record pass/fail/blocked/human-owned result.',
1256
+ });
1257
+ }
1258
+ if (!/(screenshot|payload|visible|observed|browser|api evidence|rendered)/i.test(evidence)) {
1259
+ errors.push({
1260
+ category: 'ui_verification_gap',
1261
+ message: 'UI Acceptance Evidence must include observed UI/payload/screenshot/rendered evidence, not only navigation success.',
1262
+ });
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ if (hasAnySection(planSections, ['complexity / performance budget', 'complexity performance budget', 'performance budget', 'complexity budget'])) {
1268
+ const evidence = readAnySection(executionSections, ['complexity / performance evidence', 'performance evidence', 'complexity verify evidence']);
1269
+ if (!evidence) {
1270
+ errors.push({
1271
+ category: 'unrun_required_check',
1272
+ message: 'Plan contains Complexity / Performance Budget but execution.md is missing Complexity / Performance Evidence.',
1273
+ });
1274
+ } else {
1275
+ if (!/(duration|timing|ms|sec|seconds|rows|row count|explain|n\+1|hot path)/i.test(evidence)) {
1276
+ errors.push({
1277
+ category: 'insufficient_evidence',
1278
+ message: 'Complexity / Performance Evidence must include timing, row-count, EXPLAIN, N+1, or hot-path evidence.',
1279
+ });
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ if (hasAnySection(planSections, ['optimization strategy', 'optimization review strategy', 'optimization gate'])) {
1285
+ const strategy = readAnySection(planSections, ['optimization strategy', 'optimization review strategy', 'optimization gate']);
1286
+ const requiresEvidence = /\bO[23]\b/.test(strategy);
1287
+ const evidence = readAnySection(executionSections, ['optimization review evidence', 'optimization evidence', 'optimizer evidence']);
1288
+ if (requiresEvidence && !evidence) {
1289
+ errors.push({
1290
+ category: 'unrun_required_check',
1291
+ message: 'Plan contains O2/O3 Optimization Strategy but execution.md is missing Optimization Review Evidence.',
1292
+ });
1293
+ } else if (requiresEvidence && !/(review|finding|n\+1|repeated scan|nested|o\(n\^2\)|timing|rows|row count|benchmark|explain|defer|pass|no obvious)/i.test(evidence)) {
1294
+ errors.push({
1295
+ category: 'insufficient_evidence',
1296
+ message: 'Optimization Review Evidence must include hot-path review, anti-pattern findings, timing/rows/benchmark/EXPLAIN, or explicit deferred findings.',
1297
+ });
1298
+ }
1299
+ }
1300
+
1301
+ if (hasAnySection(planSections, ['production rollout gate', 'production rollout', 'rollout gate', 'deployment gate'])) {
1302
+ const evidence = readAnySection(executionSections, ['production rollout evidence', 'rollout evidence', 'deployment evidence']);
1303
+ if (!evidence) {
1304
+ errors.push({
1305
+ category: 'missing_evidence',
1306
+ message: 'Plan contains Production Rollout Gate but execution.md is missing Production Rollout Evidence.',
1307
+ });
1308
+ } else if (!/(rollback|env|environment|deploy|post-?deploy|logs|metrics|smoke|monitor|evidence)/i.test(evidence)) {
1309
+ errors.push({
1310
+ category: 'insufficient_evidence',
1311
+ message: 'Production Rollout Evidence must include rollback/env/deploy/post-deploy logs, metrics, smoke, or monitor evidence.',
1312
+ });
1313
+ }
1314
+ }
1315
+
1316
+ if (hasAnySection(planSections, ['source sync / provider gate', 'source sync provider gate', 'source sync gate', 'provider gate'])) {
1317
+ const evidence = readAnySection(executionSections, ['source sync / provider evidence', 'source sync evidence', 'provider evidence']);
1318
+ if (!evidence) {
1319
+ errors.push({
1320
+ category: 'missing_evidence',
1321
+ message: 'Plan contains Source Sync / Provider Gate but execution.md is missing Source Sync / Provider Evidence.',
1322
+ });
1323
+ } else if (!/(idempot|retry|pagination|rate limit|raw record|coverage|parity|count|sample|partial failure|replay|audit)/i.test(evidence)) {
1324
+ errors.push({
1325
+ category: 'insufficient_evidence',
1326
+ message: 'Source Sync / Provider Evidence must include idempotency, retry/pagination/rate-limit, raw-record, coverage/parity, sample, replay, or failure-handling evidence.',
1327
+ });
1328
+ }
1329
+ }
1330
+
1331
+ return errors;
1332
+ }
1333
+
1334
+ function hasAnySection(sections, names) {
1335
+ return Boolean(readAnySection(sections, names));
1336
+ }
1337
+
1338
+ function readAnySection(sections, names) {
1339
+ const normalizedNames = new Set(names.map(normalizeHeading));
1340
+ for (const [title, body] of sections.entries()) {
1341
+ if (normalizedNames.has(normalizeHeading(title))) {
1342
+ return body.trim();
1343
+ }
1344
+ }
1345
+ return '';
1346
+ }
1347
+
1348
+ function buildCheckerQuestions({ risk, qualityGates }) {
1349
+ const questions = [
1350
+ 'Does the active slice have clear allowed and forbidden scope?',
1351
+ 'Are the planned files/modules aligned with current repo boundaries?',
1352
+ 'Does the verification plan prove the actual user/operational outcome, not only command success?',
1353
+ ];
1354
+ if (qualityGates.uiRequired) {
1355
+ questions.push('Will the UI Acceptance Scenarios catch the concrete regressions a user would care about?');
1356
+ }
1357
+ if (qualityGates.complexityRequired) {
1358
+ questions.push('Does the plan avoid N+1 queries, repeated scans, heavy render work and unbounded backfills on hot paths?');
1359
+ }
1360
+ if (qualityGates.optimizationRequired) {
1361
+ questions.push('Does the Optimization Strategy prevent likely inefficient implementation before Execute, with a bounded O-tier budget and stop rule?');
1362
+ }
1363
+ if (qualityGates.productionRolloutRequired) {
1364
+ questions.push('Does the plan include production/runtime rollback, environment and post-deploy evidence?');
1365
+ }
1366
+ if (qualityGates.sourceSyncProviderRequired || risk.riskTriggers.includes('worker-queue')) {
1367
+ questions.push('Does the plan bound provider/worker execution by scope, window, credentials, queue ownership and coverage evidence?');
1368
+ }
1369
+ return questions;
1370
+ }
1371
+
1372
+ function normalizeRoots(value) {
1373
+ if (!Array.isArray(value)) {
1374
+ return [];
1375
+ }
1376
+ return value.map((root) => String(root).replace(/^\.\/+/, '').replace(/\/+$/, '')).filter(Boolean);
1377
+ }
1378
+
1379
+ function matchesAnyRoot(ref, roots) {
1380
+ return roots.some((root) => ref === root || ref.startsWith(`${root}/`));
1381
+ }
1382
+
1383
+ function titleFromPlaybookName(name) {
1384
+ const acronyms = new Map([
1385
+ ['ui', 'UI'],
1386
+ ]);
1387
+ return name
1388
+ .split('-')
1389
+ .map((part) => acronyms.get(part) || `${part[0].toUpperCase()}${part.slice(1)}`)
1390
+ .join(' ');
1391
+ }
1392
+
1393
+ function excerptMarkdown(content, maxChars = 1600) {
1394
+ const normalized = normalizeMarkdownBody(content || '');
1395
+ if (!normalized) {
1396
+ return '- Empty playbook.';
1397
+ }
1398
+ if (normalized.length <= maxChars) {
1399
+ return normalized;
1400
+ }
1401
+ return `${normalized.slice(0, maxChars).trimEnd()}\n\n[...truncated...]`;
1402
+ }
1403
+
1404
+ export function readJsonFile(filePath) {
1405
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1406
+ }
1407
+
1408
+ export function normalizeHeading(value) {
1409
+ return value
1410
+ .replace(/[`*_]/g, '')
1411
+ .replace(/^\s*\d+[\).\s-]+/, '')
1412
+ .replace(/\s+/g, ' ')
1413
+ .trim()
1414
+ .toLowerCase();
1415
+ }
1416
+
1417
+ export function normalizeMarkdownBody(value) {
1418
+ return normalizeLineEndings(value)
1419
+ .split('\n')
1420
+ .map((line) => line.trimEnd())
1421
+ .join('\n')
1422
+ .replace(/\n{3,}/g, '\n\n')
1423
+ .trim();
1424
+ }
1425
+
1426
+ export function normalizeLineEndings(value) {
1427
+ return value.replace(/\r\n?/g, '\n');
1428
+ }
1429
+
1430
+ export function sha256(value) {
1431
+ return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
1432
+ }
1433
+
1434
+ export function sha256Json(value) {
1435
+ return sha256(JSON.stringify(value));
1436
+ }
1437
+
1438
+ export function escapeTableCell(value) {
1439
+ return value.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
1440
+ }
1441
+
1442
+ export function relativePath(filePath) {
1443
+ return path.relative(repoRoot, filePath).split(path.sep).join('/');
1444
+ }
1445
+
1446
+ export function escapeRegExp(value) {
1447
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1448
+ }