@besales/ops-framework 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ - Added `ops-agent closeout <TASK>` as the Supervisor entrypoint before final task closure.
6
+ - Closeout now validates Verify and Retrospective readiness, automatically runs learning closeout, pauses for unresolved learning decisions and can apply approved learning with `--apply-approved`.
7
+ - Added generated project script support for `agent:closeout`.
8
+
9
+ ## 0.1.5
10
+
11
+ - Added `ops-agent learning-closeout <TASK>` as the default post-retrospective learning checkpoint.
12
+ - Expanded `learning-review.md` into per-learning approval cards with summary, target, source, reason, proposed change, scope, confidence, risk and decision.
13
+ - Preserved existing pending learning index entries when refreshing task-specific learning candidates.
14
+ - Added closeout guard coverage for `learning-closeout.md`, `learning-index.json`, `learning-review.md` and `learning-report.md`.
15
+ - Added generated project script support for `agent:learning-closeout`.
16
+
3
17
  ## 0.1.4
4
18
 
5
19
  - Switched generated project scripts from repeated `yarn dlx` execution to installed `ops-agent` package scripts.
package/README.md CHANGED
@@ -178,6 +178,8 @@ Do not commit that `file:` dependency to production projects. It is only for pac
178
178
  - `update-memory`
179
179
  - `learning-audit`
180
180
  - `learning-report`
181
+ - `learning-closeout`
182
+ - `closeout`
181
183
  - `test/self-test`
182
184
 
183
185
  ## Learning Loop
@@ -201,6 +203,8 @@ ops-agent learning-review
201
203
  ops-agent learning-audit
202
204
  ops-agent update-memory --apply-approved
203
205
  ops-agent learning-report
