@besales/ops-framework 0.1.5 → 0.1.7
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 +13 -0
- package/README.md +29 -0
- package/bin/closeout.mjs +224 -0
- package/bin/closeout.test.mjs +46 -0
- package/bin/initiative.mjs +530 -0
- package/bin/initiative.test.mjs +92 -0
- package/bin/lib/bootstrap-utils.mjs +6 -0
- package/bin/lib/bootstrap-utils.test.mjs +3 -0
- package/bin/lib/project-config.mjs +2 -0
- package/bin/ops-agent.mjs +6 -1
- package/package.json +1 -1
- package/prompts/supervisor.md +1 -1
- package/templates/retrospective.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
- Added the first Initiative Framework layer above tasks for MVPs, large feature trains and multi-phase work.
|
|
6
|
+
- Added `initiative-create`, `initiative-add-work-package`, `initiative-status` and `initiative-next`.
|
|
7
|
+
- Added work-package task materialization so a work package remains a normal task while slices stay inside `plan.md` and `execution.md`.
|
|
8
|
+
- Added `ops.initiativesDir` project config support and generated project scripts for initiative commands.
|
|
9
|
+
|
|
10
|
+
## 0.1.6
|
|
11
|
+
|
|
12
|
+
- Added `ops-agent closeout <TASK>` as the Supervisor entrypoint before final task closure.
|
|
13
|
+
- Closeout now validates Verify and Retrospective readiness, automatically runs learning closeout, pauses for unresolved learning decisions and can apply approved learning with `--apply-approved`.
|
|
14
|
+
- Added generated project script support for `agent:closeout`.
|
|
15
|
+
|
|
3
16
|
## 0.1.5
|
|
4
17
|
|
|
5
18
|
- Added `ops-agent learning-closeout <TASK>` as the default post-retrospective learning checkpoint.
|
package/README.md
CHANGED
|
@@ -179,6 +179,11 @@ Do not commit that `file:` dependency to production projects. It is only for pac
|
|
|
179
179
|
- `learning-audit`
|
|
180
180
|
- `learning-report`
|
|
181
181
|
- `learning-closeout`
|
|
182
|
+
- `closeout`
|
|
183
|
+
- `initiative-create`
|
|
184
|
+
- `initiative-add-work-package`
|
|
185
|
+
- `initiative-status`
|
|
186
|
+
- `initiative-next`
|
|
182
187
|
- `test/self-test`
|
|
183
188
|
|
|
184
189
|
## Learning Loop
|
|
@@ -203,6 +208,7 @@ ops-agent learning-audit
|
|
|
203
208
|
ops-agent update-memory --apply-approved
|
|
204
209
|
ops-agent learning-report
|
|
205
210
|
ops-agent learning-closeout TASK-001-example
|
|
211
|
+
ops-agent closeout TASK-001-example
|
|
206
212
|
```
|
|
207
213
|
|
|
208
214
|
`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.
|
|
@@ -213,8 +219,31 @@ ops-agent learning-closeout TASK-001-example
|
|
|
213
219
|
|
|
214
220
|
`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.
|
|
215
221
|
|
|
222
|
+
`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.
|
|
223
|
+
|
|
216
224
|
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.
|
|
217
225
|
|
|
226
|
+
## Initiative Framework
|
|
227
|
+
|
|
228
|
+
Initiatives are the program-level layer above tasks. Use them for MVPs, large feature trains and multi-phase functionality where a one-task-at-a-time flow would be too slow.
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
ops-agent initiative-create delivery-os-mvp --title "Delivery OS MVP" --mode fast_mvp
|
|
232
|
+
ops-agent initiative-add-work-package delivery-os-mvp WP-001-foundation --title "Foundation"
|
|
233
|
+
ops-agent initiative-status delivery-os-mvp
|
|
234
|
+
ops-agent initiative-next delivery-os-mvp --materialize-task
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The hierarchy is:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
Initiative
|
|
241
|
+
-> Work-package task
|
|
242
|
+
-> Execution slices inside plan.md/execution.md
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Work packages are materialized as normal tasks, so `brief/research/plan/check/execute/verify/retrospective/learning` still works at the audit boundary. Slices are not separate tasks; they use fast execution, micro-verify and slice evidence inside the work-package task. Create a separate task only when a slice triggers scope, risk, architecture or human-approval escalation.
|
|
246
|
+
|
|
218
247
|
## Feedback Intake
|
|
219
248
|
|
|
220
249
|
Feedback is stage-agnostic. Any user question, correction, review note or learning observation during an active task should be captured before it is acted on:
|
package/bin/closeout.mjs
ADDED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
getFlag,
|
|
6
|
+
parseCliArgs,
|
|
7
|
+
updateMarkdownSection,
|
|
8
|
+
} from './lib/check-context-utils.mjs';
|
|
9
|
+
import {
|
|
10
|
+
createTask,
|
|
11
|
+
summarizeChanges,
|
|
12
|
+
} from './lib/bootstrap-utils.mjs';
|
|
13
|
+
import { resolveProjectContext } from './lib/project-config.mjs';
|
|
14
|
+
|
|
15
|
+
const INITIATIVE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
|
+
const WORK_PACKAGE_ID_PATTERN = /^WP-\d{3}-[a-z0-9][a-z0-9-]*$/;
|
|
17
|
+
|
|
18
|
+
export function main() {
|
|
19
|
+
const command = process.argv[2];
|
|
20
|
+
const args = parseCliArgs(process.argv.slice(3));
|
|
21
|
+
try {
|
|
22
|
+
if (command === 'initiative-create') {
|
|
23
|
+
const result = createInitiative({
|
|
24
|
+
projectRoot: process.cwd(),
|
|
25
|
+
initiativeId: args.positional[0],
|
|
26
|
+
title: getFlag(args, 'title', null),
|
|
27
|
+
goal: getFlag(args, 'goal', null),
|
|
28
|
+
mode: getFlag(args, 'mode', 'fast_mvp'),
|
|
29
|
+
force: args.flags.has('force'),
|
|
30
|
+
});
|
|
31
|
+
printChangeSummary(`Initiative created: ${result.initiativeId}`, result.changes);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (command === 'initiative-add-work-package') {
|
|
35
|
+
const result = addWorkPackage({
|
|
36
|
+
projectRoot: process.cwd(),
|
|
37
|
+
initiativeId: args.positional[0],
|
|
38
|
+
workPackageId: args.positional[1],
|
|
39
|
+
title: getFlag(args, 'title', null),
|
|
40
|
+
goal: getFlag(args, 'goal', null),
|
|
41
|
+
mode: getFlag(args, 'mode', 'standard_work_package'),
|
|
42
|
+
force: args.flags.has('force'),
|
|
43
|
+
});
|
|
44
|
+
printChangeSummary(`Work package added: ${result.workPackageId}`, result.changes);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (command === 'initiative-status') {
|
|
48
|
+
const result = initiativeStatus({
|
|
49
|
+
projectRoot: process.cwd(),
|
|
50
|
+
initiativeId: args.positional[0],
|
|
51
|
+
});
|
|
52
|
+
printInitiativeStatus(result);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (command === 'initiative-next') {
|
|
56
|
+
const result = initiativeNext({
|
|
57
|
+
projectRoot: process.cwd(),
|
|
58
|
+
initiativeId: args.positional[0],
|
|
59
|
+
materializeTask: args.flags.has('materialize-task'),
|
|
60
|
+
taskId: getFlag(args, 'task-id', null),
|
|
61
|
+
force: args.flags.has('force'),
|
|
62
|
+
});
|
|
63
|
+
printInitiativeNext(result);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
fail('Usage: ops-agent initiative-create|initiative-add-work-package|initiative-status|initiative-next ...');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
fail(error.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createInitiative({
|
|
73
|
+
projectRoot = process.cwd(),
|
|
74
|
+
initiativeId,
|
|
75
|
+
title,
|
|
76
|
+
goal,
|
|
77
|
+
mode = 'fast_mvp',
|
|
78
|
+
force = false,
|
|
79
|
+
} = {}) {
|
|
80
|
+
assertInitiativeId(initiativeId);
|
|
81
|
+
const context = resolveProjectContext({ cwd: projectRoot });
|
|
82
|
+
const initiativeDir = path.join(context.initiativesRoot, initiativeId);
|
|
83
|
+
const workPackagesDir = path.join(initiativeDir, 'work-packages');
|
|
84
|
+
const changes = [];
|
|
85
|
+
ensureDirectory(context.initiativesRoot, changes);
|
|
86
|
+
ensureDirectory(initiativeDir, changes);
|
|
87
|
+
ensureDirectory(workPackagesDir, changes);
|
|
88
|
+
const displayTitle = title || humanizeSlug(initiativeId);
|
|
89
|
+
const displayGoal = goal || 'Define MVP/program outcome before planning work packages.';
|
|
90
|
+
writeFileIfAllowed(path.join(initiativeDir, 'initiative.yaml'), buildInitiativeYaml({
|
|
91
|
+
initiativeId,
|
|
92
|
+
title: displayTitle,
|
|
93
|
+
goal: displayGoal,
|
|
94
|
+
mode,
|
|
95
|
+
}), { force, changes });
|
|
96
|
+
writeFileIfAllowed(path.join(initiativeDir, 'initiative.md'), buildInitiativeMarkdown({
|
|
97
|
+
initiativeId,
|
|
98
|
+
title: displayTitle,
|
|
99
|
+
goal: displayGoal,
|
|
100
|
+
mode,
|
|
101
|
+
}), { force, changes });
|
|
102
|
+
writeFileIfAllowed(path.join(initiativeDir, 'status.md'), buildInitiativeStatus({
|
|
103
|
+
initiativeId,
|
|
104
|
+
title: displayTitle,
|
|
105
|
+
}), { force, changes });
|
|
106
|
+
writeFileIfAllowed(path.join(workPackagesDir, 'README.md'), buildWorkPackagesReadme(), { force, changes });
|
|
107
|
+
return {
|
|
108
|
+
initiativeId,
|
|
109
|
+
initiativeDir,
|
|
110
|
+
changes,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function addWorkPackage({
|
|
115
|
+
projectRoot = process.cwd(),
|
|
116
|
+
initiativeId,
|
|
117
|
+
workPackageId,
|
|
118
|
+
title,
|
|
119
|
+
goal,
|
|
120
|
+
mode = 'standard_work_package',
|
|
121
|
+
force = false,
|
|
122
|
+
} = {}) {
|
|
123
|
+
assertInitiativeId(initiativeId);
|
|
124
|
+
assertWorkPackageId(workPackageId);
|
|
125
|
+
const context = resolveProjectContext({ cwd: projectRoot });
|
|
126
|
+
const initiativeDir = path.join(context.initiativesRoot, initiativeId);
|
|
127
|
+
if (!fs.existsSync(initiativeDir)) {
|
|
128
|
+
throw new Error(`Initiative not found: ${initiativeId}. Run initiative-create first.`);
|
|
129
|
+
}
|
|
130
|
+
const workPackageDir = path.join(initiativeDir, 'work-packages', workPackageId);
|
|
131
|
+
const changes = [];
|
|
132
|
+
ensureDirectory(workPackageDir, changes);
|
|
133
|
+
writeFileIfAllowed(path.join(workPackageDir, 'work-package.md'), buildWorkPackageMarkdown({
|
|
134
|
+
initiativeId,
|
|
135
|
+
workPackageId,
|
|
136
|
+
title: title || humanizeSlug(workPackageId.replace(/^WP-\d{3}-/, '')),
|
|
137
|
+
goal: goal || 'Define work-package outcome before materializing a task.',
|
|
138
|
+
mode,
|
|
139
|
+
}), { force, changes });
|
|
140
|
+
return {
|
|
141
|
+
initiativeId,
|
|
142
|
+
workPackageId,
|
|
143
|
+
workPackageDir,
|
|
144
|
+
changes,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function initiativeStatus({ projectRoot = process.cwd(), initiativeId } = {}) {
|
|
149
|
+
const initiative = readInitiative({ projectRoot, initiativeId });
|
|
150
|
+
const workPackages = listWorkPackages(initiative.initiativeDir);
|
|
151
|
+
return {
|
|
152
|
+
...initiative,
|
|
153
|
+
workPackages,
|
|
154
|
+
counts: countBy(workPackages, (wp) => wp.status || 'pending'),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function initiativeNext({
|
|
159
|
+
projectRoot = process.cwd(),
|
|
160
|
+
initiativeId,
|
|
161
|
+
materializeTask = false,
|
|
162
|
+
taskId = null,
|
|
163
|
+
force = false,
|
|
164
|
+
} = {}) {
|
|
165
|
+
const status = initiativeStatus({ projectRoot, initiativeId });
|
|
166
|
+
const next = status.workPackages.find((wp) => ['pending', 'ready'].includes(wp.status));
|
|
167
|
+
if (!next) {
|
|
168
|
+
return {
|
|
169
|
+
initiativeId,
|
|
170
|
+
next: null,
|
|
171
|
+
materializedTask: null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
let materializedTask = null;
|
|
175
|
+
if (materializeTask) {
|
|
176
|
+
materializedTask = materializeWorkPackageTask({
|
|
177
|
+
projectRoot,
|
|
178
|
+
initiativeId,
|
|
179
|
+
workPackage: next,
|
|
180
|
+
taskId: taskId || taskIdForWorkPackage(next.id),
|
|
181
|
+
force,
|
|
182
|
+
});
|
|
183
|
+
updateWorkPackageStatus({
|
|
184
|
+
workPackagePath: next.path,
|
|
185
|
+
status: 'in_progress',
|
|
186
|
+
taskId: materializedTask.taskId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
initiativeId,
|
|
191
|
+
next,
|
|
192
|
+
materializedTask,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function materializeWorkPackageTask({ projectRoot, initiativeId, workPackage, taskId, force }) {
|
|
197
|
+
const task = createTask({
|
|
198
|
+
projectRoot,
|
|
199
|
+
taskId,
|
|
200
|
+
title: workPackage.title,
|
|
201
|
+
owner: 'Supervisor',
|
|
202
|
+
force,
|
|
203
|
+
});
|
|
204
|
+
const briefPath = path.join(task.taskDir, 'brief.md');
|
|
205
|
+
const planPath = path.join(task.taskDir, 'plan.md');
|
|
206
|
+
const brief = fs.readFileSync(briefPath, 'utf8');
|
|
207
|
+
const plan = fs.readFileSync(planPath, 'utf8');
|
|
208
|
+
fs.writeFileSync(briefPath, updateMarkdownSection(brief, 'Initiative Context', [
|
|
209
|
+
`- Initiative: \`${initiativeId}\``,
|
|
210
|
+
`- Work package: \`${workPackage.id}\``,
|
|
211
|
+
`- Work package mode: \`${workPackage.mode}\``,
|
|
212
|
+
`- Goal: ${workPackage.goal}`,
|
|
213
|
+
'',
|
|
214
|
+
'This task is a work-package task. Do not create separate tasks for planned slices unless scope/risk escalation requires it.',
|
|
215
|
+
].join('\n')));
|
|
216
|
+
fs.writeFileSync(planPath, updateMarkdownSection(plan, 'Execution Slices', renderExecutionSlices(workPackage)));
|
|
217
|
+
return task;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readInitiative({ projectRoot, initiativeId }) {
|
|
221
|
+
assertInitiativeId(initiativeId);
|
|
222
|
+
const context = resolveProjectContext({ cwd: projectRoot });
|
|
223
|
+
const initiativeDir = path.join(context.initiativesRoot, initiativeId);
|
|
224
|
+
if (!fs.existsSync(initiativeDir)) {
|
|
225
|
+
throw new Error(`Initiative not found: ${initiativeId}`);
|
|
226
|
+
}
|
|
227
|
+
const meta = readSimpleYaml(path.join(initiativeDir, 'initiative.yaml'));
|
|
228
|
+
return {
|
|
229
|
+
initiativeId,
|
|
230
|
+
initiativeDir,
|
|
231
|
+
title: meta.title || humanizeSlug(initiativeId),
|
|
232
|
+
mode: meta.mode || 'fast_mvp',
|
|
233
|
+
goal: meta.goal || '',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listWorkPackages(initiativeDir) {
|
|
238
|
+
const root = path.join(initiativeDir, 'work-packages');
|
|
239
|
+
if (!fs.existsSync(root)) {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
243
|
+
.filter((entry) => entry.isDirectory() && WORK_PACKAGE_ID_PATTERN.test(entry.name))
|
|
244
|
+
.map((entry) => readWorkPackage(path.join(root, entry.name, 'work-package.md'), entry.name))
|
|
245
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readWorkPackage(filePath, fallbackId) {
|
|
249
|
+
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
250
|
+
return {
|
|
251
|
+
id: readInlineField(content, 'ID') || fallbackId,
|
|
252
|
+
title: readInlineField(content, 'Title') || humanizeSlug(fallbackId.replace(/^WP-\d{3}-/, '')),
|
|
253
|
+
status: readInlineField(content, 'Status') || 'pending',
|
|
254
|
+
task: readInlineField(content, 'Task') || '',
|
|
255
|
+
mode: readInlineField(content, 'Mode') || 'standard_work_package',
|
|
256
|
+
goal: readSection(content, 'Goal') || '',
|
|
257
|
+
slices: readBulletsFromSection(content, 'Slices'),
|
|
258
|
+
path: filePath,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function updateWorkPackageStatus({ workPackagePath, status, taskId }) {
|
|
263
|
+
const content = fs.readFileSync(workPackagePath, 'utf8');
|
|
264
|
+
let updated = content
|
|
265
|
+
.replace(/^Status:.*$/m, `Status: ${status}`)
|
|
266
|
+
.replace(/^Task:.*$/m, `Task: ${taskId}`);
|
|
267
|
+
if (updated === content) {
|
|
268
|
+
updated = `${content.trimEnd()}\n\nStatus: ${status}\nTask: ${taskId}\n`;
|
|
269
|
+
}
|
|
270
|
+
fs.writeFileSync(workPackagePath, updated.endsWith('\n') ? updated : `${updated}\n`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildInitiativeYaml({ initiativeId, title, goal, mode }) {
|
|
274
|
+
return [
|
|
275
|
+
'schemaVersion: 1',
|
|
276
|
+
`id: ${initiativeId}`,
|
|
277
|
+
`title: ${title}`,
|
|
278
|
+
`mode: ${mode}`,
|
|
279
|
+
'humanAttention: milestone_and_escalations',
|
|
280
|
+
'riskPolicy: strict_on_high_risk_only',
|
|
281
|
+
`goal: ${goal}`,
|
|
282
|
+
'',
|
|
283
|
+
].join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function buildInitiativeMarkdown({ initiativeId, title, goal, mode }) {
|
|
287
|
+
return [
|
|
288
|
+
`# ${title}`,
|
|
289
|
+
'',
|
|
290
|
+
`ID: ${initiativeId}`,
|
|
291
|
+
`Mode: ${mode}`,
|
|
292
|
+
'Human attention: milestone_and_escalations',
|
|
293
|
+
'Risk policy: strict_on_high_risk_only',
|
|
294
|
+
'',
|
|
295
|
+
'## Goal',
|
|
296
|
+
'',
|
|
297
|
+
goal,
|
|
298
|
+
'',
|
|
299
|
+
'## Phases',
|
|
300
|
+
'',
|
|
301
|
+
'- Phase 1: Foundation',
|
|
302
|
+
'- Phase 2: Core workflows',
|
|
303
|
+
'- Phase 3: UI/API integration',
|
|
304
|
+
'- Phase 4: QA, rollout and closeout',
|
|
305
|
+
'',
|
|
306
|
+
'## Work Package Policy',
|
|
307
|
+
'',
|
|
308
|
+
'- Work package is materialized as a normal task and remains the audit/logging unit.',
|
|
309
|
+
'- Slices live inside the work-package task plan/execution, not as separate task folders.',
|
|
310
|
+
'- Create a separate task only when a slice triggers scope, risk or architecture escalation.',
|
|
311
|
+
'',
|
|
312
|
+
].join('\n');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildInitiativeStatus({ initiativeId, title }) {
|
|
316
|
+
return [
|
|
317
|
+
'# Initiative Status',
|
|
318
|
+
'',
|
|
319
|
+
`Initiative: ${initiativeId}`,
|
|
320
|
+
`Title: ${title}`,
|
|
321
|
+
'Current phase: planning',
|
|
322
|
+
'Current work package: none',
|
|
323
|
+
'Human approval needed: yes',
|
|
324
|
+
'Next step: Add work packages, then run initiative-next.',
|
|
325
|
+
'',
|
|
326
|
+
].join('\n');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildWorkPackagesReadme() {
|
|
330
|
+
return [
|
|
331
|
+
'# Work Packages',
|
|
332
|
+
'',
|
|
333
|
+
'Each `WP-000-slug` is a planned work-package scope.',
|
|
334
|
+
'',
|
|
335
|
+
'Use `ops-agent initiative-next <initiative> --materialize-task` to turn the next pending work package into a normal task.',
|
|
336
|
+
'',
|
|
337
|
+
].join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildWorkPackageMarkdown({ initiativeId, workPackageId, title, goal, mode }) {
|
|
341
|
+
return [
|
|
342
|
+
'# Work Package',
|
|
343
|
+
'',
|
|
344
|
+
`ID: ${workPackageId}`,
|
|
345
|
+
`Initiative: ${initiativeId}`,
|
|
346
|
+
`Title: ${title}`,
|
|
347
|
+
'Status: pending',
|
|
348
|
+
'Task:',
|
|
349
|
+
`Mode: ${mode}`,
|
|
350
|
+
'',
|
|
351
|
+
'## Goal',
|
|
352
|
+
'',
|
|
353
|
+
goal,
|
|
354
|
+
'',
|
|
355
|
+
'## Boundary',
|
|
356
|
+
'',
|
|
357
|
+
'- Included:',
|
|
358
|
+
'- Excluded:',
|
|
359
|
+
'- Escalate to separate task if:',
|
|
360
|
+
'',
|
|
361
|
+
'## Slices',
|
|
362
|
+
'',
|
|
363
|
+
'- Slice 1: Define the first implementation slice.',
|
|
364
|
+
'- Slice 2: Add the next implementation slice.',
|
|
365
|
+
'- Slice 3: Add micro-verify and evidence.',
|
|
366
|
+
'',
|
|
367
|
+
'## Acceptance',
|
|
368
|
+
'',
|
|
369
|
+
'- Work-package task has completed Verify.',
|
|
370
|
+
'- Slice ledger is recorded in execution.md.',
|
|
371
|
+
'- Learning closeout is completed before task closeout.',
|
|
372
|
+
'',
|
|
373
|
+
].join('\n');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function renderExecutionSlices(workPackage) {
|
|
377
|
+
const slices = workPackage.slices.length
|
|
378
|
+
? workPackage.slices
|
|
379
|
+
: ['Slice 1: Define implementation slice before Execute.'];
|
|
380
|
+
return [
|
|
381
|
+
`Work package: \`${workPackage.id}\``,
|
|
382
|
+
`Mode: \`${workPackage.mode}\``,
|
|
383
|
+
'',
|
|
384
|
+
'| Slice | Intent | Micro-verify | Escalation rule |',
|
|
385
|
+
'| --- | --- | --- | --- |',
|
|
386
|
+
...slices.map((slice, index) => `| S-${String(index + 1).padStart(2, '0')} | ${escapeTable(slice)} | targeted test/typecheck/evidence | create separate task or return to Plan if scope/risk expands |`),
|
|
387
|
+
'',
|
|
388
|
+
].join('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readSimpleYaml(filePath) {
|
|
392
|
+
if (!fs.existsSync(filePath)) {
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
const result = {};
|
|
396
|
+
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
|
397
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
398
|
+
if (match) {
|
|
399
|
+
result[match[1]] = match[2].trim();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readInlineField(content, field) {
|
|
406
|
+
const match = new RegExp(`^${escapeRegExp(field)}:\\s*(.*)$`, 'm').exec(content);
|
|
407
|
+
return match ? match[1].trim() : '';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readSection(content, heading) {
|
|
411
|
+
const match = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*\\n([\\s\\S]*?)(?=^##\\s+|\\s*$)`, 'm').exec(content);
|
|
412
|
+
return match ? match[1].trim() : '';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function readBulletsFromSection(content, heading) {
|
|
416
|
+
return readSection(content, heading)
|
|
417
|
+
.split(/\r?\n/)
|
|
418
|
+
.map((line) => line.replace(/^[-*]\s+/, '').trim())
|
|
419
|
+
.filter(Boolean);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function taskIdForWorkPackage(workPackageId) {
|
|
423
|
+
return workPackageId.replace(/^WP-/, 'TASK-');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function countBy(items, readKey) {
|
|
427
|
+
const counts = {};
|
|
428
|
+
for (const item of items) {
|
|
429
|
+
const key = readKey(item);
|
|
430
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
431
|
+
}
|
|
432
|
+
return counts;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function ensureDirectory(directory, changes) {
|
|
436
|
+
if (fs.existsSync(directory)) {
|
|
437
|
+
changes.push({ path: directory, kind: 'directory', status: 'existing' });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
441
|
+
changes.push({ path: directory, kind: 'directory', status: 'created' });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function writeFileIfAllowed(filePath, content, { force, changes }) {
|
|
445
|
+
const exists = fs.existsSync(filePath);
|
|
446
|
+
if (exists && !force) {
|
|
447
|
+
changes.push({ path: filePath, kind: 'file', status: 'existing' });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
451
|
+
fs.writeFileSync(filePath, content.endsWith('\n') ? content : `${content}\n`);
|
|
452
|
+
changes.push({ path: filePath, kind: 'file', status: exists ? 'overwritten' : 'created' });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function printChangeSummary(title, changes) {
|
|
456
|
+
const summary = summarizeChanges(changes);
|
|
457
|
+
console.log(title);
|
|
458
|
+
console.log(`- created: ${summary.created}`);
|
|
459
|
+
console.log(`- existing: ${summary.existing}`);
|
|
460
|
+
console.log(`- overwritten: ${summary.overwritten}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function printInitiativeStatus(result) {
|
|
464
|
+
console.log(`Initiative status: ${result.initiativeId}`);
|
|
465
|
+
console.log(`- title: ${result.title}`);
|
|
466
|
+
console.log(`- mode: ${result.mode}`);
|
|
467
|
+
console.log(`- workPackages: ${result.workPackages.length}`);
|
|
468
|
+
for (const [status, count] of Object.entries(result.counts)) {
|
|
469
|
+
console.log(`- ${status}: ${count}`);
|
|
470
|
+
}
|
|
471
|
+
for (const wp of result.workPackages) {
|
|
472
|
+
console.log(` - ${wp.id}: ${wp.status}${wp.task ? ` -> ${wp.task}` : ''}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function printInitiativeNext(result) {
|
|
477
|
+
console.log(`Initiative next: ${result.initiativeId}`);
|
|
478
|
+
if (!result.next) {
|
|
479
|
+
console.log('- no pending work packages');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
console.log(`- workPackage: ${result.next.id}`);
|
|
483
|
+
console.log(`- title: ${result.next.title}`);
|
|
484
|
+
console.log(`- mode: ${result.next.mode}`);
|
|
485
|
+
if (result.materializedTask) {
|
|
486
|
+
const summary = summarizeChanges(result.materializedTask.changes);
|
|
487
|
+
console.log(`- task: ${result.materializedTask.taskId}`);
|
|
488
|
+
console.log(`- taskDir: ${result.materializedTask.taskDir}`);
|
|
489
|
+
console.log(`- created: ${summary.created}`);
|
|
490
|
+
console.log(`- existing: ${summary.existing}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function assertInitiativeId(value) {
|
|
495
|
+
if (!INITIATIVE_ID_PATTERN.test(value || '')) {
|
|
496
|
+
throw new Error(`Initiative id must match ${INITIATIVE_ID_PATTERN}: ${value || '<missing>'}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function assertWorkPackageId(value) {
|
|
501
|
+
if (!WORK_PACKAGE_ID_PATTERN.test(value || '')) {
|
|
502
|
+
throw new Error(`Work package id must match ${WORK_PACKAGE_ID_PATTERN}: ${value || '<missing>'}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function humanizeSlug(slug) {
|
|
507
|
+
return slug
|
|
508
|
+
.replace(/^(TASK|WP)-\d{3}-/, '')
|
|
509
|
+
.split('-')
|
|
510
|
+
.filter(Boolean)
|
|
511
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
512
|
+
.join(' ');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function escapeTable(value) {
|
|
516
|
+
return String(value).replace(/\|/g, '\\|');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function escapeRegExp(value) {
|
|
520
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function fail(message) {
|
|
524
|
+
console.error(`Error: ${message}`);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
529
|
+
main();
|
|
530
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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 {
|
|
6
|
+
addWorkPackage,
|
|
7
|
+
createInitiative,
|
|
8
|
+
initiativeNext,
|
|
9
|
+
initiativeStatus,
|
|
10
|
+
} from './initiative.mjs';
|
|
11
|
+
|
|
12
|
+
describe('initiative framework', () => {
|
|
13
|
+
it('creates an initiative and summarizes work-package status', () => {
|
|
14
|
+
const root = makeProject();
|
|
15
|
+
|
|
16
|
+
createInitiative({
|
|
17
|
+
projectRoot: root,
|
|
18
|
+
initiativeId: 'delivery-os-mvp',
|
|
19
|
+
title: 'Delivery OS MVP',
|
|
20
|
+
goal: 'Build the first MVP train.',
|
|
21
|
+
});
|
|
22
|
+
addWorkPackage({
|
|
23
|
+
projectRoot: root,
|
|
24
|
+
initiativeId: 'delivery-os-mvp',
|
|
25
|
+
workPackageId: 'WP-001-foundation',
|
|
26
|
+
title: 'Foundation',
|
|
27
|
+
goal: 'Create the foundation.',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const status = initiativeStatus({ projectRoot: root, initiativeId: 'delivery-os-mvp' });
|
|
31
|
+
|
|
32
|
+
expect(status.mode).toBe('fast_mvp');
|
|
33
|
+
expect(status.workPackages).toHaveLength(1);
|
|
34
|
+
expect(status.workPackages[0]).toMatchObject({
|
|
35
|
+
id: 'WP-001-foundation',
|
|
36
|
+
status: 'pending',
|
|
37
|
+
title: 'Foundation',
|
|
38
|
+
});
|
|
39
|
+
expect(status.counts.pending).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('materializes the next work package as a task with execution slices', () => {
|
|
43
|
+
const root = makeProject();
|
|
44
|
+
createInitiative({
|
|
45
|
+
projectRoot: root,
|
|
46
|
+
initiativeId: 'delivery-os-mvp',
|
|
47
|
+
title: 'Delivery OS MVP',
|
|
48
|
+
});
|
|
49
|
+
addWorkPackage({
|
|
50
|
+
projectRoot: root,
|
|
51
|
+
initiativeId: 'delivery-os-mvp',
|
|
52
|
+
workPackageId: 'WP-001-foundation',
|
|
53
|
+
title: 'Foundation',
|
|
54
|
+
goal: 'Create the foundation.',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result = initiativeNext({
|
|
58
|
+
projectRoot: root,
|
|
59
|
+
initiativeId: 'delivery-os-mvp',
|
|
60
|
+
materializeTask: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.materializedTask.taskId).toBe('TASK-001-foundation');
|
|
64
|
+
const taskDir = path.join(root, 'ops', 'agent-pipeline', 'tasks', 'TASK-001-foundation');
|
|
65
|
+
const brief = fs.readFileSync(path.join(taskDir, 'brief.md'), 'utf8');
|
|
66
|
+
const plan = fs.readFileSync(path.join(taskDir, 'plan.md'), 'utf8');
|
|
67
|
+
const workPackage = fs.readFileSync(path.join(root, 'ops', 'agent-pipeline', 'initiatives', 'delivery-os-mvp', 'work-packages', 'WP-001-foundation', 'work-package.md'), 'utf8');
|
|
68
|
+
expect(brief).toContain('## Initiative Context');
|
|
69
|
+
expect(brief).toContain('Work package: `WP-001-foundation`');
|
|
70
|
+
expect(plan).toContain('## Execution Slices');
|
|
71
|
+
expect(plan).toContain('S-01');
|
|
72
|
+
expect(workPackage).toContain('Status: in_progress');
|
|
73
|
+
expect(workPackage).toContain('Task: TASK-001-foundation');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function makeProject() {
|
|
78
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-initiative-'));
|
|
79
|
+
fs.mkdirSync(path.join(root, 'ops'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(root, 'ops', 'project.ops.yaml'), [
|
|
81
|
+
'name: TestProject',
|
|
82
|
+
'ops:',
|
|
83
|
+
' legacyPipelineDir: ops/agent-pipeline',
|
|
84
|
+
' tasksDir: ops/agent-pipeline/tasks',
|
|
85
|
+
' initiativesDir: ops/agent-pipeline/initiatives',
|
|
86
|
+
' memoryDir: ops/agent-pipeline/memory',
|
|
87
|
+
' cacheDir: ops/agent-pipeline/cache',
|
|
88
|
+
' playbooksDir: ops/agent-pipeline/playbooks',
|
|
89
|
+
'',
|
|
90
|
+
].join('\n'));
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
@@ -120,6 +120,11 @@ export function buildOpsScripts(packageSpec) {
|
|
|
120
120
|
'agent:learning-audit': run('learning-audit'),
|
|
121
121
|
'agent:learning-report': run('learning-report'),
|
|
122
122
|
'agent:learning-closeout': run('learning-closeout'),
|
|
123
|
+
'agent:closeout': run('closeout'),
|
|
124
|
+
'agent:initiative-create': run('initiative-create'),
|
|
125
|
+
'agent:initiative-add-work-package': run('initiative-add-work-package'),
|
|
126
|
+
'agent:initiative-status': run('initiative-status'),
|
|
127
|
+
'agent:initiative-next': run('initiative-next'),
|
|
123
128
|
'agent:test': run('test/self-test'),
|
|
124
129
|
};
|
|
125
130
|
}
|
|
@@ -289,6 +294,7 @@ function buildProjectConfig({ projectName, opsRoot }) {
|
|
|
289
294
|
'ops:',
|
|
290
295
|
` legacyPipelineDir: ${opsRoot}/agent-pipeline`,
|
|
291
296
|
` tasksDir: ${opsRoot}/agent-pipeline/tasks`,
|
|
297
|
+
` initiativesDir: ${opsRoot}/agent-pipeline/initiatives`,
|
|
292
298
|
` memoryDir: ${opsRoot}/agent-pipeline/memory`,
|
|
293
299
|
` cacheDir: ${opsRoot}/agent-pipeline/cache`,
|
|
294
300
|
` playbooksDir: ${opsRoot}/agent-pipeline/playbooks`,
|
|
@@ -139,6 +139,9 @@ describe('buildOpsScripts', () => {
|
|
|
139
139
|
expect(scripts.ops).toBe('ops-agent');
|
|
140
140
|
expect(scripts['agent:quality-gates']).toBe('ops-agent quality-gates');
|
|
141
141
|
expect(scripts['agent:learning-closeout']).toBe('ops-agent learning-closeout');
|
|
142
|
+
expect(scripts['agent:closeout']).toBe('ops-agent closeout');
|
|
143
|
+
expect(scripts['agent:initiative-create']).toBe('ops-agent initiative-create');
|
|
144
|
+
expect(scripts['agent:initiative-next']).toBe('ops-agent initiative-next');
|
|
142
145
|
expect(scripts['agent:test']).toBe('ops-agent test/self-test');
|
|
143
146
|
});
|
|
144
147
|
});
|
|
@@ -134,6 +134,7 @@ function buildConfiguredContext({ projectRoot, configPath, config }) {
|
|
|
134
134
|
frameworkRoot,
|
|
135
135
|
pipelineRoot: legacyPipelineRoot,
|
|
136
136
|
tasksRoot: resolveProjectPath(projectRoot, ops.tasksDir || 'ops/agent-pipeline/tasks'),
|
|
137
|
+
initiativesRoot: resolveProjectPath(projectRoot, ops.initiativesDir || 'ops/agent-pipeline/initiatives'),
|
|
137
138
|
memoryRoot: resolveProjectPath(projectRoot, ops.memoryDir || 'ops/agent-pipeline/memory'),
|
|
138
139
|
cacheRoot: resolveProjectPath(projectRoot, ops.cacheDir || 'ops/agent-pipeline/cache'),
|
|
139
140
|
promptsRoot: path.join(frameworkRoot, 'prompts'),
|
|
@@ -163,6 +164,7 @@ function buildLegacyContext({ cwd }) {
|
|
|
163
164
|
frameworkRoot,
|
|
164
165
|
pipelineRoot: legacyPipelineRoot,
|
|
165
166
|
tasksRoot: path.join(legacyPipelineRoot, 'tasks'),
|
|
167
|
+
initiativesRoot: path.join(legacyPipelineRoot, 'initiatives'),
|
|
166
168
|
memoryRoot: path.join(legacyPipelineRoot, 'memory'),
|
|
167
169
|
cacheRoot: path.join(legacyPipelineRoot, 'cache'),
|
|
168
170
|
promptsRoot: path.join(frameworkRoot, 'prompts'),
|
package/bin/ops-agent.mjs
CHANGED
|
@@ -33,6 +33,11 @@ const COMMANDS = new Map([
|
|
|
33
33
|
['learning-audit', 'learning-loop.mjs'],
|
|
34
34
|
['learning-report', 'learning-loop.mjs'],
|
|
35
35
|
['learning-closeout', 'learning-loop.mjs'],
|
|
36
|
+
['closeout', 'closeout.mjs'],
|
|
37
|
+
['initiative-create', 'initiative.mjs'],
|
|
38
|
+
['initiative-add-work-package', 'initiative.mjs'],
|
|
39
|
+
['initiative-status', 'initiative.mjs'],
|
|
40
|
+
['initiative-next', 'initiative.mjs'],
|
|
36
41
|
['test/self-test', null],
|
|
37
42
|
]);
|
|
38
43
|
|
|
@@ -57,7 +62,7 @@ function main() {
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
const script = path.join(binRoot, COMMANDS.get(command));
|
|
60
|
-
const scriptArgs =
|
|
65
|
+
const scriptArgs = ['learning-loop.mjs', 'initiative.mjs'].includes(COMMANDS.get(command)) ? [command, ...args] : args;
|
|
61
66
|
const result = spawnSync(process.execPath, [script, ...scriptArgs], {
|
|
62
67
|
cwd: process.cwd(),
|
|
63
68
|
env: process.env,
|
package/package.json
CHANGED
package/prompts/supervisor.md
CHANGED
|
@@ -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 запускает `
|
|
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,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
## Learning Loop Audit
|
|
18
18
|
|
|
19
|
+
- `ops-agent closeout <TASK>` запущен как основной closeout entrypoint: `[fill in]`
|
|
19
20
|
- `ops-agent learning-closeout <TASK>` выполнен после заполнения retrospective: `[fill in]`
|
|
20
21
|
- `ops-agent memory-candidates` выполнен после заполнения retrospective/check/verify feedback: `[fill in]`
|
|
21
22
|
- `ops-agent learning-index` создал human-reviewable decisions: `[fill in]`
|