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