206
+ ops-agent learning-closeout TASK-001-example
207
+ ops-agent closeout TASK-001-example
204
208
  ```
205
209
 
206
210
  `memory-candidates` creates structured learning cards with source, reason hash, learning layer, confidence, problem, lesson, repeat risk, proposed wording and suggested target. `learning-index` turns those cards into human-reviewable decisions. `update-memory` only writes approved entries when `--apply-approved` is passed.
@@ -209,6 +213,10 @@ ops-agent learning-report
209
213
 
210
214
  `learning-report` writes `ops/agent-pipeline/memory/learning-report.md`. Show the review pack before approval and the report during closeout so the human can see what the framework learned, what is still pending, and which approved entries were written to project memory or project playbooks.
211
215
 
216
+ `learning-closeout <TASK>` is the default closeout checkpoint after retrospective. It collects candidates from that task, refreshes `learning-index.json`, writes `learning-review.md`, writes `learning-report.md`, creates `learning-closeout.md` inside the task and updates `status.md` so human approval is visible. The review must show every learning candidate individually with summary, target, source artifact, reason, proposed change, scope, confidence, promotion risk and the current decision.
217
+
218
+ `closeout <TASK>` is the Supervisor entrypoint before any final closure. It validates `verify.result.json` and `retrospective.md`, automatically runs `learning-closeout <TASK>`, pauses when learning decisions are still `pending` or `rewrite`, and after `--apply-approved` applies approved learning, refreshes the report and runs the final guard unless `--skip-guard` is passed.
219
+
212
220
  Shared playbook candidates are intentionally manual-review only. Promote them through a separate reviewed framework task, not by auto-writing project-specific observations into the shared package.
213
221
 
214
222
  ## Feedback Intake
@@ -0,0 +1,224 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ appendOrchestrationLog,
7
+ parseCliArgs,
8
+ projectContext,
9
+ readJsonFile,
10
+ readTaskFile,
11
+ repoRoot,
12
+ resolveTaskDir,
13
+ updateStatus,
14
+ } from './lib/check-context-utils.mjs';
15
+ import {
16
+ INDEX_FILE,
17
+ REPORT_FILE,
18
+ updateMemory,
19
+ writeLearningCloseout,
20
+ writeLearningReport,
21
+ } from './learning-loop.mjs';
22
+
23
+ export function main() {
24
+ const args = parseCliArgs(process.argv.slice(2));
25
+ const taskArg = args.positional[0] || args.flags.get('task');
26
+ if (!taskArg) {
27
+ fail('Usage: ops-agent closeout <TASK-id-or-task-path> [--apply-approved] [--skip-guard]');
28
+ }
29
+
30
+ try {
31
+ const result = closeoutTask({
32
+ taskArg,
33
+ applyApproved: args.flags.has('apply-approved'),
34
+ runGuard: !args.flags.has('skip-guard'),
35
+ });
36
+ printCloseoutResult(result);
37
+ if (!result.ok) {
38
+ process.exit(1);
39
+ }
40
+ } catch (error) {
41
+ fail(error.message);
42
+ }
43
+ }
44
+
45
+ export function closeoutTask({ taskArg, applyApproved = false, runGuard = true } = {}) {
46
+ const taskDir = resolveTaskDir(taskArg);
47
+ const taskId = path.basename(taskDir);
48
+ const prerequisiteIssues = validateCloseoutPrerequisites(taskDir);
49
+ if (prerequisiteIssues.length) {
50
+ updateStatus(taskDir, {
51
+ stage: 'Closeout Blocked',
52
+ supervisorAction: 'Closeout orchestration blocked by missing prerequisites.',
53
+ nextStep: prerequisiteIssues.join(' '),
54
+ humanApproval: 'no',
55
+ });
56
+ appendOrchestrationLog(taskDir, `closeout blocked; issues=${prerequisiteIssues.length}`);
57
+ return {
58
+ ok: false,
59
+ taskId,
60
+ status: 'blocked_prerequisites',
61
+ issues: prerequisiteIssues,
62
+ };
63
+ }
64
+
65
+ writeLearningCloseout({ taskArg });
66
+ const beforeApply = readLearningDecisionSummary();
67
+ if (applyApproved) {
68
+ updateMemory({ applyApproved: true });
69
+ writeLearningReport();
70
+ }
71
+ const afterApply = readLearningDecisionSummary();
72
+ const unresolved = afterApply.pending + afterApply.rewrite;
73
+
74
+ if (unresolved > 0) {
75
+ updateStatus(taskDir, {
76
+ stage: 'Retrospective / Learning Review',
77
+ supervisorAction: 'Closeout paused for human learning decisions.',
78
+ nextStep: `Review ${relativeProjectPath(path.join(projectContext.memoryRoot, 'learning-review.md'))}; set pending/rewrite entries in ${relativeProjectPath(path.join(projectContext.memoryRoot, INDEX_FILE))} to promote, defer or reject; rerun agent:closeout --apply-approved.`,
79
+ humanApproval: 'yes',
80
+ });
81
+ appendOrchestrationLog(taskDir, `closeout paused for learning decisions; pending=${afterApply.pending}; rewrite=${afterApply.rewrite}`);
82
+ return {
83
+ ok: false,
84
+ taskId,
85
+ status: 'learning_review_required',
86
+ beforeApply,
87
+ afterApply,
88
+ issues: [`Learning decisions unresolved: pending=${afterApply.pending}, rewrite=${afterApply.rewrite}.`],
89
+ };
90
+ }
91
+
92
+ updateStatus(taskDir, {
93
+ stage: 'Closeout Audit',
94
+ supervisorAction: 'Closeout orchestration completed learning loop; ready for final guard.',
95
+ nextStep: 'Review learning-report.md and guard-task result before marking the task Closed, Accepted or Ready To Pause.',
96
+ humanApproval: 'yes',
97
+ });
98
+ appendOrchestrationLog(taskDir, `closeout learning loop resolved; promoted=${afterApply.promote}; deferred=${afterApply.defer}; rejected=${afterApply.reject}`);
99
+
100
+ const guard = runGuard ? runGuardTask(taskArg) : { ok: true, skipped: true, output: 'guard skipped by --skip-guard' };
101
+ if (!guard.ok) {
102
+ return {
103
+ ok: false,
104
+ taskId,
105
+ status: 'guard_failed',
106
+ beforeApply,
107
+ afterApply,
108
+ guard,
109
+ issues: ['Closeout guard failed. Fix listed issues before final closure.'],
110
+ };
111
+ }
112
+
113
+ return {
114
+ ok: true,
115
+ taskId,
116
+ status: 'ready_for_human_closeout',
117
+ beforeApply,
118
+ afterApply,
119
+ guard,
120
+ issues: [],
121
+ };
122
+ }
123
+
124
+ export function validateCloseoutPrerequisites(taskDir) {
125
+ const issues = [];
126
+ const verifyPath = path.join(taskDir, 'verify.result.json');
127
+ if (!fs.existsSync(verifyPath)) {
128
+ issues.push('verify.result.json is missing; run Verify before closeout.');
129
+ } else {
130
+ try {
131
+ const verify = readJsonFile(verifyPath);
132
+ if (!['pass', 'pass_with_notes'].includes(verify.verdict)) {
133
+ issues.push(`verify.result.json verdict=${verify.verdict}; closeout requires pass or pass_with_notes.`);
134
+ }
135
+ } catch (error) {
136
+ issues.push(`verify.result.json is invalid JSON: ${error.message}`);
137
+ }
138
+ }
139
+
140
+ const retrospective = readTaskFile(taskDir, 'retrospective.md');
141
+ if (!retrospective.trim()) {
142
+ issues.push('retrospective.md is missing or empty; fill Retrospective before closeout.');
143
+ }
144
+ if (/\[fill in\]|`not_started`|Retrospective has not started/i.test(retrospective)) {
145
+ issues.push('retrospective.md still contains placeholders or not_started markers; complete Retrospective before closeout.');
146
+ }
147
+ return issues;
148
+ }
149
+
150
+ function readLearningDecisionSummary() {
151
+ const indexPath = path.join(projectContext.memoryRoot, INDEX_FILE);
152
+ if (!fs.existsSync(indexPath)) {
153
+ return {
154
+ total: 0,
155
+ pending: 0,
156
+ promote: 0,
157
+ defer: 0,
158
+ reject: 0,
159
+ rewrite: 0,
160
+ };
161
+ }
162
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
163
+ const summary = {
164
+ total: 0,
165
+ pending: 0,
166
+ promote: 0,
167
+ defer: 0,
168
+ reject: 0,
169
+ rewrite: 0,
170
+ };
171
+ for (const entry of index.entries || []) {
172
+ const decision = entry.decision || 'pending';
173
+ summary.total += 1;
174
+ summary[decision] = (summary[decision] || 0) + 1;
175
+ }
176
+ return summary;
177
+ }
178
+
179
+ function runGuardTask(taskArg) {
180
+ const result = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'guard-task.mjs'), taskArg], {
181
+ cwd: repoRoot,
182
+ encoding: 'utf8',
183
+ });
184
+ return {
185
+ ok: result.status === 0,
186
+ status: result.status,
187
+ output: `${result.stdout || ''}${result.stderr || ''}`.trim(),
188
+ };
189
+ }
190
+
191
+ function printCloseoutResult(result) {
192
+ console.log(`Closeout ${result.status} for ${result.taskId}`);
193
+ for (const issue of result.issues || []) {
194
+ console.log(`- ${issue}`);
195
+ }
196
+ if (result.afterApply) {
197
+ console.log(`- learning total: ${result.afterApply.total}`);
198
+ console.log(`- learning pending: ${result.afterApply.pending}`);
199
+ console.log(`- learning rewrite: ${result.afterApply.rewrite}`);
200
+ console.log(`- learning promoted: ${result.afterApply.promote}`);
201
+ console.log(`- learning deferred: ${result.afterApply.defer}`);
202
+ console.log(`- learning rejected: ${result.afterApply.reject}`);
203
+ console.log(`- learning report: ${relativeProjectPath(path.join(projectContext.memoryRoot, REPORT_FILE))}`);
204
+ }
205
+ if (result.guard) {
206
+ console.log(`- guard: ${result.guard.ok ? 'ok' : 'failed'}`);
207
+ if (result.guard.output) {
208
+ console.log(result.guard.output);
209
+ }
210
+ }
211
+ }
212
+
213
+ function relativeProjectPath(filePath) {
214
+ return path.relative(projectContext.projectRoot, filePath) || path.basename(filePath);
215
+ }
216
+
217
+ function fail(message) {
218
+ console.error(`Error: ${message}`);
219
+ process.exit(1);
220
+ }
221
+
222
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
223
+ main();
224
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { validateCloseoutPrerequisites } from './closeout.mjs';
6
+
7
+ describe('closeout', () => {
8
+ it('blocks closeout when verify result is missing and retrospective still has placeholders', () => {
9
+ const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-closeout-blocked-'));
10
+ fs.writeFileSync(path.join(taskDir, 'retrospective.md'), [
11
+ '# Retrospective',
12
+ '',
13
+ '## Статус',
14
+ '',
15
+ '`not_started`',
16
+ '',
17
+ '- `ops-agent closeout <TASK>` запущен как основной closeout entrypoint: `[fill in]`',
18
+ ].join('\n'));
19
+
20
+ const issues = validateCloseoutPrerequisites(taskDir);
21
+
22
+ expect(issues).toContain('verify.result.json is missing; run Verify before closeout.');
23
+ expect(issues).toContain('retrospective.md still contains placeholders or not_started markers; complete Retrospective before closeout.');
24
+ });
25
+
26
+ it('allows closeout prerequisites when verify passed and retrospective is complete', () => {
27
+ const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-closeout-ready-'));
28
+ fs.writeFileSync(path.join(taskDir, 'verify.result.json'), JSON.stringify({
29
+ schemaVersion: 1,
30
+ verdict: 'pass_with_notes',
31
+ }, null, 2));
32
+ fs.writeFileSync(path.join(taskDir, 'retrospective.md'), [
33
+ '# Retrospective',
34
+ '',
35
+ '## Статус',
36
+ '',
37
+ '`completed`',
38
+ '',
39
+ '## Переиспользуемые выводы',
40
+ '',
41
+ '- Feedback capture worked and learning review was shown.',
42
+ ].join('\n'));
43
+
44
+ expect(validateCloseoutPrerequisites(taskDir)).toEqual([]);
45
+ });
46
+ });
@@ -33,6 +33,7 @@ function main() {
33
33
  validateHumanGateSummary(taskDir, errors);
34
34
  validateFeedback(taskDir, errors);
35
35
  validateExecutionEvidence(taskDir, errors);
36
+ validateLearningCloseout(taskDir, errors);
36
37
  validateStatusSync(taskDir, errors);
37
38
 
38
39
  if (errors.length > 0) {
@@ -49,6 +50,30 @@ function main() {
49
50
  }
50
51
  }
51
52
 
53
+ function validateLearningCloseout(taskDir, errors) {
54
+ const stage = readStatusStage(taskDir);
55
+ const stagesRequiringLearningCloseout = new Set([
56
+ 'Closeout Audit',
57
+ 'Human Closeout Gate',
58
+ 'Closed',
59
+ 'Accepted',
60
+ 'Ready To Pause',
61
+ ]);
62
+ if (!stagesRequiringLearningCloseout.has(stage)) {
63
+ return;
64
+ }
65
+ const taskLearningCloseoutPath = path.join(taskDir, 'learning-closeout.md');
66
+ if (!fs.existsSync(taskLearningCloseoutPath)) {
67
+ errors.push('learning-closeout.md is missing before closeout. Run ops-agent learning-closeout <TASK> and show learning-review.md to human.');
68
+ }
69
+ for (const fileName of ['learning-index.json', 'learning-review.md', 'learning-report.md']) {
70
+ const filePath = path.join(projectContext.memoryRoot, fileName);
71
+ if (!fs.existsSync(filePath)) {
72
+ errors.push(`${fileName} is missing before closeout. Run ops-agent learning-closeout <TASK>.`);
73
+ }
74
+ }
75
+ }
76
+
52
77
  function validateHumanGateSummary(taskDir, errors) {
53
78
  const stage = readStatusStage(taskDir);
54
79
  if (stage !== 'Human Gate') {
@@ -3,9 +3,12 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import {
6
+ appendOrchestrationLog,
6
7
  getFlag,
7
8
  parseCliArgs,
8
9
  projectContext,
10
+ resolveTaskDir,
11
+ updateStatus,
9
12
  } from './lib/check-context-utils.mjs';
10
13
 
11
14
  export const CANDIDATES_FILE = 'learning-candidates.md';
@@ -13,6 +16,7 @@ export const INDEX_FILE = 'learning-index.json';
13
16
  export const APPROVED_FILE = 'approved-learning.md';
14
17
  export const REPORT_FILE = 'learning-report.md';
15
18
  export const REVIEW_FILE = 'learning-review.md';
19
+ export const CLOSEOUT_FILE = 'learning-closeout.md';
16
20
  const PROJECT_PLAYBOOK_TARGET_PREFIX = 'project-playbook/';
17
21
  const MEMORY_TARGET_PREFIX = 'memory/';
18
22
 
@@ -44,15 +48,22 @@ export function main() {
44
48
  writeLearningReport();
45
49
  return;
46
50
  }
47
- fail('Usage: ops-agent memory-candidates|learning-index|learning-review|update-memory|learning-audit|learning-report');
51
+ if (command === 'learning-closeout') {
52
+ writeLearningCloseout({
53
+ taskArg: args.positional[0] || getFlag(args, 'task'),
54
+ limit: Number(getFlag(args, 'limit', 20)),
55
+ });
56
+ return;
57
+ }
58
+ fail('Usage: ops-agent memory-candidates|learning-index|learning-review|update-memory|learning-audit|learning-report|learning-closeout');
48
59
  } catch (error) {
49
60
  fail(error.message);
50
61
  }
51
62
  }
52
63
 
53
- export function writeMemoryCandidates({ limit }) {
64
+ export function writeMemoryCandidates({ limit, taskDir = null } = {}) {
54
65
  ensureMemoryRoot();
55
- const candidates = collectLearningCandidates({ limit });
66
+ const candidates = collectLearningCandidates({ limit, taskDir });
56
67
  const content = [
57
68
  '# Learning Candidates',
58
69
  '',
@@ -81,32 +92,21 @@ export function writeMemoryCandidates({ limit }) {
81
92
  fs.writeFileSync(path.join(projectContext.memoryRoot, CANDIDATES_FILE), content.endsWith('\n') ? content : `${content}\n`);
82
93
  console.log(`Learning candidates written: ${path.join(projectContext.memoryRoot, CANDIDATES_FILE)}`);
83
94
  console.log(`- candidates: ${candidates.length}`);
95
+ return candidates;
84
96
  }
85
97
 
86
- export function writeLearningIndex() {
98
+ export function writeLearningIndex({ preserveExisting = true } = {}) {
87
99
  ensureMemoryRoot();
88
100
  const candidatesPath = path.join(projectContext.memoryRoot, CANDIDATES_FILE);
89
101
  if (!fs.existsSync(candidatesPath)) {
90
102
  throw new Error(`Missing ${CANDIDATES_FILE}. Run ops-agent memory-candidates first.`);
91
103
  }
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
- }));
104
+ const existingEntries = preserveExisting ? readExistingLearningEntries() : [];
105
+ const entries = mergeLearningIndexEntries({
106
+ candidateEntries: parseLearningCandidatesMarkdown(fs.readFileSync(candidatesPath, 'utf8'))
107
+ .map((candidate) => buildLearningIndexEntry(candidate, existingEntries)),
108
+ existingEntries,
109
+ });
110
110
  const index = {
111
111
  schemaVersion: 1,
112
112
  generatedAt: new Date().toISOString(),
@@ -116,6 +116,7 @@ export function writeLearningIndex() {
116
116
  fs.writeFileSync(path.join(projectContext.memoryRoot, INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
117
117
  console.log(`Learning index written: ${path.join(projectContext.memoryRoot, INDEX_FILE)}`);
118
118
  console.log(`- entries: ${entries.length}`);
119
+ return index;
119
120
  }
120
121
 
121
122
  export function writeLearningReview({ memoryRoot = projectContext.memoryRoot, projectRoot = projectContext.projectRoot } = {}) {
@@ -173,6 +174,45 @@ export function writeLearningReview({ memoryRoot = projectContext.memoryRoot, pr
173
174
  fs.writeFileSync(path.join(memoryRoot, REVIEW_FILE), content.endsWith('\n') ? content : `${content}\n`);
174
175
  console.log(`Learning review written: ${path.join(memoryRoot, REVIEW_FILE)}`);
175
176
  console.log(`- pending: ${pending.length}`);
177
+ return {
178
+ entries,
179
+ pending,
180
+ reviewPath: path.join(memoryRoot, REVIEW_FILE),
181
+ };
182
+ }
183
+
184
+ export function writeLearningCloseout({ taskArg, limit = 20 } = {}) {
185
+ if (!taskArg) {
186
+ throw new Error('Usage: ops-agent learning-closeout <TASK-id-or-task-path> [--limit 20]');
187
+ }
188
+ const taskDir = resolveTaskDir(taskArg);
189
+ const taskId = path.basename(taskDir);
190
+ const candidates = writeMemoryCandidates({ limit, taskDir });
191
+ const index = writeLearningIndex({ preserveExisting: true });
192
+ const review = writeLearningReview();
193
+ writeLearningReport();
194
+ const closeoutPath = writeLearningCloseoutSummary({
195
+ taskDir,
196
+ taskId,
197
+ candidates,
198
+ entries: index.entries || [],
199
+ reviewPath: review.reviewPath,
200
+ });
201
+ updateStatus(taskDir, {
202
+ stage: 'Retrospective / Learning Review',
203
+ supervisorAction: 'Generated learning closeout candidates, review pack and report.',
204
+ nextStep: 'Show learning-review.md to human; set each learning-index.json decision to promote, defer, reject or rewrite; then run update-memory --apply-approved and learning-report.',
205
+ humanApproval: 'yes',
206
+ });
207
+ appendOrchestrationLog(taskDir, `learning closeout generated; candidates=${candidates.length}; review=${relativeProjectPath(review.reviewPath)}; summary=${path.basename(closeoutPath)}`);
208
+ console.log(`Learning closeout ready for ${taskId}`);
209
+ console.log(`- candidates: ${candidates.length}`);
210
+ console.log(`- review: ${relativeProjectPath(review.reviewPath)}`);
211
+ console.log(`- report: ${relativeProjectPath(path.join(projectContext.memoryRoot, REPORT_FILE))}`);
212
+ console.log(`- task summary: ${relativeProjectPath(closeoutPath)}`);
213
+ for (const line of renderConsoleLearningCards(index.entries || [])) {
214
+ console.log(line);
215
+ }
176
216
  }
177
217
 
178
218
  export function updateMemory({ applyApproved }) {
@@ -279,20 +319,26 @@ export function writeLearningReport({ memoryRoot = projectContext.memoryRoot, pr
279
319
  console.log(`Learning report written: ${path.join(memoryRoot, REPORT_FILE)}`);
280
320
  }
281
321
 
282
- export function collectLearningCandidates({ limit, tasksRoot = projectContext.tasksRoot } = {}) {
322
+ export function collectLearningCandidates({ limit, tasksRoot = projectContext.tasksRoot, taskDir = null } = {}) {
283
323
  const candidates = [];
284
324
  const seen = new Set();
285
- if (!fs.existsSync(tasksRoot)) {
325
+ const taskDirs = taskDir
326
+ ? [taskDir]
327
+ : fs.existsSync(tasksRoot)
328
+ ? fs.readdirSync(tasksRoot)
329
+ .filter((name) => name.startsWith('TASK-'))
330
+ .sort()
331
+ .reverse()
332
+ .map((name) => path.join(tasksRoot, name))
333
+ : [];
334
+ if (!taskDirs.length) {
286
335
  return candidates;
287
336
  }
288
- const taskDirs = fs.readdirSync(tasksRoot)
289
- .filter((name) => name.startsWith('TASK-'))
290
- .sort()
291
- .reverse();
292
- for (const taskName of taskDirs) {
337
+ for (const taskPath of taskDirs) {
338
+ const taskName = path.basename(taskPath);
293
339
  for (const fileName of ['retrospective.md', 'feedback.md', 'execution-feedback.md', 'verify.md', 'check.md']) {
294
340
  const sourceArtifact = `${taskName}/${fileName}`;
295
- const filePath = path.join(tasksRoot, taskName, fileName);
341
+ const filePath = path.join(taskPath, fileName);
296
342
  if (!fs.existsSync(filePath)) {
297
343
  continue;
298
344
  }
@@ -510,6 +556,78 @@ function suggestLearningTarget(text) {
510
556
  return `${MEMORY_TARGET_PREFIX}${APPROVED_FILE}`;
511
557
  }
512
558
 
559
+ function buildLearningIndexEntry(candidate, existingEntries) {
560
+ const previous = existingEntries.find((entry) => entry.reasonHash === candidate.reasonHash)
561
+ || existingEntries.find((entry) => entry.id === candidate.id);
562
+ return {
563
+ id: previous?.id || candidate.id,
564
+ source: candidate.source,
565
+ sourceArtifact: candidate.sourceArtifact,
566
+ reasonHash: candidate.reasonHash,
567
+ kind: candidate.kind,
568
+ learningLayer: candidate.learningLayer,
569
+ summary: candidate.lesson,
570
+ confidence: candidate.confidence,
571
+ problem: candidate.problem,
572
+ lesson: candidate.lesson,
573
+ repeatRisk: candidate.repeatRisk,
574
+ scope: scopeForTarget(previous?.target || candidate.suggestedTarget),
575
+ risk: riskForCandidate(candidate),
576
+ candidate: candidate.text,
577
+ proposedChange: previous?.proposedChange || candidate.proposedWording,
578
+ proposedWording: previous?.proposedWording || candidate.proposedWording,
579
+ decision: previous?.decision || 'pending',
580
+ target: previous?.target || candidate.suggestedTarget,
581
+ notes: previous?.notes || '',
582
+ };
583
+ }
584
+
585
+ function mergeLearningIndexEntries({ candidateEntries, existingEntries }) {
586
+ const seen = new Set(candidateEntries.map((entry) => entry.reasonHash || entry.id));
587
+ const preserved = existingEntries.filter((entry) => {
588
+ const key = entry.reasonHash || entry.id;
589
+ if (!key || seen.has(key)) {
590
+ return false;
591
+ }
592
+ seen.add(key);
593
+ return true;
594
+ });
595
+ return [...candidateEntries, ...preserved];
596
+ }
597
+
598
+ function readExistingLearningEntries() {
599
+ const indexPath = path.join(projectContext.memoryRoot, INDEX_FILE);
600
+ if (!fs.existsSync(indexPath)) {
601
+ return [];
602
+ }
603
+ try {
604
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
605
+ return Array.isArray(index.entries) ? index.entries : [];
606
+ } catch {
607
+ return [];
608
+ }
609
+ }
610
+
611
+ function scopeForTarget(target) {
612
+ if (target?.startsWith('shared-playbook/')) {
613
+ return 'cross-project candidate; manual shared-framework review required before promotion.';
614
+ }
615
+ if (target?.startsWith(PROJECT_PLAYBOOK_TARGET_PREFIX)) {
616
+ return 'current project playbook overlay; project-specific procedures, routes, commands or runtime quirks.';
617
+ }
618
+ return 'current project memory; durable architecture, product or process knowledge.';
619
+ }
620
+
621
+ function riskForCandidate(candidate) {
622
+ if (candidate.suggestedTarget?.startsWith('shared-playbook/')) {
623
+ return 'High if promoted without validation: a task-specific observation could become cross-project guidance.';
624
+ }
625
+ if (candidate.confidence === 'low') {
626
+ return 'Medium: wording may be too vague or one-off; prefer defer/rewrite unless confirmed.';
627
+ }
628
+ return 'Low if reviewed: promotion is human-approved and source-linked.';
629
+ }
630
+
513
631
  function readMarkdownField(body, fieldName) {
514
632
  const pattern = new RegExp(`^- ${escapeRegExp(fieldName)}: (.*)$`, 'm');
515
633
  const match = pattern.exec(body);
@@ -622,24 +740,66 @@ function renderReviewEntries(entries) {
622
740
  return ['- None.'];
623
741
  }
624
742
  return entries.flatMap((entry) => [
625
- `### ${entry.id}`,
743
+ `### ${entry.id}: ${entry.summary || entry.lesson || entry.candidate || 'Learning candidate'}`,
626
744
  '',
