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.
Files changed (49) hide show
  1. package/dist/services/operate/advisors.d.ts +65 -5
  2. package/dist/services/operate/advisors.d.ts.map +1 -1
  3. package/dist/services/operate/advisors.js +101 -6
  4. package/dist/services/operate/advisors.js.map +1 -1
  5. package/dist/services/operate/config.d.ts +10 -0
  6. package/dist/services/operate/config.d.ts.map +1 -1
  7. package/dist/services/operate/config.js +53 -6
  8. package/dist/services/operate/config.js.map +1 -1
  9. package/dist/services/operate/engine.d.ts +22 -6
  10. package/dist/services/operate/engine.d.ts.map +1 -1
  11. package/dist/services/operate/engine.js +20 -9
  12. package/dist/services/operate/engine.js.map +1 -1
  13. package/dist/services/operate/evidence-readiness.d.ts +11 -1
  14. package/dist/services/operate/evidence-readiness.d.ts.map +1 -1
  15. package/dist/services/operate/evidence-readiness.js +12 -2
  16. package/dist/services/operate/evidence-readiness.js.map +1 -1
  17. package/dist/services/operate/evidence.d.ts +30 -1
  18. package/dist/services/operate/evidence.d.ts.map +1 -1
  19. package/dist/services/operate/evidence.js +219 -10
  20. package/dist/services/operate/evidence.js.map +1 -1
  21. package/dist/services/operate/index.d.ts.map +1 -1
  22. package/dist/services/operate/index.js +5 -0
  23. package/dist/services/operate/index.js.map +1 -1
  24. package/dist/services/operate/lifecycle.d.ts.map +1 -1
  25. package/dist/services/operate/lifecycle.js +17 -1
  26. package/dist/services/operate/lifecycle.js.map +1 -1
  27. package/dist/services/operate/maintenance.d.ts +11 -1
  28. package/dist/services/operate/maintenance.d.ts.map +1 -1
  29. package/dist/services/operate/maintenance.js +78 -11
  30. package/dist/services/operate/maintenance.js.map +1 -1
  31. package/dist/services/operate/projection-persistence.d.ts +35 -7
  32. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  33. package/dist/services/operate/projection-persistence.js +122 -53
  34. package/dist/services/operate/projection-persistence.js.map +1 -1
  35. package/dist/services/operate/projection.d.ts +9 -1
  36. package/dist/services/operate/projection.d.ts.map +1 -1
  37. package/dist/services/operate/projection.js +12 -9
  38. package/dist/services/operate/projection.js.map +1 -1
  39. package/dist/services/operate/reports.d.ts +9 -0
  40. package/dist/services/operate/reports.d.ts.map +1 -1
  41. package/dist/services/operate/reports.js +20 -1
  42. package/dist/services/operate/reports.js.map +1 -1
  43. package/dist/services/operate/routes.d.ts +84 -0
  44. package/dist/services/operate/routes.d.ts.map +1 -1
  45. package/dist/services/operate/routes.js +519 -10
  46. package/dist/services/operate/routes.js.map +1 -1
  47. package/dist/services/operate/types.d.ts +6 -5
  48. package/dist/services/operate/types.d.ts.map +1 -1
  49. 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
- const kind = actionKind(input.finding);
92
- const slug = slugify(input.finding.title);
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
- : `.planr/operate/decisions/${id}.json`;
100
- // A quick-task route validates against the additive v1.3 route-plan schema —
101
- // the only route-plan schema whose kind enum includes `create-quick-task`.
102
- // Every other kind keeps the frozen v1.2 envelope untouched.
103
- const protocolVersion = kind === 'create-quick-task' ? OPERATE_MISSION_PROTOCOL_VERSION : OPERATE_PROTOCOL_VERSION;
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
- evidenceRefs: [...input.finding.evidenceRefs].sort(),
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
  }