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/CHANGELOG.md +332 -268
- package/README.es.md +250 -160
- package/README.fr.md +250 -160
- package/README.hi.md +250 -160
- package/README.it.md +250 -160
- package/README.ja.md +250 -160
- package/README.md +287 -204
- package/README.pt-BR.md +250 -160
- package/README.zh.md +250 -160
- package/bin/roleos.mjs +205 -140
- package/package.json +51 -51
- package/src/entry-cmd.mjs +59 -0
- package/src/entry.mjs +357 -0
- package/src/run-cmd.mjs +405 -0
- package/src/run.mjs +949 -0
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
|
+
}
|