627
- `- Source: \`${entry.sourceArtifact || entry.source}\``,
628
- `- Current decision: \`${entry.decision || 'pending'}\``,
745
+ `- Summary: ${entry.summary || entry.lesson || 'Not specified.'}`,
629
746
  `- Suggested target: \`${entry.target || 'memory/approved-learning.md'}\``,
630
- `- Kind: \`${entry.kind || 'unknown'}\``,
631
- `- Learning layer: \`${entry.learningLayer || learningLayerForKind(entry.kind)}\``,
747
+ `- Source artifact: \`${entry.sourceArtifact || entry.source}\``,
748
+ `- Reason hash: \`${entry.reasonHash || 'missing'}\``,
749
+ `- Reason: ${entry.problem || 'Not specified.'}`,
750
+ `- Proposed change: ${entry.proposedChange || entry.proposedWording || entry.candidate}`,
751
+ `- Scope: ${entry.scope || scopeForTarget(entry.target)}`,
632
752
  `- 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}`,
753
+ `- Risk if promoted: ${entry.risk || 'Not specified.'}`,
754
+ `- Repeat risk if ignored: ${entry.repeatRisk || 'Not specified.'}`,
755
+ `- Current decision: \`${entry.decision || 'pending'}\``,
756
+ `- Human notes: ${entry.notes || '(empty)'}`,
637
757
  '',
