openplanr 1.18.0 → 1.19.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.
- package/dist/services/operate/advisors.d.ts +65 -5
- package/dist/services/operate/advisors.d.ts.map +1 -1
- package/dist/services/operate/advisors.js +101 -6
- package/dist/services/operate/advisors.js.map +1 -1
- package/dist/services/operate/config.d.ts +10 -0
- package/dist/services/operate/config.d.ts.map +1 -1
- package/dist/services/operate/config.js +53 -6
- package/dist/services/operate/config.js.map +1 -1
- package/dist/services/operate/engine.d.ts +22 -6
- package/dist/services/operate/engine.d.ts.map +1 -1
- package/dist/services/operate/engine.js +20 -9
- package/dist/services/operate/engine.js.map +1 -1
- package/dist/services/operate/evidence-readiness.d.ts +11 -1
- package/dist/services/operate/evidence-readiness.d.ts.map +1 -1
- package/dist/services/operate/evidence-readiness.js +12 -2
- package/dist/services/operate/evidence-readiness.js.map +1 -1
- package/dist/services/operate/evidence.d.ts +30 -1
- package/dist/services/operate/evidence.d.ts.map +1 -1
- package/dist/services/operate/evidence.js +219 -10
- package/dist/services/operate/evidence.js.map +1 -1
- package/dist/services/operate/index.d.ts.map +1 -1
- package/dist/services/operate/index.js +5 -0
- package/dist/services/operate/index.js.map +1 -1
- package/dist/services/operate/lifecycle.d.ts.map +1 -1
- package/dist/services/operate/lifecycle.js +17 -1
- package/dist/services/operate/lifecycle.js.map +1 -1
- package/dist/services/operate/maintenance.d.ts +11 -1
- package/dist/services/operate/maintenance.d.ts.map +1 -1
- package/dist/services/operate/maintenance.js +78 -11
- package/dist/services/operate/maintenance.js.map +1 -1
- package/dist/services/operate/projection-persistence.d.ts +35 -7
- package/dist/services/operate/projection-persistence.d.ts.map +1 -1
- package/dist/services/operate/projection-persistence.js +122 -53
- package/dist/services/operate/projection-persistence.js.map +1 -1
- package/dist/services/operate/projection.d.ts +9 -1
- package/dist/services/operate/projection.d.ts.map +1 -1
- package/dist/services/operate/projection.js +12 -9
- package/dist/services/operate/projection.js.map +1 -1
- package/dist/services/operate/reports.d.ts +9 -0
- package/dist/services/operate/reports.d.ts.map +1 -1
- package/dist/services/operate/reports.js +20 -1
- package/dist/services/operate/reports.js.map +1 -1
- package/dist/services/operate/routes.d.ts +84 -0
- package/dist/services/operate/routes.d.ts.map +1 -1
- package/dist/services/operate/routes.js +519 -10
- package/dist/services/operate/routes.js.map +1 -1
- package/dist/services/operate/types.d.ts +6 -5
- package/dist/services/operate/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { renderTemplate } from '../template-service.js';
|
|
3
4
|
import { createOperatingArtifactGenerationPlan, generatedArtifactWrites, generateOperatingRouteArtifact, readStoredOperatingArtifactGeneration, resolveOperatingArtifactGenerator, } from './artifact-route-generation.js';
|
|
4
5
|
import { canonicalDigest, canonicalize, sha256Digest } from './canonical.js';
|
|
5
|
-
import { operatingProjectKey } from './config.js';
|
|
6
|
+
import { operatingProjectKey, validateOperatingConfiguration } from './config.js';
|
|
7
|
+
import { semanticallyEquivalentFindings } from './consolidation.js';
|
|
6
8
|
import { OperatingEventStore } from './event-store.js';
|
|
7
9
|
import { applyJournalTransaction, prepareJournalTransaction, readJournal, rollbackJournalTransaction, } from './journal.js';
|
|
8
10
|
import { withOperatingLock } from './lock-service.js';
|
|
@@ -31,7 +33,14 @@ function isSmallBoundedImplementation(finding) {
|
|
|
31
33
|
finding.ease >= 4 &&
|
|
32
34
|
finding.impact <= 2);
|
|
33
35
|
}
|
|
34
|
-
function actionKind(finding) {
|
|
36
|
+
function actionKind(finding, isEpicAnchor = false) {
|
|
37
|
+
// Consolidation-level grouping elects `create-epic` first: when this finding
|
|
38
|
+
// is the anchor of a 2+-member group of related accepted findings, the whole
|
|
39
|
+
// theme routes to one epic (the anchor is carried in `findingId`; the full
|
|
40
|
+
// member-finding list lives in the generated epic markdown, per clarifications
|
|
41
|
+
// Option A). Every single-finding lane classification below is unchanged.
|
|
42
|
+
if (isEpicAnchor)
|
|
43
|
+
return 'create-epic';
|
|
35
44
|
if (finding.lane === 'OWNER')
|
|
36
45
|
return 'create-decision';
|
|
37
46
|
if (finding.lane === 'AGENT')
|
|
@@ -42,6 +51,169 @@ function actionKind(finding) {
|
|
|
42
51
|
return 'create-quick-task';
|
|
43
52
|
return 'create-spec';
|
|
44
53
|
}
|
|
54
|
+
function text(value) {
|
|
55
|
+
return typeof value === 'string' ? value : '';
|
|
56
|
+
}
|
|
57
|
+
function evidenceRefList(value) {
|
|
58
|
+
return Array.isArray(value)
|
|
59
|
+
? [...new Set(value.filter((entry) => typeof entry === 'string'))].sort()
|
|
60
|
+
: [];
|
|
61
|
+
}
|
|
62
|
+
function toEpicMember(finding) {
|
|
63
|
+
return {
|
|
64
|
+
id: finding.id,
|
|
65
|
+
title: text(finding.title) || finding.id,
|
|
66
|
+
problem: text(finding.problem),
|
|
67
|
+
proposal: text(finding.proposal),
|
|
68
|
+
evidenceRefs: evidenceRefList(finding.evidenceRefs),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const EPIC_THEME_STOP_WORDS = new Set([
|
|
72
|
+
'the',
|
|
73
|
+
'a',
|
|
74
|
+
'an',
|
|
75
|
+
'and',
|
|
76
|
+
'or',
|
|
77
|
+
'to',
|
|
78
|
+
'of',
|
|
79
|
+
'for',
|
|
80
|
+
'in',
|
|
81
|
+
'on',
|
|
82
|
+
'with',
|
|
83
|
+
'is',
|
|
84
|
+
'are',
|
|
85
|
+
'be',
|
|
86
|
+
'from',
|
|
87
|
+
'by',
|
|
88
|
+
'that',
|
|
89
|
+
'this',
|
|
90
|
+
'it',
|
|
91
|
+
'its',
|
|
92
|
+
]);
|
|
93
|
+
function themeWords(title) {
|
|
94
|
+
return title
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.normalize('NFKD')
|
|
97
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
98
|
+
.split(/\s+/)
|
|
99
|
+
.filter((word) => word.length > 2 && !EPIC_THEME_STOP_WORDS.has(word));
|
|
100
|
+
}
|
|
101
|
+
/** Deterministic, human-legible epic title derived from the members' shared words. */
|
|
102
|
+
function deriveEpicTheme(members, category) {
|
|
103
|
+
const counts = new Map();
|
|
104
|
+
for (const member of members) {
|
|
105
|
+
for (const word of new Set(themeWords(member.title))) {
|
|
106
|
+
counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const anchorOrder = themeWords(members[0]?.title ?? '');
|
|
110
|
+
const shared = [...counts.entries()]
|
|
111
|
+
.filter(([, count]) => count >= 2)
|
|
112
|
+
.map(([word]) => word)
|
|
113
|
+
.sort((left, right) => {
|
|
114
|
+
const leftIndex = anchorOrder.indexOf(left);
|
|
115
|
+
const rightIndex = anchorOrder.indexOf(right);
|
|
116
|
+
return ((leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
|
117
|
+
(rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex) || left.localeCompare(right));
|
|
118
|
+
})
|
|
119
|
+
.slice(0, 5);
|
|
120
|
+
const focus = shared.length > 0 ? shared.join(' ') : `related ${category}`;
|
|
121
|
+
const capped = focus.charAt(0).toUpperCase() + focus.slice(1);
|
|
122
|
+
return `${capped} (${members.length} related findings)`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Group related accepted findings into epic candidates. Two accepted findings
|
|
126
|
+
* are related when they share a non-empty normalized `category`, share the same
|
|
127
|
+
* evidence-derived `fingerprint` lineage, or are semantically equivalent per
|
|
128
|
+
* `consolidation.ts` (which subsumes the Chair merge-proposal source, since a
|
|
129
|
+
* merged finding keeps that shared category/fingerprint). Only components of 2+
|
|
130
|
+
* members become epic groups; every group is deterministic (union-find over
|
|
131
|
+
* id-sorted findings), so the FR7 report suggestion and the FR8 engine route
|
|
132
|
+
* elect exactly the same theme.
|
|
133
|
+
*/
|
|
134
|
+
export function groupRelatedAcceptedFindings(findings) {
|
|
135
|
+
const accepted = findings
|
|
136
|
+
.filter((finding) => text(finding.category).trim().length > 0)
|
|
137
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
138
|
+
const parent = accepted.map((_, index) => index);
|
|
139
|
+
const find = (index) => {
|
|
140
|
+
let current = index;
|
|
141
|
+
while (parent[current] !== current) {
|
|
142
|
+
parent[current] = parent[parent[current]];
|
|
143
|
+
current = parent[current];
|
|
144
|
+
}
|
|
145
|
+
return current;
|
|
146
|
+
};
|
|
147
|
+
const union = (left, right) => {
|
|
148
|
+
const [keep, drop] = [find(left), find(right)].sort((a, b) => a - b);
|
|
149
|
+
if (keep !== drop)
|
|
150
|
+
parent[drop] = keep;
|
|
151
|
+
};
|
|
152
|
+
const related = (left, right) => {
|
|
153
|
+
const leftCategory = text(left.category).trim().toLowerCase();
|
|
154
|
+
const rightCategory = text(right.category).trim().toLowerCase();
|
|
155
|
+
if (leftCategory && leftCategory === rightCategory)
|
|
156
|
+
return true;
|
|
157
|
+
const leftPrint = text(left.fingerprint);
|
|
158
|
+
if (leftPrint && leftPrint === text(right.fingerprint))
|
|
159
|
+
return true;
|
|
160
|
+
return semanticallyEquivalentFindings({
|
|
161
|
+
category: text(left.category),
|
|
162
|
+
title: text(left.title),
|
|
163
|
+
problem: text(left.problem),
|
|
164
|
+
proposal: text(left.proposal),
|
|
165
|
+
sensitivity: (text(left.sensitivity) || 'internal'),
|
|
166
|
+
}, {
|
|
167
|
+
category: text(right.category),
|
|
168
|
+
title: text(right.title),
|
|
169
|
+
problem: text(right.problem),
|
|
170
|
+
proposal: text(right.proposal),
|
|
171
|
+
sensitivity: (text(right.sensitivity) || 'internal'),
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
for (let left = 0; left < accepted.length; left += 1) {
|
|
175
|
+
for (let right = left + 1; right < accepted.length; right += 1) {
|
|
176
|
+
if (related(accepted[left], accepted[right]))
|
|
177
|
+
union(left, right);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const clusters = new Map();
|
|
181
|
+
accepted.forEach((finding, index) => {
|
|
182
|
+
const root = find(index);
|
|
183
|
+
clusters.set(root, [...(clusters.get(root) ?? []), finding]);
|
|
184
|
+
});
|
|
185
|
+
return [...clusters.values()]
|
|
186
|
+
.filter((cluster) => cluster.length >= 2)
|
|
187
|
+
.map((cluster) => {
|
|
188
|
+
const members = cluster
|
|
189
|
+
.map(toEpicMember)
|
|
190
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
191
|
+
const category = text(cluster[0].category);
|
|
192
|
+
return {
|
|
193
|
+
anchorId: members[0].id,
|
|
194
|
+
memberIds: members.map((member) => member.id),
|
|
195
|
+
members,
|
|
196
|
+
category,
|
|
197
|
+
theme: deriveEpicTheme(members, category),
|
|
198
|
+
evidenceRefs: [...new Set(members.flatMap((member) => member.evidenceRefs))].sort(),
|
|
199
|
+
};
|
|
200
|
+
})
|
|
201
|
+
.sort((left, right) => left.anchorId.localeCompare(right.anchorId));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Resolve the epic group anchored by `anchorId` from the committed accepted
|
|
205
|
+
* findings of a cycle. Reading the projected event store (not caller-passed
|
|
206
|
+
* state) is what keeps preview (`createOperatingRoutePlan`) and apply
|
|
207
|
+
* (`applyOperatingRoute`) byte-identical: both rebuild the same member list from
|
|
208
|
+
* the same accepted-finding set. Returns `null` when the anchor no longer heads
|
|
209
|
+
* a 2+-member group, which correctly surfaces as route drift on apply.
|
|
210
|
+
*/
|
|
211
|
+
async function resolveEpicGroupForAnchor(input) {
|
|
212
|
+
const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
|
|
213
|
+
const state = await store.state();
|
|
214
|
+
const acceptedFindings = state.findings.filter((finding) => finding.status === 'accepted' && finding.cycleId === input.cycleId);
|
|
215
|
+
return (groupRelatedAcceptedFindings(acceptedFindings).find((group) => group.anchorId === input.anchorId) ?? null);
|
|
216
|
+
}
|
|
45
217
|
function slugify(value) {
|
|
46
218
|
return (value
|
|
47
219
|
.normalize('NFKD')
|
|
@@ -60,6 +232,16 @@ export async function nextOperatingSpecOrdinal(projectRoot) {
|
|
|
60
232
|
}
|
|
61
233
|
return maximum + 1;
|
|
62
234
|
}
|
|
235
|
+
export async function nextOperatingEpicOrdinal(projectRoot) {
|
|
236
|
+
const root = path.join(projectRoot, '.planr', 'epics');
|
|
237
|
+
let maximum = 0;
|
|
238
|
+
for (const name of await readdir(root).catch(() => [])) {
|
|
239
|
+
const match = name.match(/^EPIC-(\d+)(?:-|$)/);
|
|
240
|
+
if (match)
|
|
241
|
+
maximum = Math.max(maximum, Number(match[1]));
|
|
242
|
+
}
|
|
243
|
+
return maximum + 1;
|
|
244
|
+
}
|
|
63
245
|
function routeDestinationPaths(route) {
|
|
64
246
|
const action = route.actions[0];
|
|
65
247
|
if (!action?.targetPath)
|
|
@@ -83,24 +265,52 @@ function routeDestinationPaths(route) {
|
|
|
83
265
|
`${path.posix.dirname(action.targetPath)}/${artifactId}.session.json`,
|
|
84
266
|
];
|
|
85
267
|
}
|
|
268
|
+
if (action.kind === 'create-epic') {
|
|
269
|
+
// Provenance threads forward through the epic id encoded in `targetPath`
|
|
270
|
+
// (the same id-in-destination pattern the `create-spec` branch above uses),
|
|
271
|
+
// not a new protocol sidecar: the full member-finding list lives inside the
|
|
272
|
+
// generated epic markdown, so the single epic file is the only destination.
|
|
273
|
+
return [action.targetPath];
|
|
274
|
+
}
|
|
86
275
|
return [action.targetPath];
|
|
87
276
|
}
|
|
88
277
|
export async function createOperatingRoutePlan(input) {
|
|
89
278
|
const now = input.now ?? new Date().toISOString();
|
|
90
279
|
const id = `ACT-${String(input.sequence).padStart(3, '0')}`;
|
|
91
|
-
|
|
92
|
-
|
|
280
|
+
// Epic election only fires for an already-accepted anchor finding — the engine
|
|
281
|
+
// creates routes from freshly-proposed findings, so this is skipped there (no
|
|
282
|
+
// store read, unchanged behavior); a caller that has accepted a related group
|
|
283
|
+
// reaches it. The member list is rebuilt from the committed accepted findings.
|
|
284
|
+
const epicGroup = input.finding.status === 'accepted'
|
|
285
|
+
? await resolveEpicGroupForAnchor({
|
|
286
|
+
projectRoot: input.projectRoot,
|
|
287
|
+
localRoot: input.localRoot,
|
|
288
|
+
cycleId: input.cycleId,
|
|
289
|
+
anchorId: input.finding.id,
|
|
290
|
+
})
|
|
291
|
+
: null;
|
|
292
|
+
const kind = actionKind(input.finding, Boolean(epicGroup));
|
|
293
|
+
const epicId = kind === 'create-epic'
|
|
294
|
+
? (input.epicId ??
|
|
295
|
+
`EPIC-${String(await nextOperatingEpicOrdinal(input.projectRoot)).padStart(3, '0')}`)
|
|
296
|
+
: null;
|
|
297
|
+
const slug = slugify(kind === 'create-epic' && epicGroup ? epicGroup.theme : input.finding.title);
|
|
93
298
|
const targetPath = kind === 'create-spec' || kind === 'create-instrumentation-spec'
|
|
94
299
|
? `.planr/specs/${input.specId ?? `SPEC-${String(input.sequence).padStart(3, '0')}`}-${slug}/${input.specId ?? `SPEC-${String(input.sequence).padStart(3, '0')}`}-${slug}.md`
|
|
95
300
|
: kind === 'create-cycle-artifact'
|
|
96
301
|
? `.planr/operate/cycles/${input.cycleId}/artifacts/ART-${id.slice('ACT-'.length)}-${slug}.md`
|
|
97
302
|
: kind === 'create-quick-task'
|
|
98
303
|
? `.planr/quick/QUICK-${id.slice('ACT-'.length)}-${slug}.md`
|
|
99
|
-
:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
304
|
+
: kind === 'create-epic'
|
|
305
|
+
? `.planr/epics/${epicId}-${slug}.md`
|
|
306
|
+
: `.planr/operate/decisions/${id}.json`;
|
|
307
|
+
// A quick-task or epic route validates against the additive v1.3 route-plan
|
|
308
|
+
// schema — the only route-plan schema whose kind enum includes
|
|
309
|
+
// `create-quick-task`/`create-epic`. Every other kind keeps the frozen v1.2
|
|
310
|
+
// envelope untouched.
|
|
311
|
+
const protocolVersion = kind === 'create-quick-task' || kind === 'create-epic'
|
|
312
|
+
? OPERATE_MISSION_PROTOCOL_VERSION
|
|
313
|
+
: OPERATE_PROTOCOL_VERSION;
|
|
104
314
|
const action = {
|
|
105
315
|
id,
|
|
106
316
|
findingId: input.finding.id,
|
|
@@ -108,7 +318,12 @@ export async function createOperatingRoutePlan(input) {
|
|
|
108
318
|
owner: input.finding.owner,
|
|
109
319
|
kind,
|
|
110
320
|
dependsOn: [],
|
|
111
|
-
|
|
321
|
+
// An epic route carries the anchor finding as `findingId` but cites the whole
|
|
322
|
+
// group's evidence (union), so the single reviewable route covers every
|
|
323
|
+
// member's citations; other kinds keep the finding's own refs.
|
|
324
|
+
evidenceRefs: kind === 'create-epic' && epicGroup
|
|
325
|
+
? epicGroup.evidenceRefs
|
|
326
|
+
: [...input.finding.evidenceRefs].sort(),
|
|
112
327
|
reversible: true,
|
|
113
328
|
requiresConfirmation: true,
|
|
114
329
|
targetPath,
|
|
@@ -160,6 +375,7 @@ export async function createOperatingRoutePlan(input) {
|
|
|
160
375
|
finding: input.finding,
|
|
161
376
|
config: input.config,
|
|
162
377
|
now,
|
|
378
|
+
localRoot: input.localRoot,
|
|
163
379
|
});
|
|
164
380
|
const previewDigest = routeWritesPreviewDigest(inputDigest, plannedWrites.writes, plannedWrites.generationPlan?.planDigest);
|
|
165
381
|
const routeDigest = canonicalDigest({
|
|
@@ -194,6 +410,244 @@ export async function createOperatingRoutePlan(input) {
|
|
|
194
410
|
};
|
|
195
411
|
return assertOperatingArtifact('operating-route-plan', route);
|
|
196
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Every finding id a `create-epic` route was ever proposed against. Mirrors the
|
|
415
|
+
* engine's `existingRoutedFindingIds`, but scoped to epic routes only, so epic
|
|
416
|
+
* election is idempotent: a group whose anchor already heads a committed epic
|
|
417
|
+
* route is never re-elected, no matter how many of its members are later
|
|
418
|
+
* accepted.
|
|
419
|
+
*/
|
|
420
|
+
function existingEpicRouteFindingIds(events) {
|
|
421
|
+
const ids = new Set();
|
|
422
|
+
for (const event of events) {
|
|
423
|
+
if (event.type !== 'route.proposed')
|
|
424
|
+
continue;
|
|
425
|
+
const record = event.payload.record;
|
|
426
|
+
if (!record || typeof record !== 'object')
|
|
427
|
+
continue;
|
|
428
|
+
for (const action of record.actions ?? []) {
|
|
429
|
+
if (action.kind === 'create-epic' && typeof action.findingId === 'string') {
|
|
430
|
+
ids.add(action.findingId);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return ids;
|
|
435
|
+
}
|
|
436
|
+
function maximumRouteOrdinal(routes) {
|
|
437
|
+
let maximum = 0;
|
|
438
|
+
for (const route of routes) {
|
|
439
|
+
const match = typeof route.id === 'string' ? /^ACT-(\d+)$/.exec(route.id) : null;
|
|
440
|
+
if (match)
|
|
441
|
+
maximum = Math.max(maximum, Number(match[1]));
|
|
442
|
+
}
|
|
443
|
+
return maximum;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Bind an elected epic route to the same evidence/provider state the cycle's
|
|
447
|
+
* other routes already carry, so its provenance is consistent with the DEV/OWNER
|
|
448
|
+
* routes the same accepted findings produced. A group's members were routed on
|
|
449
|
+
* proposal, so a reference route always exists; the offline fallback only guards
|
|
450
|
+
* the theoretical no-prior-route case (neither digest is re-derived at apply, so
|
|
451
|
+
* any self-consistent binding is valid).
|
|
452
|
+
*/
|
|
453
|
+
async function referenceCycleRouteDigests(projectRoot, state, cycleId) {
|
|
454
|
+
for (const projected of state.routes) {
|
|
455
|
+
if (String(projected.cycleId) !== cycleId)
|
|
456
|
+
continue;
|
|
457
|
+
const route = await readOperatingRoute(projectRoot, String(projected.id)).catch(() => null);
|
|
458
|
+
if (route?.evidenceDigest && route?.providerDigest) {
|
|
459
|
+
return { evidenceDigest: route.evidenceDigest, providerDigest: route.providerDigest };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const offline = canonicalDigest({ provider: 'offline' });
|
|
463
|
+
return { evidenceDigest: offline, providerDigest: offline };
|
|
464
|
+
}
|
|
465
|
+
async function appendRouteEvent(store, lock, head, input) {
|
|
466
|
+
const event = await store.append({
|
|
467
|
+
...input,
|
|
468
|
+
actor: input.actor ?? { kind: 'human', id: 'operate-cli' },
|
|
469
|
+
expectedHead: head.hash,
|
|
470
|
+
});
|
|
471
|
+
const next = { sequence: event.sequence, hash: event.eventHash };
|
|
472
|
+
await lock.advanceEventHead(head, next);
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Write a proposed route file through the write-ahead journal and append its
|
|
477
|
+
* `route.proposed` event, exactly the way the engine proposes a freshly-elected
|
|
478
|
+
* route (recoverable orphaned-proposal handling included), so an epic route
|
|
479
|
+
* elected at acceptance time is indistinguishable from an engine-proposed one.
|
|
480
|
+
*/
|
|
481
|
+
async function commitProposedRoute(store, lock, projectRoot, localRoot, route, head) {
|
|
482
|
+
const relativePath = `.planr/operate/routes/${route.id}.json`;
|
|
483
|
+
const content = `${canonicalize(route)}\n`;
|
|
484
|
+
const transactionId = `TXN-${route.cycleId}-${route.id}-proposal`;
|
|
485
|
+
const transactionRoot = path.join(resolveOperatingPaths(projectRoot, { localRoot }).transactions, transactionId);
|
|
486
|
+
const manifestPath = path.join(transactionRoot, 'journal.json');
|
|
487
|
+
const existingBytes = await readFile(path.join(projectRoot, relativePath), 'utf8').catch((error) => {
|
|
488
|
+
if (error.code === 'ENOENT')
|
|
489
|
+
return null;
|
|
490
|
+
throw error;
|
|
491
|
+
});
|
|
492
|
+
const journal = existingBytes === null
|
|
493
|
+
? await prepareJournalTransaction(projectRoot, {
|
|
494
|
+
writes: [{ relativePath, operation: 'create', content }],
|
|
495
|
+
eventHead: head,
|
|
496
|
+
previewDigest: route.previewDigest,
|
|
497
|
+
transactionId,
|
|
498
|
+
localRoot,
|
|
499
|
+
})
|
|
500
|
+
: { root: transactionRoot, manifestPath, record: await readJournal(manifestPath) };
|
|
501
|
+
if (existingBytes !== null) {
|
|
502
|
+
if (existingBytes !== content ||
|
|
503
|
+
journal.record.state !== 'committed' ||
|
|
504
|
+
journal.record.previewDigest !== route.previewDigest) {
|
|
505
|
+
throw new OperateError('E_OPERATE_TRANSACTION_INVALID', `Orphaned epic route proposal ${route.id} does not match its committed journal.`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
await applyJournalTransaction(projectRoot, journal, {
|
|
510
|
+
currentEventHead: head,
|
|
511
|
+
revalidateEventHead: async () => (await store.replay()).eventHead,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
await store.putRecord('route', structuredClone(route), {
|
|
516
|
+
correlationId: route.cycleId,
|
|
517
|
+
createdAt: route.createdAt,
|
|
518
|
+
});
|
|
519
|
+
return await appendRouteEvent(store, lock, head, {
|
|
520
|
+
type: 'route.proposed',
|
|
521
|
+
cycleId: route.cycleId,
|
|
522
|
+
entityId: route.id,
|
|
523
|
+
evidenceRefs: route.actions.flatMap((action) => action.evidenceRefs),
|
|
524
|
+
payload: { record: route },
|
|
525
|
+
// A v1.3 (create-epic) route plan embedded in the event payload stamps the
|
|
526
|
+
// event v1.3, whose schema accepts either route-plan version.
|
|
527
|
+
...(route.protocolVersion === OPERATE_MISSION_PROTOCOL_VERSION
|
|
528
|
+
? { protocolVersion: OPERATE_MISSION_PROTOCOL_VERSION }
|
|
529
|
+
: {}),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
await rollbackJournalTransaction(projectRoot, journal).catch(() => undefined);
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Re-evaluate a cycle's accepted findings for epic election and PROPOSE + accept
|
|
539
|
+
* one governed `create-epic` route per themed 2+-member group that does not yet
|
|
540
|
+
* have one. This is the operator-reachable producer of FR8 epic routes: it runs
|
|
541
|
+
* right after a finding transitions to `accepted` through `governOperatingFinding`,
|
|
542
|
+
* so accepting a related group yields a `create-epic` route through the same
|
|
543
|
+
* journal-backed proposal path the engine uses for freshly-proposed findings —
|
|
544
|
+
* reusing T-006's `groupRelatedAcceptedFindings`/`resolveEpicGroupForAnchor`/
|
|
545
|
+
* `actionKind` so FR7's rendered suggestion and FR8's route always name the same
|
|
546
|
+
* theme.
|
|
547
|
+
*
|
|
548
|
+
* Election never writes the epic markdown and never applies the route (accept ≠
|
|
549
|
+
* apply): it only proposes the route and accepts it — mirroring exactly how
|
|
550
|
+
* governance accepts a finding's individual route — leaving the digest-bound,
|
|
551
|
+
* human-gated `routes apply` as the separate acting step. It is idempotent: a
|
|
552
|
+
* group whose anchor already heads a committed `create-epic` route is skipped, so
|
|
553
|
+
* re-electing (accepting further members of the same theme) never duplicates the
|
|
554
|
+
* epic route. Membership growth before apply fails CLOSED rather than silently
|
|
555
|
+
* writing a different epic: `resolveEpicGroupForAnchor` re-derives the member
|
|
556
|
+
* list from the then-current accepted findings, so a route proposed for {A,B}
|
|
557
|
+
* whose group has grown to {A,B,C} no longer matches its digest-bound preview
|
|
558
|
+
* and `applyOperatingRoute` rejects it with `E_OPERATE_ROUTE_DRIFT` — the same
|
|
559
|
+
* guard every other route kind carries. An individually-routed finding is never
|
|
560
|
+
* re-routed individually — only the group-level epic route is added.
|
|
561
|
+
*/
|
|
562
|
+
export async function electAcceptedFindingEpicRoutes(input) {
|
|
563
|
+
const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
|
|
564
|
+
const initial = await store.replay();
|
|
565
|
+
const state = await store.state();
|
|
566
|
+
const acceptedFindings = state.findings.filter((finding) => finding.status === 'accepted' && String(finding.cycleId) === input.cycleId);
|
|
567
|
+
const candidateGroups = groupRelatedAcceptedFindings(acceptedFindings);
|
|
568
|
+
if (candidateGroups.length === 0)
|
|
569
|
+
return [];
|
|
570
|
+
const routedForEpic = existingEpicRouteFindingIds(initial.events);
|
|
571
|
+
const pending = candidateGroups.filter((group) => !group.memberIds.some((memberId) => routedForEpic.has(memberId)));
|
|
572
|
+
if (pending.length === 0)
|
|
573
|
+
return [];
|
|
574
|
+
const config = await validateOperatingConfiguration(input.projectRoot);
|
|
575
|
+
const reference = await referenceCycleRouteDigests(input.projectRoot, state, input.cycleId);
|
|
576
|
+
const now = input.now ?? new Date().toISOString();
|
|
577
|
+
return withOperatingLock(input.projectRoot, {
|
|
578
|
+
projectKey: operatingProjectKey(input.projectRoot),
|
|
579
|
+
expectedEventHead: initial.eventHead,
|
|
580
|
+
currentEventHead: initial.eventHead,
|
|
581
|
+
localRoot: input.localRoot,
|
|
582
|
+
}, async (lock) => {
|
|
583
|
+
const lockedReplay = await store.replay();
|
|
584
|
+
lock.assertEventHead(lockedReplay.eventHead);
|
|
585
|
+
const lockedState = await store.state();
|
|
586
|
+
// Recompute idempotence + ordinals under the lock so two concurrent
|
|
587
|
+
// acceptances can never both elect the same group or collide on ids.
|
|
588
|
+
const alreadyRouted = existingEpicRouteFindingIds(lockedReplay.events);
|
|
589
|
+
const anchorFindings = new Map(lockedState.findings.map((finding) => [finding.id, finding]));
|
|
590
|
+
const workspace = await refreshOperatingWorkspaceManifest(input.projectRoot, {
|
|
591
|
+
localRoot: input.localRoot,
|
|
592
|
+
ignoredControlPaths: [...ROUTE_MANAGED_WORKSPACE_PATHS],
|
|
593
|
+
});
|
|
594
|
+
let head = lockedReplay.eventHead;
|
|
595
|
+
let routeOrdinal = maximumRouteOrdinal(lockedState.routes);
|
|
596
|
+
let epicOrdinal = await nextOperatingEpicOrdinal(input.projectRoot);
|
|
597
|
+
const elected = [];
|
|
598
|
+
for (const group of pending) {
|
|
599
|
+
if (group.memberIds.some((memberId) => alreadyRouted.has(memberId)))
|
|
600
|
+
continue;
|
|
601
|
+
const anchor = anchorFindings.get(group.anchorId);
|
|
602
|
+
if (!anchor || anchor.status !== 'accepted')
|
|
603
|
+
continue;
|
|
604
|
+
const route = await createOperatingRoutePlan({
|
|
605
|
+
projectRoot: input.projectRoot,
|
|
606
|
+
localRoot: input.localRoot,
|
|
607
|
+
cycleId: input.cycleId,
|
|
608
|
+
finding: anchor,
|
|
609
|
+
config,
|
|
610
|
+
workspace,
|
|
611
|
+
eventHead: head,
|
|
612
|
+
evidenceDigest: reference.evidenceDigest,
|
|
613
|
+
providerDigest: reference.providerDigest,
|
|
614
|
+
sequence: ++routeOrdinal,
|
|
615
|
+
epicId: `EPIC-${String(epicOrdinal).padStart(3, '0')}`,
|
|
616
|
+
now,
|
|
617
|
+
});
|
|
618
|
+
// Defensive: the pending filter already guarantees a 2+-member group, so
|
|
619
|
+
// the anchor elects `create-epic`. Skip rather than mis-propose otherwise.
|
|
620
|
+
if (route.actions[0]?.kind !== 'create-epic')
|
|
621
|
+
continue;
|
|
622
|
+
epicOrdinal += 1;
|
|
623
|
+
head = await commitProposedRoute(store, lock, input.projectRoot, input.localRoot, route, head);
|
|
624
|
+
// Accept the elected route exactly the way governance accepts a finding's
|
|
625
|
+
// individual route (proposed → accepted, apply-ready) — never applied, so
|
|
626
|
+
// accept ≠ apply holds: no epic bytes and no route.applied are written here.
|
|
627
|
+
head = await appendRouteEvent(store, lock, head, {
|
|
628
|
+
type: 'route.accepted',
|
|
629
|
+
cycleId: input.cycleId,
|
|
630
|
+
entityId: route.id,
|
|
631
|
+
evidenceRefs: route.actions.flatMap((action) => action.evidenceRefs),
|
|
632
|
+
payload: { routeDigest: route.routeDigest, confirmationDigest: route.previewDigest },
|
|
633
|
+
});
|
|
634
|
+
for (const memberId of group.memberIds)
|
|
635
|
+
alreadyRouted.add(memberId);
|
|
636
|
+
elected.push(route);
|
|
637
|
+
}
|
|
638
|
+
if (elected.length === 0)
|
|
639
|
+
return [];
|
|
640
|
+
const finalState = await store.state();
|
|
641
|
+
await store.writeCheckpoint(finalState);
|
|
642
|
+
await persistOperatingProjections({
|
|
643
|
+
projectRoot: input.projectRoot,
|
|
644
|
+
localRoot: input.localRoot,
|
|
645
|
+
state: finalState,
|
|
646
|
+
revalidateEventHead: async () => (await store.replay()).eventHead,
|
|
647
|
+
});
|
|
648
|
+
return elected;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
197
651
|
async function assertRouteWorkspaceCurrent(input) {
|
|
198
652
|
const observed = await refreshOperatingWorkspaceManifest(input.projectRoot, {
|
|
199
653
|
localRoot: input.localRoot,
|
|
@@ -441,6 +895,59 @@ async function buildRouteWrites(input) {
|
|
|
441
895
|
],
|
|
442
896
|
};
|
|
443
897
|
}
|
|
898
|
+
if (action.kind === 'create-epic') {
|
|
899
|
+
// Rebuild the member list from the committed accepted findings so preview and
|
|
900
|
+
// apply are byte-identical; the shared v1.3 route action stays single-anchor
|
|
901
|
+
// (`findingId`), and the full membership lives only here, in the epic doc.
|
|
902
|
+
const group = await resolveEpicGroupForAnchor({
|
|
903
|
+
projectRoot: input.projectRoot,
|
|
904
|
+
localRoot: input.localRoot,
|
|
905
|
+
cycleId: input.route.cycleId,
|
|
906
|
+
anchorId: action.findingId,
|
|
907
|
+
});
|
|
908
|
+
if (!group) {
|
|
909
|
+
throw new OperateError('E_OPERATE_TRANSACTION_INVALID', `Route ${input.route.id} no longer anchors a 2+-member accepted-finding group.`);
|
|
910
|
+
}
|
|
911
|
+
const epicId = action.targetPath.match(/(?:^|\/)(EPIC-[0-9]+)(?:-|\.|\/)/)?.[1];
|
|
912
|
+
if (!epicId) {
|
|
913
|
+
throw new OperateError('E_OPERATE_TRANSACTION_INVALID', 'Epic route target does not encode a canonical EPIC id.');
|
|
914
|
+
}
|
|
915
|
+
const memberList = group.members.map((member) => `${member.id}: ${sanitizeGeneratedPlainText(member.title)}${member.evidenceRefs.length > 0 ? ` (evidence: ${member.evidenceRefs.join(', ')})` : ''}`);
|
|
916
|
+
// Reuse the CLI's epic authoring seam (cli/commands/epic.ts → createArtifact →
|
|
917
|
+
// renderTemplate on `epics/epic.md.hbs`). We render through the same template
|
|
918
|
+
// for byte-identical epic shape, but emit the content into the write-ahead
|
|
919
|
+
// journal instead of createArtifact's direct disk write, because a route must
|
|
920
|
+
// be transactional and byte-exact reversible. `now` is the frozen route
|
|
921
|
+
// timestamp so preview and apply agree.
|
|
922
|
+
const content = await renderTemplate('epics/epic.md.hbs', {
|
|
923
|
+
id: epicId,
|
|
924
|
+
title: group.theme,
|
|
925
|
+
owner: action.owner,
|
|
926
|
+
date: input.now.slice(0, 10),
|
|
927
|
+
projectName: path.basename(input.projectRoot),
|
|
928
|
+
businessValue: `Consolidates ${group.memberIds.length} related accepted findings from operating cycle ${input.route.cycleId} into one themed epic so they are planned together.`,
|
|
929
|
+
targetUsers: `Decision owner ${action.owner} and the planning team.`,
|
|
930
|
+
problemStatement: `Related accepted findings ${group.memberIds.join(', ')} share the "${group.category}" theme surfaced by the Operating Board and warrant one coordinated epic.`,
|
|
931
|
+
solutionOverview: sanitizeGeneratedPlainText(group.members
|
|
932
|
+
.map((member) => member.proposal)
|
|
933
|
+
.filter(Boolean)
|
|
934
|
+
.join(' ')),
|
|
935
|
+
successCriteriaList: group.members.map((member) => `Address ${member.id} — ${sanitizeGeneratedPlainText(member.title)} — using its cited evidence${member.evidenceRefs.length > 0 ? `: ${member.evidenceRefs.join(', ')}` : ''}.`),
|
|
936
|
+
keyFeatures: memberList,
|
|
937
|
+
dependencies: `Operating cycle ${input.route.cycleId}; anchor finding ${group.anchorId}; evidence: ${group.evidenceRefs.join(', ')}.`,
|
|
938
|
+
risks: 'No security, privacy, payment-integrity, or tenant-isolation regression. PLAN and SHIP are never invoked automatically by this operating route.',
|
|
939
|
+
featureIds: [],
|
|
940
|
+
});
|
|
941
|
+
return {
|
|
942
|
+
writes: [
|
|
943
|
+
{
|
|
944
|
+
relativePath: action.targetPath,
|
|
945
|
+
operation: 'create',
|
|
946
|
+
content,
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
};
|
|
950
|
+
}
|
|
444
951
|
if (action.kind === 'create-quick-task') {
|
|
445
952
|
const quickId = `QUICK-${action.id.slice('ACT-'.length)}`;
|
|
446
953
|
const created = input.now.slice(0, 10);
|
|
@@ -734,6 +1241,7 @@ export async function applyOperatingRoute(input) {
|
|
|
734
1241
|
finding,
|
|
735
1242
|
config: input.config,
|
|
736
1243
|
now: input.route.createdAt,
|
|
1244
|
+
localRoot: input.localRoot,
|
|
737
1245
|
...(artifactGeneration?.state === 'generated' ? { artifactGeneration } : {}),
|
|
738
1246
|
});
|
|
739
1247
|
const plannedDigest = routeWritesPreviewDigest(input.route.inputDigest, built.writes, built.generationPlan?.planDigest);
|
|
@@ -769,6 +1277,7 @@ export async function applyOperatingRoute(input) {
|
|
|
769
1277
|
finding,
|
|
770
1278
|
config: input.config,
|
|
771
1279
|
now: input.route.createdAt,
|
|
1280
|
+
localRoot: input.localRoot,
|
|
772
1281
|
artifactGeneration,
|
|
773
1282
|
});
|
|
774
1283
|
}
|