role-os 1.8.0 → 2.0.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/run.mjs ADDED
@@ -0,0 +1,949 @@
1
+ /**
2
+ * Persistent Run Engine — Phase U (v2.0.0)
3
+ *
4
+ * One command from task description to active execution.
5
+ * Runs persist to disk so sessions can resume.
6
+ *
7
+ * `roleos run "<task>"` → entry decision → plan → execute
8
+ * `roleos resume` → continue interrupted run
9
+ * `roleos next` → show what's next
10
+ * `roleos explain` → show current state
11
+ *
12
+ * Interventions: reroute, split, escalate, retry, block, reopen
13
+ */
14
+
15
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { decideEntry } from "./entry.mjs";
18
+ import { getMission } from "./mission.mjs";
19
+ import { TEAM_PACKS, getPack } from "./packs.mjs";
20
+ import { ROLE_CATALOG } from "./route.mjs";
21
+ import { ROLE_ARTIFACT_CONTRACTS } from "./artifacts.mjs";
22
+ import { getHandoffContract } from "./artifacts.mjs";
23
+
24
+ // ── Run directory ────────────────────────────────────────────────────────────
25
+
26
+ const RUNS_DIR = ".claude/runs";
27
+
28
+ function runsDir(cwd) {
29
+ return join(cwd, RUNS_DIR);
30
+ }
31
+
32
+ function runPath(cwd, id) {
33
+ return join(runsDir(cwd), `${id}.json`);
34
+ }
35
+
36
+ // ── Run statuses ─────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * @typedef {"planning"|"running"|"paused"|"completed"|"partial"|"failed"} RunStatus
40
+ */
41
+
42
+ /**
43
+ * @typedef {"pending"|"active"|"completed"|"partial"|"failed"|"blocked"|"skipped"} StepStatus
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} RunStep
48
+ * @property {number} index
49
+ * @property {string} role
50
+ * @property {string} produces - artifact type this step should produce
51
+ * @property {StepStatus} status
52
+ * @property {string|null} artifact - produced artifact content/reference
53
+ * @property {string|null} note
54
+ * @property {string|null} guidance - step-local operator guidance
55
+ * @property {string|null} startedAt
56
+ * @property {string|null} completedAt
57
+ */
58
+
59
+ /**
60
+ * @typedef {Object} PersistentRun
61
+ * @property {string} id
62
+ * @property {string} taskDescription
63
+ * @property {"mission"|"pack"|"free-routing"} entryLevel
64
+ * @property {string|null} missionKey
65
+ * @property {string|null} packKey
66
+ * @property {object} entryDecision - full entry decision snapshot
67
+ * @property {RunStatus} status
68
+ * @property {RunStep[]} steps
69
+ * @property {Array<object>} escalations
70
+ * @property {Array<object>} interventions - operator interventions log
71
+ * @property {string} createdAt
72
+ * @property {string|null} pausedAt
73
+ * @property {string|null} completedAt
74
+ * @property {object|null} completionReport
75
+ */
76
+
77
+ let _counter = 0;
78
+
79
+ // ── Create a run ─────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Create a new persistent run from a task description.
83
+ * Uses decideEntry() to pick the right level, then builds steps.
84
+ *
85
+ * @param {string} taskDescription
86
+ * @param {string} cwd - working directory (for persistence)
87
+ * @param {object} [opts]
88
+ * @param {string} [opts.forceMission] - force a specific mission key
89
+ * @param {string} [opts.forcePack] - force a specific pack key
90
+ * @returns {PersistentRun}
91
+ */
92
+ export function createPersistentRun(taskDescription, cwd, opts = {}) {
93
+ if (!taskDescription || !taskDescription.trim()) {
94
+ throw new Error("Task description required");
95
+ }
96
+
97
+ const entry = decideEntry(taskDescription);
98
+ let level = entry.level;
99
+ let missionKey = null;
100
+ let packKey = null;
101
+ let steps;
102
+
103
+ // Force overrides
104
+ if (opts.forceMission) {
105
+ const mission = getMission(opts.forceMission);
106
+ if (!mission) throw new Error(`Mission "${opts.forceMission}" not found`);
107
+ level = "mission";
108
+ missionKey = opts.forceMission;
109
+ steps = buildMissionSteps(opts.forceMission);
110
+ } else if (opts.forcePack) {
111
+ const pack = getPack(opts.forcePack);
112
+ if (!pack) throw new Error(`Pack "${opts.forcePack}" not found`);
113
+ level = "pack";
114
+ packKey = opts.forcePack;
115
+ steps = buildPackSteps(opts.forcePack);
116
+ } else if (level === "mission" && entry.mission) {
117
+ missionKey = entry.mission.key;
118
+ steps = buildMissionSteps(entry.mission.key);
119
+ } else if (level === "pack" && entry.pack) {
120
+ packKey = entry.pack.key;
121
+ steps = buildPackSteps(entry.pack.key);
122
+ } else {
123
+ // Free routing — build minimal steps from entry hints
124
+ steps = buildFreeRoutingSteps(taskDescription);
125
+ }
126
+
127
+ const id = `run-${Date.now()}-${++_counter}`;
128
+
129
+ const run = {
130
+ id,
131
+ taskDescription: taskDescription.trim(),
132
+ entryLevel: level,
133
+ missionKey,
134
+ packKey,
135
+ entryDecision: entry,
136
+ status: "planning",
137
+ steps,
138
+ escalations: [],
139
+ interventions: [],
140
+ createdAt: new Date().toISOString(),
141
+ pausedAt: null,
142
+ completedAt: null,
143
+ completionReport: null,
144
+ };
145
+
146
+ // Persist
147
+ saveRun(cwd, run);
148
+
149
+ return run;
150
+ }
151
+
152
+ // ── Step builders ────────────────────────────────────────────────────────────
153
+
154
+ function buildMissionSteps(missionKey) {
155
+ const mission = getMission(missionKey);
156
+ return mission.artifactFlow.map((step, i) => ({
157
+ index: i,
158
+ role: step.role,
159
+ produces: step.produces,
160
+ status: "pending",
161
+ artifact: null,
162
+ note: null,
163
+ guidance: buildStepGuidance(step.role, step.produces, mission),
164
+ startedAt: null,
165
+ completedAt: null,
166
+ }));
167
+ }
168
+
169
+ function buildPackSteps(packKey) {
170
+ const pack = getPack(packKey);
171
+ const handoff = getHandoffContract(packKey);
172
+ const roles = pack.chainOrder
173
+ ? pack.chainOrder.split(" → ")
174
+ : pack.roles;
175
+
176
+ return roles.map((roleName, i) => {
177
+ const artifact = handoff?.flow?.[i]?.produces || guessArtifact(roleName);
178
+ return {
179
+ index: i,
180
+ role: roleName,
181
+ produces: artifact,
182
+ status: "pending",
183
+ artifact: null,
184
+ note: null,
185
+ guidance: buildStepGuidance(roleName, artifact, null),
186
+ startedAt: null,
187
+ completedAt: null,
188
+ };
189
+ });
190
+ }
191
+
192
+ function buildFreeRoutingSteps(taskDescription) {
193
+ // Free routing gets a minimal 3-step chain:
194
+ // 1. Analysis (best-fit role from catalog)
195
+ // 2. Execution (operator decides)
196
+ // 3. Review (Critic Reviewer)
197
+ return [
198
+ {
199
+ index: 0,
200
+ role: "Repo Researcher",
201
+ produces: "analysis",
202
+ status: "pending",
203
+ artifact: null,
204
+ note: null,
205
+ guidance: "Analyze the task, identify affected code/systems, produce a findings report.",
206
+ startedAt: null,
207
+ completedAt: null,
208
+ },
209
+ {
210
+ index: 1,
211
+ role: "Backend Engineer",
212
+ produces: "implementation",
213
+ status: "pending",
214
+ artifact: null,
215
+ note: null,
216
+ guidance: "Execute the task based on analysis. Operator may reroute this step to a different role.",
217
+ startedAt: null,
218
+ completedAt: null,
219
+ },
220
+ {
221
+ index: 2,
222
+ role: "Critic Reviewer",
223
+ produces: "verdict",
224
+ status: "pending",
225
+ artifact: null,
226
+ note: null,
227
+ guidance: "Review all artifacts. Accept, reject, or block with evidence.",
228
+ startedAt: null,
229
+ completedAt: null,
230
+ },
231
+ ];
232
+ }
233
+
234
+ // ── Step guidance ────────────────────────────────────────────────────────────
235
+
236
+ function buildStepGuidance(roleName, produces, mission) {
237
+ const contract = ROLE_ARTIFACT_CONTRACTS[roleName];
238
+ const lines = [];
239
+
240
+ lines.push(`Role: ${roleName}`);
241
+ lines.push(`Produce: ${produces}`);
242
+
243
+ if (contract) {
244
+ if (contract.requiredSections) {
245
+ lines.push(`Required sections: ${contract.requiredSections.join(", ")}`);
246
+ }
247
+ if (contract.completionRule) {
248
+ lines.push(`Done when: ${contract.completionRule}`);
249
+ }
250
+ }
251
+
252
+ if (mission?.stopConditions) {
253
+ lines.push(`Stop conditions: ${mission.stopConditions.join("; ")}`);
254
+ }
255
+
256
+ return lines.join("\n");
257
+ }
258
+
259
+ function guessArtifact(roleName) {
260
+ const map = {
261
+ "Product Strategist": "strategy-brief",
262
+ "Spec Writer": "implementation-spec",
263
+ "Backend Engineer": "change-plan",
264
+ "Frontend Developer": "change-plan",
265
+ "Test Engineer": "test-package",
266
+ "Security Reviewer": "security-findings",
267
+ "Critic Reviewer": "verdict",
268
+ "Docs Architect": "docs-update",
269
+ "Repo Researcher": "analysis",
270
+ "Deployment Verifier": "deploy-check",
271
+ "Release Engineer": "release-plan",
272
+ "Launch Strategist": "launch-plan",
273
+ "Launch Copywriter": "copy-package",
274
+ "Community Manager": "community-plan",
275
+ "Competitive Analyst": "competitive-analysis",
276
+ };
277
+ return map[roleName] || "artifact";
278
+ }
279
+
280
+ // ── Step lifecycle ───────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Start the next pending step in a run.
284
+ * @param {PersistentRun} run
285
+ * @param {string} cwd
286
+ * @returns {RunStep|null}
287
+ */
288
+ export function startNext(run, cwd) {
289
+ const next = run.steps.find(s => s.status === "pending");
290
+ if (!next) return null;
291
+
292
+ next.status = "active";
293
+ next.startedAt = new Date().toISOString();
294
+ run.status = "running";
295
+
296
+ saveRun(cwd, run);
297
+ return next;
298
+ }
299
+
300
+ /**
301
+ * Complete the current active step.
302
+ * @param {PersistentRun} run
303
+ * @param {string} artifact
304
+ * @param {string} [note]
305
+ * @param {string} cwd
306
+ * @returns {RunStep}
307
+ */
308
+ export function completeCurrentStep(run, artifact, note, cwd) {
309
+ const active = run.steps.find(s => s.status === "active");
310
+ if (!active) throw new Error("No active step to complete");
311
+
312
+ active.status = "completed";
313
+ active.artifact = artifact;
314
+ active.note = note || null;
315
+ active.completedAt = new Date().toISOString();
316
+
317
+ // Check if all done
318
+ const allDone = run.steps.every(s =>
319
+ s.status === "completed" || s.status === "skipped"
320
+ );
321
+ if (allDone) {
322
+ run.status = "completed";
323
+ run.completedAt = new Date().toISOString();
324
+ }
325
+
326
+ saveRun(cwd, run);
327
+ return active;
328
+ }
329
+
330
+ /**
331
+ * Fail the current active step.
332
+ * @param {PersistentRun} run
333
+ * @param {"partial"|"failed"} status
334
+ * @param {string} reason
335
+ * @param {string} cwd
336
+ * @returns {RunStep}
337
+ */
338
+ export function failCurrentStep(run, status, reason, cwd) {
339
+ if (status !== "partial" && status !== "failed") {
340
+ throw new Error(`Invalid fail status: "${status}"`);
341
+ }
342
+
343
+ const active = run.steps.find(s => s.status === "active");
344
+ if (!active) throw new Error("No active step to fail");
345
+
346
+ active.status = status;
347
+ active.note = reason;
348
+ active.completedAt = new Date().toISOString();
349
+
350
+ // Block downstream pending steps
351
+ let foundActive = false;
352
+ for (const step of run.steps) {
353
+ if (step === active) { foundActive = true; continue; }
354
+ if (foundActive && step.status === "pending") {
355
+ step.status = "blocked";
356
+ step.note = `Blocked: upstream ${active.role} ${status}`;
357
+ }
358
+ }
359
+
360
+ run.status = status;
361
+ run.completedAt = new Date().toISOString();
362
+
363
+ saveRun(cwd, run);
364
+ return active;
365
+ }
366
+
367
+ /**
368
+ * Pause a running run (for session resume later).
369
+ * @param {PersistentRun} run
370
+ * @param {string} cwd
371
+ */
372
+ export function pauseRun(run, cwd) {
373
+ if (run.status !== "running" && run.status !== "planning") return;
374
+ run.status = "paused";
375
+ run.pausedAt = new Date().toISOString();
376
+ saveRun(cwd, run);
377
+ }
378
+
379
+ /**
380
+ * Resume a paused run.
381
+ * @param {PersistentRun} run
382
+ * @param {string} cwd
383
+ * @returns {RunStep|null} The active or next step
384
+ */
385
+ export function resumeRun(run, cwd) {
386
+ if (run.status !== "paused") {
387
+ throw new Error(`Cannot resume run in "${run.status}" state`);
388
+ }
389
+
390
+ // Check if there's already an active step
391
+ const active = run.steps.find(s => s.status === "active");
392
+ if (active) {
393
+ run.status = "running";
394
+ run.pausedAt = null;
395
+ saveRun(cwd, run);
396
+ return active;
397
+ }
398
+
399
+ // Otherwise start the next pending step
400
+ run.status = "running";
401
+ run.pausedAt = null;
402
+ const next = run.steps.find(s => s.status === "pending");
403
+ if (next) {
404
+ next.status = "active";
405
+ next.startedAt = new Date().toISOString();
406
+ }
407
+ saveRun(cwd, run);
408
+ return next || null;
409
+ }
410
+
411
+ // ── Interventions ────────────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * Reroute a step to a different role.
415
+ * @param {PersistentRun} run
416
+ * @param {number} stepIndex
417
+ * @param {string} newRole
418
+ * @param {string} reason
419
+ * @param {string} cwd
420
+ */
421
+ export function reroute(run, stepIndex, newRole, reason, cwd) {
422
+ const step = run.steps[stepIndex];
423
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
424
+ if (step.status === "completed") throw new Error("Cannot reroute a completed step");
425
+ const inCatalog = ROLE_CATALOG.some(r => r.name === newRole);
426
+ if (!inCatalog) throw new Error(`Role "${newRole}" not in catalog`);
427
+
428
+ const oldRole = step.role;
429
+ step.role = newRole;
430
+ step.guidance = buildStepGuidance(newRole, step.produces, null);
431
+
432
+ run.interventions.push({
433
+ type: "reroute",
434
+ stepIndex,
435
+ from: oldRole,
436
+ to: newRole,
437
+ reason,
438
+ timestamp: new Date().toISOString(),
439
+ });
440
+
441
+ saveRun(cwd, run);
442
+ }
443
+
444
+ /**
445
+ * Record an escalation during execution.
446
+ * @param {PersistentRun} run
447
+ * @param {string} from
448
+ * @param {string} to
449
+ * @param {string} trigger
450
+ * @param {string} action
451
+ * @param {string} cwd
452
+ * @returns {{reopened: boolean, warning: string|null}}
453
+ */
454
+ export function escalate(run, from, to, trigger, action, cwd) {
455
+ const escalation = {
456
+ from, to, trigger, action,
457
+ timestamp: new Date().toISOString(),
458
+ reopened: false,
459
+ warning: null,
460
+ };
461
+ run.escalations.push(escalation);
462
+
463
+ // Find the LAST matching completed step for the target role
464
+ let targetStep = null;
465
+ for (let i = run.steps.length - 1; i >= 0; i--) {
466
+ if (run.steps[i].role === to && run.steps[i].status === "completed") {
467
+ targetStep = run.steps[i];
468
+ break;
469
+ }
470
+ }
471
+
472
+ if (targetStep) {
473
+ targetStep.status = "pending";
474
+ targetStep.artifact = null;
475
+ targetStep.note = `Re-opened by escalation: ${trigger}`;
476
+ targetStep.completedAt = null;
477
+ escalation.reopened = true;
478
+
479
+ // Unblock downstream steps that were blocked by this step's absence
480
+ for (let i = targetStep.index + 1; i < run.steps.length; i++) {
481
+ if (run.steps[i].status === "blocked") {
482
+ run.steps[i].status = "pending";
483
+ run.steps[i].note = `Unblocked: ${to} re-opened for escalation`;
484
+ }
485
+ }
486
+ } else {
487
+ const inChain = run.steps.some(s => s.role === to);
488
+ escalation.warning = inChain
489
+ ? `Role "${to}" has no completed step to re-open.`
490
+ : `Role "${to}" is not in this run's chain.`;
491
+ }
492
+
493
+ run.interventions.push({
494
+ type: "escalate",
495
+ from, to, trigger, action,
496
+ reopened: escalation.reopened,
497
+ warning: escalation.warning,
498
+ timestamp: escalation.timestamp,
499
+ });
500
+
501
+ saveRun(cwd, run);
502
+ return { reopened: escalation.reopened, warning: escalation.warning };
503
+ }
504
+
505
+ /**
506
+ * Retry a failed/partial step.
507
+ * @param {PersistentRun} run
508
+ * @param {number} stepIndex
509
+ * @param {string} cwd
510
+ */
511
+ export function retry(run, stepIndex, cwd) {
512
+ const step = run.steps[stepIndex];
513
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
514
+ if (step.status !== "failed" && step.status !== "partial") {
515
+ throw new Error(`Step ${stepIndex} is "${step.status}", not failed/partial`);
516
+ }
517
+
518
+ step.status = "pending";
519
+ step.artifact = null;
520
+ step.note = `Retried (was ${step.status})`;
521
+ step.completedAt = null;
522
+
523
+ // Unblock downstream
524
+ for (let i = stepIndex + 1; i < run.steps.length; i++) {
525
+ if (run.steps[i].status === "blocked") {
526
+ run.steps[i].status = "pending";
527
+ run.steps[i].note = `Unblocked: step ${stepIndex} retried`;
528
+ }
529
+ }
530
+
531
+ // Reset run status if it was failed/partial
532
+ if (run.status === "failed" || run.status === "partial") {
533
+ run.status = "paused";
534
+ run.completedAt = null;
535
+ }
536
+
537
+ run.interventions.push({
538
+ type: "retry",
539
+ stepIndex,
540
+ role: step.role,
541
+ timestamp: new Date().toISOString(),
542
+ });
543
+
544
+ saveRun(cwd, run);
545
+ }
546
+
547
+ /**
548
+ * Mark a step as blocked with a reason.
549
+ * @param {PersistentRun} run
550
+ * @param {number} stepIndex
551
+ * @param {string} reason
552
+ * @param {string} cwd
553
+ */
554
+ export function blockStep(run, stepIndex, reason, cwd) {
555
+ const step = run.steps[stepIndex];
556
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
557
+
558
+ step.status = "blocked";
559
+ step.note = reason;
560
+ step.completedAt = new Date().toISOString();
561
+
562
+ run.interventions.push({
563
+ type: "block",
564
+ stepIndex,
565
+ role: step.role,
566
+ reason,
567
+ timestamp: new Date().toISOString(),
568
+ });
569
+
570
+ saveRun(cwd, run);
571
+ }
572
+
573
+ /**
574
+ * Reopen a completed step (force re-execution).
575
+ * @param {PersistentRun} run
576
+ * @param {number} stepIndex
577
+ * @param {string} reason
578
+ * @param {string} cwd
579
+ */
580
+ export function reopenStep(run, stepIndex, reason, cwd) {
581
+ const step = run.steps[stepIndex];
582
+ if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
583
+ if (step.status !== "completed" && step.status !== "partial") {
584
+ throw new Error(`Step ${stepIndex} is "${step.status}", can only reopen completed/partial`);
585
+ }
586
+
587
+ step.status = "pending";
588
+ step.artifact = null;
589
+ step.note = `Re-opened: ${reason}`;
590
+ step.completedAt = null;
591
+
592
+ // Reset run status if it was completed
593
+ if (run.status === "completed") {
594
+ run.status = "paused";
595
+ run.completedAt = null;
596
+ }
597
+
598
+ run.interventions.push({
599
+ type: "reopen",
600
+ stepIndex,
601
+ role: step.role,
602
+ reason,
603
+ timestamp: new Date().toISOString(),
604
+ });
605
+
606
+ saveRun(cwd, run);
607
+ }
608
+
609
+ // ── Introspection ────────────────────────────────────────────────────────────
610
+
611
+ /**
612
+ * Get the current position in a run.
613
+ * @param {PersistentRun} run
614
+ * @returns {{activeStep: RunStep|null, nextStep: RunStep|null, completedCount: number, totalSteps: number, progress: string}}
615
+ */
616
+ export function getPosition(run) {
617
+ const active = run.steps.find(s => s.status === "active");
618
+ const next = active ? null : run.steps.find(s => s.status === "pending");
619
+ const completed = run.steps.filter(s => s.status === "completed").length;
620
+ const total = run.steps.length;
621
+
622
+ return {
623
+ activeStep: active || null,
624
+ nextStep: next || null,
625
+ completedCount: completed,
626
+ totalSteps: total,
627
+ progress: `${completed}/${total}`,
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Explain the current run state in detail.
633
+ * @param {PersistentRun} run
634
+ * @returns {string}
635
+ */
636
+ export function explainRun(run) {
637
+ const pos = getPosition(run);
638
+ const lines = [];
639
+
640
+ // Header
641
+ const levelLabel = run.entryLevel === "mission"
642
+ ? `Mission: ${getMission(run.missionKey)?.name || run.missionKey}`
643
+ : run.entryLevel === "pack"
644
+ ? `Pack: ${getPack(run.packKey)?.name || run.packKey}`
645
+ : "Free Routing";
646
+
647
+ lines.push(`# Run: ${run.id}`);
648
+ lines.push(`**Task:** ${run.taskDescription}`);
649
+ lines.push(`**Level:** ${levelLabel}`);
650
+ lines.push(`**Status:** ${run.status.toUpperCase()} (${pos.progress} steps completed)`);
651
+ lines.push(`**Created:** ${run.createdAt}`);
652
+ if (run.pausedAt) lines.push(`**Paused:** ${run.pausedAt}`);
653
+ if (run.completedAt) lines.push(`**Completed:** ${run.completedAt}`);
654
+
655
+ // Steps
656
+ lines.push("");
657
+ lines.push("## Steps");
658
+ for (const step of run.steps) {
659
+ const icon = step.status === "completed" ? "[x]" :
660
+ step.status === "active" ? "[>]" :
661
+ step.status === "partial" ? "[~]" :
662
+ step.status === "failed" ? "[!]" :
663
+ step.status === "blocked" ? "[-]" :
664
+ step.status === "skipped" ? "[s]" : "[ ]";
665
+ const artifact = step.artifact ? ` → ${step.produces}` : "";
666
+ const note = step.note ? ` (${step.note})` : "";
667
+ lines.push(` ${icon} ${step.index}. ${step.role}${artifact}${note}`);
668
+ }
669
+
670
+ // Active step guidance
671
+ if (pos.activeStep) {
672
+ lines.push("");
673
+ lines.push("## Current Step Guidance");
674
+ lines.push(pos.activeStep.guidance || "No specific guidance.");
675
+ } else if (pos.nextStep) {
676
+ lines.push("");
677
+ lines.push("## Next Step");
678
+ lines.push(`${pos.nextStep.role} → ${pos.nextStep.produces}`);
679
+ lines.push(pos.nextStep.guidance || "No specific guidance.");
680
+ }
681
+
682
+ // Escalations
683
+ if (run.escalations.length > 0) {
684
+ lines.push("");
685
+ lines.push("## Escalations");
686
+ for (const esc of run.escalations) {
687
+ lines.push(` - ${esc.from} → ${esc.to}: ${esc.trigger}`);
688
+ }
689
+ }
690
+
691
+ // Interventions
692
+ if (run.interventions.length > 0) {
693
+ lines.push("");
694
+ lines.push("## Interventions");
695
+ for (const iv of run.interventions) {
696
+ lines.push(` - [${iv.type}] ${iv.timestamp}: ${JSON.stringify(iv)}`);
697
+ }
698
+ }
699
+
700
+ return lines.join("\n");
701
+ }
702
+
703
+ /**
704
+ * Format a short "what's next" summary.
705
+ * @param {PersistentRun} run
706
+ * @returns {string}
707
+ */
708
+ export function formatNext(run) {
709
+ const pos = getPosition(run);
710
+
711
+ if (run.status === "completed") {
712
+ return `Run completed (${pos.progress} steps). All done.`;
713
+ }
714
+
715
+ if (run.status === "failed" || run.status === "partial") {
716
+ const failedStep = run.steps.find(s => s.status === "failed" || s.status === "partial");
717
+ return `Run ${run.status} at step ${failedStep?.index || "?"} (${failedStep?.role || "?"}). ` +
718
+ `Use \`roleos retry ${failedStep?.index}\` to retry or \`roleos escalate\` to reroute.`;
719
+ }
720
+
721
+ if (pos.activeStep) {
722
+ return `Active: step ${pos.activeStep.index} — ${pos.activeStep.role} → ${pos.activeStep.produces}\n` +
723
+ `Progress: ${pos.progress}\n\n` +
724
+ (pos.activeStep.guidance || "");
725
+ }
726
+
727
+ if (pos.nextStep) {
728
+ return `Next: step ${pos.nextStep.index} — ${pos.nextStep.role} → ${pos.nextStep.produces}\n` +
729
+ `Progress: ${pos.progress}\n` +
730
+ `Run \`roleos next\` to start this step.\n\n` +
731
+ (pos.nextStep.guidance || "");
732
+ }
733
+
734
+ return `Run is ${run.status}. No actionable steps.`;
735
+ }
736
+
737
+ // ── Completion report ────────────────────────────────────────────────────────
738
+
739
+ /**
740
+ * Generate a completion report for a finished run.
741
+ * @param {PersistentRun} run
742
+ * @returns {object}
743
+ */
744
+ export function generateReport(run) {
745
+ const pos = getPosition(run);
746
+ const artifacts = run.steps
747
+ .filter(s => s.status === "completed" && s.artifact)
748
+ .map(s => ({ role: s.role, type: s.produces, artifact: s.artifact }));
749
+
750
+ const levelName = run.entryLevel === "mission"
751
+ ? getMission(run.missionKey)?.name || run.missionKey
752
+ : run.entryLevel === "pack"
753
+ ? getPack(run.packKey)?.name || run.packKey
754
+ : "Free Routing";
755
+
756
+ const honestPartial = run.missionKey
757
+ ? getMission(run.missionKey)?.honestPartial
758
+ : null;
759
+
760
+ const isComplete = run.status === "completed";
761
+ const isPartial = run.status === "partial";
762
+ const isFailed = run.status === "failed";
763
+
764
+ const report = {
765
+ runId: run.id,
766
+ entryLevel: run.entryLevel,
767
+ levelName,
768
+ taskDescription: run.taskDescription,
769
+ outcome: run.status,
770
+ progress: pos.progress,
771
+ createdAt: run.createdAt,
772
+ completedAt: run.completedAt,
773
+ steps: run.steps.map(s => ({
774
+ index: s.index,
775
+ role: s.role,
776
+ produces: s.produces,
777
+ status: s.status,
778
+ hasArtifact: !!s.artifact,
779
+ note: s.note,
780
+ })),
781
+ artifactsProduced: artifacts.length,
782
+ artifactChain: artifacts,
783
+ escalationCount: run.escalations.length,
784
+ interventionCount: run.interventions.length,
785
+ honestPartial: (isPartial || isFailed) ? honestPartial : null,
786
+ verdict: isComplete
787
+ ? "Run completed — all steps passed."
788
+ : isPartial
789
+ ? `Run partially completed (${pos.progress}).${honestPartial ? " " + honestPartial : ""}`
790
+ : isFailed
791
+ ? `Run failed at step ${run.steps.find(s => s.status === "failed")?.index ?? "?"} ` +
792
+ `(${run.steps.find(s => s.status === "failed")?.role || "unknown"}).`
793
+ : `Run still in progress (${pos.progress}).`,
794
+ };
795
+
796
+ run.completionReport = report;
797
+ return report;
798
+ }
799
+
800
+ /**
801
+ * Format a completion report as human-readable text.
802
+ * @param {object} report
803
+ * @returns {string}
804
+ */
805
+ export function formatReport(report) {
806
+ const lines = [];
807
+
808
+ lines.push(`# Run Report: ${report.levelName}`);
809
+ lines.push("");
810
+ lines.push(`**Task:** ${report.taskDescription}`);
811
+ lines.push(`**Entry:** ${report.entryLevel}`);
812
+ lines.push(`**Outcome:** ${report.outcome.toUpperCase()}`);
813
+ lines.push(`**Progress:** ${report.progress}`);
814
+ lines.push(`**Artifacts:** ${report.artifactsProduced}`);
815
+ if (report.escalationCount > 0) lines.push(`**Escalations:** ${report.escalationCount}`);
816
+ if (report.interventionCount > 0) lines.push(`**Interventions:** ${report.interventionCount}`);
817
+
818
+ lines.push("");
819
+ lines.push("## Steps");
820
+ for (const step of report.steps) {
821
+ const icon = step.status === "completed" ? "[x]" :
822
+ step.status === "active" ? "[>]" :
823
+ step.status === "partial" ? "[~]" :
824
+ step.status === "failed" ? "[!]" :
825
+ step.status === "blocked" ? "[-]" : "[ ]";
826
+ const artifact = step.hasArtifact ? ` → ${step.produces}` : "";
827
+ const note = step.note ? ` (${step.note})` : "";
828
+ lines.push(` ${icon} ${step.index}. ${step.role}${artifact}${note}`);
829
+ }
830
+
831
+ if (report.honestPartial) {
832
+ lines.push("");
833
+ lines.push("## Honest Partial");
834
+ lines.push(report.honestPartial);
835
+ }
836
+
837
+ lines.push("");
838
+ lines.push("## Verdict");
839
+ lines.push(report.verdict);
840
+
841
+ return lines.join("\n");
842
+ }
843
+
844
+ // ── Persistence ──────────────────────────────────────────────────────────────
845
+
846
+ /**
847
+ * Save a run to disk.
848
+ * @param {string} cwd
849
+ * @param {PersistentRun} run
850
+ */
851
+ export function saveRun(cwd, run) {
852
+ const dir = runsDir(cwd);
853
+ mkdirSync(dir, { recursive: true });
854
+ writeFileSync(runPath(cwd, run.id), JSON.stringify(run, null, 2));
855
+ }
856
+
857
+ /**
858
+ * Load a run from disk.
859
+ * @param {string} cwd
860
+ * @param {string} id
861
+ * @returns {PersistentRun|null}
862
+ */
863
+ export function loadRun(cwd, id) {
864
+ const p = runPath(cwd, id);
865
+ if (!existsSync(p)) return null;
866
+ return JSON.parse(readFileSync(p, "utf-8"));
867
+ }
868
+
869
+ /**
870
+ * List all runs in the working directory.
871
+ * @param {string} cwd
872
+ * @returns {Array<{id: string, task: string, status: string, level: string, createdAt: string}>}
873
+ */
874
+ export function listRuns(cwd) {
875
+ const dir = runsDir(cwd);
876
+ if (!existsSync(dir)) return [];
877
+
878
+ return readdirSync(dir)
879
+ .filter(f => f.endsWith(".json"))
880
+ .map(f => {
881
+ try {
882
+ const run = JSON.parse(readFileSync(join(dir, f), "utf-8"));
883
+ return {
884
+ id: run.id,
885
+ task: run.taskDescription,
886
+ status: run.status,
887
+ level: run.entryLevel,
888
+ createdAt: run.createdAt,
889
+ };
890
+ } catch { return null; }
891
+ })
892
+ .filter(Boolean)
893
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
894
+ }
895
+
896
+ /**
897
+ * Find the most recent active (running/paused/planning) run.
898
+ * @param {string} cwd
899
+ * @returns {PersistentRun|null}
900
+ */
901
+ export function findActiveRun(cwd) {
902
+ const dir = runsDir(cwd);
903
+ if (!existsSync(dir)) return null;
904
+
905
+ const files = readdirSync(dir)
906
+ .filter(f => f.endsWith(".json"))
907
+ .sort().reverse(); // newest first by filename (timestamp in ID)
908
+
909
+ for (const f of files) {
910
+ try {
911
+ const run = JSON.parse(readFileSync(join(dir, f), "utf-8"));
912
+ if (["running", "paused", "planning", "failed", "partial"].includes(run.status)) {
913
+ return run;
914
+ }
915
+ } catch { /* skip corrupt files */ }
916
+ }
917
+
918
+ return null;
919
+ }
920
+
921
+ // ── Friction measurement ─────────────────────────────────────────────────────
922
+
923
+ /**
924
+ * Measure operator friction for a completed run.
925
+ * Counts touches (interventions, manual steps, escalations).
926
+ * @param {PersistentRun} run
927
+ * @returns {{totalTouches: number, interventions: number, escalations: number, manualSteps: number, stepsWithNotes: number, frictionScore: string}}
928
+ */
929
+ export function measureFriction(run) {
930
+ const interventions = run.interventions.length;
931
+ const escalations = run.escalations.length;
932
+ const manualSteps = run.steps.length; // all steps require operator for now
933
+ const stepsWithNotes = run.steps.filter(s => s.note).length;
934
+ const totalTouches = interventions + escalations + manualSteps;
935
+
936
+ let frictionScore;
937
+ if (totalTouches <= run.steps.length) frictionScore = "low";
938
+ else if (totalTouches <= run.steps.length * 2) frictionScore = "medium";
939
+ else frictionScore = "high";
940
+
941
+ return {
942
+ totalTouches,
943
+ interventions,
944
+ escalations,
945
+ manualSteps,
946
+ stepsWithNotes,
947
+ frictionScore,
948
+ };
949
+ }