638
758
  'Decision to set in `learning-index.json`: `promote | defer | reject | rewrite`',
639
759
  '',
640
760
  ]);
641
761
  }
642
762
 
763
+ function renderConsoleLearningCards(entries) {
764
+ const pending = entries.filter((entry) => !entry.decision || entry.decision === 'pending');
765
+ if (!pending.length) {
766
+ return ['- pending learning cards: none'];
767
+ }
768
+ return [
769
+ '- pending learning cards:',
770
+ ...pending.map((entry) => ` - ${entry.id}: ${entry.summary || entry.lesson || entry.candidate} -> ${entry.target || 'memory/approved-learning.md'}`),
771
+ ];
772
+ }
773
+
774
+ function writeLearningCloseoutSummary({ taskDir, taskId, candidates, entries, reviewPath }) {
775
+ const pending = entries.filter((entry) => !entry.decision || entry.decision === 'pending');
776
+ const content = [
777
+ '# Learning Closeout',
778
+ '',
779
+ `Task: \`${taskId}\``,
780
+ `Generated at: \`${new Date().toISOString()}\``,
781
+ '',
782
+ '## Human Action Required',
783
+ '',
784
+ `Review \`${relativeProjectPath(reviewPath)}\` and set every pending entry in \`${relativeProjectPath(path.join(projectContext.memoryRoot, INDEX_FILE))}\` to \`promote\`, \`defer\`, \`reject\` or \`rewrite\`.`,
785
+ '',
786
+ '## Summary',
787
+ '',
788
+ `- Candidates collected from this task: ${candidates.length}`,
789
+ `- Pending learning decisions: ${pending.length}`,
790
+ `- Learning review: \`${relativeProjectPath(reviewPath)}\``,
791
+ `- Learning report: \`${relativeProjectPath(path.join(projectContext.memoryRoot, REPORT_FILE))}\``,
792
+ '',
793
+ '## Pending Cards',
794
+ '',
795
+ ...renderReviewEntries(pending),
796
+ '',
797
+ ].join('\n');
798
+ const closeoutPath = path.join(taskDir, CLOSEOUT_FILE);
799
+ fs.writeFileSync(closeoutPath, content.endsWith('\n') ? content : `${content}\n`);
800
+ return closeoutPath;
801
+ }
802
+
643
803
  function relativeProjectPath(filePath, projectRoot = projectContext.projectRoot) {
644
804
  return path.relative(projectRoot, filePath) || path.basename(filePath);
645
805
  }
