@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,658 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ getFlag,
7
+ parseCliArgs,
8
+ projectContext,
9
+ } from './lib/check-context-utils.mjs';
10
+
11
+ export const CANDIDATES_FILE = 'learning-candidates.md';
12
+ export const INDEX_FILE = 'learning-index.json';
13
+ export const APPROVED_FILE = 'approved-learning.md';
14
+ export const REPORT_FILE = 'learning-report.md';
15
+ export const REVIEW_FILE = 'learning-review.md';
16
+ const PROJECT_PLAYBOOK_TARGET_PREFIX = 'project-playbook/';
17
+ const MEMORY_TARGET_PREFIX = 'memory/';
18
+
19
+ export function main() {
20
+ const command = process.argv[2];
21
+ const args = parseCliArgs(process.argv.slice(3));
22
+ try {
23
+ if (command === 'memory-candidates') {
24
+ writeMemoryCandidates({ limit: Number(getFlag(args, 'limit', 20)) });
25
+ return;
26
+ }
27
+ if (command === 'learning-index') {
28
+ writeLearningIndex();
29
+ return;
30
+ }
31
+ if (command === 'learning-review') {
32
+ writeLearningReview();
33
+ return;
34
+ }
35
+ if (command === 'update-memory') {
36
+ updateMemory({ applyApproved: args.flags.has('apply-approved') });
37
+ return;
38
+ }
39
+ if (command === 'learning-audit') {
40
+ auditLearning();
41
+ return;
42
+ }
43
+ if (command === 'learning-report') {
44
+ writeLearningReport();
45
+ return;
46
+ }
47
+ fail('Usage: ops-agent memory-candidates|learning-index|learning-review|update-memory|learning-audit|learning-report');
48
+ } catch (error) {
49
+ fail(error.message);
50
+ }
51
+ }
52
+
53
+ export function writeMemoryCandidates({ limit }) {
54
+ ensureMemoryRoot();
55
+ const candidates = collectLearningCandidates({ limit });
56
+ const content = [
57
+ '# Learning Candidates',
58
+ '',
59
+ 'Generated candidates from retrospective/check/verify/task artifacts.',
60
+ '',
61
+ 'Human must promote, defer or reject candidates through `learning-index.json` before memory/playbooks are updated.',
62
+ '',
63
+ ...candidates.map((candidate, index) => [
64
+ `## LC-${String(index + 1).padStart(3, '0')}`,
65
+ '',
66
+ `- Source: \`${candidate.source}\``,
67
+ `- Source artifact: \`${candidate.sourceArtifact}\``,
68
+ `- Reason hash: \`${candidate.reasonHash}\``,
69
+ `- Kind: \`${candidate.kind}\``,
70
+ `- Learning layer: \`${candidate.learningLayer}\``,
71
+ `- Confidence: \`${candidate.confidence}\``,
72
+ `- Problem: ${candidate.problem}`,
73
+ `- Lesson: ${candidate.lesson}`,
74
+ `- Repeat risk: ${candidate.repeatRisk}`,
75
+ `- Candidate: ${candidate.text}`,
76
+ `- Proposed wording: ${candidate.proposedWording}`,
77
+ `- Suggested target: \`${candidate.suggestedTarget}\``,
78
+ '',
79
+ ].join('\n')),
80
+ ].join('\n');
81
+ fs.writeFileSync(path.join(projectContext.memoryRoot, CANDIDATES_FILE), content.endsWith('\n') ? content : `${content}\n`);
82
+ console.log(`Learning candidates written: ${path.join(projectContext.memoryRoot, CANDIDATES_FILE)}`);
83
+ console.log(`- candidates: ${candidates.length}`);
84
+ }
85
+
86
+ export function writeLearningIndex() {
87
+ ensureMemoryRoot();
88
+ const candidatesPath = path.join(projectContext.memoryRoot, CANDIDATES_FILE);
89
+ if (!fs.existsSync(candidatesPath)) {
90
+ throw new Error(`Missing ${CANDIDATES_FILE}. Run ops-agent memory-candidates first.`);
91
+ }
92
+ const entries = parseLearningCandidatesMarkdown(fs.readFileSync(candidatesPath, 'utf8'))
93
+ .map((candidate) => ({
94
+ id: candidate.id,
95
+ source: candidate.source,
96
+ sourceArtifact: candidate.sourceArtifact,
97
+ reasonHash: candidate.reasonHash,
98
+ kind: candidate.kind,
99
+ learningLayer: candidate.learningLayer,
100
+ confidence: candidate.confidence,
101
+ problem: candidate.problem,
102
+ lesson: candidate.lesson,
103
+ repeatRisk: candidate.repeatRisk,
104
+ candidate: candidate.text,
105
+ proposedWording: candidate.proposedWording,
106
+ decision: 'pending',
107
+ target: candidate.suggestedTarget,
108
+ notes: '',
109
+ }));
110
+ const index = {
111
+ schemaVersion: 1,
112
+ generatedAt: new Date().toISOString(),
113
+ instructions: 'Human edits decision to promote/defer/reject. update-memory only applies decision=promote.',
114
+ entries,
115
+ };
116
+ fs.writeFileSync(path.join(projectContext.memoryRoot, INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
117
+ console.log(`Learning index written: ${path.join(projectContext.memoryRoot, INDEX_FILE)}`);
118
+ console.log(`- entries: ${entries.length}`);
119
+ }
120
+
121
+ export function writeLearningReview({ memoryRoot = projectContext.memoryRoot, projectRoot = projectContext.projectRoot } = {}) {
122
+ fs.mkdirSync(memoryRoot, { recursive: true });
123
+ const indexPath = path.join(memoryRoot, INDEX_FILE);
124
+ if (!fs.existsSync(indexPath)) {
125
+ throw new Error(`Missing ${INDEX_FILE}. Run ops-agent learning-index first.`);
126
+ }
127
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
128
+ const entries = index.entries || [];
129
+ const pending = entries.filter((entry) => !entry.decision || entry.decision === 'pending');
130
+ const content = [
131
+ '# Learning Review',
132
+ '',
133
+ `Generated at: \`${new Date().toISOString()}\``,
134
+ '',
135
+ '## Human Approval Contract',
136
+ '',
137
+ 'The framework proposes learning candidates, but a human decides what becomes durable memory or playbook guidance.',
138
+ '',
139
+ 'Allowed decisions in `learning-index.json`:',
140
+ '',
141
+ '- `promote`: apply this entry with `ops-agent update-memory --apply-approved`.',
142
+ '- `defer`: keep for later review; do not apply now.',
143
+ '- `reject`: discard as noise, wrong, duplicated or too task-specific.',
144
+ '- `rewrite`: idea is useful, but wording/target must be edited before promotion.',
145
+ '',
146
+ 'For `rewrite`, edit `proposedWording`, `target` and `notes`, then change `decision` to `promote` when ready.',
147
+ '',
148
+ 'Shared playbook candidates are manual-review only and require a separate shared-framework task.',
149
+ '',
150
+ '## Review Summary',
151
+ '',
152
+ `- Learning index: \`${relativeProjectPath(indexPath, projectRoot)}\``,
153
+ `- Total entries: ${entries.length}`,
154
+ `- Pending entries: ${pending.length}`,
155
+ `- Already promoted: ${entries.filter((entry) => entry.decision === 'promote').length}`,
156
+ `- Deferred: ${entries.filter((entry) => entry.decision === 'defer').length}`,
157
+ `- Rejected: ${entries.filter((entry) => entry.decision === 'reject').length}`,
158
+ `- Rewrite needed: ${entries.filter((entry) => entry.decision === 'rewrite').length}`,
159
+ '',
160
+ '## Decision Checklist',
161
+ '',
162
+ '- Promote only lessons that are true beyond one accidental moment.',
163
+ '- Prefer project playbooks for concrete routes, commands, services, environments and provider quirks.',
164
+ '- Prefer project memory for durable architecture/product/process facts.',
165
+ '- Prefer shared playbook manual review only for generic cross-project rules.',
166
+ '- Reject raw logs, duplicate JSON fields, vague reminders and already-covered rules.',
167
+ '',
168
+ '## Pending Decisions',
169
+ '',
170
+ ...renderReviewEntries(pending),
171
+ '',
172
+ ].join('\n');
173
+ fs.writeFileSync(path.join(memoryRoot, REVIEW_FILE), content.endsWith('\n') ? content : `${content}\n`);
174
+ console.log(`Learning review written: ${path.join(memoryRoot, REVIEW_FILE)}`);
175
+ console.log(`- pending: ${pending.length}`);
176
+ }
177
+
178
+ export function updateMemory({ applyApproved }) {
179
+ ensureMemoryRoot();
180
+ if (!applyApproved) {
181
+ console.log('No changes applied. Re-run with --apply-approved after human edits learning-index.json.');
182
+ return;
183
+ }
184
+ const indexPath = path.join(projectContext.memoryRoot, INDEX_FILE);
185
+ if (!fs.existsSync(indexPath)) {
186
+ throw new Error(`Missing ${INDEX_FILE}. Run ops-agent learning-index first.`);
187
+ }
188
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
189
+ const approved = (index.entries || []).filter((entry) => entry.decision === 'promote');
190
+ if (!approved.length) {
191
+ console.log('No approved learning entries to apply.');
192
+ return;
193
+ }
194
+ const byTarget = groupApprovedByTarget(approved);
195
+ for (const [target, entries] of byTarget.entries()) {
196
+ const targetPath = resolveApprovedTarget(target);
197
+ const previous = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf8').trimEnd() : buildInitialTargetContent(target);
198
+ const addition = entries.map((entry) => formatApprovedEntry(entry)).join('\n');
199
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
200
+ fs.writeFileSync(targetPath, `${previous}\n${addition}\n`);
201
+ console.log(`Approved learning appended: ${targetPath}`);
202
+ }
203
+ console.log(`- applied: ${approved.length}`);
204
+ writeLearningReport();
205
+ }
206
+
207
+ export function auditLearning() {
208
+ ensureMemoryRoot();
209
+ const indexPath = path.join(projectContext.memoryRoot, INDEX_FILE);
210
+ if (!fs.existsSync(indexPath)) {
211
+ console.log('Learning audit: no learning-index.json found.');
212
+ return;
213
+ }
214
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
215
+ const counts = {};
216
+ for (const entry of index.entries || []) {
217
+ counts[entry.decision] = (counts[entry.decision] || 0) + 1;
218
+ }
219
+ console.log('Learning audit');
220
+ console.log(`- total: ${(index.entries || []).length}`);
221
+ for (const [decision, count] of Object.entries(counts)) {
222
+ console.log(`- ${decision}: ${count}`);
223
+ }
224
+ console.log(`- report: ${path.join(projectContext.memoryRoot, REPORT_FILE)}`);
225
+ }
226
+
227
+ export function writeLearningReport({ memoryRoot = projectContext.memoryRoot, projectRoot = projectContext.projectRoot } = {}) {
228
+ fs.mkdirSync(memoryRoot, { recursive: true });
229
+ const indexPath = path.join(memoryRoot, INDEX_FILE);
230
+ const candidatesPath = path.join(memoryRoot, CANDIDATES_FILE);
231
+ const index = fs.existsSync(indexPath)
232
+ ? JSON.parse(fs.readFileSync(indexPath, 'utf8'))
233
+ : { entries: [] };
234
+ const entries = index.entries || [];
235
+ const counts = countBy(entries, (entry) => entry.decision || 'pending');
236
+ const targetCounts = countBy(entries, (entry) => entry.target || 'memory/approved-learning.md');
237
+ const promoted = entries.filter((entry) => entry.decision === 'promote');
238
+ const pending = entries.filter((entry) => !entry.decision || entry.decision === 'pending');
239
+ const content = [
240
+ '# Learning Report',
241
+ '',
242
+ `Generated at: \`${new Date().toISOString()}\``,
243
+ '',
244
+ '## Summary',
245
+ '',
246
+ `- Candidates file: \`${relativeProjectPath(candidatesPath, projectRoot)}\``,
247
+ `- Learning index: \`${relativeProjectPath(indexPath, projectRoot)}\``,
248
+ `- Learning review: \`${relativeProjectPath(path.join(memoryRoot, REVIEW_FILE), projectRoot)}\``,
249
+ `- Total entries: ${entries.length}`,
250
+ `- Pending human decisions: ${pending.length}`,
251
+ `- Promoted entries: ${promoted.length}`,
252
+ `- Deferred entries: ${counts.defer || 0}`,
253
+ `- Rejected entries: ${counts.reject || 0}`,
254
+ '',
255
+ '## Decisions By Target',
256
+ '',
257
+ ...Object.entries(targetCounts).map(([target, count]) => `- \`${target}\`: ${count}`),
258
+ ...(Object.keys(targetCounts).length ? [] : ['- No learning targets yet.']),
259
+ '',
260
+ '## Promoted Learning',
261
+ '',
262
+ ...renderReportEntries(promoted),
263
+ '',
264
+ '## Pending Review',
265
+ '',
266
+ ...renderReportEntries(pending),
267
+ '',
268
+ '## Closeout Visibility',
269
+ '',
270
+ '- Run `ops-agent memory-candidates` after retrospective/check/verify feedback is complete.',
271
+ '- Run `ops-agent learning-index` to create human-reviewable decisions.',
272
+ '- Run `ops-agent learning-review` to create a human-readable approval pack.',
273
+ '- Edit `learning-index.json`: set each useful entry to `promote`, `defer` or `reject`.',
274
+ '- Run `ops-agent update-memory --apply-approved` to write approved entries into project memory or project playbooks.',
275
+ '- Re-run `ops-agent learning-report` and show this report during closeout.',
276
+ '',
277
+ ].join('\n');
278
+ fs.writeFileSync(path.join(memoryRoot, REPORT_FILE), content.endsWith('\n') ? content : `${content}\n`);
279
+ console.log(`Learning report written: ${path.join(memoryRoot, REPORT_FILE)}`);
280
+ }
281
+
282
+ export function collectLearningCandidates({ limit, tasksRoot = projectContext.tasksRoot } = {}) {
283
+ const candidates = [];
284
+ const seen = new Set();
285
+ if (!fs.existsSync(tasksRoot)) {
286
+ return candidates;
287
+ }
288
+ const taskDirs = fs.readdirSync(tasksRoot)
289
+ .filter((name) => name.startsWith('TASK-'))
290
+ .sort()
291
+ .reverse();
292
+ for (const taskName of taskDirs) {
293
+ for (const fileName of ['retrospective.md', 'feedback.md', 'execution-feedback.md', 'verify.md', 'check.md']) {
294
+ const sourceArtifact = `${taskName}/${fileName}`;
295
+ const filePath = path.join(tasksRoot, taskName, fileName);
296
+ if (!fs.existsSync(filePath)) {
297
+ continue;
298
+ }
299
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
300
+ for (const line of lines) {
301
+ const normalized = normalizeCandidateText(line);
302
+ if (!isLearningCandidateLine(normalized)) {
303
+ continue;
304
+ }
305
+ if (normalized.length < 20) {
306
+ continue;
307
+ }
308
+ const reasonHash = learningReasonHash(normalized);
309
+ if (seen.has(reasonHash)) {
310
+ continue;
311
+ }
312
+ seen.add(reasonHash);
313
+ candidates.push(buildLearningCard({
314
+ source: sourceArtifact,
315
+ sourceArtifact,
316
+ reasonHash,
317
+ text: normalized,
318
+ }));
319
+ if (candidates.length >= limit) {
320
+ return candidates;
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return candidates;
326
+ }
327
+
328
+ export function parseLearningCandidatesMarkdown(content) {
329
+ const entries = [];
330
+ const sections = content.split(/^##\s+/m).slice(1);
331
+ for (const section of sections) {
332
+ const [rawId, ...bodyLines] = section.split('\n');
333
+ const body = bodyLines.join('\n');
334
+ const id = rawId.trim();
335
+ const source = readMarkdownField(body, 'Source');
336
+ const sourceArtifact = readMarkdownField(body, 'Source artifact') || source;
337
+ const reasonHash = readMarkdownField(body, 'Reason hash') || learningReasonHash(readMarkdownField(body, 'Candidate'));
338
+ const kind = readMarkdownField(body, 'Kind') || classifyLearningKind(readMarkdownField(body, 'Candidate'));
339
+ const learningLayer = readMarkdownField(body, 'Learning layer') || learningLayerForKind(kind);
340
+ const confidence = readMarkdownField(body, 'Confidence') || confidenceForCandidate(readMarkdownField(body, 'Candidate'));
341
+ const problem = readMarkdownField(body, 'Problem') || summarizeProblem(readMarkdownField(body, 'Candidate'));
342
+ const lesson = readMarkdownField(body, 'Lesson') || summarizeLesson(readMarkdownField(body, 'Candidate'));
343
+ const repeatRisk = readMarkdownField(body, 'Repeat risk') || summarizeRepeatRisk(readMarkdownField(body, 'Candidate'));
344
+ const text = readMarkdownField(body, 'Candidate');
345
+ const proposedWording = readMarkdownField(body, 'Proposed wording') || proposeLearningWording(text, kind);
346
+ const suggestedTarget = readMarkdownField(body, 'Suggested target') || suggestLearningTarget(text);
347
+ if (!id || !source || !text) {
348
+ continue;
349
+ }
350
+ entries.push({
351
+ id,
352
+ source,
353
+ sourceArtifact,
354
+ reasonHash,
355
+ kind,
356
+ learningLayer,
357
+ confidence,
358
+ problem,
359
+ lesson,
360
+ repeatRisk,
361
+ text,
362
+ proposedWording,
363
+ suggestedTarget,
364
+ });
365
+ }
366
+ return entries;
367
+ }
368
+
369
+ export function buildLearningCard({ source, sourceArtifact, reasonHash, text }) {
370
+ const kind = classifyLearningKind(text);
371
+ return {
372
+ source,
373
+ sourceArtifact,
374
+ reasonHash,
375
+ kind,
376
+ learningLayer: learningLayerForKind(kind),
377
+ confidence: confidenceForCandidate(text),
378
+ problem: summarizeProblem(text),
379
+ lesson: summarizeLesson(text),
380
+ repeatRisk: summarizeRepeatRisk(text),
381
+ text,
382
+ proposedWording: proposeLearningWording(text, kind),
383
+ suggestedTarget: suggestLearningTarget(text),
384
+ };
385
+ }
386
+
387
+ export function normalizeCandidateText(line) {
388
+ return line
389
+ .replace(/^[-*]\s*/, '')
390
+ .replace(/\s+/g, ' ')
391
+ .trim();
392
+ }
393
+
394
+ export function learningReasonHash(text) {
395
+ return crypto.createHash('sha256').update(text.toLowerCase()).digest('hex').slice(0, 16);
396
+ }
397
+
398
+ function isLearningCandidateLine(text) {
399
+ if (isLearningMetadataLine(text)) {
400
+ return false;
401
+ }
402
+ return /(memory|playbook|learning|повтор|вывод|стоит|нужно|should|must|route|provider|deploy|rollout|acceptance|gate)/i.test(text);
403
+ }
404
+
405
+ function isLearningMetadataLine(text) {
406
+ return /^(classification|rationale|requires new human gate|requires research refresh|requires plan refresh|supervisor decision|created at|event id|source|source artifact|reason hash|kind|learning layer|confidence|problem|lesson|repeat risk|suggested target):/i.test(text)
407
+ || /^"[^"]+"\s*:/i.test(text)
408
+ || /^"?classification"?\s*:/i.test(text)
409
+ || /^"?requires[A-Za-z\s]*"?\s*:/i.test(text);
410
+ }
411
+
412
+ function classifyLearningKind(text) {
413
+ if (/(shared|cross-project|reusable|generic|across projects|общ(ий|ая)|переиспольз)/i.test(text)) {
414
+ return 'shared-playbook-candidate';
415
+ }
416
+ if (/(playbook|route|scenario|acceptance|rollout|deploy|provider|source sync|source-sync|worker|backfill|replay)/i.test(text)) {
417
+ return 'project-playbook-candidate';
418
+ }
419
+ return 'project-memory-candidate';
420
+ }
421
+
422
+ function learningLayerForKind(kind) {
423
+ if (kind === 'shared-playbook-candidate') {
424
+ return 'shared-playbook-manual-review';
425
+ }
426
+ if (kind === 'project-playbook-candidate') {
427
+ return 'project-playbook-overlay';
428
+ }
429
+ return 'project-memory';
430
+ }
431
+
432
+ function confidenceForCandidate(text) {
433
+ if (/(must|обязан|нельзя|hard gate|block|regression|повтор|again|снова)/i.test(text)) {
434
+ return 'high';
435
+ }
436
+ if (/(should|нужно|стоит|лучше|важно|missing|risk)/i.test(text)) {
437
+ return 'medium';
438
+ }
439
+ return 'low';
440
+ }
441
+
442
+ function summarizeProblem(text) {
443
+ if (/(повтор|again|снова|regression)/i.test(text)) {
444
+ return 'A repeated issue or regression risk was observed in task artifacts.';
445
+ }
446
+ if (/(feedback|фидбэк|memory|памят|learning|ретроспектив)/i.test(text)) {
447
+ return 'A process observation should be preserved so future tasks do not lose useful context.';
448
+ }
449
+ if (/(playbook|acceptance|route|provider|rollout|deploy|worker|gate)/i.test(text)) {
450
+ return 'A reusable procedure or checklist gap was identified.';
451
+ }
452
+ return 'A task artifact contains a potentially reusable lesson.';
453
+ }
454
+
455
+ function summarizeLesson(text) {
456
+ if (/(optimization|оптим|n\+1|repeated scan|o\(n\^2\)|complexity)/i.test(text)) {
457
+ return 'Future plans should include a bounded optimization review before implementation on similar hot paths.';
458
+ }
459
+ if (/(feedback|фидбэк)/i.test(text)) {
460
+ return 'User feedback must be captured as a first-class learning source at any task stage.';
461
+ }
462
+ if (/(ui|route|acceptance|browser|visible)/i.test(text)) {
463
+ return 'Future UI-visible work should carry concrete acceptance scenarios and project-specific route coverage.';
464
+ }
465
+ if (/(provider|source sync|source-sync|ingestion|pagination|rate limit|oauth)/i.test(text)) {
466
+ return 'Future provider/source-sync work should reuse explicit idempotency, retry and parity checks.';
467
+ }
468
+ if (/(production|rollout|deploy|runtime|environment)/i.test(text)) {
469
+ return 'Future production-runtime work should include rollout, rollback and post-deploy evidence.';
470
+ }
471
+ return 'Preserve the lesson and review it before similar future work.';
472
+ }
473
+
474
+ function summarizeRepeatRisk(text) {
475
+ if (/(must|обязан|нельзя|hard gate|block|regression|повтор|again|снова)/i.test(text)) {
476
+ return 'high: likely to repeat or cause a gate failure if not captured.';
477
+ }
478
+ if (/(should|нужно|стоит|важно|missing|risk)/i.test(text)) {
479
+ return 'medium: useful for reducing future rework.';
480
+ }
481
+ return 'low: keep for review, but do not promote without human confirmation.';
482
+ }
483
+
484
+ function proposeLearningWording(text, kind) {
485
+ const prefix = kind === 'project-playbook-candidate'
486
+ ? 'Project playbook rule'
487
+ : kind === 'shared-playbook-candidate'
488
+ ? 'Shared playbook candidate'
489
+ : 'Project memory note';
490
+ return `${prefix}: ${text}`;
491
+ }
492
+
493
+ function suggestLearningTarget(text) {
494
+ const kind = classifyLearningKind(text);
495
+ if (kind === 'shared-playbook-candidate') {
496
+ return 'shared-playbook/manual-review';
497
+ }
498
+ if (kind === 'project-playbook-candidate') {
499
+ if (/(ui|route|visible|acceptance|screen|browser)/i.test(text)) {
500
+ return `${PROJECT_PLAYBOOK_TARGET_PREFIX}ui-acceptance.md`;
501
+ }
502
+ if (/(source sync|source-sync|provider|ingestion|pagination|rate limit|oauth)/i.test(text)) {
503
+ return `${PROJECT_PLAYBOOK_TARGET_PREFIX}source-sync-provider.md`;
504
+ }
505
+ if (/(production|rollout|deploy|runtime|environment)/i.test(text)) {
506
+ return `${PROJECT_PLAYBOOK_TARGET_PREFIX}production-rollout.md`;
507
+ }
508
+ return `${PROJECT_PLAYBOOK_TARGET_PREFIX}project-notes.md`;
509
+ }
510
+ return `${MEMORY_TARGET_PREFIX}${APPROVED_FILE}`;
511
+ }
512
+
513
+ function readMarkdownField(body, fieldName) {
514
+ const pattern = new RegExp(`^- ${escapeRegExp(fieldName)}: (.*)$`, 'm');
515
+ const match = pattern.exec(body);
516
+ if (!match) {
517
+ return '';
518
+ }
519
+ return match[1].replace(/^`|`$/g, '').trim();
520
+ }
521
+
522
+ function ensureMemoryRoot() {
523
+ fs.mkdirSync(projectContext.memoryRoot, { recursive: true });
524
+ }
525
+
526
+ function groupApprovedByTarget(entries) {
527
+ const byTarget = new Map();
528
+ for (const entry of entries) {
529
+ const target = entry.target || `${MEMORY_TARGET_PREFIX}${APPROVED_FILE}`;
530
+ const current = byTarget.get(target) || [];
531
+ current.push(entry);
532
+ byTarget.set(target, current);
533
+ }
534
+ return byTarget;
535
+ }
536
+
537
+ function resolveApprovedTarget(target) {
538
+ if (target === 'shared-playbook' || target.startsWith('shared-playbook/')) {
539
+ throw new Error('shared-playbook promotion is manual; copy approved entries into the shared package through a reviewed task.');
540
+ }
541
+ if (target === 'project-playbook') {
542
+ throw new Error(`Project playbook target must include a file name, for example ${PROJECT_PLAYBOOK_TARGET_PREFIX}ui-acceptance.md.`);
543
+ }
544
+ if (target === 'memory') {
545
+ return path.join(projectContext.memoryRoot, APPROVED_FILE);
546
+ }
547
+ if (target.startsWith(MEMORY_TARGET_PREFIX)) {
548
+ return safeProjectRelativeTarget(projectContext.memoryRoot, target.slice(MEMORY_TARGET_PREFIX.length));
549
+ }
550
+ if (target.startsWith(PROJECT_PLAYBOOK_TARGET_PREFIX)) {
551
+ if (!projectContext.projectPlaybooksRoot) {
552
+ throw new Error('Project playbooks root is not configured in ops.playbooksDir.');
553
+ }
554
+ return safeProjectRelativeTarget(projectContext.projectPlaybooksRoot, target.slice(PROJECT_PLAYBOOK_TARGET_PREFIX.length));
555
+ }
556
+ if (target === 'defer') {
557
+ throw new Error('Promoted entries cannot target defer.');
558
+ }
559
+ throw new Error(`Unsupported learning target: ${target}`);
560
+ }
561
+
562
+ function safeProjectRelativeTarget(root, relativeTarget) {
563
+ if (!relativeTarget || path.isAbsolute(relativeTarget) || relativeTarget.split(/[\\/]+/).includes('..')) {
564
+ throw new Error(`Unsafe learning target path: ${relativeTarget || '<missing>'}`);
565
+ }
566
+ return path.join(root, relativeTarget);
567
+ }
568
+
569
+ function buildInitialTargetContent(target) {
570
+ const title = target.startsWith(PROJECT_PLAYBOOK_TARGET_PREFIX)
571
+ ? '# Project Playbook Learning'
572
+ : '# Approved Learning';
573
+ return `${title}\n`;
574
+ }
575
+
576
+ function formatApprovedEntry(entry) {
577
+ return [
578
+ '',
579
+ `## ${entry.id}`,
580
+ '',
581
+ `- Target: \`${entry.target}\``,
582
+ `- Source: \`${entry.source}\``,
583
+ `- Confidence: \`${entry.confidence || 'unknown'}\``,
584
+ `- Problem: ${entry.problem || 'Not specified.'}`,
585
+ `- Lesson: ${entry.lesson || 'Not specified.'}`,
586
+ `- Repeat risk: ${entry.repeatRisk || 'Not specified.'}`,
587
+ `- Learning: ${entry.proposedWording || entry.candidate}`,
588
+ entry.notes ? `- Notes: ${entry.notes}` : '',
589
+ ].filter(Boolean).join('\n');
590
+ }
591
+
592
+ function countBy(entries, readKey) {
593
+ const counts = {};
594
+ for (const entry of entries) {
595
+ const key = readKey(entry);
596
+ counts[key] = (counts[key] || 0) + 1;
597
+ }
598
+ return counts;
599
+ }
600
+
601
+ function renderReportEntries(entries) {
602
+ if (!entries.length) {
603
+ return ['- None.'];
604
+ }
605
+ return entries.flatMap((entry) => [
606
+ `### ${entry.id}`,
607
+ '',
608
+ `- Decision: \`${entry.decision || 'pending'}\``,
609
+ `- Target: \`${entry.target || 'memory/approved-learning.md'}\``,
610
+ `- Source: \`${entry.sourceArtifact || entry.source}\``,
611
+ `- Kind: \`${entry.kind || 'unknown'}\``,
612
+ `- Confidence: \`${entry.confidence || 'unknown'}\``,
613
+ `- Problem: ${entry.problem || 'Not specified.'}`,
614
+ `- Lesson: ${entry.lesson || 'Not specified.'}`,
615
+ `- Proposed wording: ${entry.proposedWording || entry.candidate}`,
616
+ '',
617
+ ]);
618
+ }
619
+
620
+ function renderReviewEntries(entries) {
621
+ if (!entries.length) {
622
+ return ['- None.'];
623
+ }
624
+ return entries.flatMap((entry) => [
625
+ `### ${entry.id}`,
626
+ '',
627
+ `- Source: \`${entry.sourceArtifact || entry.source}\``,
628
+ `- Current decision: \`${entry.decision || 'pending'}\``,
629
+ `- Suggested target: \`${entry.target || 'memory/approved-learning.md'}\``,
630
+ `- Kind: \`${entry.kind || 'unknown'}\``,
631
+ `- Learning layer: \`${entry.learningLayer || learningLayerForKind(entry.kind)}\``,
632
+ `- Confidence: \`${entry.confidence || 'unknown'}\``,
633
+ `- Repeat risk: ${entry.repeatRisk || 'Not specified.'}`,
634
+ `- Problem: ${entry.problem || 'Not specified.'}`,
635
+ `- Lesson: ${entry.lesson || 'Not specified.'}`,
636
+ `- Proposed wording: ${entry.proposedWording || entry.candidate}`,
637
+ '',
638
+ 'Decision to set in `learning-index.json`: `promote | defer | reject | rewrite`',
639
+ '',
640
+ ]);
641
+ }
642
+
643
+ function relativeProjectPath(filePath, projectRoot = projectContext.projectRoot) {
644
+ return path.relative(projectRoot, filePath) || path.basename(filePath);
645
+ }
646
+
647
+ function escapeRegExp(value) {
648
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
649
+ }
650
+
651
+ function fail(message) {
652
+ console.error(`Error: ${message}`);
653
+ process.exit(1);
654
+ }
655
+
656
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
657
+ main();
658
+ }