odai-dsh-agent 0.2.0 → 0.2.2
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/LICENSE +21 -21
- package/README.md +138 -134
- package/bin/odai-dsh-agent.mjs +108 -108
- package/package.json +69 -69
- package/preset/odai/agent.cordis.yml +260 -260
- package/preset/odai/preset.yml +3 -3
- package/preset/odai/runtime/compaction-config.mjs +388 -388
- package/preset/odai/runtime/context-activation.mjs +71 -0
- package/preset/odai/runtime/context-capability.mjs +86 -0
- package/preset/odai/runtime/governance.mjs +128 -121
- package/preset/odai/runtime/human-care.mjs +45 -0
- package/preset/odai/runtime/human-safety-continuity.mjs +2 -2
- package/preset/odai/runtime/human-safety.mjs +3 -3
- package/preset/odai/runtime/index.mjs +2461 -1977
- package/preset/odai/runtime/output-config.mjs +360 -304
- package/preset/odai/runtime/research-packet.mjs +150 -150
- package/preset/odai/runtime/responsibility-gap.mjs +8 -10
- package/preset/odai/runtime/responsibility-scope.mjs +199 -0
- package/preset/odai/runtime/role-overlays.mjs +27 -27
- package/preset/odai/runtime/route-card.mjs +168 -152
- package/preset/odai/runtime/router.mjs +683 -651
- package/preset/odai/runtime/routing-config.mjs +480 -436
- package/preset/odai/runtime/routing-context.mjs +321 -321
- package/preset/odai/runtime/semantic-memory-store.mjs +452 -452
- package/preset/odai/runtime/semantic-memory.mjs +842 -842
- package/preset/odai/runtime/session-compat.mjs +389 -389
- package/preset/odai/runtime/skill-bundle.mjs +318 -318
- package/preset/odai/runtime/skill-evolution.mjs +1413 -1413
- package/preset/odai/skills/odai/SKILL.md +69 -68
- package/preset/odai/skills/odai/agents/openai.yaml +4 -4
- package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
- package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
- package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
- package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
- package/preset/odai/skills/odai/assets/routing-roles/executor.md +11 -11
- package/preset/odai/skills/odai/assets/routing-roles/frontend.md +7 -7
- package/preset/odai/skills/odai/assets/routing-roles/planner.md +29 -29
- package/preset/odai/skills/odai/assets/routing-roles/researcher.md +6 -6
- package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
- package/preset/odai/skills/odai/assets/task-state.md +14 -14
- package/preset/odai/skills/odai/manifest.json +37 -36
- package/preset/odai/skills/odai/references/care.md +26 -0
- package/preset/odai/skills/odai/references/craft.md +54 -54
- package/preset/odai/skills/odai/references/dao.md +30 -30
- package/preset/odai/skills/odai/references/human-safety.md +33 -54
- package/preset/odai/skills/odai/references/leverage.md +53 -53
- package/preset/odai/skills/odai/references/planning.md +82 -82
- package/preset/odai/skills/odai/references/support.md +36 -36
- package/preset/odai/skills/odai/references/verification.md +23 -23
- package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
- package/preset/odai/skills/odai/scripts/build-routing.mjs +274 -274
- package/preset/odai/skills/odai/scripts/install-routing.mjs +397 -397
- package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
- package/preset/odai/skills/odai/scripts/run-role.mjs +336 -336
- package/preset/odai/skills/odai/scripts/run-routing.mjs +1154 -1154
- package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
- package/scripts/verify-agent-load.mjs +257 -257
- package/src/dsh-version.mjs +6 -2
- package/src/installer.mjs +382 -382
|
@@ -1,150 +1,150 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
-
|
|
5
|
-
const PACKET_FIELDS = new Set(["schemaVersion", "question", "facts", "conflicts", "unknowns", "stop"]);
|
|
6
|
-
const FACT_FIELDS = new Set(["claim", "excerpt", "source", "authority"]);
|
|
7
|
-
const SOURCE_FIELDS = new Set(["path", "line"]);
|
|
8
|
-
const DEFAULT_MAX_CHARS = 8_000;
|
|
9
|
-
const DEFAULT_MAX_FACTS = 12;
|
|
10
|
-
const DEFAULT_MAX_ITEMS = 8;
|
|
11
|
-
|
|
12
|
-
function plainObject(value, field) {
|
|
13
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
-
throw new TypeError(`${field} must be an object`);
|
|
15
|
-
}
|
|
16
|
-
return value;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function exactFields(value, allowed, field) {
|
|
20
|
-
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
21
|
-
if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function text(value, field, maxChars = 1_500) {
|
|
25
|
-
if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${field} must be a non-empty string`);
|
|
26
|
-
const normalized = value.trim();
|
|
27
|
-
if (normalized.length > maxChars) throw new TypeError(`${field} exceeds ${maxChars} characters`);
|
|
28
|
-
return normalized;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function stringList(value, field, maxItems) {
|
|
32
|
-
if (!Array.isArray(value) || value.length > maxItems) {
|
|
33
|
-
throw new TypeError(`${field} must be an array with at most ${maxItems} items`);
|
|
34
|
-
}
|
|
35
|
-
return Object.freeze(value.map((item, index) => text(item, `${field}[${index}]`, 800)));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sourcePointer(value, field) {
|
|
39
|
-
const source = plainObject(value, field);
|
|
40
|
-
exactFields(source, SOURCE_FIELDS, field);
|
|
41
|
-
const path = text(source.path, `${field}.path`, 500);
|
|
42
|
-
if (path.startsWith("/") || path.startsWith("~") || path.split(/[\\/]/u).includes("..")) {
|
|
43
|
-
throw new TypeError(`${field}.path must be repository-relative and may not traverse parent directories`);
|
|
44
|
-
}
|
|
45
|
-
if (!Number.isSafeInteger(source.line) || source.line <= 0) {
|
|
46
|
-
throw new TypeError(`${field}.line must be a positive integer`);
|
|
47
|
-
}
|
|
48
|
-
return Object.freeze({ path, line: source.line });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function parseJsonText(output) {
|
|
52
|
-
const trimmed = String(output ?? "").trim();
|
|
53
|
-
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/iu.exec(trimmed);
|
|
54
|
-
return JSON.parse(fenced ? fenced[1] : trimmed);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function parseResearchPacket(output, options = {}) {
|
|
58
|
-
const maxChars = Number.isSafeInteger(options.maxChars) && options.maxChars > 0
|
|
59
|
-
? options.maxChars
|
|
60
|
-
: DEFAULT_MAX_CHARS;
|
|
61
|
-
const raw = String(output ?? "");
|
|
62
|
-
if (raw.length > maxChars) throw new TypeError(`research packet exceeds ${maxChars} characters`);
|
|
63
|
-
const packet = plainObject(parseJsonText(raw), "research packet");
|
|
64
|
-
exactFields(packet, PACKET_FIELDS, "research packet");
|
|
65
|
-
if (packet.schemaVersion !== 1) throw new TypeError("research packet.schemaVersion must be 1");
|
|
66
|
-
if (!Array.isArray(packet.facts) || packet.facts.length < 2 || packet.facts.length > DEFAULT_MAX_FACTS) {
|
|
67
|
-
throw new TypeError(`research packet.facts must contain 2-${DEFAULT_MAX_FACTS} source-backed facts`);
|
|
68
|
-
}
|
|
69
|
-
const facts = Object.freeze(packet.facts.map((value, index) => {
|
|
70
|
-
const fact = plainObject(value, `research packet.facts[${index}]`);
|
|
71
|
-
exactFields(fact, FACT_FIELDS, `research packet.facts[${index}]`);
|
|
72
|
-
return Object.freeze({
|
|
73
|
-
claim: text(fact.claim, `research packet.facts[${index}].claim`, 1_200),
|
|
74
|
-
excerpt: text(fact.excerpt, `research packet.facts[${index}].excerpt`, 1_200),
|
|
75
|
-
source: sourcePointer(fact.source, `research packet.facts[${index}].source`),
|
|
76
|
-
authority: text(fact.authority, `research packet.facts[${index}].authority`, 500),
|
|
77
|
-
});
|
|
78
|
-
}));
|
|
79
|
-
if (new Set(facts.map((fact) => fact.source.path)).size < 2) {
|
|
80
|
-
throw new TypeError("research packet must cite at least two distinct source paths");
|
|
81
|
-
}
|
|
82
|
-
const normalized = Object.freeze({
|
|
83
|
-
schemaVersion: 1,
|
|
84
|
-
question: text(packet.question, "research packet.question", 1_200),
|
|
85
|
-
facts,
|
|
86
|
-
conflicts: stringList(packet.conflicts, "research packet.conflicts", DEFAULT_MAX_ITEMS),
|
|
87
|
-
unknowns: stringList(packet.unknowns, "research packet.unknowns", DEFAULT_MAX_ITEMS),
|
|
88
|
-
stop: text(packet.stop, "research packet.stop", 1_000),
|
|
89
|
-
});
|
|
90
|
-
const digest = createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
91
|
-
return Object.freeze({
|
|
92
|
-
...normalized,
|
|
93
|
-
digest,
|
|
94
|
-
sourceCount: new Set(facts.map((fact) => fact.source.path)).size,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isInside(root, candidate) {
|
|
99
|
-
const fromRoot = relative(root, candidate);
|
|
100
|
-
return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function verifyResearchPacketSources(packet, projectRoot) {
|
|
104
|
-
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
105
|
-
throw new TypeError("research packet verification requires the controller project root");
|
|
106
|
-
}
|
|
107
|
-
const root = realpathSync(resolve(projectRoot));
|
|
108
|
-
for (const [index, fact] of packet.facts.entries()) {
|
|
109
|
-
let sourcePath;
|
|
110
|
-
try {
|
|
111
|
-
sourcePath = realpathSync(resolve(root, fact.source.path));
|
|
112
|
-
} catch {
|
|
113
|
-
throw new TypeError(`research packet.facts[${index}].source.path does not exist`);
|
|
114
|
-
}
|
|
115
|
-
if (!isInside(root, sourcePath)) {
|
|
116
|
-
throw new TypeError(`research packet.facts[${index}].source.path resolves outside the project root`);
|
|
117
|
-
}
|
|
118
|
-
const stats = statSync(sourcePath);
|
|
119
|
-
if (!stats.isFile()) throw new TypeError(`research packet.facts[${index}].source.path must be a file`);
|
|
120
|
-
if (stats.size > 4 * 1024 * 1024) {
|
|
121
|
-
throw new TypeError(`research packet.facts[${index}].source.path exceeds the verification size limit`);
|
|
122
|
-
}
|
|
123
|
-
const sourceLine = readFileSync(sourcePath, "utf8").split(/\r?\n/u)[fact.source.line - 1];
|
|
124
|
-
if (sourceLine === undefined) {
|
|
125
|
-
throw new TypeError(`research packet.facts[${index}].source.line is outside the source file`);
|
|
126
|
-
}
|
|
127
|
-
if (sourceLine.trim() !== fact.excerpt.trim()) {
|
|
128
|
-
throw new TypeError(`research packet.facts[${index}].excerpt does not match the cited source line`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return Object.freeze({ ...packet, sourcesVerified: true });
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function renderResearchPacket(packet) {
|
|
135
|
-
return [
|
|
136
|
-
"# Odai bounded researcher evidence packet",
|
|
137
|
-
`digest: sha256:${packet.digest}`,
|
|
138
|
-
`sources: ${packet.sourceCount}`,
|
|
139
|
-
"This packet is a retrieval index, not authority, planning, acceptance, or permission to act. Sample every route-changing source before deciding; reacquire only missing or conflicting evidence.",
|
|
140
|
-
"",
|
|
141
|
-
JSON.stringify({
|
|
142
|
-
schemaVersion: packet.schemaVersion,
|
|
143
|
-
question: packet.question,
|
|
144
|
-
facts: packet.facts,
|
|
145
|
-
conflicts: packet.conflicts,
|
|
146
|
-
unknowns: packet.unknowns,
|
|
147
|
-
stop: packet.stop,
|
|
148
|
-
}, null, 2),
|
|
149
|
-
].join("\n");
|
|
150
|
-
}
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const PACKET_FIELDS = new Set(["schemaVersion", "question", "facts", "conflicts", "unknowns", "stop"]);
|
|
6
|
+
const FACT_FIELDS = new Set(["claim", "excerpt", "source", "authority"]);
|
|
7
|
+
const SOURCE_FIELDS = new Set(["path", "line"]);
|
|
8
|
+
const DEFAULT_MAX_CHARS = 8_000;
|
|
9
|
+
const DEFAULT_MAX_FACTS = 12;
|
|
10
|
+
const DEFAULT_MAX_ITEMS = 8;
|
|
11
|
+
|
|
12
|
+
function plainObject(value, field) {
|
|
13
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
throw new TypeError(`${field} must be an object`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function exactFields(value, allowed, field) {
|
|
20
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
21
|
+
if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function text(value, field, maxChars = 1_500) {
|
|
25
|
+
if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${field} must be a non-empty string`);
|
|
26
|
+
const normalized = value.trim();
|
|
27
|
+
if (normalized.length > maxChars) throw new TypeError(`${field} exceeds ${maxChars} characters`);
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stringList(value, field, maxItems) {
|
|
32
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
33
|
+
throw new TypeError(`${field} must be an array with at most ${maxItems} items`);
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze(value.map((item, index) => text(item, `${field}[${index}]`, 800)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sourcePointer(value, field) {
|
|
39
|
+
const source = plainObject(value, field);
|
|
40
|
+
exactFields(source, SOURCE_FIELDS, field);
|
|
41
|
+
const path = text(source.path, `${field}.path`, 500);
|
|
42
|
+
if (path.startsWith("/") || path.startsWith("~") || path.split(/[\\/]/u).includes("..")) {
|
|
43
|
+
throw new TypeError(`${field}.path must be repository-relative and may not traverse parent directories`);
|
|
44
|
+
}
|
|
45
|
+
if (!Number.isSafeInteger(source.line) || source.line <= 0) {
|
|
46
|
+
throw new TypeError(`${field}.line must be a positive integer`);
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze({ path, line: source.line });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseJsonText(output) {
|
|
52
|
+
const trimmed = String(output ?? "").trim();
|
|
53
|
+
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/iu.exec(trimmed);
|
|
54
|
+
return JSON.parse(fenced ? fenced[1] : trimmed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseResearchPacket(output, options = {}) {
|
|
58
|
+
const maxChars = Number.isSafeInteger(options.maxChars) && options.maxChars > 0
|
|
59
|
+
? options.maxChars
|
|
60
|
+
: DEFAULT_MAX_CHARS;
|
|
61
|
+
const raw = String(output ?? "");
|
|
62
|
+
if (raw.length > maxChars) throw new TypeError(`research packet exceeds ${maxChars} characters`);
|
|
63
|
+
const packet = plainObject(parseJsonText(raw), "research packet");
|
|
64
|
+
exactFields(packet, PACKET_FIELDS, "research packet");
|
|
65
|
+
if (packet.schemaVersion !== 1) throw new TypeError("research packet.schemaVersion must be 1");
|
|
66
|
+
if (!Array.isArray(packet.facts) || packet.facts.length < 2 || packet.facts.length > DEFAULT_MAX_FACTS) {
|
|
67
|
+
throw new TypeError(`research packet.facts must contain 2-${DEFAULT_MAX_FACTS} source-backed facts`);
|
|
68
|
+
}
|
|
69
|
+
const facts = Object.freeze(packet.facts.map((value, index) => {
|
|
70
|
+
const fact = plainObject(value, `research packet.facts[${index}]`);
|
|
71
|
+
exactFields(fact, FACT_FIELDS, `research packet.facts[${index}]`);
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
claim: text(fact.claim, `research packet.facts[${index}].claim`, 1_200),
|
|
74
|
+
excerpt: text(fact.excerpt, `research packet.facts[${index}].excerpt`, 1_200),
|
|
75
|
+
source: sourcePointer(fact.source, `research packet.facts[${index}].source`),
|
|
76
|
+
authority: text(fact.authority, `research packet.facts[${index}].authority`, 500),
|
|
77
|
+
});
|
|
78
|
+
}));
|
|
79
|
+
if (new Set(facts.map((fact) => fact.source.path)).size < 2) {
|
|
80
|
+
throw new TypeError("research packet must cite at least two distinct source paths");
|
|
81
|
+
}
|
|
82
|
+
const normalized = Object.freeze({
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
question: text(packet.question, "research packet.question", 1_200),
|
|
85
|
+
facts,
|
|
86
|
+
conflicts: stringList(packet.conflicts, "research packet.conflicts", DEFAULT_MAX_ITEMS),
|
|
87
|
+
unknowns: stringList(packet.unknowns, "research packet.unknowns", DEFAULT_MAX_ITEMS),
|
|
88
|
+
stop: text(packet.stop, "research packet.stop", 1_000),
|
|
89
|
+
});
|
|
90
|
+
const digest = createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
...normalized,
|
|
93
|
+
digest,
|
|
94
|
+
sourceCount: new Set(facts.map((fact) => fact.source.path)).size,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isInside(root, candidate) {
|
|
99
|
+
const fromRoot = relative(root, candidate);
|
|
100
|
+
return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function verifyResearchPacketSources(packet, projectRoot) {
|
|
104
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
105
|
+
throw new TypeError("research packet verification requires the controller project root");
|
|
106
|
+
}
|
|
107
|
+
const root = realpathSync(resolve(projectRoot));
|
|
108
|
+
for (const [index, fact] of packet.facts.entries()) {
|
|
109
|
+
let sourcePath;
|
|
110
|
+
try {
|
|
111
|
+
sourcePath = realpathSync(resolve(root, fact.source.path));
|
|
112
|
+
} catch {
|
|
113
|
+
throw new TypeError(`research packet.facts[${index}].source.path does not exist`);
|
|
114
|
+
}
|
|
115
|
+
if (!isInside(root, sourcePath)) {
|
|
116
|
+
throw new TypeError(`research packet.facts[${index}].source.path resolves outside the project root`);
|
|
117
|
+
}
|
|
118
|
+
const stats = statSync(sourcePath);
|
|
119
|
+
if (!stats.isFile()) throw new TypeError(`research packet.facts[${index}].source.path must be a file`);
|
|
120
|
+
if (stats.size > 4 * 1024 * 1024) {
|
|
121
|
+
throw new TypeError(`research packet.facts[${index}].source.path exceeds the verification size limit`);
|
|
122
|
+
}
|
|
123
|
+
const sourceLine = readFileSync(sourcePath, "utf8").split(/\r?\n/u)[fact.source.line - 1];
|
|
124
|
+
if (sourceLine === undefined) {
|
|
125
|
+
throw new TypeError(`research packet.facts[${index}].source.line is outside the source file`);
|
|
126
|
+
}
|
|
127
|
+
if (sourceLine.trim() !== fact.excerpt.trim()) {
|
|
128
|
+
throw new TypeError(`research packet.facts[${index}].excerpt does not match the cited source line`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze({ ...packet, sourcesVerified: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderResearchPacket(packet) {
|
|
135
|
+
return [
|
|
136
|
+
"# Odai bounded researcher evidence packet",
|
|
137
|
+
`digest: sha256:${packet.digest}`,
|
|
138
|
+
`sources: ${packet.sourceCount}`,
|
|
139
|
+
"This packet is a retrieval index, not authority, planning, acceptance, or permission to act. Sample every route-changing source before deciding; reacquire only missing or conflicting evidence.",
|
|
140
|
+
"",
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
schemaVersion: packet.schemaVersion,
|
|
143
|
+
question: packet.question,
|
|
144
|
+
facts: packet.facts,
|
|
145
|
+
conflicts: packet.conflicts,
|
|
146
|
+
unknowns: packet.unknowns,
|
|
147
|
+
stop: packet.stop,
|
|
148
|
+
}, null, 2),
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
@@ -3,12 +3,10 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { CONFIGURABLE_ROLES } from "./routing-config.mjs";
|
|
4
4
|
|
|
5
5
|
export const RESPONSIBILITY_GAP_PROMPT = [
|
|
6
|
-
"## Odai
|
|
7
|
-
"Users
|
|
8
|
-
"Keep work direct when the
|
|
9
|
-
"
|
|
10
|
-
"Use responsibility=user only when a missing user-owned choice, priority, or unacceptable outcome changes the route. Ask one concise question after the tool accepts that gap. Do not ask users for repository facts, technical investigation, implementation details the project already determines, or internal routing choices.",
|
|
11
|
-
"Do not submit a gap merely because a task is complex, risky, multi-step, uses a role word, has a configured model, or could use a cheaper model. Do not resubmit an unchanged gap or state.",
|
|
6
|
+
"## Odai responsibility gaps",
|
|
7
|
+
"Users provide goals, constraints, materials, and acceptance; they never need to request internal roles or handoffs.",
|
|
8
|
+
"Keep work direct when the controller can close it reliably. Call odai_responsibility_gap only when current evidence shows an independent capability or user-decision gap that can change a concrete result; keywords, complexity, risk, configured models, and price are insufficient. The runtime decides direct, inline, same-turn, child, or user-question handling.",
|
|
9
|
+
"Use responsibility=user only for a missing user-owned choice, priority, or unacceptable outcome, then ask exactly the accepted concise question. Do not ask users for repository facts or implementation details the project can determine. Do not resubmit unchanged state.",
|
|
12
10
|
].join("\n");
|
|
13
11
|
|
|
14
12
|
const RESPONSIBILITIES = Object.freeze([...CONFIGURABLE_ROLES, "user"]);
|
|
@@ -58,10 +56,10 @@ export function createResponsibilityGapTool(options = {}) {
|
|
|
58
56
|
required: ["responsibility", "gap", "evidenceRefs", "expectedChange"],
|
|
59
57
|
properties: {
|
|
60
58
|
responsibility: { type: "string", enum: [...RESPONSIBILITIES] },
|
|
61
|
-
gap: { type: "string"
|
|
62
|
-
evidenceRefs: { type: "array", items: { type: "string" }
|
|
63
|
-
expectedChange: { type: "string"
|
|
64
|
-
question: { type: "string"
|
|
59
|
+
gap: { type: "string" },
|
|
60
|
+
evidenceRefs: { type: "array", items: { type: "string" } },
|
|
61
|
+
expectedChange: { type: "string" },
|
|
62
|
+
question: { type: "string" },
|
|
65
63
|
},
|
|
66
64
|
},
|
|
67
65
|
output: {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const SHORT_SCOPE_ROLES = new Set(["planner", "reviewer"]);
|
|
4
|
+
const WORK_SCOPE_ROLES = new Set(["executor", "frontend"]);
|
|
5
|
+
|
|
6
|
+
function routeSnapshot(value) {
|
|
7
|
+
if (typeof value?.provider !== "string" || typeof value?.model !== "string") return undefined;
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
provider: value.provider,
|
|
10
|
+
model: value.model,
|
|
11
|
+
...(value.reasoningEffort === undefined ? {} : { reasoningEffort: value.reasoningEffort }),
|
|
12
|
+
...(value.maxTokens === undefined ? {} : { maxTokens: value.maxTokens }),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rolePolicy(role) {
|
|
17
|
+
if (SHORT_SCOPE_ROLES.has(role)) return "read-only-tool-chain";
|
|
18
|
+
if (WORK_SCOPE_ROLES.has(role)) return "bounded-work-tool-chain";
|
|
19
|
+
throw new TypeError(`unsupported in-place responsibility: ${role}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createResponsibilityScope({
|
|
23
|
+
turn,
|
|
24
|
+
startStep,
|
|
25
|
+
role,
|
|
26
|
+
route,
|
|
27
|
+
source,
|
|
28
|
+
decision,
|
|
29
|
+
routeValidated = false,
|
|
30
|
+
cardId,
|
|
31
|
+
resumeOfScopeId,
|
|
32
|
+
}) {
|
|
33
|
+
if (!Number.isSafeInteger(turn) || turn < 1) throw new TypeError("scope turn must be a positive integer");
|
|
34
|
+
if (!Number.isSafeInteger(startStep) || startStep < 1) throw new TypeError("scope startStep must be a positive integer");
|
|
35
|
+
const requestedRoute = routeSnapshot(route);
|
|
36
|
+
if (!requestedRoute) throw new TypeError("scope route must contain provider and model");
|
|
37
|
+
if (resumeOfScopeId !== undefined && (typeof resumeOfScopeId !== "string" || resumeOfScopeId === "")) {
|
|
38
|
+
throw new TypeError("resumeOfScopeId must be a non-empty string");
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
state: "pending",
|
|
43
|
+
turn,
|
|
44
|
+
startStep,
|
|
45
|
+
role,
|
|
46
|
+
route: requestedRoute,
|
|
47
|
+
source,
|
|
48
|
+
decision,
|
|
49
|
+
continuationPolicy: rolePolicy(role),
|
|
50
|
+
stopPolicy: "terminal-response-or-ownership-boundary",
|
|
51
|
+
routeValidated: routeValidated === true,
|
|
52
|
+
...(cardId ? { cardId } : {}),
|
|
53
|
+
...(resumeOfScopeId ? { resumeOfScopeId } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function claimResponsibilityScope(scope, { step, baseRoute, temporaryRoute, routeMode }) {
|
|
58
|
+
if (scope?.state !== "pending") return scope;
|
|
59
|
+
if (step !== scope.startStep) throw new Error("responsibility scope may only be claimed by its start step");
|
|
60
|
+
const base = routeSnapshot(baseRoute);
|
|
61
|
+
const temporary = routeSnapshot(temporaryRoute);
|
|
62
|
+
if (!base || !temporary) throw new TypeError("claimed responsibility scope requires base and temporary routes");
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
...scope,
|
|
65
|
+
state: "active",
|
|
66
|
+
claimedStep: step,
|
|
67
|
+
baseRoute: base,
|
|
68
|
+
temporaryRoute: temporary,
|
|
69
|
+
routeMode,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function responsibilityScopeOwnsRequest(scope, turn, step) {
|
|
74
|
+
return Boolean(scope
|
|
75
|
+
&& ["pending", "active"].includes(scope.state)
|
|
76
|
+
&& scope.turn === turn
|
|
77
|
+
&& Number.isSafeInteger(step)
|
|
78
|
+
&& step >= scope.startStep);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isDirectHumanMessage(message) {
|
|
82
|
+
return message?.role === "user" && message?.source?.kind === "user";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assistantHasToolCalls(event) {
|
|
86
|
+
return Array.isArray(event?.data?.message?.content)
|
|
87
|
+
&& event.data.message.content.some((block) => block?.type === "tool-call");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function responsibilityScopeStopReason(scope, event) {
|
|
91
|
+
if (!scope || !event) return undefined;
|
|
92
|
+
if (event.type === "agent/inbox/spliced"
|
|
93
|
+
&& Array.isArray(event.data?.inserted)
|
|
94
|
+
&& event.data.inserted.some(isDirectHumanMessage)) {
|
|
95
|
+
return "direct-user-input";
|
|
96
|
+
}
|
|
97
|
+
if (event.type === "assistant/message"
|
|
98
|
+
&& event.data?.turn === scope.turn
|
|
99
|
+
&& Number.isSafeInteger(event.data?.step)
|
|
100
|
+
&& event.data.step >= scope.startStep
|
|
101
|
+
&& !assistantHasToolCalls(event)) {
|
|
102
|
+
return "terminal-response";
|
|
103
|
+
}
|
|
104
|
+
if (event.type === "turn/end" && event.data?.turn === scope.turn) return "turn-ended";
|
|
105
|
+
if (scope.cardId && event.type === "odai/route-card-cleared" && event.data?.cardId === scope.cardId) return "route-card-cleared";
|
|
106
|
+
if (scope.cardId && event.type === "odai/route-card-claim-released" && event.data?.cardId === scope.cardId) return "route-card-released";
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scopeEventData(scope) {
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
scopeId: scope.id,
|
|
113
|
+
turn: scope.turn,
|
|
114
|
+
startStep: scope.startStep,
|
|
115
|
+
role: scope.role,
|
|
116
|
+
requestedRoute: scope.route,
|
|
117
|
+
continuationPolicy: scope.continuationPolicy,
|
|
118
|
+
stopPolicy: scope.stopPolicy,
|
|
119
|
+
...(scope.source ? { routeSource: scope.source } : {}),
|
|
120
|
+
...(scope.cardId ? { routeCardId: scope.cardId } : {}),
|
|
121
|
+
...(scope.resumeOfScopeId ? { resumeOfScopeId: scope.resumeOfScopeId } : {}),
|
|
122
|
+
...(scope.baseRoute ? { baseRoute: scope.baseRoute } : {}),
|
|
123
|
+
...(scope.temporaryRoute ? { temporaryRoute: scope.temporaryRoute } : {}),
|
|
124
|
+
...(scope.routeMode ? { routeMode: scope.routeMode } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function responsibilityScopeStartedEvent(scope) {
|
|
129
|
+
return scopeEventData(scope);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function responsibilityScopeClaimedEvent(scope) {
|
|
133
|
+
if (scope?.state !== "active") throw new TypeError("only an active responsibility scope can be recorded as claimed");
|
|
134
|
+
return scopeEventData(scope);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function responsibilityScopeStoppedEvent(scope, reason, position = {}) {
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
...scopeEventData(scope),
|
|
140
|
+
...(Number.isSafeInteger(position.step) ? { stopStep: position.step } : {}),
|
|
141
|
+
reason,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function latestDanglingResponsibilityScope(events) {
|
|
146
|
+
const states = new Map();
|
|
147
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
148
|
+
const scopeId = event?.data?.scopeId;
|
|
149
|
+
if (typeof scopeId !== "string") continue;
|
|
150
|
+
if (event.type === "odai/responsibility-scope-started") states.set(scopeId, { state: "started", data: event.data });
|
|
151
|
+
else if (event.type === "odai/responsibility-scope-claimed") states.set(scopeId, { state: "claimed", data: event.data });
|
|
152
|
+
else if (event.type === "odai/responsibility-scope-stopped") states.set(scopeId, { state: "stopped", data: event.data });
|
|
153
|
+
}
|
|
154
|
+
return [...states.values()].findLast((entry) => entry.state === "claimed")?.data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function latestStoppedResponsibilityScope(events, turn) {
|
|
158
|
+
return (Array.isArray(events) ? events : []).findLast((event) => (
|
|
159
|
+
event?.type === "odai/responsibility-scope-stopped"
|
|
160
|
+
&& event.data?.turn === turn
|
|
161
|
+
&& typeof event.data?.scopeId === "string"
|
|
162
|
+
))?.data;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function pendingResponsibilityInterruption(events) {
|
|
166
|
+
const settled = new Set();
|
|
167
|
+
for (let index = (Array.isArray(events) ? events.length : 0) - 1; index >= 0; index -= 1) {
|
|
168
|
+
const event = events[index];
|
|
169
|
+
const scopeId = event?.data?.scopeId;
|
|
170
|
+
if (typeof scopeId !== "string") continue;
|
|
171
|
+
if (["odai/responsibility-interruption-consumed", "odai/responsibility-interruption-cleared"].includes(event.type)) {
|
|
172
|
+
settled.add(scopeId);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (event.type === "odai/responsibility-interrupted") {
|
|
176
|
+
return event.data?.reason === "max-tokens" && !settled.has(scopeId)
|
|
177
|
+
? event.data
|
|
178
|
+
: undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function pendingResponsibilityScopeRestoration(events) {
|
|
185
|
+
let candidate;
|
|
186
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
187
|
+
if (event?.type === "odai/responsibility-scope-stopped"
|
|
188
|
+
&& event.data?.baseRoute
|
|
189
|
+
&& event.data?.temporaryRoute) {
|
|
190
|
+
candidate = event.data;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (candidate
|
|
194
|
+
&& event?.type === "odai/responsibility-scope-restored"
|
|
195
|
+
&& event.data?.scopeId === candidate.scopeId
|
|
196
|
+
&& event.data?.status === "applied") candidate = undefined;
|
|
197
|
+
}
|
|
198
|
+
return candidate;
|
|
199
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
const DSH_ROLE_OVERLAYS = Object.freeze({
|
|
2
|
-
researcher: `## DSH researcher execution boundary
|
|
3
|
-
|
|
4
|
-
This responsibility runs as one read-only child before the primary routing decision. Use only read, glob, grep, and other non-mutating source tools; do not run shell commands, edit, plan, recommend, approve, or delegate. Return JSON only, with no fence and no fields beyond this exact shape: {"schemaVersion":1,"question":"...","facts":[{"claim":"...","excerpt":"exact complete cited line","source":{"path":"repository/relative/path","line":1},"authority":"source role and freshness boundary"}],"conflicts":[],"unknowns":[],"stop":"..."}. Return the smallest useful 2-6 facts from at least two distinct files. DSH validates every cited file, line, and excerpt inside the project root before hashing and exposing this packet to the controller or planner.`,
|
|
5
|
-
planner: `## DSH planner execution boundary
|
|
6
|
-
|
|
7
|
-
In auto mode this responsibility runs by replacing the
|
|
8
|
-
executor: `## DSH executor execution boundary
|
|
9
|
-
|
|
10
|
-
In auto mode this responsibility is reachable when an active frozen route card proves observable separation benefit and either the original current task already authorizes implementation or the user explicitly continues it. It runs in
|
|
11
|
-
reviewer: `## DSH reviewer execution boundary
|
|
12
|
-
|
|
13
|
-
Start an independent child only from a bounded, hash-addressed packet that contains verified tool evidence. The child's effective request header must match its configured route before its output is accepted. Without such a packet, auto mode
|
|
14
|
-
frontend: `## DSH frontend execution boundary
|
|
15
|
-
|
|
16
|
-
This responsibility runs in
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export function dshRoleContract(role, canonicalContract, referenceContracts = {}) {
|
|
20
|
-
const canonical = typeof canonicalContract === "string" ? canonicalContract.trim() : "";
|
|
21
|
-
const overlay = DSH_ROLE_OVERLAYS[role];
|
|
22
|
-
if (!canonical) throw new Error(`canonical ${role} responsibility contract is unavailable`);
|
|
23
|
-
const craft = role === "frontend" && typeof referenceContracts.craft === "string"
|
|
24
|
-
? `## Canonical craft reference\n\n${referenceContracts.craft.trim()}`
|
|
25
|
-
: "";
|
|
26
|
-
return [canonical, craft, overlay].filter(Boolean).join("\n\n");
|
|
27
|
-
}
|
|
1
|
+
const DSH_ROLE_OVERLAYS = Object.freeze({
|
|
2
|
+
researcher: `## DSH researcher execution boundary
|
|
3
|
+
|
|
4
|
+
This responsibility runs as one read-only child before the primary routing decision. Use only read, glob, grep, and other non-mutating source tools; do not run shell commands, edit, plan, recommend, approve, or delegate. Return JSON only, with no fence and no fields beyond this exact shape: {"schemaVersion":1,"question":"...","facts":[{"claim":"...","excerpt":"exact complete cited line","source":{"path":"repository/relative/path","line":1},"authority":"source role and freshness boundary"}],"conflicts":[],"unknowns":[],"stop":"..."}. Return the smallest useful 2-6 facts from at least two distinct files. DSH validates every cited file, line, and excerpt inside the project root before hashing and exposing this packet to the controller or planner.`,
|
|
5
|
+
planner: `## DSH planner execution boundary
|
|
6
|
+
|
|
7
|
+
In auto mode this responsibility runs by replacing the controller route only inside one explicit responsibility scope, not by starting a child. The scope may continue through read-only tool calls, but ends on a terminal response, new direct-human input, failure, cancellation, route mismatch, or turn boundary. When the user-persisted planner mapping explicitly includes maxTokens, that responsibility ceiling overrides the controller ceiling only inside this scope. Retain the current conversation and project context, use only read-only evidence, and do not implement or edit. If the result is mode: planned, execute: executor, and separation has a concrete observable benefit, use odai_route_card to freeze exactly one structured card with non-empty evidence, scope, acceptance conditions, and stop condition. When the original current task already authorizes implementation, immediately submit one executor gap through odai_responsibility_gap so the same task continues without another user prompt. For plan-only work, a new task, expanded scope, or missing user-owned authorization, stop for the minimal user decision instead. Otherwise do not create a route card.`,
|
|
8
|
+
executor: `## DSH executor execution boundary
|
|
9
|
+
|
|
10
|
+
In auto mode this responsibility is reachable when an active frozen route card proves observable separation benefit and either the original current task already authorizes implementation or the user explicitly continues it. It runs in one bounded controller responsibility scope because DSH routed children are read-only. The scope may continue only through its own tool chain and ends on a terminal response, new direct-human input, failure, cancellation, route mismatch, card release, or turn boundary. When the user-persisted executor mapping explicitly includes maxTokens, that responsibility ceiling overrides the controller ceiling only inside this scope. Follow the claimed card exactly; do not reinterpret the request, broaden scope, or claim final acceptance. The card is consumed after the effective executor request route is verified; that route receipt admits one executor attempt but does not keep the responsibility scope alive by itself.`,
|
|
11
|
+
reviewer: `## DSH reviewer execution boundary
|
|
12
|
+
|
|
13
|
+
Start an independent child only from a bounded, hash-addressed packet that contains verified tool evidence. The child's effective request header must match its configured route before its output is accepted. Without such a packet, auto mode keeps the current controller route and may perform only a controller-local read-only check while it gathers project-available acceptance evidence. That local check is not independent acceptance, must not claim reviewer approval, and must not stop the authorized task solely to ask the user for artifacts the project can produce.`,
|
|
14
|
+
frontend: `## DSH frontend execution boundary
|
|
15
|
+
|
|
16
|
+
This responsibility runs in one bounded controller responsibility scope so it retains the active conversation, workspace, dev-server, and browser context. It is not an independent child. The scope may continue only through its own tool chain and ends on a terminal response, new direct-human input, failure, cancellation, route mismatch, or turn boundary. DSH may use a user-persisted frontend provider/model/reasoning route and, only when that mapping explicitly includes maxTokens, apply that responsibility ceiling inside this scope. The runtime compares the effective DSH request header with that mapping and records an applied, mismatch, or unverified receipt; configuration and self-report are not routing evidence.`,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function dshRoleContract(role, canonicalContract, referenceContracts = {}) {
|
|
20
|
+
const canonical = typeof canonicalContract === "string" ? canonicalContract.trim() : "";
|
|
21
|
+
const overlay = DSH_ROLE_OVERLAYS[role];
|
|
22
|
+
if (!canonical) throw new Error(`canonical ${role} responsibility contract is unavailable`);
|
|
23
|
+
const craft = role === "frontend" && typeof referenceContracts.craft === "string"
|
|
24
|
+
? `## Canonical craft reference\n\n${referenceContracts.craft.trim()}`
|
|
25
|
+
: "";
|
|
26
|
+
return [canonical, craft, overlay].filter(Boolean).join("\n\n");
|
|
27
|
+
}
|