@@ -170,6 +170,29 @@ describe('learning loop', () => {
170
170
  const review = fs.readFileSync(path.join(memoryRoot, 'learning-review.md'), 'utf8');
171
171
  expect(review).toContain('## Human Approval Contract');
172
172
  expect(review).toContain('Decision to set in `learning-index.json`: `promote | defer | reject | rewrite`');
173
+ expect(review).toContain('### LC-001: Capture feedback at every stage.');
174
+ expect(review).toContain('- Summary: Capture feedback at every stage.');
175
+ expect(review).toContain('- Source artifact: `TASK-001/feedback.md`');
176
+ expect(review).toContain('- Reason hash: `missing`');
177
+ expect(review).toContain('- Proposed change: Project memory note: capture feedback at every stage.');
178
+ expect(review).toContain('- Scope: current project memory; durable architecture, product or process knowledge.');
179
+ expect(review).toContain('- Risk if promoted: Not specified.');
173
180
  expect(review).toContain('Project memory note: capture feedback at every stage.');
174
181
  });
182
+
183
+ it('collects closeout candidates from one task when taskDir is provided', () => {
184
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-learning-task-filter-'));
185
+ const taskA = path.join(root, 'TASK-001-current');
186
+ const taskB = path.join(root, 'TASK-002-other');
187
+ fs.mkdirSync(taskA, { recursive: true });
188
+ fs.mkdirSync(taskB, { recursive: true });
189
+ fs.writeFileSync(path.join(taskA, 'retrospective.md'), '- UI acceptance playbook should include current route.\n');
190
+ fs.writeFileSync(path.join(taskB, 'retrospective.md'), '- UI acceptance playbook should include other route.\n');
191
+
192
+ const candidates = collectLearningCandidates({ taskDir: taskA, tasksRoot: root, limit: 10 });
193
+
194
+ expect(candidates).toHaveLength(1);
195
+ expect(candidates[0].sourceArtifact).toBe('TASK-001-current/retrospective.md');
196
+ expect(candidates[0].text).toContain('current route');
197
+ });
175
198
  });
