role-os 2.9.0 → 2.9.1
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 +37 -0
- package/README.es.md +28 -11
- package/README.fr.md +25 -8
- package/README.hi.md +25 -8
- package/README.it.md +28 -11
- package/README.ja.md +27 -10
- package/README.md +25 -8
- package/README.pt-BR.md +25 -8
- package/README.zh.md +25 -8
- package/bin/roleos.mjs +3 -2
- package/package.json +1 -1
- package/src/artifacts.mjs +14 -7
- package/src/audit-cmd.mjs +23 -23
- package/src/brainstorm-roles.mjs +6 -0
- package/src/citation-panel.mjs +26 -1
- package/src/composite.mjs +4 -0
- package/src/entry.mjs +2 -2
- package/src/hooks.mjs +107 -27
- package/src/knowledge/analyze-artifact-evidence.mjs +19 -9
- package/src/knowledge/fallback-policy.mjs +19 -7
- package/src/knowledge/resolve-overlay.mjs +21 -8
- package/src/knowledge/retrieve-for-dispatch.mjs +9 -4
- package/src/mission-run.mjs +11 -2
- package/src/packs-cmd.mjs +1 -1
- package/src/review.mjs +11 -2
- package/src/role-dossiers.json +1 -1
- package/src/route.mjs +41 -8
- package/src/run-cmd.mjs +0 -1
- package/src/run.mjs +67 -15
- package/src/session.mjs +3 -1
- package/src/specialist/capability-gate.mjs +35 -18
- package/src/specialist/dispatch.mjs +8 -3
- package/src/specialist/registry.mjs +6 -0
- package/src/specialist/shadow.mjs +13 -3
- package/src/specialist/state.mjs +94 -26
- package/src/state-machine.mjs +2 -2
- package/src/status.mjs +4 -2
- package/src/swarm/build-gate.mjs +11 -2
- package/src/swarm/persist-bridge.mjs +4 -3
- package/src/swarm-cmd.mjs +88 -46
- package/src/verify-citations-cmd.mjs +17 -1
- package/src/verify-citations.mjs +31 -7
- package/starter-pack/README.md +22 -14
- package/starter-pack/handbook.md +4 -4
- package/starter-pack/policy/routing-rules.md +42 -0
- package/starter-pack/policy/tool-permissions.md +21 -0
- package/starter-pack/workflows/full-treatment.md +27 -16
|
@@ -11,13 +11,26 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
|
|
12
12
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
14
|
-
// Default overlay search paths
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
// Default overlay search paths. ROLEOS_KNOWLEDGE_ROLES overrides everything;
|
|
15
|
+
// otherwise we look for a knowledge-core checkout SIBLING to the role-os repo
|
|
16
|
+
// (e.g. <workspace>/knowledge-core next to <workspace>/role-os), then a nested
|
|
17
|
+
// checkout, then role-os's own local knowledge dir.
|
|
18
|
+
function defaultOverlayPaths() {
|
|
19
|
+
const paths = [];
|
|
20
|
+
if (process.env.ROLEOS_KNOWLEDGE_ROLES) {
|
|
21
|
+
paths.push(resolve(process.env.ROLEOS_KNOWLEDGE_ROLES));
|
|
22
|
+
}
|
|
23
|
+
paths.push(
|
|
24
|
+
// Sibling knowledge-core checkout (development) — __dirname is <role-os>/src/knowledge,
|
|
25
|
+
// so three levels up is the workspace that contains both repos.
|
|
26
|
+
join(resolve(__dirname, "..", "..", ".."), "knowledge-core", "knowledge", "roles"),
|
|
27
|
+
// Nested knowledge-core checkout inside role-os
|
|
28
|
+
join(resolve(__dirname, "..", ".."), "knowledge-core", "knowledge", "roles"),
|
|
29
|
+
// Fallback: role-os local knowledge dir
|
|
30
|
+
join(resolve(__dirname, ".."), "knowledge", "roles"),
|
|
31
|
+
);
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Resolve the overlay for a role.
|
|
@@ -28,7 +41,7 @@ const OVERLAY_PATHS = [
|
|
|
28
41
|
* @returns {{ overlay: object, path: string } | null}
|
|
29
42
|
*/
|
|
30
43
|
export function resolveOverlay(roleId, options = {}) {
|
|
31
|
-
const paths = options.searchPaths ||
|
|
44
|
+
const paths = options.searchPaths || defaultOverlayPaths();
|
|
32
45
|
|
|
33
46
|
for (const dir of paths) {
|
|
34
47
|
const filePath = join(dir, `${roleId}.json`);
|
|
@@ -71,7 +71,7 @@ export async function retrieveForDispatch({ roleId, taskText, packetContextSumma
|
|
|
71
71
|
|
|
72
72
|
return {
|
|
73
73
|
bundle,
|
|
74
|
-
status: deriveStatus(fallback),
|
|
74
|
+
status: deriveStatus(fallback, bundle),
|
|
75
75
|
fallback,
|
|
76
76
|
};
|
|
77
77
|
}
|
|
@@ -82,18 +82,23 @@ export async function retrieveForDispatch({ roleId, taskText, packetContextSumma
|
|
|
82
82
|
|
|
83
83
|
return {
|
|
84
84
|
bundle,
|
|
85
|
-
status: deriveStatus(fallback),
|
|
85
|
+
status: deriveStatus(fallback, bundle),
|
|
86
86
|
fallback,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* Derive packet knowledge status from fallback state.
|
|
92
|
+
*
|
|
93
|
+
* no_overlay with selected chunks maps to "weak" (shared-corpus-only evidence),
|
|
94
|
+
* matching fallback-policy's "continue — using shared corpus only" action.
|
|
95
|
+
* "none" is reserved for genuinely empty retrievals, since renderKnowledgeBlock()
|
|
96
|
+
* drops the knowledge block entirely for status "none".
|
|
92
97
|
*/
|
|
93
|
-
function deriveStatus(fallback) {
|
|
98
|
+
function deriveStatus(fallback, bundle) {
|
|
94
99
|
switch (fallback.state) {
|
|
95
100
|
case "healthy": return "strong";
|
|
96
|
-
case "no_overlay": return "none";
|
|
101
|
+
case "no_overlay": return (bundle?.selected?.length ?? 0) > 0 ? "weak" : "none";
|
|
97
102
|
case "no_strong_match": return "weak";
|
|
98
103
|
case "stale_dominant": return "stale";
|
|
99
104
|
case "conflicting": return "conflicted";
|
package/src/mission-run.mjs
CHANGED
|
@@ -114,11 +114,12 @@ export function createRun(missionKey, taskDescription, options = {}) {
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
116
|
* Build steps from manifest for dynamic dispatch missions.
|
|
117
|
+
* Exported so the persistent runner (run.mjs) can scale steps from a manifest.
|
|
117
118
|
* @param {Object} mission
|
|
118
119
|
* @param {Object} manifest - The audit-manifest.json content
|
|
119
120
|
* @returns {MissionStep[]}
|
|
120
121
|
*/
|
|
121
|
-
function buildDynamicSteps(mission, manifest) {
|
|
122
|
+
export function buildDynamicSteps(mission, manifest) {
|
|
122
123
|
const dd = mission.dynamicDispatch;
|
|
123
124
|
const steps = [];
|
|
124
125
|
|
|
@@ -198,11 +199,12 @@ function buildDynamicSteps(mission, manifest) {
|
|
|
198
199
|
/**
|
|
199
200
|
* Build steps from swarm manifest for dogfood-swarm missions.
|
|
200
201
|
* Creates domain agent steps per stage with coordinator gates.
|
|
202
|
+
* Exported so the persistent runner (run.mjs) can scale steps from a manifest.
|
|
201
203
|
* @param {Object} mission
|
|
202
204
|
* @param {Object} manifest - The swarm-manifest.json content
|
|
203
205
|
* @returns {MissionStep[]}
|
|
204
206
|
*/
|
|
205
|
-
function buildSwarmSteps(mission, manifest) {
|
|
207
|
+
export function buildSwarmSteps(mission, manifest) {
|
|
206
208
|
const steps = [];
|
|
207
209
|
const domains = manifest.domains || [];
|
|
208
210
|
const stages = manifest.stages || ["health-a", "health-b", "health-c", "feature", "treatment"];
|
|
@@ -412,6 +414,13 @@ export function recordEscalation(run, from, to, trigger, action) {
|
|
|
412
414
|
targetStep.note = `Re-opened by escalation: ${trigger}`;
|
|
413
415
|
targetStep.completedAt = null;
|
|
414
416
|
escalation.reopened = true;
|
|
417
|
+
|
|
418
|
+
// A completed run with a re-opened step is no longer complete —
|
|
419
|
+
// reset run status so the pending step is actionable again.
|
|
420
|
+
if (run.status === "completed") {
|
|
421
|
+
run.status = "running";
|
|
422
|
+
run.completedAt = null;
|
|
423
|
+
}
|
|
415
424
|
} else {
|
|
416
425
|
// S4-F2: Target role not found in chain (or no completed step to re-open).
|
|
417
426
|
// Warn the operator instead of silently doing nothing.
|
package/src/packs-cmd.mjs
CHANGED
|
@@ -57,7 +57,7 @@ function runSuggest(packetFile) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
console.log(`\nNext: roleos route ${packetFile} --pack
|
|
60
|
+
console.log(`\nNext: roleos route ${packetFile} --pack=${result.pack}\n`);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// ── Show ──────────────────────────────────────────────────────────────────────
|
package/src/review.mjs
CHANGED
|
@@ -32,9 +32,18 @@ export async function reviewCommand(args) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Extract task ID from the packet
|
|
35
|
-
const taskIdMatch = content.match(/## Task ID\n(.+)/);
|
|
35
|
+
const taskIdMatch = content.match(/## Task ID\r?\n(.+)/);
|
|
36
36
|
const taskId = taskIdMatch ? taskIdMatch[1].trim() : basename(packetFile, ".md");
|
|
37
37
|
|
|
38
|
+
// Extract the producing role from the packet — reject escalations route the
|
|
39
|
+
// retry back to the role that PRODUCED the output, never the reviewer.
|
|
40
|
+
// Falls back to Orchestrator when the section is absent or unassigned.
|
|
41
|
+
const assignedRoleMatch = content.match(/## Assigned Role\r?\n(.+)/);
|
|
42
|
+
const assignedRole = assignedRoleMatch ? assignedRoleMatch[1].trim() : null;
|
|
43
|
+
const producingRole = assignedRole && !assignedRole.startsWith("<!--")
|
|
44
|
+
? assignedRole
|
|
45
|
+
: "Orchestrator";
|
|
46
|
+
|
|
38
47
|
console.log(`\nroleos review — ${verdict}\n`);
|
|
39
48
|
console.log(`Packet: ${packetFile}`);
|
|
40
49
|
console.log(`Task ID: ${taskId}\n`);
|
|
@@ -99,7 +108,7 @@ ${nextOwner}
|
|
|
99
108
|
console.log(`\nEscalation (auto-routed):`);
|
|
100
109
|
console.log(formatEscalation(escalation));
|
|
101
110
|
} else if (verdict === "reject") {
|
|
102
|
-
const escalation = resolveRejected(reason,
|
|
111
|
+
const escalation = resolveRejected(reason, producingRole);
|
|
103
112
|
console.log(`\nEscalation (auto-routed):`);
|
|
104
113
|
console.log(formatEscalation(escalation));
|
|
105
114
|
}
|
package/src/role-dossiers.json
CHANGED
package/src/route.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { resolve, dirname } from "node:path";
|
|
2
|
+
import { resolve, dirname, join } from "node:path";
|
|
3
3
|
import { readFileSafe } from "./fs-utils.mjs";
|
|
4
4
|
import { detectConflicts } from "./conflicts.mjs";
|
|
5
5
|
import { resolveConflict, resolveSplit, formatEscalation } from "./escalation.mjs";
|
|
6
6
|
import { suggestPack, getPack, checkPackMismatch, getPackRoles } from "./packs.mjs";
|
|
7
7
|
|
|
8
|
-
// ── Full
|
|
8
|
+
// ── Full Role Catalog ────────────────────────────────────────────────────────
|
|
9
9
|
// Every role in the OS is scoreable. Keywords from routing-rules.md + contracts.
|
|
10
10
|
// Triggers are strong multi-word signals worth bonus points.
|
|
11
11
|
|
|
@@ -502,7 +502,7 @@ function scoreRole(role, content, packetType, deliverableType) {
|
|
|
502
502
|
// ── Type detection ────────────────────────────────────────────────────────────
|
|
503
503
|
|
|
504
504
|
function detectType(content) {
|
|
505
|
-
const typeMatch = content.match(/## Packet Type\n(\w+)/);
|
|
505
|
+
const typeMatch = content.match(/## Packet Type\r?\n(\w+)/);
|
|
506
506
|
if (typeMatch && ["feature", "integration", "identity"].includes(typeMatch[1])) {
|
|
507
507
|
return typeMatch[1];
|
|
508
508
|
}
|
|
@@ -521,7 +521,7 @@ function detectType(content) {
|
|
|
521
521
|
// ── Deliverable type extraction ───────────────────────────────────────────────
|
|
522
522
|
|
|
523
523
|
function extractDeliverableType(content) {
|
|
524
|
-
const match = content.match(/## Deliverable Type\n(\w+)/);
|
|
524
|
+
const match = content.match(/## Deliverable Type\r?\n(\w+)/);
|
|
525
525
|
if (match && DELIVERABLE_TYPES.includes(match[1])) return match[1];
|
|
526
526
|
return null;
|
|
527
527
|
}
|
|
@@ -553,17 +553,34 @@ function assessConfidence(scoredRoles) {
|
|
|
553
553
|
|
|
554
554
|
// ── File reference extraction ─────────────────────────────────────────────────
|
|
555
555
|
|
|
556
|
-
|
|
556
|
+
/**
|
|
557
|
+
* Find the base directory file refs should resolve against: the nearest
|
|
558
|
+
* ancestor of the packet that contains .claude/ (the repo root), falling
|
|
559
|
+
* back to the current working directory for packets outside any repo.
|
|
560
|
+
*/
|
|
561
|
+
function repoBaseFor(packetFile) {
|
|
562
|
+
let dir = dirname(packetFile);
|
|
563
|
+
for (let i = 0; i < 20; i++) {
|
|
564
|
+
if (existsSync(join(dir, ".claude"))) return dir;
|
|
565
|
+
const parent = dirname(dir);
|
|
566
|
+
if (parent === dir) break;
|
|
567
|
+
dir = parent;
|
|
568
|
+
}
|
|
569
|
+
return process.cwd();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function extractFileRefs(content, packetFile) {
|
|
557
573
|
const refs = [];
|
|
558
|
-
const inputsMatch = content.match(/## Inputs\n([\s\S]*?)(?=\n## |\n---)/);
|
|
574
|
+
const inputsMatch = content.match(/## Inputs\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---)/);
|
|
559
575
|
if (!inputsMatch) return refs;
|
|
560
576
|
|
|
577
|
+
const base = repoBaseFor(packetFile);
|
|
561
578
|
const inputsSection = inputsMatch[1];
|
|
562
579
|
const pathPattern = /(?:^|\s|`)((?:\.\/|\.\.\/|[a-zA-Z][\w\-]*\/)[^\s`\n,)]+\.\w+)/gm;
|
|
563
580
|
let match;
|
|
564
581
|
while ((match = pathPattern.exec(inputsSection)) !== null) {
|
|
565
582
|
const ref = match[1];
|
|
566
|
-
const resolved = resolve(
|
|
583
|
+
const resolved = resolve(base, ref);
|
|
567
584
|
refs.push({ ref, resolved, exists: existsSync(resolved) });
|
|
568
585
|
}
|
|
569
586
|
|
|
@@ -610,6 +627,22 @@ const HANDOFF_HINTS = {
|
|
|
610
627
|
|
|
611
628
|
export async function routeCommand(args) {
|
|
612
629
|
const verbose = args.includes("--verbose");
|
|
630
|
+
|
|
631
|
+
// A bare --pack would be silently swallowed as a flag and the pack name
|
|
632
|
+
// treated as the packet file — reject loudly instead of mis-routing.
|
|
633
|
+
if (args.includes("--pack")) {
|
|
634
|
+
const err = new Error("The --pack flag requires a value: use --pack=<name> (e.g. --pack=feature)");
|
|
635
|
+
err.exitCode = 1;
|
|
636
|
+
err.hint = "Run 'roleos packs list' for available pack names.";
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const knownFlag = a => a === "--verbose" || a === "--debug" || a.startsWith("--pack=");
|
|
641
|
+
const unknownFlags = args.filter(a => a.startsWith("--") && !knownFlag(a));
|
|
642
|
+
if (unknownFlags.length > 0) {
|
|
643
|
+
console.log(`! Ignoring unrecognized flag(s): ${unknownFlags.join(", ")}`);
|
|
644
|
+
}
|
|
645
|
+
|
|
613
646
|
const packFlag = args.find(a => a.startsWith("--pack="));
|
|
614
647
|
const requestedPack = packFlag ? packFlag.split("=")[1] : null;
|
|
615
648
|
const packetFile = args.find(a => !a.startsWith("--"));
|
|
@@ -632,7 +665,7 @@ export async function routeCommand(args) {
|
|
|
632
665
|
const type = detectType(content);
|
|
633
666
|
const deliverableType = extractDeliverableType(content);
|
|
634
667
|
|
|
635
|
-
// Score
|
|
668
|
+
// Score every role in the catalog
|
|
636
669
|
const allScored = ROLE_CATALOG.map(role => ({
|
|
637
670
|
role,
|
|
638
671
|
...scoreRole(role, content, type, deliverableType),
|
package/src/run-cmd.mjs
CHANGED
package/src/run.mjs
CHANGED
|
@@ -16,9 +16,11 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { decideEntry } from "./entry.mjs";
|
|
18
18
|
import { getMission } from "./mission.mjs";
|
|
19
|
-
import { TEAM_PACKS, getPack } from "./packs.mjs";
|
|
19
|
+
import { TEAM_PACKS, getPack, getPackRoles } from "./packs.mjs";
|
|
20
20
|
import { ROLE_CATALOG } from "./route.mjs";
|
|
21
21
|
import { ROLE_ARTIFACT_CONTRACTS, validateArtifact, getHandoffContract } from "./artifacts.mjs";
|
|
22
|
+
import { buildSwarmSteps, buildDynamicSteps } from "./mission-run.mjs";
|
|
23
|
+
import { isValidStepTransition } from "./state-machine.mjs";
|
|
22
24
|
import { retrieveForDispatch, isKnowledgeConfigured } from "./knowledge/index.mjs";
|
|
23
25
|
|
|
24
26
|
// ── Run directory ────────────────────────────────────────────────────────────
|
|
@@ -87,6 +89,9 @@ let _counter = 0;
|
|
|
87
89
|
* @param {object} [opts]
|
|
88
90
|
* @param {string} [opts.forceMission] - force a specific mission key
|
|
89
91
|
* @param {string} [opts.forcePack] - force a specific pack key
|
|
92
|
+
* @param {object} [opts.manifest] - dispatch manifest for dynamic missions
|
|
93
|
+
* (swarm-manifest.json for dogfood-swarm, audit-manifest.json for deep-audit);
|
|
94
|
+
* scales steps per domain/component instead of using the static artifactFlow
|
|
90
95
|
* @returns {PersistentRun}
|
|
91
96
|
*/
|
|
92
97
|
export async function createPersistentRun(taskDescription, cwd, opts = {}) {
|
|
@@ -106,7 +111,7 @@ export async function createPersistentRun(taskDescription, cwd, opts = {}) {
|
|
|
106
111
|
if (!mission) throw new Error(`Mission "${opts.forceMission}" not found`);
|
|
107
112
|
level = "mission";
|
|
108
113
|
missionKey = opts.forceMission;
|
|
109
|
-
steps = buildMissionSteps(opts.forceMission);
|
|
114
|
+
steps = buildMissionSteps(opts.forceMission, opts.manifest);
|
|
110
115
|
} else if (opts.forcePack) {
|
|
111
116
|
const pack = getPack(opts.forcePack);
|
|
112
117
|
if (!pack) throw new Error(`Pack "${opts.forcePack}" not found`);
|
|
@@ -172,9 +177,23 @@ export async function createPersistentRun(taskDescription, cwd, opts = {}) {
|
|
|
172
177
|
|
|
173
178
|
// ── Step builders ────────────────────────────────────────────────────────────
|
|
174
179
|
|
|
175
|
-
function buildMissionSteps(missionKey) {
|
|
180
|
+
function buildMissionSteps(missionKey, manifest = null) {
|
|
176
181
|
const mission = getMission(missionKey);
|
|
177
|
-
|
|
182
|
+
|
|
183
|
+
// Dynamic dispatch — when a manifest is supplied, scale steps from it:
|
|
184
|
+
// dogfood-swarm builds per-domain steps with stage/gate metadata,
|
|
185
|
+
// deep-audit builds one auditor step per component/boundary.
|
|
186
|
+
let flow = mission.artifactFlow;
|
|
187
|
+
if (manifest && mission.dynamicDispatch) {
|
|
188
|
+
flow = missionKey === "dogfood-swarm"
|
|
189
|
+
? buildSwarmSteps(mission, manifest)
|
|
190
|
+
: buildDynamicSteps(mission, manifest);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Spread first so dispatch metadata (stage, domain, isGate, userApproval,
|
|
194
|
+
// parcel, consumedBy, …) carries through, then pin the run.mjs step shape.
|
|
195
|
+
return flow.map((step, i) => ({
|
|
196
|
+
...step,
|
|
178
197
|
index: i,
|
|
179
198
|
role: step.role,
|
|
180
199
|
produces: step.produces,
|
|
@@ -190,12 +209,26 @@ function buildMissionSteps(missionKey) {
|
|
|
190
209
|
function buildPackSteps(packKey) {
|
|
191
210
|
const pack = getPack(packKey);
|
|
192
211
|
const handoff = getHandoffContract(packKey);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
212
|
+
|
|
213
|
+
// Derive steps from the pack's real roster (includes the final review gate
|
|
214
|
+
// and conditional Orchestrator) — never from chainOrder prose, which for
|
|
215
|
+
// brainstorm/deep-audit/swarm contains pseudo-role fragments.
|
|
216
|
+
const roles = getPackRoles(packKey) || pack.roles;
|
|
217
|
+
|
|
218
|
+
// Fail loudly on roles missing from the catalog — a run built around a
|
|
219
|
+
// nonexistent role breaks guidance, artifact contracts, and escalation.
|
|
220
|
+
const unknown = roles.filter(name => !ROLE_CATALOG.some(r => r.name === name));
|
|
221
|
+
if (unknown.length > 0) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Pack "${packKey}" contains roles not in the role catalog: ${unknown.join(", ")}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
196
226
|
|
|
197
227
|
return roles.map((roleName, i) => {
|
|
198
|
-
|
|
228
|
+
// Resolve produces by role lookup into the handoff flow — index alignment
|
|
229
|
+
// is wrong whenever the roster and flow are ordered differently.
|
|
230
|
+
const flowEntry = handoff?.flow?.find(f => f.role === roleName);
|
|
231
|
+
const artifact = flowEntry?.produces || guessArtifact(roleName);
|
|
199
232
|
return {
|
|
200
233
|
index: i,
|
|
201
234
|
role: roleName,
|
|
@@ -279,6 +312,7 @@ function buildStepGuidance(roleName, produces, mission) {
|
|
|
279
312
|
|
|
280
313
|
function guessArtifact(roleName) {
|
|
281
314
|
const map = {
|
|
315
|
+
"Orchestrator": "decomposition-plan",
|
|
282
316
|
"Product Strategist": "strategy-brief",
|
|
283
317
|
"Spec Writer": "implementation-spec",
|
|
284
318
|
"Backend Engineer": "change-plan",
|
|
@@ -512,6 +546,13 @@ export function escalate(run, from, to, trigger, action, cwd) {
|
|
|
512
546
|
run.steps[i].note = `Unblocked: ${to} re-opened for escalation`;
|
|
513
547
|
}
|
|
514
548
|
}
|
|
549
|
+
|
|
550
|
+
// A completed run with a re-opened step is no longer complete —
|
|
551
|
+
// mirror reopenStep so formatNext doesn't report "All done."
|
|
552
|
+
if (run.status === "completed") {
|
|
553
|
+
run.status = "paused";
|
|
554
|
+
run.completedAt = null;
|
|
555
|
+
}
|
|
515
556
|
} else {
|
|
516
557
|
const inChain = run.steps.some(s => s.role === to);
|
|
517
558
|
escalation.warning = inChain
|
|
@@ -540,13 +581,15 @@ export function escalate(run, from, to, trigger, action, cwd) {
|
|
|
540
581
|
export function retry(run, stepIndex, cwd) {
|
|
541
582
|
const step = run.steps[stepIndex];
|
|
542
583
|
if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
|
|
543
|
-
|
|
544
|
-
|
|
584
|
+
// blocked is retryable so an operator block (or a mistaken one) has a recovery path
|
|
585
|
+
if (step.status !== "failed" && step.status !== "partial" && step.status !== "blocked") {
|
|
586
|
+
throw new Error(`Step ${stepIndex} is "${step.status}", not failed/partial/blocked`);
|
|
545
587
|
}
|
|
546
588
|
|
|
589
|
+
const previousStatus = step.status;
|
|
547
590
|
step.status = "pending";
|
|
548
591
|
step.artifact = null;
|
|
549
|
-
step.note = `Retried (was ${
|
|
592
|
+
step.note = `Retried (was ${previousStatus})`;
|
|
550
593
|
step.completedAt = null;
|
|
551
594
|
|
|
552
595
|
// Unblock downstream
|
|
@@ -583,6 +626,14 @@ export function retry(run, stepIndex, cwd) {
|
|
|
583
626
|
export function blockStep(run, stepIndex, reason, cwd) {
|
|
584
627
|
const step = run.steps[stepIndex];
|
|
585
628
|
if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
|
|
629
|
+
// Enforce the canonical state machine — blocking a completed/failed step
|
|
630
|
+
// would strand the run with no CLI recovery path.
|
|
631
|
+
if (!isValidStepTransition(step.status, "blocked")) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`Cannot block step ${stepIndex} (${step.role}) — invalid transition "${step.status}" → "blocked". ` +
|
|
634
|
+
`Only pending or active steps can be blocked.`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
586
637
|
|
|
587
638
|
step.status = "blocked";
|
|
588
639
|
step.note = reason;
|
|
@@ -609,8 +660,8 @@ export function blockStep(run, stepIndex, reason, cwd) {
|
|
|
609
660
|
export function reopenStep(run, stepIndex, reason, cwd) {
|
|
610
661
|
const step = run.steps[stepIndex];
|
|
611
662
|
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`);
|
|
663
|
+
if (step.status !== "completed" && step.status !== "partial" && step.status !== "blocked") {
|
|
664
|
+
throw new Error(`Step ${stepIndex} is "${step.status}", can only reopen completed/partial/blocked`);
|
|
614
665
|
}
|
|
615
666
|
|
|
616
667
|
step.status = "pending";
|
|
@@ -743,7 +794,7 @@ export function formatNext(run) {
|
|
|
743
794
|
|
|
744
795
|
if (run.status === "failed" || run.status === "partial") {
|
|
745
796
|
const failedStep = run.steps.find(s => s.status === "failed" || s.status === "partial");
|
|
746
|
-
return `Run ${run.status} at step ${failedStep?.index
|
|
797
|
+
return `Run ${run.status} at step ${failedStep?.index ?? "?"} (${failedStep?.role || "?"}). ` +
|
|
747
798
|
`Use \`roleos retry ${failedStep?.index}\` to retry or \`roleos escalate\` to reroute.`;
|
|
748
799
|
}
|
|
749
800
|
|
|
@@ -920,7 +971,7 @@ export function loadRun(cwd, id) {
|
|
|
920
971
|
/**
|
|
921
972
|
* List all runs in the working directory.
|
|
922
973
|
* @param {string} cwd
|
|
923
|
-
* @returns {Array<{id: string, task: string, status: string, level: string, createdAt: string}>}
|
|
974
|
+
* @returns {Array<{id: string, task: string, status: string, level: string, missionKey: string|null, createdAt: string}>}
|
|
924
975
|
*/
|
|
925
976
|
export function listRuns(cwd) {
|
|
926
977
|
const dir = runsDir(cwd);
|
|
@@ -936,6 +987,7 @@ export function listRuns(cwd) {
|
|
|
936
987
|
task: run.taskDescription,
|
|
937
988
|
status: run.status,
|
|
938
989
|
level: run.entryLevel,
|
|
990
|
+
missionKey: run.missionKey ?? null,
|
|
939
991
|
createdAt: run.createdAt,
|
|
940
992
|
};
|
|
941
993
|
} catch { return null; }
|
package/src/session.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { writeFileSafe } from "./fs-utils.mjs";
|
|
17
17
|
import { scaffoldHooks, generateHooksConfig } from "./hooks.mjs";
|
|
18
|
+
import { ROLE_CATALOG } from "./route.mjs";
|
|
19
|
+
import { TEAM_PACKS } from "./packs.mjs";
|
|
18
20
|
|
|
19
21
|
// ── roleos init claude ────────────────────────────────────────────────────────
|
|
20
22
|
|
|
@@ -302,7 +304,7 @@ Before starting non-trivial work in this repo, route the task through Role OS:
|
|
|
302
304
|
3. Use structured handoffs between roles
|
|
303
305
|
4. Review with evidence-based verdicts
|
|
304
306
|
|
|
305
|
-
Role OS provides
|
|
307
|
+
Role OS provides ${ROLE_CATALOG.length} specialized roles across ${Object.keys(TEAM_PACKS).length} packs (${Object.keys(TEAM_PACKS).join(", ")}). It detects broken chains, auto-routes recovery, and requires structured evidence in every verdict.
|
|
306
308
|
|
|
307
309
|
If the task is composite (feature + docs + launch), Role OS will recommend splitting into child packets with dependency ordering.
|
|
308
310
|
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
* not, so its asymmetry runs the other way.)
|
|
22
22
|
*
|
|
23
23
|
* The grant manifest (`.claude/role-os/capabilities.json`, director-authored) maps an action id to a
|
|
24
|
-
* grant, e.g.: { "npm:publish": { "granted": true, "
|
|
24
|
+
* grant, e.g.: { "npm:publish": { "granted": true, "expires": "2026-07-01" } }. The gate enforces
|
|
25
|
+
* `granted` and `expires` ONLY — any other field (e.g. a "scope" annotation) is informational/audit
|
|
26
|
+
* metadata and does NOT narrow the grant: a granted action id authorizes ALL matching invocations.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -41,17 +43,21 @@ const _bash = (re) => (toolName, call) =>
|
|
|
41
43
|
/**
|
|
42
44
|
* The GATED SET — the irreversible / world-touching actions from the NAMED_COMPENSATORS standard.
|
|
43
45
|
* Each entry: { id, label, test(toolName, call) -> boolean }. Detection is deterministic + pattern-
|
|
44
|
-
* based and errs toward FLAGGING (a benign match just needs a one-line grant)
|
|
45
|
-
*
|
|
46
|
+
* based and errs toward FLAGGING (a benign match just needs a one-line grant). The patterns allow
|
|
47
|
+
* flags between the command word and its verb — `git -C <dir> push`, `npm --workspace <pkg> publish`,
|
|
48
|
+
* `gh release -R <repo> create` all match — without crossing a shell separator (| ; & or newline).
|
|
49
|
+
* KNOWN LIMIT: script indirection (`npm run release` wrapping a publish, a shell script invoking
|
|
50
|
+
* git push) is NOT detected — the gate bounds direct invocations; it is opt-in defense-in-depth,
|
|
51
|
+
* not an evasion-proof sandbox.
|
|
46
52
|
*/
|
|
47
53
|
export const GATED_ACTIONS = [
|
|
48
|
-
{ id: "npm:publish", label: "npm/pnpm/yarn publish", test: _bash(/\b(?:npm|pnpm|yarn)\
|
|
49
|
-
{ id: "pypi:publish", label: "PyPI publish (twine/uv)", test: _bash(/\btwine\
|
|
50
|
-
{ id: "gh:release", label: "gh release create", test: _bash(/\bgh\
|
|
51
|
-
{ id: "gh:pr-create", label: "gh pr create", test: _bash(/\bgh\
|
|
52
|
-
{ id: "gh:repo-edit", label: "gh repo edit/delete", test: _bash(/\bgh\
|
|
53
|
-
{ id: "git:push", label: "git push", test: _bash(/\bgit\
|
|
54
|
-
{ id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", test: _bash(/\bgh-pages\b|\bpages\
|
|
54
|
+
{ id: "npm:publish", label: "npm/pnpm/yarn publish", test: _bash(/\b(?:npm|pnpm|yarn)\b[^|;&\n]*\bpublish\b/) },
|
|
55
|
+
{ id: "pypi:publish", label: "PyPI publish (twine/uv)", test: _bash(/\btwine\b[^|;&\n]*\bupload\b|\buv\b[^|;&\n]*\bpublish\b/) },
|
|
56
|
+
{ id: "gh:release", label: "gh release create", test: _bash(/\bgh\b[^|;&\n]*\brelease\b[^|;&\n]*\bcreate\b/) },
|
|
57
|
+
{ id: "gh:pr-create", label: "gh pr create", test: _bash(/\bgh\b[^|;&\n]*\bpr\b[^|;&\n]*\bcreate\b/) },
|
|
58
|
+
{ id: "gh:repo-edit", label: "gh repo edit/delete", test: _bash(/\bgh\b[^|;&\n]*\brepo\b[^|;&\n]*\b(?:edit|delete)\b/) },
|
|
59
|
+
{ id: "git:push", label: "git push", test: _bash(/\bgit\b[^|;&\n]*\bpush\b/) },
|
|
60
|
+
{ id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", test: _bash(/\bgh-pages\b|\bpages\b[^|;&\n]*\bdeploy\b/) },
|
|
55
61
|
];
|
|
56
62
|
|
|
57
63
|
/** Read the director's capability manifest, or {} if absent/malformed (=> nothing granted). */
|
|
@@ -66,15 +72,24 @@ export function loadCapabilities(cwd) {
|
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Evaluate `actionId`'s grant. Returns null when granted and valid; otherwise a short problem
|
|
77
|
+
* string for the deny reason. An unparseable `expires` is treated as INVALID (deny) — a
|
|
78
|
+
* fail-closed gate must never turn a typo'd date into a permanent grant.
|
|
79
|
+
*/
|
|
80
|
+
function _grantProblem(manifest, actionId, now) {
|
|
71
81
|
const g = manifest && manifest[actionId];
|
|
72
|
-
if (!g || typeof g !== "object" || g.granted !== true)
|
|
82
|
+
if (!g || typeof g !== "object" || g.granted !== true) {
|
|
83
|
+
return `No capability "${actionId}" is granted in ${CAPABILITIES_FILE}`;
|
|
84
|
+
}
|
|
73
85
|
if (typeof g.expires === "string") {
|
|
74
86
|
const t = Date.parse(g.expires);
|
|
75
|
-
if (
|
|
87
|
+
if (Number.isNaN(t)) {
|
|
88
|
+
return `The grant for "${actionId}" has an unparseable "expires" value ("${g.expires}") — an invalid expiry DENIES (fail-closed), it never extends the grant; fix the date`;
|
|
89
|
+
}
|
|
90
|
+
if (t < now) return `The grant for "${actionId}" expired at ${g.expires}`;
|
|
76
91
|
}
|
|
77
|
-
return
|
|
92
|
+
return null;
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
/**
|
|
@@ -100,14 +115,16 @@ export function capabilityGate(cwd, toolName, toolInput, opts = {}) {
|
|
|
100
115
|
if (!action) return { denied: false }; // not an irreversible action -> allow
|
|
101
116
|
const manifest = opts.capabilities || loadCapabilities(cwd);
|
|
102
117
|
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
103
|
-
|
|
118
|
+
const problem = _grantProblem(manifest, action.id, now);
|
|
119
|
+
if (!problem) return { denied: false };
|
|
104
120
|
return {
|
|
105
121
|
denied: true,
|
|
106
122
|
action: action.id,
|
|
107
123
|
reason:
|
|
108
124
|
`Capability gate: "${action.label}" is an irreversible action requiring an explicit grant. ` +
|
|
109
|
-
|
|
110
|
-
`
|
|
125
|
+
`${problem}. To authorize it, the director adds {"${action.id}": {"granted": true}}, ` +
|
|
126
|
+
`optionally with an "expires" date. (Note: the gate enforces only "granted"/"expires" — ` +
|
|
127
|
+
`a grant authorizes ALL matching ${action.label} calls; a "scope" field is informational only.)`,
|
|
111
128
|
};
|
|
112
129
|
} catch {
|
|
113
130
|
// A gate that errors must not silently allow an irreversible action: if a gated action matched
|
|
@@ -145,7 +145,6 @@ export async function dispatchSpecialist({
|
|
|
145
145
|
if (specialistCall.ok) {
|
|
146
146
|
result = specialistCall.verdict;
|
|
147
147
|
source = "specialist";
|
|
148
|
-
recordDispatch(state, role, windowSize, parseIsoMs(nowIso));
|
|
149
148
|
} else {
|
|
150
149
|
// Specialist call failed → fail open to Claude. The gate's "route" was specialist, but
|
|
151
150
|
// the realized source is Claude. Both are recorded in the receipt.
|
|
@@ -157,6 +156,11 @@ export async function dispatchSpecialist({
|
|
|
157
156
|
source = "claude";
|
|
158
157
|
}
|
|
159
158
|
|
|
159
|
+
// Record EVERY dispatch (both routes) in the quota window, tagged with the REALIZED source.
|
|
160
|
+
// The window only rolls when Claude traffic is recorded too — recording only specialist
|
|
161
|
+
// successes froze the window and locked every role out permanently once the quota tripped.
|
|
162
|
+
recordDispatch(state, source, windowSize, parseIsoMs(nowIso));
|
|
163
|
+
|
|
160
164
|
// ── Shadow probe ─────────────────────────────────────────────────────────────────────────
|
|
161
165
|
// Probes only fire when the dispatch actually went to a specialist (source === "specialist").
|
|
162
166
|
// A failed-open dispatch already ran Claude; there is nothing left to probe.
|
|
@@ -175,12 +179,13 @@ export async function dispatchSpecialist({
|
|
|
175
179
|
claude_summary: summarize(claudeVerdict),
|
|
176
180
|
});
|
|
177
181
|
resetProbeCounter(state, role);
|
|
178
|
-
const { probes, rate, shouldHalt } = checkHalt(eventsPath, role, N, tau);
|
|
182
|
+
const { probes, rate, agreed: agreedCount, shouldHalt } = checkHalt(eventsPath, role, N, tau);
|
|
179
183
|
shadow = { fired: true, agreed, probes, rate, halt_triggered: shouldHalt };
|
|
180
184
|
if (shouldHalt && !getHalt(state, role).halted) {
|
|
181
185
|
const reason = contrastiveHaltMessage({ role, probes, rate, tau });
|
|
182
186
|
setHalt(state, role, { reason, since: nowIso });
|
|
183
|
-
|
|
187
|
+
// `agreed` is checkHalt's exact window count — never recompute it lossily from the rate.
|
|
188
|
+
appendHaltEvent(eventsPath, { role, ts: nowIso, reason, probes, agreed: agreedCount, rate, tau });
|
|
184
189
|
}
|
|
185
190
|
} else {
|
|
186
191
|
shadow = { fired: false, counter: c };
|
|
@@ -199,6 +199,12 @@ function validateVersion(v, tag) {
|
|
|
199
199
|
if (v.exam_centroid !== undefined && !Array.isArray(v.exam_centroid)) {
|
|
200
200
|
errors.push(`${tag}: exam_centroid, if present, must be an array of numbers`);
|
|
201
201
|
}
|
|
202
|
+
// ood_floor feeds defaultOodFn directly (gate.mjs): a string silently falls back to the 0.4
|
|
203
|
+
// default and a value outside cosine range disables routing entirely — both must fail loudly
|
|
204
|
+
// at load time, mirroring the R5 gate_threshold check.
|
|
205
|
+
if (v.ood_floor !== undefined && (typeof v.ood_floor !== "number" || !(v.ood_floor >= -1 && v.ood_floor <= 1))) {
|
|
206
|
+
errors.push(`${tag}: R5 — ood_floor, if present, must be a number in [-1, 1] (cosine range; got ${JSON.stringify(v.ood_floor)})`);
|
|
207
|
+
}
|
|
202
208
|
return errors;
|
|
203
209
|
}
|
|
204
210
|
|