role-os 1.2.0 → 1.4.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/src/replan.mjs ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Adaptive Replanning — Phase P
3
+ *
4
+ * When reality changes mid-run, revise the plan without collapsing
5
+ * into manual rescue or restarting everything. Preserve valid work,
6
+ * reopen stale work, insert missing branches, keep the run truthful.
7
+ */
8
+
9
+ import { ARTIFACT_CONTRACTS, invalidateDownstream } from "./composite.mjs";
10
+
11
+ // ── Change events ─────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * @typedef {"scope-change"|"artifact-changed"|"new-requirement"|"review-finding"|"dependency-discovered"|"priority-change"} ChangeType
15
+ */
16
+
17
+ /**
18
+ * Create a structured change event.
19
+ *
20
+ * @param {ChangeType} type
21
+ * @param {object} detail
22
+ * @returns {object}
23
+ */
24
+ export function createChangeEvent(type, detail) {
25
+ const VALID_TYPES = [
26
+ "scope-change",
27
+ "artifact-changed",
28
+ "new-requirement",
29
+ "review-finding",
30
+ "dependency-discovered",
31
+ "priority-change",
32
+ ];
33
+
34
+ if (!VALID_TYPES.includes(type)) {
35
+ throw new Error(`Invalid change type: "${type}". Valid types: ${VALID_TYPES.join(", ")}`);
36
+ }
37
+
38
+ return {
39
+ type,
40
+ detail: {
41
+ description: detail.description || "",
42
+ affectedCategory: detail.affectedCategory || null,
43
+ newCategory: detail.newCategory || null,
44
+ newPack: detail.newPack || null,
45
+ insertAfter: detail.insertAfter || null,
46
+ invalidatesArtifacts: detail.invalidatesArtifacts || [],
47
+ ...detail,
48
+ },
49
+ timestamp: new Date().toISOString(),
50
+ };
51
+ }
52
+
53
+ // ── Impact analysis ───────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Analyze the impact of a change event on a composite execution.
57
+ *
58
+ * @param {object} exec - CompositeExecution from composite.mjs
59
+ * @param {object} changeEvent
60
+ * @returns {{
61
+ * validChildren: string[],
62
+ * staleChildren: string[],
63
+ * staleArtifacts: string[],
64
+ * needsNewChild: boolean,
65
+ * needsReorder: boolean,
66
+ * needsPackChange: boolean,
67
+ * summary: string,
68
+ * }}
69
+ */
70
+ export function analyzeImpact(exec, changeEvent) {
71
+ const { type, detail } = changeEvent;
72
+ const validChildren = [];
73
+ const staleChildren = [];
74
+ const staleArtifacts = [];
75
+ let needsNewChild = false;
76
+ let needsReorder = false;
77
+ let needsPackChange = false;
78
+
79
+ switch (type) {
80
+ case "scope-change": {
81
+ // Scope change affects everything downstream of the affected category
82
+ const affected = detail.affectedCategory;
83
+ if (affected) {
84
+ for (const child of exec.children) {
85
+ if (child.category === affected || isDependentOn(exec, child.category, affected)) {
86
+ if (child.status === "completed") {
87
+ staleChildren.push(child.category);
88
+ staleArtifacts.push(...child.producedArtifacts);
89
+ }
90
+ } else {
91
+ if (child.status === "completed") validChildren.push(child.category);
92
+ }
93
+ }
94
+ }
95
+ break;
96
+ }
97
+
98
+ case "artifact-changed": {
99
+ // Specific artifacts are invalidated
100
+ for (const artifactType of detail.invalidatesArtifacts) {
101
+ const producer = exec.artifactLedger.find(a => a.type === artifactType);
102
+ if (producer) {
103
+ staleArtifacts.push(artifactType);
104
+ // Find consumers of this artifact
105
+ for (const child of exec.children) {
106
+ if (child.receivedArtifacts?.includes(artifactType) && child.status === "completed") {
107
+ staleChildren.push(child.category);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ // Everything not stale is valid
113
+ for (const child of exec.children) {
114
+ if (child.status === "completed" && !staleChildren.includes(child.category)) {
115
+ validChildren.push(child.category);
116
+ }
117
+ }
118
+ break;
119
+ }
120
+
121
+ case "new-requirement": {
122
+ needsNewChild = !!detail.newCategory;
123
+ // Existing completed work stays valid unless the new requirement
124
+ // changes what they depend on
125
+ for (const child of exec.children) {
126
+ if (child.status === "completed") validChildren.push(child.category);
127
+ }
128
+ break;
129
+ }
130
+
131
+ case "review-finding": {
132
+ // A review finding typically requires inserting a new branch
133
+ needsNewChild = !!detail.newCategory;
134
+ // The category that produced the finding is valid (the finding came FROM it)
135
+ // Downstream might need revalidation
136
+ if (detail.affectedCategory) {
137
+ for (const child of exec.children) {
138
+ if (isDependentOn(exec, child.category, detail.affectedCategory) && child.status === "completed") {
139
+ staleChildren.push(child.category);
140
+ }
141
+ }
142
+ }
143
+ for (const child of exec.children) {
144
+ if (child.status === "completed" && !staleChildren.includes(child.category)) {
145
+ validChildren.push(child.category);
146
+ }
147
+ }
148
+ break;
149
+ }
150
+
151
+ case "dependency-discovered": {
152
+ // A missing prerequisite was found — may need reorder or new child
153
+ needsNewChild = !!detail.newCategory;
154
+ needsReorder = true;
155
+ for (const child of exec.children) {
156
+ if (child.status === "completed") validChildren.push(child.category);
157
+ }
158
+ break;
159
+ }
160
+
161
+ case "priority-change": {
162
+ needsReorder = true;
163
+ // Priority change doesn't invalidate completed work
164
+ for (const child of exec.children) {
165
+ if (child.status === "completed") validChildren.push(child.category);
166
+ }
167
+ break;
168
+ }
169
+ }
170
+
171
+ const summaryParts = [];
172
+ if (validChildren.length > 0) summaryParts.push(`${validChildren.length} children still valid`);
173
+ if (staleChildren.length > 0) summaryParts.push(`${staleChildren.length} children stale`);
174
+ if (staleArtifacts.length > 0) summaryParts.push(`${staleArtifacts.length} artifacts invalidated`);
175
+ if (needsNewChild) summaryParts.push("new child packet needed");
176
+ if (needsReorder) summaryParts.push("reorder needed");
177
+
178
+ return {
179
+ validChildren,
180
+ staleChildren,
181
+ staleArtifacts,
182
+ needsNewChild,
183
+ needsReorder,
184
+ needsPackChange,
185
+ summary: summaryParts.join("; ") || "No impact detected.",
186
+ };
187
+ }
188
+
189
+ // ── Selective replanning ──────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Apply a replan to a composite execution. Returns the plan diff.
193
+ *
194
+ * @param {object} exec - CompositeExecution
195
+ * @param {object} impact - From analyzeImpact()
196
+ * @param {object} changeEvent
197
+ * @returns {{ actions: object[], diff: object }}
198
+ */
199
+ export function replan(exec, impact, changeEvent) {
200
+ const actions = [];
201
+ const before = snapshotState(exec);
202
+
203
+ // 1. Invalidate stale children
204
+ if (impact.staleChildren.length > 0) {
205
+ for (const cat of impact.staleChildren) {
206
+ const child = exec.children.find(c => c.category === cat);
207
+ if (child && child.status === "completed") {
208
+ child.status = "pending";
209
+ child.result = null;
210
+ child.producedArtifacts = [];
211
+ child.completedAt = null;
212
+
213
+ // Remove stale artifacts
214
+ exec.artifactLedger = exec.artifactLedger.filter(a => a.fromCategory !== cat);
215
+
216
+ actions.push({
217
+ action: "invalidate",
218
+ category: cat,
219
+ reason: `Stale due to ${changeEvent.type}: ${changeEvent.detail.description}`,
220
+ });
221
+ }
222
+ }
223
+ }
224
+
225
+ // 2. Insert new child if needed
226
+ if (impact.needsNewChild && changeEvent.detail.newCategory) {
227
+ const newCat = changeEvent.detail.newCategory;
228
+ const existing = exec.children.find(c => c.category === newCat);
229
+
230
+ if (!existing) {
231
+ const insertAfter = changeEvent.detail.insertAfter;
232
+ const contract = ARTIFACT_CONTRACTS[newCat];
233
+
234
+ const newChild = {
235
+ category: newCat,
236
+ pack: changeEvent.detail.newPack || newCat,
237
+ status: "pending",
238
+ dependsOn: insertAfter ? [insertAfter] : [],
239
+ producedArtifacts: [],
240
+ receivedArtifacts: [],
241
+ result: null,
242
+ blockInfo: null,
243
+ startedAt: null,
244
+ completedAt: null,
245
+ };
246
+
247
+ // Insert after the specified position
248
+ if (insertAfter) {
249
+ const idx = exec.children.findIndex(c => c.category === insertAfter);
250
+ exec.children.splice(idx + 1, 0, newChild);
251
+
252
+ // Update dependencies: children that depended on insertAfter now depend on newCat
253
+ for (const child of exec.children) {
254
+ if (child.category !== newCat && child.dependsOn.includes(insertAfter)) {
255
+ child.dependsOn = child.dependsOn.map(d => d === insertAfter ? newCat : d);
256
+ }
257
+ }
258
+ // The new child depends on insertAfter
259
+ newChild.dependsOn = [insertAfter];
260
+ } else {
261
+ exec.children.push(newChild);
262
+ }
263
+
264
+ exec.dependencyOrder = exec.children.map(c => c.category);
265
+
266
+ actions.push({
267
+ action: "insert",
268
+ category: newCat,
269
+ pack: newChild.pack,
270
+ insertAfter: insertAfter || "end",
271
+ reason: changeEvent.detail.description,
272
+ });
273
+ }
274
+ }
275
+
276
+ // 3. Reorder if needed
277
+ if (impact.needsReorder && !impact.needsNewChild) {
278
+ // Simple reorder: move priority-changed category earlier
279
+ // For now, this is recorded as an action but the actual reorder
280
+ // requires operator confirmation since it may change semantics
281
+ actions.push({
282
+ action: "reorder-suggested",
283
+ reason: changeEvent.detail.description,
284
+ note: "Reorder requires operator confirmation.",
285
+ });
286
+ }
287
+
288
+ // Reset parent status
289
+ if (exec.status === "completed") {
290
+ exec.status = "running";
291
+ exec.completedAt = null;
292
+ }
293
+
294
+ const after = snapshotState(exec);
295
+
296
+ return {
297
+ actions,
298
+ diff: buildDiff(before, after, changeEvent),
299
+ };
300
+ }
301
+
302
+ // ── Plan diff ─────────────────────────────────────────────────────────────────
303
+
304
+ function snapshotState(exec) {
305
+ return {
306
+ childCount: exec.children.length,
307
+ categories: exec.children.map(c => c.category),
308
+ statuses: Object.fromEntries(exec.children.map(c => [c.category, c.status])),
309
+ artifactCount: exec.artifactLedger.length,
310
+ parentStatus: exec.status,
311
+ };
312
+ }
313
+
314
+ function buildDiff(before, after, changeEvent) {
315
+ const changes = [];
316
+
317
+ // New children
318
+ const added = after.categories.filter(c => !before.categories.includes(c));
319
+ if (added.length > 0) changes.push(`Added: ${added.join(", ")}`);
320
+
321
+ // Status changes
322
+ for (const cat of before.categories) {
323
+ if (before.statuses[cat] !== after.statuses[cat]) {
324
+ changes.push(`${cat}: ${before.statuses[cat]} → ${after.statuses[cat]}`);
325
+ }
326
+ }
327
+
328
+ // Artifact changes
329
+ if (before.artifactCount !== after.artifactCount) {
330
+ changes.push(`Artifacts: ${before.artifactCount} → ${after.artifactCount}`);
331
+ }
332
+
333
+ return {
334
+ trigger: changeEvent.type,
335
+ description: changeEvent.detail.description,
336
+ changes,
337
+ preserved: before.categories.filter(c => before.statuses[c] === "completed" && after.statuses[c] === "completed"),
338
+ reopened: before.categories.filter(c => before.statuses[c] === "completed" && after.statuses[c] === "pending"),
339
+ inserted: added,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Format a plan diff for display.
345
+ *
346
+ * @param {object} result - From replan()
347
+ * @returns {string}
348
+ */
349
+ export function formatReplan(result) {
350
+ const lines = [
351
+ `\nAdaptive Replan`,
352
+ `───────────────`,
353
+ `Trigger: ${result.diff.trigger}`,
354
+ `Reason: ${result.diff.description}`,
355
+ ];
356
+
357
+ if (result.actions.length === 0) {
358
+ lines.push(`\nNo changes needed.`);
359
+ return lines.join("\n");
360
+ }
361
+
362
+ lines.push(`\nActions taken:`);
363
+ for (const a of result.actions) {
364
+ if (a.action === "invalidate") {
365
+ lines.push(` ↻ ${a.category} — reopened (${a.reason})`);
366
+ } else if (a.action === "insert") {
367
+ lines.push(` + ${a.category} (${a.pack} pack) — inserted after ${a.insertAfter}`);
368
+ } else if (a.action === "reorder-suggested") {
369
+ lines.push(` ⚠ Reorder suggested — ${a.reason}`);
370
+ }
371
+ }
372
+
373
+ if (result.diff.preserved.length > 0) {
374
+ lines.push(`\nPreserved (still valid):`);
375
+ for (const c of result.diff.preserved) {
376
+ lines.push(` ✓ ${c}`);
377
+ }
378
+ }
379
+
380
+ if (result.diff.reopened.length > 0) {
381
+ lines.push(`\nReopened (stale):`);
382
+ for (const c of result.diff.reopened) {
383
+ lines.push(` ↻ ${c}`);
384
+ }
385
+ }
386
+
387
+ if (result.diff.inserted.length > 0) {
388
+ lines.push(`\nInserted:`);
389
+ for (const c of result.diff.inserted) {
390
+ lines.push(` + ${c}`);
391
+ }
392
+ }
393
+
394
+ return lines.join("\n");
395
+ }
396
+
397
+ // ── Helper ────────────────────────────────────────────────────────────────────
398
+
399
+ function isDependentOn(exec, category, upstream) {
400
+ const child = exec.children.find(c => c.category === category);
401
+ if (!child) return false;
402
+ if (child.dependsOn.includes(upstream)) return true;
403
+ return child.dependsOn.some(dep => isDependentOn(exec, dep, upstream));
404
+ }