@@ -119,6 +119,8 @@ export function buildOpsScripts(packageSpec) {
119
119
  'agent:update-memory': run('update-memory'),
120
120
  'agent:learning-audit': run('learning-audit'),
121
121
  'agent:learning-report': run('learning-report'),
122
+ 'agent:learning-closeout': run('learning-closeout'),
123
+ 'agent:closeout': run('closeout'),
122
124
  'agent:test': run('test/self-test'),
123
125
  };
124
126
  }
@@ -138,6 +138,8 @@ describe('buildOpsScripts', () => {
138
138
 
139
139
  expect(scripts.ops).toBe('ops-agent');
140
140
  expect(scripts['agent:quality-gates']).toBe('ops-agent quality-gates');
141
+ expect(scripts['agent:learning-closeout']).toBe('ops-agent learning-closeout');
142
+ expect(scripts['agent:closeout']).toBe('ops-agent closeout');
141
143
  expect(scripts['agent:test']).toBe('ops-agent test/self-test');
142
144
  });
143
145
  });
package/bin/ops-agent.mjs CHANGED
@@ -32,6 +32,8 @@ const COMMANDS = new Map([
32
32
  ['update-memory', 'learning-loop.mjs'],
33
33
  ['learning-audit', 'learning-loop.mjs'],
34
34
  ['learning-report', 'learning-loop.mjs'],
35
+ ['learning-closeout', 'learning-loop.mjs'],
36
+ ['closeout', 'closeout.mjs'],
35
37
  ['test/self-test', null],
36
38
  ]);
