@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,512 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ computeTaskContextInputs,
6
+ isCheckContextCurrent,
7
+ projectContext,
8
+ parseMarkdownSections,
9
+ readJsonFile,
10
+ readStatusStage,
11
+ readTaskFile,
12
+ validateExecutionEvidenceForPlan,
13
+ } from './check-context-utils.mjs';
14
+
15
+ export const TASK_MANIFEST_FILE = 'task-manifest.json';
16
+ export const ALLOWED_TASK_MODES = ['fast', 'standard', 'strict'];
17
+ export const ALLOWED_TASK_PHASES = ['plan', 'check', 'execute', 'verify', 'human_gate', 'retrospective'];
18
+ export const LOOP_REMEDIATION_THRESHOLD = 2;
19
+
20
+ export function buildTaskManifest({ taskDir, now = new Date().toISOString(), existing = readExistingManifest(taskDir) } = {}) {
21
+ const taskId = path.basename(taskDir);
22
+ const preserved = preserveManualManifestFields(existing);
23
+ const inputs = computeTaskContextInputs(taskDir);
24
+ const executionContent = readTaskFile(taskDir, 'execution.md');
25
+ const evidenceIssues = validateExecutionEvidenceForPlan({
26
+ planContent: inputs.taskArtifacts.get('plan.md'),
27
+ executionContent,
28
+ });
29
+ const checkContextState = isCheckContextCurrent(taskDir);
30
+ const checkResult = readOptionalJson(taskDir, 'check.result.json');
31
+ const verifyResult = readOptionalJson(taskDir, 'verify.result.json');
32
+ const inferredPhase = normalizePhase(existing?.phase || inferPhaseFromTask(taskDir));
33
+ const mode = normalizeTaskMode(existing?.mode || extractTaskMode(inputs.taskArtifacts.get('plan.md')) || 'standard');
34
+ const previousPhase = existing?.phase || null;
35
+ const loopDetector = updateLoopDetector({
36
+ existing: existing?.loopDetector,
37
+ results: [
38
+ { source: 'checker', value: checkResult },
39
+ { source: 'verifier', value: verifyResult },
40
+ ],
41
+ });
42
+
43
+ return {
44
+ ...preserved,
45
+ schemaVersion: 1,
46
+ taskId,
47
+ mode,
48
+ phase: inferredPhase,
49
+ gates: {
50
+ uiAcceptance: {
51
+ required: inputs.qualityGates.uiRequired,
52
+ planComplete: inputs.qualityGates.uiAcceptance.present,
53
+ evidenceRequired: hasPlanGate(inputs.taskArtifacts.get('plan.md'), ['ui acceptance scenarios', 'ui acceptance', 'ui scenarios']),
54
+ evidenceComplete: !evidenceIssues.some((issue) => issue.category === 'ui_verification_gap'),
55
+ },
56
+ complexityPerformance: {
57
+ required: inputs.qualityGates.complexityRequired,
58
+ planComplete: inputs.qualityGates.complexityBudget.present,
59
+ evidenceRequired: hasPlanGate(inputs.taskArtifacts.get('plan.md'), ['complexity / performance budget', 'complexity performance budget', 'performance budget', 'complexity budget']),
60
+ evidenceComplete: !evidenceIssues.some((issue) => ['unrun_required_check', 'insufficient_evidence'].includes(issue.category)),
61
+ },
62
+ optimizationReview: {
63
+ required: inputs.qualityGates.optimizationRequired,
64
+ tier: inputs.qualityGates.optimizationTier,
65
+ planComplete: inputs.qualityGates.optimizationStrategy.present,
66
+ evidenceRequired: hasPlanGate(inputs.taskArtifacts.get('plan.md'), ['optimization strategy', 'optimization review strategy', 'optimization gate']),
67
+ evidenceComplete: !evidenceIssues.some((issue) => issue.message.includes('Optimization Review Evidence')),
68
+ },
69
+ productionRollout: {
70
+ required: inputs.qualityGates.productionRolloutRequired,
71
+ planComplete: inputs.qualityGates.productionRollout.present,
72
+ evidenceRequired: hasPlanGate(inputs.taskArtifacts.get('plan.md'), ['production rollout gate', 'production rollout', 'rollout gate', 'deployment gate']),
73
+ evidenceComplete: !evidenceIssues.some((issue) => issue.message.includes('Production Rollout')),
74
+ },
75
+ sourceSyncProvider: {
76
+ required: inputs.qualityGates.sourceSyncProviderRequired,
77
+ planComplete: inputs.qualityGates.sourceSyncProvider.present,
78
+ evidenceRequired: hasPlanGate(inputs.taskArtifacts.get('plan.md'), ['source sync / provider gate', 'source sync provider gate', 'source sync gate', 'provider gate']),
79
+ evidenceComplete: !evidenceIssues.some((issue) => issue.message.includes('Source Sync / Provider')),
80
+ },
81
+ },
82
+ context: {
83
+ planSha: inputs.planFingerprint.planSha,
84
+ planFingerprintVersion: inputs.planFingerprint.planFingerprintVersion,
85
+ memorySha: inputs.memorySha,
86
+ riskProfile: inputs.risk.riskProfile,
87
+ riskTriggers: inputs.risk.riskTriggers,
88
+ riskWarnings: riskConfigWarnings(),
89
+ checkContextCurrent: checkContextState.current,
90
+ },
91
+ requiredEvidenceIssues: evidenceIssues,
92
+ qualitySignals: inputs.qualityGates.missingSignals,
93
+ lastCheckResult: summarizeResult(checkResult),
94
+ lastVerifyResult: summarizeResult(verifyResult),
95
+ loopDetector,
96
+ timestamps: {
97
+ createdAt: existing?.timestamps?.createdAt || now,
98
+ updatedAt: now,
99
+ phaseChangedAt: previousPhase && previousPhase !== inferredPhase
100
+ ? now
101
+ : existing?.timestamps?.phaseChangedAt || now,
102
+ },
103
+ };
104
+ }
105
+
106
+ export function validateManifest(manifest) {
107
+ const errors = [];
108
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
109
+ return ['Manifest must be a JSON object.'];
110
+ }
111
+ if (manifest.schemaVersion !== 1) {
112
+ errors.push('schemaVersion must be 1.');
113
+ }
114
+ if (!manifest.taskId) {
115
+ errors.push('taskId is required.');
116
+ }
117
+ if (!ALLOWED_TASK_MODES.includes(manifest.mode)) {
118
+ errors.push(`mode must be one of: ${ALLOWED_TASK_MODES.join(', ')}.`);
119
+ }
120
+ if (!ALLOWED_TASK_PHASES.includes(manifest.phase)) {
121
+ errors.push(`phase must be one of: ${ALLOWED_TASK_PHASES.join(', ')}.`);
122
+ }
123
+ for (const [gateName, gate] of Object.entries(manifest.gates || {})) {
124
+ if (gate.required && !gate.planComplete) {
125
+ errors.push(`${gateName} is required but planComplete=false.`);
126
+ }
127
+ }
128
+ if (manifest.loopDetector?.requiresConsolidatedRemediation && !hasConsolidatedRemediation(manifest)) {
129
+ errors.push('Loop detector requires consolidated remediation before another checker/verifier loop.');
130
+ }
131
+ return errors;
132
+ }
133
+
134
+ export function preflightTask({ taskDir, targetPhase }) {
135
+ const manifest = buildTaskManifest({ taskDir });
136
+ const warnings = manifest.context.riskWarnings || [];
137
+ const issues = validateManifest(manifest).map((message) => ({
138
+ category: 'manifest_validation',
139
+ message,
140
+ }));
141
+
142
+ if ((targetPhase === 'execute' || targetPhase === 'verify' || targetPhase === 'human_gate') && !manifest.context.checkContextCurrent) {
143
+ issues.push({
144
+ category: 'stale_check_context',
145
+ message: 'check-context.json/checker-context-pack.md is stale or missing. Run agent:build-check-context or agent:run-check before this transition.',
146
+ });
147
+ }
148
+ if ((targetPhase === 'execute' || targetPhase === 'verify' || targetPhase === 'human_gate') && manifest.qualitySignals.length > 0) {
149
+ for (const signal of manifest.qualitySignals) {
150
+ issues.push({
151
+ category: 'plan_quality_gate',
152
+ message: signal,
153
+ });
154
+ }
155
+ }
156
+ if ((targetPhase === 'verify' || targetPhase === 'human_gate') && manifest.requiredEvidenceIssues.length > 0) {
157
+ for (const issue of manifest.requiredEvidenceIssues) {
158
+ issues.push(issue);
159
+ }
160
+ }
161
+ if (targetPhase === 'verify' || targetPhase === 'human_gate') {
162
+ issues.push(...inspectExecutionLedgerEvidence(taskDir));
163
+ }
164
+ if ((targetPhase === 'execute' || targetPhase === 'verify' || targetPhase === 'human_gate') && manifest.loopDetector.requiresConsolidatedRemediation) {
165
+ issues.push({
166
+ category: 'loop_detector',
167
+ message: 'Repeated checker/verifier return reason detected. Add consolidated remediation before continuing.',
168
+ });
169
+ }
170
+
171
+ return {
172
+ ok: issues.length === 0,
173
+ targetPhase,
174
+ manifest,
175
+ issues,
176
+ warnings,
177
+ };
178
+ }
179
+
180
+ export function transitionTaskManifest({ taskDir, targetPhase, now = new Date().toISOString() }) {
181
+ const normalizedPhase = normalizePhase(targetPhase);
182
+ const preflight = preflightTask({ taskDir, targetPhase: normalizedPhase });
183
+ if (!preflight.ok) {
184
+ return {
185
+ ok: false,
186
+ manifest: preflight.manifest,
187
+ issues: preflight.issues,
188
+ warnings: preflight.warnings,
189
+ };
190
+ }
191
+ const manifest = {
192
+ ...preflight.manifest,
193
+ phase: normalizedPhase,
194
+ timestamps: {
195
+ ...preflight.manifest.timestamps,
196
+ updatedAt: now,
197
+ phaseChangedAt: preflight.manifest.phase === normalizedPhase
198
+ ? preflight.manifest.timestamps.phaseChangedAt
199
+ : now,
200
+ },
201
+ };
202
+ return {
203
+ ok: true,
204
+ manifest,
205
+ issues: [],
206
+ warnings: preflight.warnings,
207
+ };
208
+ }
209
+
210
+ function riskConfigWarnings() {
211
+ const risk = projectContext.risk || {};
212
+ const warnings = [];
213
+ if (!Array.isArray(risk.uiRoots) || risk.uiRoots.length === 0) {
214
+ warnings.push('risk.uiRoots is empty; path-based UI detection is disabled until the project config is filled.');
215
+ }
216
+ if (!Array.isArray(risk.backendRoots) || risk.backendRoots.length === 0) {
217
+ warnings.push('risk.backendRoots is empty; path-based user-visible API detection is limited to text signals.');
218
+ }
219
+ if (!Array.isArray(risk.workerRoots) || risk.workerRoots.length === 0) {
220
+ warnings.push('risk.workerRoots is empty; path-based worker detection is limited to text signals.');
221
+ }
222
+ return warnings;
223
+ }
224
+
225
+ export function readExistingManifest(taskDir) {
226
+ const filePath = path.join(taskDir, TASK_MANIFEST_FILE);
227
+ if (!fs.existsSync(filePath)) {
228
+ return null;
229
+ }
230
+ return readJsonFile(filePath);
231
+ }
232
+
233
+ export function writeTaskManifest(taskDir, manifest) {
234
+ fs.writeFileSync(path.join(taskDir, TASK_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
235
+ }
236
+
237
+ export function recordLlmInputUsage({ taskDir, stage, packMeta, attempts = [], rerunCount = 0, now = new Date().toISOString() }) {
238
+ const existing = readExistingManifest(taskDir);
239
+ if (!existing) {
240
+ return null;
241
+ }
242
+ const normalizedAttempts = attempts.length > 0
243
+ ? attempts
244
+ : [{
245
+ mode: packMeta.mode,
246
+ estimatedTokens: packMeta.estimatedTokens,
247
+ bytes: packMeta.bytes,
248
+ capTokens: packMeta.capTokens,
249
+ overCap: packMeta.overCap,
250
+ outcome: 'recorded',
251
+ }];
252
+ const manifest = {
253
+ ...existing,
254
+ llmInput: {
255
+ ...(existing.llmInput || {}),
256
+ [stage]: {
257
+ selectedMode: packMeta.mode,
258
+ estimatedTokens: packMeta.estimatedTokens,
259
+ bytes: packMeta.bytes,
260
+ capTokens: packMeta.capTokens,
261
+ compactedArtifacts: packMeta.compactedArtifacts,
262
+ overCap: packMeta.overCap,
263
+ rerunCount,
264
+ attempts: normalizedAttempts,
265
+ cumulativeEstimatedTokens: normalizedAttempts.reduce((sum, attempt) => sum + (attempt.estimatedTokens || 0), 0),
266
+ updatedAt: now,
267
+ },
268
+ },
269
+ timestamps: {
270
+ ...(existing.timestamps || {}),
271
+ updatedAt: now,
272
+ },
273
+ };
274
+ writeTaskManifest(taskDir, manifest);
275
+ return manifest;
276
+ }
277
+
278
+ export function extractTaskMode(planContent) {
279
+ const sections = parseMarkdownSections(planContent || '');
280
+ const raw = sections.get('Task Mode') || sections.get('Speed mode') || sections.get('Mode') || '';
281
+ const text = `${raw}\n${planContent || ''}`.toLowerCase();
282
+ if (/\bfast\b/.test(text)) {
283
+ return 'fast';
284
+ }
285
+ if (/\bstrict\b/.test(text)) {
286
+ return 'strict';
287
+ }
288
+ if (/\bstandard\b/.test(text)) {
289
+ return 'standard';
290
+ }
291
+ return null;
292
+ }
293
+
294
+ export function normalizeTaskMode(value) {
295
+ const normalized = String(value || '').trim().toLowerCase();
296
+ if (!ALLOWED_TASK_MODES.includes(normalized)) {
297
+ return 'standard';
298
+ }
299
+ return normalized;
300
+ }
301
+
302
+ export function normalizePhase(value) {
303
+ const normalized = String(value || '').trim().toLowerCase().replace(/[-\s]+/g, '_');
304
+ const aliases = {
305
+ plan_check: 'check',
306
+ checked: 'check',
307
+ execution: 'execute',
308
+ external_verify: 'verify',
309
+ verification: 'verify',
310
+ human: 'human_gate',
311
+ human_gate_review: 'human_gate',
312
+ retrospective_ready: 'retrospective',
313
+ };
314
+ const phase = aliases[normalized] || normalized;
315
+ if (!ALLOWED_TASK_PHASES.includes(phase)) {
316
+ return 'plan';
317
+ }
318
+ return phase;
319
+ }
320
+
321
+ export function updateLoopDetector({ existing = null, results = [] } = {}) {
322
+ const seenResultKeys = new Set(existing?.seenResultKeys || []);
323
+ const reasons = { ...(existing?.reasons || {}) };
324
+
325
+ for (const { source, value } of results) {
326
+ if (!isReturnResult(value)) {
327
+ continue;
328
+ }
329
+ const resultKey = buildResultKey(source, value);
330
+ if (seenResultKeys.has(resultKey)) {
331
+ continue;
332
+ }
333
+ seenResultKeys.add(resultKey);
334
+ for (const reason of extractResultReasons(value)) {
335
+ const hash = hashReason(reason);
336
+ const previous = reasons[hash] || {
337
+ hash,
338
+ normalizedReason: normalizeReason(reason),
339
+ count: 0,
340
+ sources: [],
341
+ };
342
+ reasons[hash] = {
343
+ ...previous,
344
+ count: previous.count + 1,
345
+ sources: Array.from(new Set([...previous.sources, source])).sort(),
346
+ };
347
+ }
348
+ }
349
+
350
+ const repeatedReasons = Object.values(reasons)
351
+ .filter((reason) => reason.count >= LOOP_REMEDIATION_THRESHOLD)
352
+ .sort((left, right) => right.count - left.count || left.hash.localeCompare(right.hash));
353
+
354
+ return {
355
+ threshold: LOOP_REMEDIATION_THRESHOLD,
356
+ requiresConsolidatedRemediation: repeatedReasons.length > 0,
357
+ repeatedReasons,
358
+ reasons,
359
+ seenResultKeys: Array.from(seenResultKeys).sort(),
360
+ };
361
+ }
362
+
363
+ export function normalizeReason(value) {
364
+ return String(value || '')
365
+ .toLowerCase()
366
+ .replace(/`[^`]+`/g, '`path`')
367
+ .replace(/\b[a-z0-9._/-]+\.(ts|tsx|js|mjs|json|md)\b/g, 'path')
368
+ .replace(/\b[fv]-\d{3}\b/g, 'id')
369
+ .replace(/\b\d+\b/g, 'n')
370
+ .replace(/\s+/g, ' ')
371
+ .trim()
372
+ .slice(0, 240);
373
+ }
374
+
375
+ function inferPhaseFromTask(taskDir) {
376
+ const verifyResult = readOptionalJson(taskDir, 'verify.result.json');
377
+ if (verifyResult?.readyForRetrospective || verifyResult?.verdict === 'pass' || verifyResult?.verdict === 'pass_with_notes') {
378
+ return 'human_gate';
379
+ }
380
+ if (fs.existsSync(path.join(taskDir, 'execution.md'))) {
381
+ return 'verify';
382
+ }
383
+ const checkResult = readOptionalJson(taskDir, 'check.result.json');
384
+ if (checkResult?.readyForHumanGate || checkResult?.verdict === 'ready_for_human_gate') {
385
+ return 'execute';
386
+ }
387
+ if (fs.existsSync(path.join(taskDir, 'check-context.json'))) {
388
+ return 'check';
389
+ }
390
+ const statusStage = readStatusStage(taskDir);
391
+ return statusStage ? normalizePhase(statusStage) : 'plan';
392
+ }
393
+
394
+ function readOptionalJson(taskDir, fileName) {
395
+ const filePath = path.join(taskDir, fileName);
396
+ if (!fs.existsSync(filePath)) {
397
+ return null;
398
+ }
399
+ return readJsonFile(filePath);
400
+ }
401
+
402
+ function hasPlanGate(planContent, names) {
403
+ const sections = parseMarkdownSections(planContent || '');
404
+ const normalizedNames = new Set(names.map((name) => name.toLowerCase()));
405
+ for (const title of sections.keys()) {
406
+ if (normalizedNames.has(title.toLowerCase())) {
407
+ return true;
408
+ }
409
+ }
410
+ return false;
411
+ }
412
+
413
+ function summarizeResult(result) {
414
+ if (!result) {
415
+ return null;
416
+ }
417
+ return {
418
+ verdict: result.verdict || null,
419
+ createdAt: result.createdAt || null,
420
+ blockingFindings: result.blockingFindings ?? result.counts?.blockingFindings ?? null,
421
+ nonBlockingFindings: result.nonBlockingFindings ?? result.counts?.nonBlockingFindings ?? null,
422
+ questions: result.humanQuestions ?? result.counts?.questions ?? null,
423
+ };
424
+ }
425
+
426
+ function inspectExecutionLedgerEvidence(taskDir) {
427
+ if (!fs.existsSync(path.join(taskDir, 'execution.md'))) {
428
+ return [];
429
+ }
430
+ const ledger = readOptionalJson(taskDir, 'execution-ledger.json');
431
+ if (!ledger) {
432
+ return [{
433
+ category: 'execution_ledger_missing',
434
+ message: 'execution-ledger.json is missing. Run agent:build-execution-ledger before Verify/Human Gate.',
435
+ }];
436
+ }
437
+
438
+ const execution = readTaskFile(taskDir, 'execution.md');
439
+ const changedFiles = Array.isArray(ledger.git?.changedFiles) ? ledger.git.changedFiles : [];
440
+ const missingFiles = changedFiles
441
+ .filter((file) => file.isTaskArtifact || file.isOpsFrameworkFile || !isUnrelatedDirtyFile(ledger, file))
442
+ .map((file) => file.path)
443
+ .filter((filePath) => filePath && !execution.includes(filePath));
444
+
445
+ if (missingFiles.length === 0) {
446
+ return [];
447
+ }
448
+ return [{
449
+ category: 'dirty_evidence_mismatch',
450
+ message: `execution.md does not mention changed files from execution-ledger.json: ${missingFiles.slice(0, 10).join(', ')}${missingFiles.length > 10 ? ', ...' : ''}.`,
451
+ }];
452
+ }
453
+
454
+ function isUnrelatedDirtyFile(ledger, file) {
455
+ const unrelated = Array.isArray(ledger.git?.unrelatedDirtyFiles) ? ledger.git.unrelatedDirtyFiles : [];
456
+ return unrelated.some((candidate) => candidate.path === file.path);
457
+ }
458
+
459
+ function isReturnResult(result) {
460
+ return Boolean(result && ['return_to_plan', 'return_to_execute', 'checker_failed', 'verifier_failed'].includes(result.verdict));
461
+ }
462
+
463
+ function buildResultKey(source, result) {
464
+ return [
465
+ source,
466
+ result.verdict || 'unknown',
467
+ result.verifierRunId || result.checkerRunId || result.createdAt || 'no-run-id',
468
+ hashReason(JSON.stringify(extractResultReasons(result))),
469
+ ].join(':');
470
+ }
471
+
472
+ function extractResultReasons(result) {
473
+ const findings = Array.isArray(result.findings) ? result.findings : [];
474
+ const reasons = findings
475
+ .map((finding) => finding.claim || finding.message || finding.expectedCorrection || '')
476
+ .filter(Boolean);
477
+ if (reasons.length > 0) {
478
+ return reasons;
479
+ }
480
+ return [result.failureReason || result.verdict || 'unknown return reason'];
481
+ }
482
+
483
+ function hashReason(value) {
484
+ return crypto.createHash('sha256').update(normalizeReason(value)).digest('hex').slice(0, 16);
485
+ }
486
+
487
+ function hasConsolidatedRemediation(manifest) {
488
+ return Boolean(manifest.consolidatedRemediationAccepted);
489
+ }
490
+
491
+ function preserveManualManifestFields(existing) {
492
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
493
+ return {};
494
+ }
495
+ const generatedKeys = new Set([
496
+ 'schemaVersion',
497
+ 'taskId',
498
+ 'mode',
499
+ 'phase',
500
+ 'gates',
501
+ 'context',
502
+ 'requiredEvidenceIssues',
503
+ 'qualitySignals',
504
+ 'lastCheckResult',
505
+ 'lastVerifyResult',
506
+ 'loopDetector',
507
+ 'timestamps',
508
+ ]);
509
+ return Object.fromEntries(
510
+ Object.entries(existing).filter(([key]) => !generatedKeys.has(key)),
511
+ );
512
+ }