37
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@besales/ops-framework",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "ops-agent": "bin/ops-agent.mjs"
@@ -193,7 +193,7 @@ Allowed ref types:
193
193
  - `human_arbitration_required` -> route to `Human Arbitration`;
194
194
  - `verifier_failed` -> остаться в `Verify` до remediation или провайдера/контекста.
195
195
  4. `Retrospective`: `retrospective.md` заполнен и содержит итоговый статус/verdict.
196
- 5. `Learning Loop Audit`: Supervisor запускает/проверяет `memory-candidates`, `learning-index`, `learning-review`, human decisions, `update-memory --apply-approved` для promoted entries and `learning-report`; human должен видеть, какие lessons предложены, какие отклонены/отложены and куда approved entries записаны.
196
+ 5. `Learning Loop Audit`: Supervisor запускает `closeout <TASK>` как основной вход в закрытие. Эта команда валидирует Verify/Retrospective, автоматически запускает `learning-closeout <TASK>`, останавливает закрытие при pending/rewrite learning decisions, а после human decisions запускается повторно с `--apply-approved`. Human должен видеть каждый learning отдельно: summary, target, source artifact, reason, proposed change, scope, confidence, risk, current decision и куда approved entries записаны.
197
197
  6. `Closeout Audit`: Supervisor проверяет `status.md`, `verify.md`, `verify.result.json`, `retrospective.md`, `orchestration-log.md`, `learning-report.md`, deferred/follow-up фиксацию и `git diff --check`.
198
198
  7. `Human Closeout Gate`: только после audit можно предлагать закрытие/паузу/task switch.
199
199
 
@@ -16,9 +16,12 @@
16
16
 
17
17
  ## Learning Loop Audit
18
18
 
19
+ - `ops-agent closeout <TASK>` запущен как основной closeout entrypoint: `[fill in]`
20
+ - `ops-agent learning-closeout <TASK>` выполнен после заполнения retrospective: `[fill in]`
19
21
  - `ops-agent memory-candidates` выполнен после заполнения retrospective/check/verify feedback: `[fill in]`
20
22
  - `ops-agent learning-index` создал human-reviewable decisions: `[fill in]`
21
23
  - `ops-agent learning-review` создал human approval pack и был показан human: `[fill in]`
24
+ - Каждый learning показан human отдельной карточкой с summary/target/source/reason/proposed change/scope/confidence/risk/decision: `[fill in]`
22
25
  - `learning-index.json` reviewed human/supervisor: `[fill in]`
23
26
  - Promoted to project memory: `[fill in]`
24
27
  - Promoted to project playbooks: `[fill in]`